返回 HackerTubeYouTube2026年11月13日视频时长:38:47

先让修改变容易:Sandi Metz 重构镀金玫瑰

RailsConf 2014 - All the Little Things by Sandi Metz

CConfreaks

完整字幕

中文
查看原视频
本篇目录

在修改功能之前,先让修改变容易

查看原文 00:54

新需求经常落在已经很难修改的代码上。既然类似行为就在这里,是不是再加一个分支就好?Sandi Metz 在这场 RailsConf 演讲中给出的答案是:先在测试保护下重新组织现有行为,再在更简单的结构中添加功能。她的总体建议是,把类和方法做得更小,让它们尽可能少地了解彼此。

Jim Weirich 向 Metz 推荐了镀金玫瑰 kata,也就是一项编程练习。她稍微调整了他的版本,并刻意把它当成生产代码来处理,只通过代码和测试了解行为。这个例子展示的是在特定代码库中的一条路径,并不意味着所有条件语句都有问题。

通过的测试既是安全网,也暴露了缺失功能

查看原文 02:14

Gilded Rose 保存名称、质量和剩余天数;调用一次 tick 就推进一次商品状态。原来的 tick 包含一个长达 43 行的条件语句。Metz 用 Flog 测得整个类为 50 分,tick 一项就有 45 分。她将 Flog 解释为一种 ABC 复杂度指标,统计赋值、分支和条件;分数越高,通常暗示推理更费力,但指标仍只是一个信号。

查看原图

她的非正式“眯眼测试”是退远一点观察代码:形状变化能显示嵌套,颜色变化可能提示混合了不同抽象层次。她在这里数出 16 个条件语句,还有反复出现的特殊名称字符串和魔法数字。Brie、Sulfuras 和 Backstage 门票都有各自的测试;Normal 也有测试,尽管原条件语句没有直接提到它的名称。

现有测试全部通过,却有六个 Conjured 测试被跳过。一个 Normal 测试表明,一次 tick 后质量和剩余天数各减一,像有销售期限的商品。Metz 直接尝试实现 Conjured 时,修好一个测试又会破坏别处。她指出,沿用已有模式会强化坏模式:已经很大的条件语句吸引更多分支,最终越来越难摆脱。

捕获一条路径,再让测试重新变绿

查看原文 09:19

Metz 暂时搁置新功能。按她的定义,重构是改变代码的组织方式而不改变行为。测试提供反馈,但只能保护它们实际覆盖到的行为。

接缝是能够改变或转接行为的位置。她先截获 Normal 路径并提前返回;四个 Normal 测试如预期失败,说明目标路径确实被执行到了。接着她将该路径委托给独立方法,再次确认四个测试失败。下图展示的是委托之前的初始截获。

查看原图

她逐个让测试通过:先恢复质量和天数各减一,再处理销售期限边界上更快的质量下降,以及质量已经为零的情况。过程中另一个测试也意外通过了。第一版代码并不漂亮,但测试变绿后,她可以调整结构:先减少剩余天数;质量为零时提前返回;否则质量减一,更新后的剩余天数满足画面所示边界时再减一。

查看原图

这是演讲中展示的代码,并不能证明所有输入下质量都有统一下限:零值检查发生在两次可能的减法之前。Metz 的操作顺序是捕获路径、观察预期失败、用简单实现让测试变绿,然后继续运行测试并改善结构。

等抽象的形状清楚后再消除重复

查看原文 13:31

她把分派改成 case 语句,截获 Brie。七个测试随之失败,让她相信自己找到了相应路径;接着她逐个实现测试要求的行为。此时 Normal 和 Brie 的算法形状很相似,只是驱动它们的值不同。Metz 没有立即合并,因为后续案例可能提供更多证据,帮助判断共同抽象究竟是什么。

查看原图

她并不是说重复应该永久保留。DRY 意为“不要重复自己”,Metz 认为它有用,但错误抽象的拆解成本可能高于暂时重复。为了不丢失线索,她建议给相关的重复代码加上相同编号的标签。

Sulfuras 带来一个意外:三个测试在方法为空时仍然通过,因为测试要求的行为就是什么也不做。她也提取了 Backstage。新的分派和方法覆盖相应路径后,她删除了旧条件语句。对于缺少可靠测试的陌生代码,她建议先在边界编写特征测试,记录实际观察到的行为,再重新组织代码;测试能提供多大信心仍取决于覆盖了哪些情况。

小方法改善结构,但重构途中可能更复杂

查看原文 17:47

得到的类形状更平坦,相关操作开始聚集。整个类的 Flog 分数从 50 降到 40;最复杂的 Backstage 方法为 12。Metz 认为方法级视角有价值,因为这些方法可以分别理解。

