《修改代码的艺术》全书导读:遗留代码不是耻辱,是一张还没铺开的防护网
- 约 34 分钟读完 - 全文 13239 字《修改代码的艺术》全书导读:遗留代码不是耻辱,是一张还没铺开的防护网
参考书籍:Michael Feathers《修改代码的艺术》(Working Effectively with Legacy Code,Prentice Hall 2004,中文版由人民邮电出版社/机械工业出版社先后引进)。 目标读者:没读过这本书、正在或即将维护"别人写的、不敢动的"代码库的程序员。 阅读目标:把全书最锋利的一个定义、一个模型(seam)、一个工作法(characterization test)、一组依赖解除技术讲清楚,最后编译成可执行的日常动作。
一、这本书是什么:一个定义就是全书最锋利的一刀
这本 2004 年的书,副标题式的宣言只有一个:遗留代码(legacy code)就是没有测试的代码(书中大意)。
先把成书背景交代一句。2004 年,极限编程和 TDD 刚兴起不久,“写新代码时先写测试"的理念已经开始传播,但行业里沉默的大多数面对的现实是:手头维护的系统写出来的时候还没有单元测试这回事。新书都在教"怎么从零写好代码”,几乎没人教"怎么面对已经写坏的代码"——而后者才是绝大多数程序员的日常。Feathers 这本书填的就是这个空档,书名里的 Working Effectively 不是修辞:它要解决的问题是"明天就要改这段代码,怎么办",不是"理想情况下代码应该长什么样"。
注意这个定义的刁钻之处。我们平常说"遗留代码",指的是"前人留下的、老旧的、看不懂的代码"——一个时间属性。Feathers 把它换成了一个工程属性:没有测试覆盖的代码,哪怕昨天刚写完,也已经是遗留代码;有测试覆盖的老代码,反而不遗留。
这一刀切下去,很多习以为常的现象立刻有了解释:
- 为什么你昨天写的代码今天就不敢改?因为它从出生起就没有测试,它就是遗留代码。
- 为什么有的十年老系统改起来反而顺手?因为核心路径上有测试,行为是可见的。
- 为什么"重写"的冲动总是错的?因为重写完的新代码依然没测试的话,你只是生产了一份新的遗留代码。
书里还有一层更狠的推论(大意):没有测试的代码就是坏代码——不管它写得多漂亮。因为判断代码好坏的第一标准不是优雅,而是"能不能安全地改",而安全的前提是你能看见改完之后行为有没有变。看不见行为,优雅就是一张裱在危房里的画。
这和 《代码大全》笔记 里"软件的首要技术使命是管理复杂度"是同一件事的两面:复杂度管不住,表现就是行为不可观测;行为不可观测,改动就退化成赌博。
这个定义还直接解开了遗留系统维护的头号死结——书中称之为一个两难困境(大意):要安全地改代码,得先有测试;要给代码加测试,往往得先改代码(把依赖解开才能放进测试夹具)。鸡生蛋蛋生鸡,多数人卡在这里就放弃了,于是继续 edit and pray。Feathers 给出的破法是这本书全部技术部分的主线:允许自己做"没有测试看护的最小改动",但只限于一种改动——为了造出 seam 而做的依赖解除。这类改动必须保守到近乎机械(提取一个方法、加一个参数,不碰任何逻辑),换回来的回报是:代码进了测试夹具,之后的一切修改都有了看护。先用最小风险换 leverage,再用 leverage 换安全——这是全书方法论的现金流结构。
顺带一提这本书的体例:它不是一本"读完放书架"的书,而是一本工具手册。前半本讲心法和工作流(怎么面对改不动的代码),后半本是一本"症状—处方"对照目录(“我改不动这个方法,因为它的依赖在构造函数里 new 出来”→ 查 Parameterize Constructor)。读法和读 《设计模式》入门 一样:第一遍通读建立索引,真正干活时按症状查表。
二、改代码的两种模式:edit and pray vs cover and modify
Feathers 把程序员修改代码的真实状态分成两种模式(书中大意):
flowchart LR
subgraph 祈祷模式["edit and pray<br/>编辑后祈祷"]
A1["读懂一部分代码"] --> A2["凭感觉改"]
A2 --> A3["编译通过"]
A3 --> A4["上线前祈祷<br/>希望没碰坏别处"]
end
subgraph 覆盖模式["cover and modify<br/>覆盖后修改"]
B1["找到改动点附近的 seam"] --> B2["先写测试<br/>罩住现有行为"]
B2 --> B3["在测试看护下改代码"]
B3 --> B4["测试红了就停<br/>绿了就继续"]
end
两种模式的差别不在技术,在恐惧的分配方式。edit and pray 把恐惧攒到最后一刻一次性兑现——祈祷上线别出事;cover and modify 把恐惧拆成无数次测试运行,每次只消化一小口。
所以这本书对测试的定位和教科书完全不同:测试在这里不是 QA 工具,不是质量部门的产出物,而是改动代码的安全网。它的价值排序是——先让"改"这个动作变得不恐怖,质量提升是顺带的收益。一个从没写过单元测试的团队,第一本要补的不是"测试覆盖率指标",而是"下一次改动之前先罩一层网"的习惯。
书中有句话的意思值得抄在工位上(大意):我们之所以要在修改前写测试,是因为在坏代码里,修改带来的伤害往往是延迟发作的——你以为只动了 A,其实 B 的行为悄悄变了,三周后才在客户现场爆出来。测试网的作用就是把"三周后"压缩成"三秒后"。
既然 cover and modify 明显更好,为什么大多数团队还是常年泡在 edit and pray 里?书里给出的解释很实际(大意):两种模式的成本结算周期不一样。edit and pray 的成本是赊账——改动当下很快,代价在未来某次线上事故里一次性扣掉,而且常常由别人买单;cover and modify 的成本是现付——写测试、解依赖的功夫当下就要掏,收益却摊在之后每一次改动里慢慢回来。人都是短视的,团队也是,所以破局的钥匙不是讲道理,而是把第一次"现付"的门槛降到足够低——这正是后半本依赖解除技术目录存在的意义:它们把"给这段代码加第一个测试"从一天的工程压缩到一小时的手术。
还有一点容易被误读:这本书讲的测试和 TDD 不是一回事,也不冲突。TDD 是"先写测试驱动出新代码的设计",往前看;这本书的 characterization test 是"先写测试固化住旧代码的行为",往后看。一个管新建,一个管存量。遗留系统里正确的姿势是两条腿走路:存量行为用 characterization test 罩住,新增逻辑用 TDD 长出来——两头往中间会师,系统的可测试面积只会越来越大。
三、seam 模型:全书的技术核心
如果这本书只带走一个词,那就是 seam(接缝)。
定义(书中大意):seam 是程序中这样一个位置——你不需要修改这段代码本身,就能改变它的行为。
为什么这个概念是技术核心?因为改遗留代码的全部困难可以归结成一句话:代码里塞满了硬编码的依赖,你没法把它放进一个测试夹具(test harness)里单独观察。 而 seam 就是你在依赖丛林里找到的下刀点——庖丁解牛,顺着纹理下刀,牛的筋骨自然分开;seam 就是代码的纹理。
Feathers 列出三类 seam(大意):
flowchart TD
S["seam<br/>不改代码就能改变行为的点"] --> O["对象接缝<br/>Object Seam"]
S --> P["预处理接缝<br/>Preprocessing Seam"]
S --> L["链接接缝<br/>Linking Seam"]
O --> O1["例子:把 new 出来的依赖<br/>换成通过参数/子类注入的替身"]
O --> O2["适用:Java / C# 等<br/>面向对象语言的主战场"]
P --> P1["例子:C/C++ 用宏和条件编译<br/>在测试构建里替换掉某个调用"]
P --> P2["适用:有预处理器的语言<br/>测试代码和生产代码两份编译"]
L --> L1["例子:换掉链接的库<br/>测试时链接桩版本而不是真库"]
L --> L2["适用:编译型语言<br/>把依赖推迟到链接期"]
三种 seam 背后的原理是同一个:程序的行为由很多个"点"共同决定——调用点、编译点、链接点、对象创建点。每多一个这样的点,就多一次不动手术也能换行为的机会。
三类 seam 各自的使用场景值得多说一句:
- 对象 seam 是日常用得最多的。书中反复强调的一个手法(大意):当一个类在构造函数里
new出一个绕不开的依赖(数据库连接、硬件接口、全局单例),你无法实例化它去测试——这时别急着重写,先看能不能造一个 seam:把依赖变成参数传进来,或者子类化后覆写那个获取依赖的方法。seam 的目标不是好设计,是 leverage(杠杆点)——一个能让你把这块代码抬进测试夹具的支点。 设计可以之后再收拾,先拿到支点再说。 - 预处理 seam 是 C/C++ 程序员的专属武器(大意):用宏和条件编译,让测试构建里的某个函数调用指向桩实现,生产构建里指向真实实现——同一份源码,两种行为,而源码本身一行没改。它也能在 Java 等语言里以"测试用构建脚本替换类路径"的形式变相存在。
- 链接 seam 更偏构建层(大意):把依赖推迟到链接期决定——测试时链接一个桩版本的库,生产时链接真库。它在遗留 C 系统里常常是把整段代码撬进测试夹具的唯一入口。
理解 seam 之后回头看 《设计模式》入门笔记 会有新感觉:工厂、策略、依赖注入这些模式,本质都是在代码里预先埋好的 seam。遗留代码的悲剧不是没有用模式,而是当年没人在乎缝留在哪里。
书里还给 seam 配了一个搭档概念——enabling point(使能点,大意):seam 是"行为可以改变的位置",enabling point 是"你在哪儿做出那个选择"。同一个 seam 可能有多个使能点:一个方法调用是 seam,而选择传哪个对象的使能点,可能在调用处、在构造函数里、在配置文件里。找 seam 的完整动作是两步:先定位"哪里能换行为",再决定"在哪儿换最顺手"。 很多改不动代码的挫败感,其实来自只做了第一步就动手——缝找到了,但选错了下针的位置。
四、characterization test:先固化行为,再动代码
有了 seam,接下来的问题是:测试写什么?这是全书反直觉的地方。
教科书说测试是"验证代码的正确性"。Feathers 说不对,在遗留代码场景里你要写的是 characterization test(特征测试/刻画测试)——它不验证代码"对不对",它只固化代码"现在是什么样"(大意)。
区别很关键。遗留代码里充满了"看起来是 bug 但业务已经依赖了十年"的行为。你没法站在上帝视角判定哪个行为是"正确"的——你能做的、必须做的,是先把当前行为原样钉在测试里,让任何意外的漂移立刻报警。正确性可以之后讨论,先保证"变化"这件事是可观测的。
这个定位和传统的回归测试也不一样。回归测试隐含着"我们曾经知道什么是对的,现在防止它退化";而面对一段没人说得清意图的遗留代码,你连"对"的基线都没有(大意)。characterization test 的诚实之处在于它承认这一点:我不知道这段代码做得对不对,但我知道它现在做成什么样,我要保证的是"我改完之后它还是这样"——或者当它必须变的时候,变的那一处是我有意为之的。 把"无意的变化"降为零,把"有意的变化"收窄到一处,这就是在遗留代码上干活的全部安全学。
一个常见疑问:特征测试要写多少才够?书中的回答务实得让人安心(大意):不需要覆盖整个系统,只需要覆盖你这次改动的影响面。用影响草图圈出"改动会波及哪些行为",把这个圈里的行为钉住就够了。圈外的代码这次没动,它的行为本来就变不了——真正的风险从来只在影响面之内。这也意味着特征测试是按需生长的:每改一次,网就密一格,从不做"覆盖率大跃进"。
完整工作法四步:
flowchart LR
A["① 选 seam<br/>找到改动点附近<br/>能下刀的接缝"] --> B["② 写观测测试<br/>断言传一个值<br/>跑出什么就钉住什么"]
B --> C["③ 改代码<br/>在测试看护下<br/>做最小修改"]
C --> D["④ 重构<br/>测试全绿的前提下<br/>顺手收拾结构"]
D -.->|"下次改动从这里继续"| A
第二步有个很实用的细节(书中大意):写特征测试时,断言先写一个你知道多半会失败的值,让测试跑一次,把真实输出抄进断言。这不是作弊——你的目的不是证明代码正确,而是用测试当探针,观测代码此刻的行为,然后把观测结果固化下来。测试从"裁判"暂时变成"记录仪",等它记录够了,才恢复裁判的身份。
第四步的顺序同样不能乱:先改出功能、绿了,再重构。重构和功能修改混在一次提交里,是遗留代码灾难的头号来源——测试红了,你分不清是功能改错了还是重构挪坏了。一次只动一种东西。
还有一个贯穿全书的概念,解释了为什么"解依赖"这件事值得花那么大代价(大意):把代码放进测试夹具,是为了同时获得两种能力——sensing(感知)和 separation(分离)。感知,是你能观测到这段代码的行为,改了之后立刻知道结果变没变;分离,是你能把这段代码从整个系统里拆出来单独跑,不必拖起数据库、硬件、整个应用。很多依赖解除技术表面上是"为了让测试能跑",实际上是在买这两样东西——sensing 买的是"看得见的胆子",separation 买的是"快"。一个要跑三分钟的测试你不会常跑,一个不常跑的测试等于没有;所以书中对测试速度的要求近乎苛刻(大意):单元测试必须快,慢测试会杀死整个反馈回路——哪怕为此要解掉更多依赖。
五、依赖解除技术族:八把常用的小刀
全书后半本是一个技术目录,解决同一个问题:类和方法被依赖焊死了,放不进测试夹具,怎么办。挑最常用、回报最快的八个讲,每个都是"一句话场景 + 一句话做法":
用之前先建立一个总认知(大意):这族技术全部是"保守手术"——它们中的大多数可以在没有测试看护的情况下做,因为每一步都机械到几乎不可能改变行为(提取一段代码、加一个参数、改一个可见性)。这不是说它们没有风险,而是说风险被压缩到了"编译器能替你检查"的量级。这是 Feathers 解开第一节那个两难困境的实际手法:用近乎无风险的微改动撬动 seam,seam 撬动测试,测试撬动之后一切真正的改动。
- Sprout Method(新生方法):要往一个没法测试的老方法里加逻辑。做法:新逻辑写成一个独立的新方法,老方法里只加一行调用——新方法可以独立测试,老方法只承担一行的风险。
- Sprout Class(新生类):要加的逻辑需要一堆新状态,塞不进老类。做法:新逻辑整体放进一个新类,老类只持有它的引用并在必要时调用——新类从第一天起就有测试。
- Wrap Method(包裹方法):要在老方法每次执行前后加点行为(记日志、发通知),但老方法不能动。做法:把老方法改名藏起来,用原方法名建一个新方法,先做新行为再调旧实现——调用方无感知。
- Wrap Class(包裹类):要给整个类的行为外加一层职责(装饰器思路)。做法:新类持有一个老类实例,对外暴露同样的接口,附加行为都在新类里——老类一行不改。
- Extract Interface(提取接口):依赖是个具体大类,实例化就要拖起半个系统。做法:把被调用的那几个方法提取成接口,测试里传一个假的实现——接缝造在类型层面。
- Extract and Override(提取并覆写):方法中间嵌着一个没法在测试里执行的调用(读硬件、发网络请求)。做法:把那一小段提取成受保护的方法,测试里子类化并覆写成空实现或桩——最小的手术换最大的可测性。
- Subclass and Override(子类化并覆写):整个类的构造函数或关键流程里有绕不开的硬依赖。做法:测试里建一个继承它的测试子类,覆写掉获取依赖的方法——不改动生产代码就撬开了行为。
- Parameterize Constructor / Method(参数化构造函数/方法):依赖是在内部
new出来的,外部完全够不着。做法:把new挪到构造参数或方法参数上,生产代码照常传真实对象,测试传假对象——这是把隐式依赖变显式的标准动作。
这八把刀的共同哲学值得记住:它们的直接目的都不是"让设计更好",而是"让代码能被测试"。Feathers 把这两件事严格分开(大意):解依赖是为了找到 leverage,一旦测试夹具立住了,设计的债可以在测试看护下慢慢还。反过来,没有测试就奔着"好设计"去大刀阔斧,那是高级版的 edit and pray。
这一族技术和 《整洁架构》笔记 里的依赖规则正好互为镜像:整洁架构讲的是"从第一天起就让依赖指向正确方向",这本书讲的是"方向已经错了十年,怎么一格一格扳回来"。前者是规划新城,后者是老城改造,工具箱不同,底层都是同一套 seam 思维。
实战中怎么选刀?有一个朴素的排序原则:能 Sprout 就不 Wrap,能 Wrap 就不动老代码内部,万不得已才进老代码里做 Extract / Parameterize。手术越小,没有测试看护的那一段就越短,风险就越可控。另外书里的技术目录比这八把刀长得多(大意)——还有把参数降为更基础类型的 Primitivize Parameter、把一组方法整体提到抽象基类的 Pull Up Feature 等等——症状不同处方不同,真到动手时值得按"症状"去翻原书后半本的对照目录,这正是它作为工具手册的用法。
六、巨型类:怎么拆一只上千行的怪兽
遗留系统里几乎必有一只巨型类——上千行、几十个方法、什么都管。Feathers 给的拆法(大意)分三步:
第一步:职责识别。 先别拆,先读。用笨办法给方法分组:把方法按"它们共同操作哪些字段、共同服务哪类调用方"聚类;给私有方法起更长的描述性名字,看名字里反复出现的主题;把类"为谁工作"的所有角色列出来。一个类里往往能数出三五个本可以独立存在的职责——这就是 《代码大全》 说的单一职责,只是这里你是逆着历史考古。
这一步有几个书中的实用技巧(大意):一是看私有方法——巨型类里的私有方法往往暗示着一个可以独立出去的职责,因为它们本来就是"类内部的内部";二是数调用方——如果类的不同方法群服务的是完全不同的调用方(报表模块用这三个方法,同步模块用那五个方法),调用方就是职责的天然分界线;三是问"这个类在为谁做决定"——决策点聚集的地方就是职责所在。这些技巧的共同点是:不依赖你对业务的先验理解,从代码自身的结构里把职责挖出来。
第二步:责任分组。 把识别出的职责画成草图:哪个方法群服务于哪个职责,哪些方法脚踩两只船。脚踩两只船的方法是拆分的难点,也是拆分的钥匙——它们往往揭示了两个职责之间真正的边界。
第三步:挤压边界。 不是一次性把大类切成五个小类(那是 edit and pray 的豪华版),而是每次只把一个职责里的一小部分挪出去——用 Sprout Class 或提取类的方式,挪一小块,测试看护下跑绿,再挪一小块。原类的边界被一圈一圈挤压收缩,像退潮一样。挤压策略的精髓是:任何时刻系统都是可运行的,任何一步都是可回退的。
flowchart LR
G["巨型类<br/>千行·几十个方法<br/>什么职责都有"] --> S1["识别职责<br/>方法分组<br/>数出 3~5 个隐藏角色"]
S1 --> S2["画责任草图<br/>找到脚踩两只船的方法<br/>那才是真正的边界"]
S2 --> S3["每次挪一小块<br/>Sprout Class / 提取类<br/>测试看护下跑绿"]
S3 --> S4["边界一圈圈收缩<br/>系统时刻可运行<br/>每步可回退"]
S4 -.->|"半年后怪兽小一半"| S3
配套的两个心法(大意):一是拆巨型类时别追求完美边界,先追求"可测试的边界"——边界丑一点没关系,测试网立住了边界可以随时再调;二是拆分过程中行为必须逐字节保持,这是 characterization test 的主场。
还有一个心理层面的提醒(书中大意):拆巨型类的进度要用"季度"来度量,不要用"冲刺"来度量。 一个上千行的类是十年业务沉积的结果,指望一个迭代拆完,结局往往是拆到一半需求来了,留下一个比原来更糟的半拆状态。正确的节奏是"每次顺手":每次因业务需求碰这个类,就顺手把一个职责的一小块挪出去。半年后回头看,怪兽已经小了一半——而没有任何一个迭代被"重构专项"耽误过。
七、改不动的时候:先理解,再下刀
书里专门处理了"代码我完全看不懂,但明天必须改"的绝境。在进工具箱之前先记住一个前提(大意):“改不动"往往不只是代码问题,还是反馈回路问题。 如果构建一次要半小时、启动一次要拖起三个中间件,那再好的 seam 和测试也救不了你——反馈慢到一定程度,人就会退回 edit and pray,因为"跑一遍验证"的成本高到不划算。所以书里把"让代码能快速进测试夹具跑起来"当作和"解依赖"同等重要的事:测试夹具的意义就在于绕开整个系统的启动成本,让你在秒级的时间里看到行为变化。工具箱如下(大意):
- 画草图,不写文档。 读不懂就画:effect sketch(影响草图)——从你要改的方法出发,画出它影响谁、谁影响它,一张图看清代码的"引力范围”;feature sketch(特征草图)——按职责给方法分组。草图的价值不在图本身,在于画的过程中你被迫建立了对代码的心智模型。
- 在代码上做标注。 临时把大方法里的段落抽出来起个名(不提交也行),或者用注释标出"这一段在干嘛"。理解会欺骗记忆,标注不会。
- scratch refactoring(草稿式重构):一条很反直觉的建议——纯粹为了理解代码,可以随手重构它(抽方法、改名、挪位置),改完扔掉,不提交。重构是理解代码最快的探针,比干读效率高得多;扔掉是为了不背负"改坏了"的风险。
- 删掉无用代码。 死代码是最大的噪音源。确认没被调用的代码就删——版本控制里它永远活着,工作区里它只会碍眼。留下来的每一行都应该是有行为意义的行。
- 新代码用 TDD。 遗留代码里没有测试不是你不写测试的理由。往遗留系统里加新逻辑时,能用 Sprout 就用 Sprout,新方法新类从第一行起就 TDD——让新长出来的部分永远是"非遗留"的,系统的测试覆盖率会随每次改动自然上涨。
- 小步快走。 每一步改动之间跑一次测试,绿了就存盘。步子小到"这一步不可能错"的程度,速度反而最快——因为省去了所有"改了一下午发现早就坏了、回滚到不知道哪个版本"的灾难恢复时间。
这套"先理解"的策略和 《代码大全》学习笔记 里的调试心法同源:理解是修改的前置条件,而理解本身是可以工程化的——草图、标注、scratch refactoring,都是把"读代码"从玄学变成动作的工具。
这个工具箱里我最想单独强调 scratch refactoring,因为它违反了多数人的直觉:花两小时重构一份代码然后全部扔掉,听起来是纯浪费。但它的账要这么算——你需要的从来不是"重构的结果",而是"重构过程中被迫看清的结构"。干读代码是被动接收,读三小时可能还是一团雾;动手重构是主动解剖,抽第三个方法的时候你就已经知道这段代码的骨架长什么样了。理解是这次任务的真实交付物之一,代码只是理解的副产品——既然是副产品,扔掉并不可惜,版本库里那份没动过的才是资产。这和写文档之前先画草图、做方案之前先写 spike 是同一个道理:用小成本的可丢弃物,换大决策的确定性。
八、全书心法:遗留代码是资产,恐惧来自看不见
读到最后会发现,这本技术书的底层是一篇心理学论文。
遗留代码不是耻辱,是资产。 它丑、它乱、它没人敢碰——但它在生产环境跑了十年,每一行丑陋的代码背后都可能固化着一次真实业务的教训、一个已经交付的需求、一个被客户依赖的行为。重写派看见的是债,Feathers 看见的是已经兑付过的价值。正确的心态不是"这破代码该推倒",而是"这份资产缺一张安全网,我来给它铺上"。
恐惧的来源是看不见行为。 程序员面对遗留代码的恐惧,本质上和走夜路的恐惧同构——不是路难走,是看不见。characterization test 做的事情,就是把行为从"隐式的、存在于某次生产事故记忆里的"变成"显式的、写在断言里的"。行为一旦可见,恐惧就有了边界,改动就有了底气。这和我在方法论里反复说的"写下来"是同构的:不可见的东西统治你,可见的东西服务你。
整本书的刀法,用庖丁解牛来对照最贴切:庖丁十九年解数千牛而刀刃若新发于硎,靠的不是力气,是"以无厚入有间"——找到牛体的天然纹理,顺着纹理下刀。seam 就是代码的纹理,characterization test 就是下刀前先摸清纹理的那只手。 对遗留代码使蛮力的人,三年换一把刀;会找 seam 的人,刀越用越快。
还有一层书里没有大声说、但全书处处都在暗示的心法:在遗留系统里,第一个写测试的人改变的不是代码,是团队的物理环境。 一张测试网一旦存在,后来者改代码的默认姿势就变了——从"祈祷"变成"跑一遍看看"。安全网和恐惧一样会传染,方向相反而已。所以不要等"团队有了测试文化"才开始写测试,顺序是反的:你写下的第一个 characterization test,就是测试文化的第一个细胞。这也是"资产"视角的推论——测试网是极少数只涨不跌的工程资产:它一旦固化住一个行为,就永远为之后所有的改动站岗,而且每新增一条断言,存量价值的保护范围就扩大一格。
flowchart TD
F["面对遗留代码的恐惧<br/>看不见行为,不敢改"] --> T["characterization test<br/>固化当前行为<br/>让不可见变可见"]
T --> S["seam<br/>找到下刀的纹理<br/>获得 leverage"]
S --> D["依赖解除技术族<br/>Sprout / Wrap / Extract<br/>Subclass / Parameterize"]
D --> R["在测试看护下<br/>改代码 + 挤压边界"]
R --> C["遗留代码逐步变成<br/>有测试覆盖的资产"]
C -.->|"恐惧被切成小份消化"| F
九、这本书值不值得读
把全书压成一张图,就是"定义 → 模型 → 工作法 → 工具箱"四层:
- 一个定义:遗留代码 = 没有测试的代码。它把"遗留"从时间属性换成工程属性,顺带解释了为什么新写的代码也会一夜之间变遗留。
- 一个模型:seam。不改代码就能改变行为的点,是一切依赖解除技术的解剖学基础。
- 一个工作法:characterization test 四步——选 seam、写观测测试、改代码、重构。把"修改遗留代码"从赌博变成流程。
- 一个工具箱:Sprout / Wrap / Extract / Subclass / Parameterize 一族依赖解除技术,按症状查表使用。
优点很明确:
- 问题定义得准。二十多年了,“遗留代码 = 没有测试的代码"依然是这个领域最锋利的一刀,没有之一。
- 技术可执行。后半本的症状—处方目录不是空谈原则,每一条都能当天上手——这在软件工程书里并不多见。
- 心态建设到位。它把"面对烂代码的羞耻和恐惧"也当成工程问题处理,给出的解法是"让行为可见”,而不是"你要勇敢"。
局限也要说清楚:成书于 2004 年,例子语言以 Java/C++/C# 为主,链接 seam、预处理 seam 这些章节对纯动态语言程序员参考价值有限;书中还没有依赖注入容器、mock 框架、容器化测试环境这些今天的标配,很多当年需要手工解的依赖,现在框架已经替你解了。但它的底层模型——seam、leverage、sensing 与 separation——不过时,因为依赖问题不过时。
推荐给两类人:正在维护没测试的老系统的工程师(直接读全书,后半本放案头当手册);写代码从不写测试、想建立测试习惯的工程师(重点读前半本,先理解"测试是安全网"这个定位再谈技术)。
阅读顺序上给一个实操建议:第一遍只读前半本,读到 characterization test 四步为止就停手去干活——找一个你正在维护的模块,完整地走一遍"找 seam → 写特征测试 → 在测试看护下改",走完一遍再回来翻后半本的技术目录。这本书的全部价值都在"用"里,纯读完不用,三个月后只剩"遗留代码 = 没测试的代码"这一句口号。
落地:编译成工程师的日常动作
把这本书的方法论编译成日常动作,不用立志,只要执行五条。这五条的共同点是都不需要任何立项、任何批准、任何"重构专项"——它们全部可以藏在日常的需求开发里完成,这正是 Feathers 式改良主义的精髓:不跟管理层要时间还债,而是让每次还债都搭在真实需求的便车上。
- 接遗留系统的第一件事不是改代码,是找 seam、写特征测试。 接到任何维护型任务,先把"我要改的地方能不能放进测试夹具"作为开工的门槛。放不进去就先解依赖——磨刀的时间不是浪费,是这笔任务里 ROI 最高的部分。
- 第二次祈祷式修改就停下来补安全网。 第一次 edit and pray 可以是形势所迫;同一个模块第二次还想凭感觉改,就是明确的信号——停下来,给这个模块写 characterization test,把网补上再走。恶习都是在"就这一次"里养成的。
- 新逻辑一律 Sprout,从第一行起可测。 往没测试的老代码里加功能时,默认走 Sprout Method / Sprout Class:老代码只加一行调用,新逻辑活在有测试的新方法新类里。让系统的"非遗留面积"随每次提交单调递增。
- 评审时多问一句:这次改动的防护网在哪? Code review 除了看 diff 本身,固定问一句"这个行为现在有测试钉住吗"。没有,就讨论要不要当场补——这句话问上一年,团队的测试文化就长出来了。
- 重构与改功能,永远不放在同一次提交。 一次只动一种东西:要么纯重构(行为逐字节不变,测试不该红),要么纯改功能(在已绿的测试看护下改)。混在一起出问题时分不清归因,等于主动放弃了测试网的保护。
一句话总结这本书:测试不是成本,是让你敢动代码的许可证;seam 不是技巧,是代码的解剖学。 读完它再看任何一个老系统,你看到的不再是"一坨烂代码",而是一张还没铺开的防护网,和网上一个个等着被找到的接缝。
最后再补一句这本书在我心里的真实位置:它不是"教你写测试"的书,而是教你在不信任代码的环境里重建信任的书——对代码的信任、对改动的信任、最终是对自己手艺的信任。这份信任的重建方法,二十年来没有变过:让行为可见,让变化可测,让每一步可回退。
相关阅读:《代码大全》学习笔记 · 《整洁架构》读书笔记 · 设计模式入门
这三篇和本文正好凑成一条线:设计模式教你"缝应该留在哪",整洁架构教你"依赖应该指向哪",代码大全教你"复杂度应该怎么管",而本文回答的是那个最狼狈也最常见的问题——前面这些都没做到,系统已经在手里了,明天还要改,怎么办。