编码
编程书籍:核心原则综合总结
Section titled “编程书籍:核心原则综合总结”书籍一:Refactoring: Improving the Design of Existing Code — Martin Fowler(1999年初版,2018年第二版)
Section titled “书籍一:Refactoring: Improving the Design of Existing Code — Martin Fowler(1999年初版,2018年第二版)”重构是在不改变代码外部行为的前提下,改善其内部结构的过程。Fowler 认为重构不是独立于编程之外的特殊活动——它是日常软件开发不可分割的一部分。其核心主张是:代码应该通过持续的、小步的、保持行为不变的结构调整,来保持可理解性、易修改性,并消除累积的设计债务。
本书的座右铭可以概括为:“任何傻瓜都能写出计算机能理解的代码。优秀的程序员写的是人类能理解的代码。“
主要原则与框架
Section titled “主要原则与框架”1. 重构的定义(名词与动词)
Section titled “1. 重构的定义(名词与动词)”Fowler 对这一双重定义非常精确:
- Refactoring(名词): 对软件内部结构所做的调整,目的是使其更容易理解、更易于修改,同时不改变其可观察行为。
- Refactoring(动词): 通过连续应用一系列重构手法来重新组织软件结构,且不改变其可观察行为。
关键约束是行为保持不变。每一个单独的步骤都不能破坏系统。这正是重构区别于重写或重新设计的地方。
2. 两顶帽子的隐喻
Section titled “2. 两顶帽子的隐喻”Fowler 引入了两顶帽子的隐喻:编程时,你在任意时刻要么戴着”添加功能”的帽子,要么戴着”重构”的帽子。添加功能时,不应改变现有代码结构;重构时,不应添加新功能。你会频繁切换帽子,但始终要清楚自己当前戴的是哪一顶。
3. 为什么要重构
Section titled “3. 为什么要重构”Fowler 列出了重构的四个主要理由:
- 重构改善软件设计。 如果不进行重构,程序的设计会随时间退化。当人们为了短期目标而在未充分理解设计的情况下修改代码时,代码就会失去结构。重构就像整理房间——把放错位置的东西归位。
- 重构使软件更容易理解。 代码的首要存在价值是让未来的程序员(包括未来的你自己)阅读和修改。重构帮助代码更清晰地表达其意图。
- 重构帮助发现 Bug。 当你理清代码结构时,你也理清了对代码的理解,那些隐藏在混乱中的 Bug 就会浮现出来。
- 重构帮助你更快地编程。 这是经济学上的论证。良好的内部质量让你能更快地添加新功能,因为你花更少的时间去理解代码和对抗设计。Fowler 称之为 Design Stamina Hypothesis(设计耐力假说):没有好的设计,你一开始会很快,但累积的混乱会急剧拖慢你的速度;通过重构,你可以持续保持开发速度。
4. 何时重构——三次法则
Section titled “4. 何时重构——三次法则”Fowler 建议:第一次做某件事时,直接做就好。第二次做类似的事时,你会对重复感到不适,但还是做了。第三次时,就该重构了。总结为:“事不过三,三则重构。”
他还描述了几种具体的重构时机:
- Preparatory refactoring(预备性重构)——为了更容易添加新功能而进行的重构。(“就好像我想往东走100英里,但与其穿过沼泽,我先往北走20英里到高速公路,然后以三倍的速度往东走100英里。”)
- Comprehension refactoring(理解性重构)——为了理解代码在做什么而进行的重构。当你看着代码需要费劲思考它在做什么时,重构它让代码直接告诉你。
- Litter-pickup refactoring(随手清理重构)——当你看到不够好但不在你当前路径上的东西时,如果改起来容易就做个小改善。让代码比你发现时更干净一点(Boy Scout Rule,童子军法则)。
- Long-term refactoring(长期重构)——某些较大的重构(替换一个库、重构一个主要组件)需要数周时间。Fowler 建议采用渐进方式:团队就方向达成一致,每个人在接触相关代码时朝目标迈出一小步。
5. 何时不应重构
Section titled “5. 何时不应重构”- 代码已经烂到需要完全重写的程度。
- 临近截止日期,重构可能使代码不稳定(不过 Fowler 指出,这通常恰恰说明应该更早重构)。
- 代码能正常工作,你永远不需要再碰它,也没人需要理解它。(这种情况很少见。)
6. 代码坏味道——完整目录
Section titled “6. 代码坏味道——完整目录”代码坏味道是代码结构可能存在问题的信号。它们是启发式判断,而非硬性规则。Fowler 的目录包括:
- Mysterious Name(神秘命名)——当你无法从名称推断出函数或变量的用途时。“当你想不出一个好名字时,这往往是更深层设计问题的信号。”
- Duplicated Code(重复代码)——相同的代码结构出现在多个地方。应当统一。
- Long Function(过长函数)——函数越长,越难理解。Fowler 强烈偏好短函数配好名字。“一个启发式规则是:每当我们觉得需要写注释来解释什么时,就写一个函数来代替。”
- Long Parameter List(过长参数列表)——参数太多的函数难以理解。通常意味着你需要一个参数对象,或应该查询另一个对象。
- Global Data(全局数据)——可以从代码库任何地方修改的数据。问题在于你无法推断谁在修改它。
- Mutable Data(可变数据)——修改数据可能在系统的遥远部分引发意外的 Bug。函数式编程对不可变性的强调有助于解决这个问题。
- Divergent Change(发散式变化)——一个模块经常因为不同的原因以不同的方式被修改。说明该模块承担了太多职责。
- Shotgun Surgery(散弹式修改)——与发散式变化相反:一个变更需要编辑许多不同的类。说明相关行为分散在太多地方。
- Feature Envy(依恋情结)——一个函数对另一个模块的数据比对自己的数据更感兴趣。它通常应该被移到它使用数据的那个模块中。
- Data Clumps(数据泥团)——经常一起出现的数据项组合(例如开始日期和结束日期)。应当提取为独立对象。
- Primitive Obsession(基本类型偏执)——过度使用基本数据类型(字符串、整数)而不是为金额、电话号码或范围等概念创建小对象。
- Repeated Switches(重复的 switch)——相同的 switch/case 语句出现在多个地方。通常意味着缺少多态。
- Loops(循环)——Fowler(在第二版中)建议管道操作(map、filter、reduce)通常可以替代循环,且更容易理解。
- Lazy Element(冗赘的元素)——一个类或函数做的事太少,不值得存在。移除这层间接。
- Speculative Generality(夸夸其谈通用性)——因为”我们将来可能需要”而添加的、当前未使用的机制。YAGNI——You Aren’t Gonna Need It(你不会需要它的)。
- Temporary Field(临时字段)——一个仅在特定情况下才被赋值的实例变量。令人困惑,因为你会期望一个对象需要它所有的变量。
- Message Chains(过长的消息链)——客户端向一个对象请求另一个对象,再请求下一个,如此往复(a.getB().getC().getD())。与导航结构紧密耦合。
- Middle Man(中间人)——一个几乎将所有事务都委托给另一个类的类。如果一半的方法都是委托,就移除这个中间人。
- Insider Trading(内幕交易)——模块之间以难以察觉和理解的方式在幕后交换数据。
- Large Class(过大的类)——一个试图做太多事情、积累了太多实例变量和方法的类。
- Alternative Classes with Different Interfaces(异曲同工的类)——做类似事情但接口不同的类。应当统一它们的接口。
- Data Class(纯数据类)——只有字段、getter 和 setter 但没有行为的类。说明操作这些数据的行为应该移入该类。
- Refused Bequest(被拒绝的遗赠)——子类继承了父类的方法和数据,但不使用或不需要其中一些。说明继承层次结构有问题。
- Comments(注释)——“当你觉得需要写注释时,先尝试重构代码,让注释变得多余。“注释本身并不坏,但它们通常表明代码本身不够清晰。解释为什么(而非是什么)的注释是合理的。
7. 重构目录——关键重构手法
Section titled “7. 重构目录——关键重构手法”本书的核心是一系列命名的重构手法目录,每个都有具体步骤和使用动机。最基本的包括:
组合方法:
- Extract Function(提炼函数)——将可以分组的代码片段提取为独立函数,用能说明其用途的名字命名。这是 Fowler 最重要的单个重构手法。“如果你需要花精力看一段代码才能搞清楚它在做什么,就应该把它提炼为一个函数,用’做什么’来命名。”
- Inline Function(内联函数)——反向操作:当函数体和函数名一样清晰时,移除函数,将函数体放回原位。
- Extract Variable(提炼变量)——为复杂表达式引入一个局部变量,赋予其有意义的名字。
- Inline Variable(内联变量)——当变量没有增加超出表达式本身的含义时,移除该变量。
搬移特性:
- Move Function(搬移函数)——将函数搬移到它更自然归属的模块(更接近它使用的数据)。
- Move Field(搬移字段)——将字段搬移到不同的类。
- Move Statements into Function / Move Statements to Callers(搬移语句到函数中/搬移语句到调用者)——调整函数的边界。
- Slide Statements(移动语句)——将相关的代码行移到相邻位置。
- Split Loop(拆分循环)——将做两件不同事情的循环拆分为两个循环(每个处理一个关注点),以便各自提炼为独立函数。
- Replace Loop with Pipeline(以管道取代循环)——用集合管道操作(filter、map 等)替代循环。
组织数据:
- Replace Temp with Query(以查询取代临时变量)——用方法调用替代临时变量,使其他方法也能访问同一个值。
- Split Variable(拆分变量)——如果一个变量被多次赋值(承担两个用途),将其拆分为独立变量。
- Replace Derived Variable with Query(以查询取代派生变量)——移除派生变量,按需计算该值。
- Encapsulate Variable(封装变量)——对于被广泛访问的数据,用 getter/setter 函数封装它。这给你一个清晰的监控变更和添加验证的切入点。
- Encapsulate Record(封装记录)——用一个隐藏数据、暴露行为的类来替代记录(数据结构)。
- Rename Variable(变量改名)——修改变量名以更好地传达其用途。
- Change Reference to Value / Change Value to Reference(将引用对象改为值对象/将值对象改为引用对象)——决定一个对象应被视为引用(共享、可变)还是值(不可变、可自由复制)。
简化条件逻辑:
- Decompose Conditional(分解条件表达式)——将条件及其各分支提炼为具有意图揭示名称的独立函数。
- Consolidate Conditional Expression(合并条件表达式)——当多个条件产生相同结果时,将它们合并。
- Replace Nested Conditional with Guard Clauses(以卫语句取代嵌套条件表达式)——用提前返回替代深层嵌套来处理特殊情况。
- Replace Conditional with Polymorphism(以多态取代条件表达式)——用多态方法分派替代 switch/case 或 if/else 链。
- Introduce Special Case (Null Object)(引入特例/空对象)——用具有默认行为的特例对象替代对特殊情况(如 null)的检查。
- Introduce Assertion(引入断言)——用断言语句使假设显式化。
重构 API:
- Separate Query from Modifier(将查询函数和修改函数分离)——返回值的函数不应同时具有副作用。
- Parameterize Function(函数参数化)——通过添加参数来合并相似的函数。
- Remove Flag Argument(移除标记参数)——用独立的显式函数替代布尔标记参数。
- Preserve Whole Object(保持对象完整)——当你从一个对象中取出多个值作为参数传递时,改为传递整个对象。
- Replace Function with Command / Replace Command with Function(以命令取代函数/以函数取代命令)——当你需要撤销、队列或复杂生命周期时,将函数封装在命令对象中。
处理继承:
- Pull Up Method / Push Down Method(函数上移/函数下移)——在继承层次中上移或下移方法。
- Pull Up Field / Push Down Field(字段上移/字段下移)——字段同理。
- Extract Superclass / Extract Subclass(提炼超类/提炼子类)——在层次结构中创建新级别。
- Collapse Hierarchy(折叠继承体系)——移除不必要的层级。
- Replace Subclass with Delegate(以委托取代子类)——用组合/委托替代继承。(Fowler 在第二版中特别强调这一点:“优先使用对象组合而非类继承。”)
- Replace Superclass with Delegate(以委托取代超类)——当继承被滥用,子类并非真正代表 is-a 关系时。
8. 重构、架构与 YAGNI
Section titled “8. 重构、架构与 YAGNI”Fowler 认为重构改变了我们思考架构的方式。不再试图一开始就把架构做对,而是先构建最简单的可行方案,随着需求变清晰再进行重构。这支持了演进式架构和 YAGNI 原则:你推迟决策直到需要时再做,因为你知道届时可以通过重构来调整。
9. 重构与测试
Section titled “9. 重构与测试”重构需要可靠的测试套件。没有测试,你无法验证你的修改是否保持了行为不变。Fowler 坚持:“在开始重构之前,确保你有一套可靠的测试。这些测试必须是自检的。” 他建议每次小的重构步骤后都运行测试。
10. 重构与性能
Section titled “10. 重构与性能”对重构的一个常见反对意见是它会损害性能。Fowler 的回答是:先写清晰的代码,然后做性能分析,最后只优化热点。他引用了 90/10 法则——大部分时间花在一小部分代码上。重构实际上使性能调优更容易,因为结构良好的代码更容易进行性能分析和优化。他提倡三步法:(1) 编写可调优的软件,(2) 用性能分析工具找到瓶颈,(3) 只优化那个瓶颈。
值得注意的示例与故事
Section titled “值得注意的示例与故事”-
The Video Store Example(视频商店示例)(第一版)/ Theatrical Players Example(戏剧演出示例)(第二版):Fowler 用一个详细的实例开篇。一个程序为视频租赁店(或剧院公司)的客户计算并打印账单。代码能运行但结构很差。在接下来的几页中,Fowler 连续应用一系列重构——Extract Function、Move Function、Replace Conditional with Polymorphism——逐步改造代码。每一步都很小且可测试。最终代码显著更清晰,添加新的输出格式(HTML 与纯文本)变得轻而易举,而在原始代码中这需要复制计算逻辑。
-
沼泽隐喻:“就好像我想往东走100英里,但与其穿过沼泽,我先往北走20英里到高速公路。“预备性重构看似绕路,但总体上节省了时间。
-
营地法则:让代码比你发现时更干净。小的改善随时间积累。
作者警告的常见陷阱
Section titled “作者警告的常见陷阱”- 没有测试就重构——极其危险;你没有安全网来捕捉回归错误。
- 将重构与功能添加混在一起——你会搞不清楚哪些改变了行为、哪些保持了行为。
- 大爆炸式重构——试图一次性重构所有东西。正确的做法是小步前进,每一步都保持系统可运行。
- 重构已发布的接口——当其他团队依赖你的 API 时,你不能随意重命名或删除。你必须保留旧接口作为新接口的转发层,并逐步废弃。
- 过度重构——重构你永远不会再修改的代码,或重构到一个尚不需要的模式(本质上是夸夸其谈通用性的变体)。
- 以重构为借口不做设计——重构并不消除前期思考的需要;它改变了二者的平衡。你仍然需要一个合理的起始设计。
书籍二:Test Driven Development: By Example — Kent Beck(2003年)
Section titled “书籍二:Test Driven Development: By Example — Kent Beck(2003年)”TDD 的核心论点看似简单:在没有先写一个失败的自动化测试之前,绝不编写任何生产代码。 Beck 主张这种纪律——先写测试——能产出更清晰的设计、更少的 Bug 和更大的程序员信心。TDD 的节奏是:
- Red(红)——编写一个会失败的测试(因为功能尚不存在)。
- Green(绿)——编写最简单的代码使测试通过——不多写一行。
- Refactor(重构)——在保持所有测试通过的同时,清理代码(和测试)。
Beck 更深层的论点是,TDD 既是技术工具也是心理工具,用于管理编程中的恐惧。“恐惧让你犹豫不决。恐惧让你不想沟通。恐惧让你回避反馈。恐惧让你暴躁。” TDD 给你勇气去做修改,因为你始终有一套全面的测试套件确保你没有破坏任何东西。
其信条是:“能运行的干净代码。” TDD 将两个关注点分离:先让它运行(Green),再让它干净(Refactor)。
主要原则与框架
Section titled “主要原则与框架”1. Red-Green-Refactor 循环
Section titled “1. Red-Green-Refactor 循环”这是 TDD 的基本心跳:
- Red:编写一个描述新行为的小测试。运行测试套件。新测试必须失败。如果它通过了,要么行为已经存在(你不需要写代码),要么测试写错了。
- Green:编写最少量的代码使测试通过。在这个阶段,写丑陋的、硬编码的甚至”有罪的”代码是明确允许的——甚至是被鼓励的。唯一的目标是绿条。
- Refactor:现在清理代码。消除测试和生产代码之间的重复。改善设计。运行测试确保一切仍然通过。
这个循环是快速的——以分钟计,而非小时。
2. Money Example(第一部分)
Section titled “2. Money Example(第一部分)”Beck 在大约17章中完成了一个多货币算术的例子。需求:一个报表系统需要处理多种货币的算术(例如 $5 + 10 CHF = $10,假设汇率为 2:1)。这个例子从头到尾展示了 TDD。
Money 示例的关键时刻:
- 从测试列表开始。 在写任何代码之前,Beck 写下他能想到的所有测试列表。这是指导开发的待办清单。随着测试被编写并通过,逐项划掉。
- Dollar multiplication(美元乘法):第一个测试检查
$5 * 2 = $10。Beck 写测试,看它失败,然后实现最简单的代码(最初硬编码结果),再逐步泛化。 - 消除重复驱动设计。 实现中硬编码的
amount = 5 * 2与测试中的5和2重复。消除这种重复迫使引入存储的amount字段和乘法操作。Beck 反复展示:测试与代码之间的重复是驱动泛化的引擎。 - Value Object pattern(值对象模式):Beck 将 Money 类作为值对象引入——不可变对象,相等性由值而非标识决定。
$5 == $5应为 true。这自然导向实现equals()和hashCode()。 - Currency handling(货币处理):引入 Franc 类。Dollar 和 Franc 最初是独立的类,存在重复代码。经过一系列步骤,Beck 提炼出公共的 Money 超类,消除子类,引入货币字符串字段。
- Expression metaphor(表达式隐喻):为处理
$5 + 10 CHF,Beck 引入了算术操作产生 Expression 对象(Sum 等)的概念,只有在用知道汇率的 Bank 调用reduce()时才解析为单一货币。这是一个强大的设计洞察,自然地从遵循 TDD 消除重复的纪律中浮现。
3. xUnit Example(第二部分)
Section titled “3. xUnit Example(第二部分)”在第二部分中,Beck 用 Python 的 TDD 构建了一个测试框架(xUnit)。这是一个自举练习——编写一个由自身测试的测试框架。它展示了:
- Test case as a class(测试用例作为类):每个测试是 TestCase 子类上的一个方法。
- setUp and tearDown(设置与清理):公共夹具的设置和清理。
- Test suite(测试套件):收集多个测试。
- Test result reporting(测试结果报告):统计运行和失败的测试数。
- The bootstrap problem(自举问题):如何测试一个测试框架?从最简单的断言(打印到控制台)开始,逐步构建。
4. TDD Patterns(第三部分)
Section titled “4. TDD Patterns(第三部分)”第三部分是实践 TDD 的模式和策略集合:
测试模式:
- Test List(测试列表)——开始之前,写下你需要的所有测试列表。想到新情况就添加。按列表工作。
- Test First(测试先行)——在代码之前编写测试。这不仅是测试技术;它是设计技术。先写测试迫使你在实现之前思考接口。
- Assert First(断言先行)——编写测试时,从写断言(期望结果)开始。然后反向填充设置和操作。这让你专注于测试实际检查的内容。
- Test Data(测试数据)——使用让测试易于阅读和理解的数据。优先使用小的、显而易见的数字(例如
$5,而不是$537.82),除非具体值很重要。 - Evident Data(显而易见的数据)——在测试中将期望值作为字面量包含,而不是计算值。如果测试写
assertEquals(10, result),读者可以直接看到期望值。在测试中包含输入和期望输出的关系,例如assertEquals(5 * 2, result)使意图一目了然。
Red Bar Patterns(红条模式——到达红色状态的策略):
- One Step Test(单步测试)——从列表中选一个你有信心能实现的测试。一次一个测试地增长系统,建立在已有基础之上。
- Starter Test(起步测试)——当你卡住时,选一个极其简单的测试来起步。即使测试一个空列表有零个元素也是有效的开始。
- Explanation Test(解释性测试)——用测试来向他人解释代码。“这是它应该如何工作的……”
- Learning Test(学习测试)——为第三方代码编写测试来验证你对其工作方式的理解。如果库发生变化,这些测试也可作为回归测试。
- Another Test(另一个测试)——如果在处理一个测试时想到了旁支想法,不要分心。把想法写到测试列表上,保持专注。
- Regression Test(回归测试)——当报告了一个 Bug 时,先写一个重现该 Bug 的测试,再修复它。测试防止 Bug 再次出现。
Green Bar Patterns(绿条模式——到达绿色状态的策略):
- Fake It (‘Til You Make It)(伪造直到成功)——返回常量或硬编码答案使测试通过,然后逐步用变量替换常量。这是合法的策略:它让你快速到达绿色状态,测试和代码之间的重复会驱动你去泛化。
- Obvious Implementation(显而易见的实现)——当你确切知道如何实现时,直接写出来。如果成功了,很好。如果测试意外失败,退回到 Fake It。
- Triangulation(三角测量)——当你不确定如何泛化时,写第二个测试来迫使泛化。如果
assertEquals(10, plus(5, 5))可以被伪造,就添加assertEquals(12, plus(5, 7))。现在你必须真正实现加法。Beck 称这是”最保守的”策略,建议仅在你真正不确定如何继续时使用。 - One to Many(从一到多)——实现需要处理集合的功能时,先让它对单个元素生效,再泛化到集合。
重构模式:
- Reconcile Differences(调和差异)——当你有两段相似的代码时,逐步使它们完全相同,然后统一。
- Isolate Change(隔离变化)——在修改代码之前,隔离需要更改的部分,这样你可以在无风险的情况下修改它。
- Migrate Data(迁移数据)——逐步改变数据表示:添加新表示,逐一转换使用处,移除旧表示。
5. “Clean Code That Works” 哲学
Section titled “5. “Clean Code That Works” 哲学”Beck 将软件开发的问题分解为两部分:
- 让它运行。
- 让它正确。
TDD 按顺序处理这两者。在 Green 阶段,任何能运行的代码都可以接受——即使是脏代码。在 Refactor 阶段,你清理代码而不用担心它是否能运行(测试保证了这一点)。通过分离这些关注点,每一个都变得更容易。
6. 勇气与信心的角色
Section titled “6. 勇气与信心的角色”Beck 将 TDD 定位为心理工具,与技术工具同等重要。测试的安全网给你勇气去:
- 大胆重构
- 添加功能而不怕破坏现有功能
- 尝试大胆的设计变更(如果失败,回退到上一个绿色状态即可)
他将此与在恐惧下编程做对比——开发者因为害怕触碰可运行的代码而添加丑陋的 workaround。
7. 测试隔离
Section titled “7. 测试隔离”每个测试应独立于其他测试。测试应该能以任何顺序运行。一个失败的测试不应导致其他测试失败。这种隔离防止级联失败,并使运行测试子集变得容易。
8. 重复的角色
Section titled “8. 重复的角色”重复是 TDD 中的头号敌人。Beck 认为消除重复是 TDD 中设计的主要驱动力。当你看到测试与代码之间的重复,或两段代码之间的重复时,消除它会导向更好的抽象。“TDD 是一种管理重复的方式。“
- 测试列表:维护一份要编写的测试书面列表。完成后划掉。想到新的就添加。
- 小步前进:如果你不确定,就走更小的步。如果你有信心,就走大步。根据你的信心水平调整步幅。
- 频繁运行测试:每次修改后都运行。最多每几分钟一次。
- 卡住就回退:如果你和代码搏斗太久且测试是红色的,考虑回退到上一个绿色状态并尝试不同的方法。
作者警告的常见陷阱
Section titled “作者警告的常见陷阱”- 跳过 Red 步骤——如果你在没有失败测试的情况下编写生产代码,你就失去了测试确实在测试某些东西的保证。你可能有一个无论如何都会通过的测试。
- 在 Green 步骤中写太多代码——诱惑是立即实现”正确的”解决方案。但这通常导致过于复杂的代码,甚至可能是不正确的。纪律是只写刚好够通过测试的代码。
- 跳过 Refactor 步骤——重构步骤是长期保持代码库整洁的关键。没有它,TDD 就退化为”带有不断增长的技术债务的测试先行开发”。
- 测试实现细节——测试应该测试行为,而非内部结构。如果你重构了内部实现,测试应该仍然通过。过度耦合的测试使重构变得痛苦,这违背了 TDD 的初衷。
- 一次写太多测试——如果你在让任何一个通过之前写了几个失败测试,你就失去了一次一个测试循环的专注和纪律。
- 把 TDD 当作测试技术——Beck 反复强调 TDD 是一种设计技术,只是碰巧产出测试作为副产品。测试很有价值,但主要好处是通过先思考接口再思考实现的纪律来获得更好的设计。
- 不维护测试列表——没有列表,你会搞不清还需要测试什么,并被旁支关注点分散注意力。
书籍三:Working Effectively with Legacy Code — Michael Feathers(2004年)
Section titled “书籍三:Working Effectively with Legacy Code — Michael Feathers(2004年)”Feathers 用一个单一的、具有挑战性的标准来定义遗留代码:“对我来说,遗留代码就是没有测试的代码。” 不管代码有多老、用什么语言写、看起来多丑。如果它有测试,你就能自信地修改它。如果没有,修改它就是危险的。
核心论点是:尽管遗留代码看起来多么无望,都存在系统的、有纪律的技术来为遗留代码添加测试,然后安全地改进它。这本书是程序员的生存指南,面向那些必须修改自己不完全理解且缺乏自动化测试的代码的人。
Feathers 承认了根本性的困境:“当我们修改代码时,我们应该有测试。要加入测试,我们往往需要修改代码。” 整本书就是在解决这个先有鸡还是先有蛋的问题。
主要原则与框架
Section titled “主要原则与框架”1. 遗留代码修改算法
Section titled “1. 遗留代码修改算法”Feathers 提出了一个五步算法,这是全书的骨干:
- 确定修改点——你需要在代码的哪个地方做修改?
- 找到测试点——你可以在哪里编写测试来验证修改点周围的行为?
- 打破依赖——什么依赖阻止你编写测试?使用书中的依赖打破技术使代码可测试。
- 编写测试——编写特征测试(characterization tests),描述代码的当前行为(不是预期行为——是实际行为)。
- 做出修改并重构——现在有了测试作为安全网,做出修改并改善设计。
2. 接缝(Seams)
Section titled “2. 接缝(Seams)”接缝的概念是本书最重要的单一概念。接缝是你可以在不编辑该位置代码的情况下改变程序行为的地方。
Feathers 精确定义:“接缝是你可以在不编辑该位置代码的情况下改变程序行为的地方。” 每个接缝都有一个启用点(enabling point)——你决定使用哪种行为的地方。
接缝的类型:
- Object Seam(对象接缝)——最常见也最有用。你可以通过替换不同的对象来替代对象的行为(通过多态)。启用点是你决定实例化哪个类的地方。例如:如果一个方法调用
new EmailSender(),你在测试中不容易替换 EmailSender。但如果它接受一个EmailSender参数,你就可以传入一个假对象。 - Preprocessing Seam(预处理接缝)——在有预处理器的语言中(如 C/C++),你可以用
#define或#include在编译时替换不同的代码。启用点是预处理器指令。 - Link Seam(链接接缝)——你可以通过链接不同的库或目标文件来替换行为。启用点是构建配置(makefile、classpath 等)。在 Java 中,你可以在 classpath 上使用不同的类;在 C 中,使用不同的
.o文件。
3. 特征测试(Characterization Tests)
Section titled “3. 特征测试(Characterization Tests)”与验证预期行为的典型测试不同,特征测试验证的是实际行为。你编写一个测试,运行它,看代码实际做了什么,然后将期望值设置为匹配该实际行为。过程如下:
- 编写一个调用代码的测试。
- 放一个你知道会失败的断言(例如
assertEquals(-1, result))。 - 运行测试。它失败了,告诉你实际结果(例如”expected -1 but was 42”)。
- 将断言改为实际值(
assertEquals(42, result))。 - 现在测试通过了,并记录了当前行为。
特征测试的目的不是验证正确性——而是锁定现有行为,以便你知道是否意外地改变了它。“当我们想要保持行为时需要的测试,就是我所说的特征测试。特征测试是描述一段代码实际行为的测试。“
4. 依赖问题
Section titled “4. 依赖问题”测试遗留代码的最大障碍是依赖。代码依赖于数据库、文件系统、网络服务、硬件、UI 框架和其他难以测试的东西。Feathers 识别了两个关键依赖问题:
- Sensing Problem(感知问题)——我们无法感知(观察)代码的效果,因为结果去了数据库、文件或我们在测试中无法轻松检查的其他系统。
- Actuation Problem(执行问题)[解读:Feathers 使用的术语是”separation”(分离)而非”actuation”] ——我们甚至无法在测试工具中实例化或运行代码,因为它依赖于我们无法设置的东西(整个应用服务器、特定的数据库状态等)。
依赖打破技术同时解决这两个问题。
5. Sprout 和 Wrap 技术
Section titled “5. Sprout 和 Wrap 技术”当你需要向遗留代码添加新功能时,Feathers 提供了两种主要策略:
Sprout Method(新芽方法):
- 将新功能编写为全新的方法。
- 为新方法编写测试(它没有遗留包袱)。
- 从现有遗留代码中调用新方法。
- 优点:新代码是经过测试的。旧代码除了调用之外没有改变。
- 你在旧的未测试代码旁边培育新的、经过测试的代码。
Sprout Class(新芽类):
- 当你甚至无法在测试工具中实例化现有类时,为新功能创建一个全新的类。
- 彻底测试新类。
- 从遗留代码中实例化并调用新类。
Wrap Method(包装方法):
- 将现有方法重命名为
doSomethingOriginal。创建一个使用原始名称的新方法,它调用doSomethingOriginal并同时调用你的新代码。 - 旧行为被保留;新行为被添加。
Wrap Class(包装类,Decorator 模式):
- 创建一个包装原始类的新类,委托现有方法,并添加新行为。
- 这本质上是将 Decorator 模式应用于遗留代码。
6. 依赖打破技术——目录
Section titled “6. 依赖打破技术——目录”本书最大的部分是一个打破依赖以使代码可测试的具体技术目录。关键技术包括:
- Extract Interface(提炼接口)——从具体类创建接口,以便在测试中替换假对象/模拟对象。这是最常用的技术之一。
- Extract and Override Call(提炼并覆盖调用)——将依赖调用提炼为独立方法,然后在测试子类中覆盖该方法以返回预设值。
- Extract and Override Factory Method(提炼并覆盖工厂方法)——当构造函数创建依赖时,将创建过程提炼为工厂方法并在测试中覆盖。
- Parameterize Constructor(参数化构造函数)——不在构造函数内部创建依赖,而是作为参数传入。生产环境的调用者创建真实依赖;测试传入假对象。
- Parameterize Method(参数化方法)——相同的思路应用于单个方法。
- Subclass and Override Method(子类化并覆盖方法)——创建一个测试子类,覆盖特定方法以打破依赖。这是面向对象遗留代码的主力技术。
- Adapt Parameter(适配参数)——当参数类型在测试中难以使用(例如 HttpServletRequest)时,创建一个薄包装接口和适配器。
- Break Out Method Object(提炼方法对象)——当一个方法太大且纠缠不清难以测试时,将其提炼为自己的类,该方法成为该类的主方法。现在你可以在测试中实例化新类,而不必拖上整个原始类。
- Encapsulate Global References(封装全局引用)——将全局变量或单例包装在一个类中,然后在测试中替换该类。
- Introduce Instance Delegator(引入实例委托者)——对于难以测试的静态方法,添加一个委托给静态方法的实例方法,然后在测试中覆盖该实例方法。
- Introduce Static Setter(引入静态 Setter)——对于单例,添加一个静态 setter 允许测试用假对象替换单例实例。
- Replace Global Reference with Getter(以 Getter 取代全局引用)——用可覆盖的 getter 方法替代对全局变量的直接访问。
- Pull Up Feature / Push Down Dependency(上移特性/下移依赖)——将你想测试的代码上移到超类,将难以测试的依赖下移到子类。测试超类。
- Lean on the Compiler(借助编译器)——做一个修改(例如改变方法签名),让编译器错误引导你找到所有需要更新的地方。这严格来说是安全技术,而非依赖打破技术。
- Skin and Wrap the API(封皮和包装 API)——当第三方 API 调用分散在代码中时,用你自己的接口包装它们。然后你可以在测试中伪造该接口。
- Supersede Instance Variable(替代实例变量)——当依赖在构造函数中创建且你无法修改构造函数时,添加一个方法允许在构造后替换实例变量(仅用于测试)。
7. “Edit and Pray” 与 “Cover and Modify” 两种方式
Section titled “7. “Edit and Pray” 与 “Cover and Modify” 两种方式”Feathers 对比了两种修改代码的方式:
- Edit and Pray(编辑后祈祷)——传统方式。仔细规划修改,执行修改,然后四处查看是否有东西坏了。这本质上是有风险的,且不可扩展。
- Cover and Modify(覆盖后修改)——为你即将修改的代码编写覆盖测试,然后自信地做出修改。测试会捕捉任何意外的行为变化。
8. 影响分析
Section titled “8. 影响分析”在编写测试之前,你需要理解一个修改可能影响什么。Feathers 称之为影响分析——追踪代码以找到可能受修改影响的所有地方。他建议绘制影响草图(简单图表,显示哪些变量和方法受修改影响)来可视化波及效应。
9. 汇聚点(Pinch Point)
Section titled “9. 汇聚点(Pinch Point)”汇聚点是影响草图中的自然收窄处——少量方法或对象调解了许多变更影响的地方。在汇聚点编写测试可以用最少的努力获得最大的覆盖。“汇聚点是影响草图中的收窄处,在那里对几个方法的测试可以检测到许多方法的变化。“
10. 处理巨型方法
Section titled “10. 处理巨型方法”Feathers 用大量篇幅讨论了非常大的方法(数百甚至数千行)。策略:
- Bulleted Method(要点式方法)——一个长方法,代码是一系列基本独立的块。你可以将每个块提炼为独立方法。
- Snarled Method(纠缠式方法)——一个长方法,所有内容都与复杂条件和共享变量交织在一起。困难得多。你可能需要引入感知变量(sensing variables)——仅为让测试观察中间状态而添加的临时变量。
- Scratch refactoring(草稿重构)——当你无法理解一个方法时,复制一份,对副本进行激进重构以理解其结构,然后扔掉副本(回退)。你获得的理解会留存,即使代码修改不会保留。“不要提交它。扔掉那些代码。那只是为了探索。“
11. 在没有测试的情况下理解代码
Section titled “11. 在没有测试的情况下理解代码”当面对你不理解且无法测试的代码时:
- Scratch refactoring(草稿重构)(如上所述)。
- 删除未使用的代码——死代码会迷惑人。如果版本控制系统有它,你可以找回来。
- 讲述系统的故事——用几句话向别人解释这个系统。你在描述哪些部分时感到困难?那些很可能就是设计最差的部分。
- Naked CRC (Class, Responsibility, Collaborator)(裸 CRC)——用索引卡来记录职责。在不看代码的情况下做这件事,捕捉你认为每个类应该做什么,然后与它实际做的进行对比。
12. 我无法将这个类放入测试工具中
Section titled “12. 我无法将这个类放入测试工具中”Feathers 用整整一章来讨论在测试中实例化困难类的实际问题。常见障碍:
- Irritating Parameter(恼人的参数)——构造函数需要一个在测试中难以创建的参数。解决方案:提炼接口,传入假对象。
- Hidden Dependency(隐藏的依赖)——构造函数在内部创建难以测试的对象。解决方案:参数化构造函数。
- Construction Blob(构造块)——构造函数与复杂的初始化纠缠在一起。解决方案:提炼并覆盖工厂方法,或替代实例变量。
- Irritating Global Dependency(恼人的全局依赖)——类依赖于单例或全局变量。解决方案:引入静态 setter 或参数化。
- Horrible Include Dependencies (C/C++)(恐怖的头文件依赖)——头文件依赖拉入了整个系统。解决方案:使用链接时替换或预处理接缝。
- Onion Parameter(洋葱参数)——你需要一个对象,它需要另一个对象,那个又需要另一个对象……深度嵌套的依赖。解决方案:在最外层提炼接口,在测试中对未使用的参数传 null(谨慎使用)。
- Aliased Parameter(别名参数)——参数类型是某个难以使用的东西的别名。解决方案:通过别名追溯找到接缝。
13. 我无法在测试工具中运行这个方法
Section titled “13. 我无法在测试工具中运行这个方法”类似地,特定方法可能难以测试:
- Hidden method(隐藏的方法)——你想测试的私有方法。选项:通过公有方法测试、将其设为公有(如果它有足够的行为值得自己的测试)、或将其移到另一个类中使其成为公有。
- Undetectable side effect(不可检测的副作用)——方法执行了工作但你无法观察结果。解决方案:将副作用提炼为你可以覆盖和感知的方法。
- Language-specific issues(语言相关问题)——Feathers 涵盖了 C、C++、Java 和其他语言的解决方案。
值得注意的示例、隐喻与故事
Section titled “值得注意的示例、隐喻与故事”-
手术隐喻:Feathers 将遗留代码的修改比作手术。你想做尽可能小的切口,进去,修复问题,然后出来。你要最小化感染(引入 Bug)的风险。测试是你的监视器,告诉你病人还活着。
-
童子军法则(应用于遗留代码):你不可能一次修复所有问题。但每次你接触代码时,让它变好一点——添加一个测试、打破一个依赖、提炼一个方法。随着时间推移,代码库会改善。
-
无人理解的系统:Feathers 描述了没有任何一个人理解全貌的系统。这对遗留系统来说是正常的。应对方式不是绝望而是纪律:用特征测试、影响分析和谨慎的依赖打破来逐步建立理解。
-
遗留代码工作的挫败感:Feathers 坦诚地谈到了情感上的困难。与遗留代码打交道令人沮丧、乏味且得不到感谢。但他认为这是软件领域最重要的工作之一,因为世界上大多数软件都是遗留代码。
实践技巧总结
Section titled “实践技巧总结”| 技术 | 适用场景 |
|---|---|
| Sprout Method/Class(新芽方法/类) | 向未测试代码添加新行为 |
| Wrap Method/Class(包装方法/类) | 在现有行为前后添加行为 |
| Extract Interface(提炼接口) | 为测试中的替换创建接缝 |
| Subclass and Override(子类化并覆盖) | 为测试打破特定依赖 |
| Parameterize Constructor/Method(参数化构造函数/方法) | 使依赖显式化且可替换 |
| Characterization Test(特征测试) | 在修改前锁定现有行为 |
| Scratch Refactoring(草稿重构) | 理解你从未见过的代码 |
| Effect Sketch(影响草图) | 可视化修改可能影响的范围 |
| Pinch Point Testing(汇聚点测试) | 高效获得最大测试覆盖 |
| Break Out Method Object(提炼方法对象) | 使巨型方法可测试 |
作者警告的常见陷阱
Section titled “作者警告的常见陷阱”- 试图一次测试所有东西——你无法为遗留系统追溯添加完整的测试覆盖。专注于你需要修改的代码。
- 不写测试就做修改——本书的全部要点是在修改之前把测试加上,即使在遗留代码中这更困难。
- 一次打破太多依赖——每次依赖打破都是对代码的修改。一次做一个,并验证没有破坏任何东西。
- 混淆特征测试与正确性测试——特征测试记录代码做了什么,而非应该做什么。如果代码有 Bug,特征测试会编码该 Bug。这是有意的——你可以稍后修复 Bug,作为单独的步骤,用单独的测试。
- 过度模拟——当你引入太多假对象和模拟对象时,测试会脱离现实。Feathers 倾向于在可能的情况下在稍高的层级测试(汇聚点)。
- 放弃——最危险的陷阱。遗留代码令人望而生畏,诱惑是直接”编辑后祈祷”。Feathers 认为有纪律的方法总是值得的,即使进展感觉很慢。
- 将”使其可测试”与”做出修改”混为一谈——这是两个独立的步骤。先让代码可测试(使用特征测试和依赖打破),然后做出行为修改。不要混在一起。
- 在修改代码之前不理解代码——影响分析和特征测试不是可选的。跳过它们会导致微妙的 Bug。
虽然每本书有不同的侧重点,但几个主题贯穿了所有三本书:
-
测试是所有改进的基础。 Fowler 要求在重构前有测试。Beck 在代码之前写测试。Feathers 在修改遗留代码之前写测试。三人都将自动化测试视为安全修改代码的不可商量的前提。
-
小步前进。 Fowler 的重构是小的、保持行为的转换。Beck 的 TDD 循环以分钟计。Feathers 提倡对遗留代码做尽可能小的切口。三人都拒绝大爆炸式的方法。
-
编程行为中的关注点分离。 Fowler 的两顶帽子(重构 vs. 添加功能)。Beck 的 Red-Green-Refactor 分离(让它运行 vs. 让它干净)。Feathers 将让代码可测试与修改行为分开。一次只做一件事的纪律。
-
重复是设计问题的根源。 Fowler 将重复代码列为主要坏味道。Beck 用消除重复作为 TDD 中设计的驱动力。Feathers 处理困扰遗留系统的累积重复。
-
代码首先是为人而非计算机存在的。 三位作者都将可读性、可理解性和沟通性置于聪明或性能之上。良好的结构服务于下一个阅读和修改代码的人。
-
演进式设计优于大规模前期设计。 Fowler 的重构实现了演进式架构。Beck 的 TDD 让设计从测试中浮现。Feathers 展示了如何将遗留代码逐步演进到更好的设计。三人都不主张扔掉一切从头开始。