查看原图

不那么直观的障碍是:她的十个中间重构步骤在分数下降之前提高了测得的复杂度。图表开始展示这种上升。Metz 认为知道目标结构有助于容忍临时支架;这是对本次过程的观察,不保证每次复杂度上升都会带来改善。

查看原图

剩余的分派仍把选择和行为绑在一起

查看原文 19:59

此时往 case 语句添加 Conjured 很容易,但 Metz 拒绝这样做:每增加一种类型仍要修改分派。她引入开闭原则,即尽量通过扩展加入行为,同时减少对已有行为的修改。她设想了不会产生无关回归的理想状态;这是设计目标,并不保证新增功能一定安全,也不能取代测试。设计原则始终需要权衡。

重复的方法后缀让她想到,每种商品都可以是有共同 tick 操作的独立对象。这个选择把“依据什么选择类型”与“选中后做什么”分开。

把行为移入对象,容忍暂时的支架

查看原文 22:18

Metz 将 Normal 的方法移进 Normal 类,补上初始化和属性,再让 Gilded Rose 创建对象并转发 tick。此时 Brie 仍在原类中计算,所以质量和剩余天数的访问暂时需要在新对象和旧状态之间选择。她对 Brie、Sulfuras 和 Backstage 重复提取,甚至为 Sulfuras 的空方法也创建了一个类。

查看原图

等每条路径都有对象后,Metz 撤回之前的一些步骤:删除小型转发方法、提取共同的构造过程,并移除临时条件访问器。现在选择过程找到一个类,构造过程生成商品对象,商品的 tick 提供具体行为。临时复杂性有明确的退出方式,而不是最终状态。

把 item 看作角色,让中间人变成工厂

查看原文 27:00

对调用方来说,item 是一个角色:只要对象能响应所需消息,就能扮演它。Metz 称之为鸭子类型。Normal、Brie 和其他实现可以对同一个 tick 消息作出不同响应,而调用方无需知道它们内部的算法。

查看原图

一个只负责转发消息的对象是值得审视的“中间人”。不过 Gilded Rose 还有一项有用的职责:根据名称选择合适的商品类。Metz 将其改成充当工厂的模块,创建并返回合适的对象;调用方随后直接与返回的对象交互。画面中的工厂方法叫 for,它先选择类,再对该类调用 new;这两个名称承担不同职责。

查看原图

共享行为确实合适时才使用继承

查看原文 29:16

商品类清晰可见后,Metz 把共同的初始化和属性移入 Item 超类。她给出的继承使用条件是:层次要浅而窄,子类位于对象图的叶节点,并且每个子类都使用超类的行为。这是她对这一设计选择的条件,并不意味着继承总是合适。

她希望超类本身也能扮演 item 角色,包括响应 tick。一种方案是让方法抛出错误,要求子类实现;这里她选择默认的空操作。Sulfuras 重写的 tick 也是同样的空操作,于是可以直接使用 Item,独立的 Sulfuras 子类随之消失。

查看原图

把配置与选择算法分开

查看原文 32:12

剩余的 case 混合了名称到类的映射,以及使用映射的算法。Metz 提取出一个 hash,也就是查找表,让工厂查询专门的类,查不到时使用默认类。此后映射可以独立于选择规则变化。她提出,如果配置变化频繁,将来可以视情况移到文件或数据库;本次演示并不要求这样做。

查看原图

比较结果时,不要混淆指标的单位

查看原文 33:31

最终的小对象版本略长,但相关行为聚集得更紧密。看似较深的缩进也包含了模块里的类,而不全是业务逻辑嵌套。幻灯片把它与原始大型条件语句并排比较。

查看原图

Metz 报告最终这一组类的 Flog 总分为 33,最复杂的类为 12,类平均分为 7。她认为读者不必同时将所有类装在脑中,因此称复杂度降低了 75%。这些数字与最初单个类的度量单位不同;百分比是她对指标的解释,并非对维护工作量的受控测量。她建议将可能出错的指标与同样不精确的个人判断结合使用。

Conjured 是最后一步,而不是第一步

查看原文 35:19

最后,Metz 添加 Conjured 子类,并在名称到类的映射中增加一项。画面里的实现先减少天数,质量已经为零时返回,否则质量减二,在所示剩余天数边界上再减二。她报告测试通过。和 Normal 一样,这个提前返回本身不能证明所有可能输入都满足质量下限。

查看原图

映射的变化值得注意:演示通过分离选择与行为来接近开闭设计,但添加这一类型仍修改了配置。Metz 最后引用 Kent Beck 的建议:先让修改变得容易,即使准备过程可能很难,然后再做容易的修改。现实中的新需求为今天的设计提供证据,无须预先预测所有未来需求。