【书摘】修改代码的艺术

六六三十六,数中有术,术中有数。阴阳燮理,机在其中。机不可设,设则不中。


本书教你如何扭转腐化,教你在面对一个错综复杂的、不透明的、令人费解的系统时如何慢慢地、逐步地将其变成一个简单的、有良好组织和设计的系统。

没有编写测试的代码是糟糕的代码,不管我们有多细心地去编写它们,不管它们有多漂亮、面向对象或封装良好,只要没有编写测试,我们实际上就不知道修改后的代码是变得更好了还是更糟了。反之,有了测试,我们就能够迅速、可验证地修改代码的行为。

良好的设计应当是所有开发者的追求,然而对于遗留代码来说,良好的设计只是我们不断逼近的目标。

修改软件

修改软件的四个主要起因:

  1. 添加新特性
  2. 修正 bug
  3. 改善设计
  4. 优化资源使用

行为对于软件来说是最重要的一样东西。软件的用户要依赖于软件的行为。用户喜欢我们添加行为(前提是新的行为确实是他们所需要的),然而如果我们改变或移除了他们原本所依赖的行为(引入 bug),那么他们就不会再相信我们。

保留既有行为不变是软件开发中最具挑战性的任务之一。即便是在改变主要特性时,通常也有很多行为是必须保留不变的。

带着反馈工作

单元测试是用于对付遗留代码的极其重要的组件之一。系统层面的回归测试地区很棒,然而相比之下,小巧而局部性的测试才是无价之宝,它们能够在进行改动的过程中不断给你反馈,是重构工作的安全性大大增强。

依赖性是软件开发中最为关键的问题之一。在处理遗留代码的过程中很大一部分工作都是围绕着『解除依赖性以便使改动变得更容易』这个目标来进行的。

接缝模型

接缝(seam),顾名思义,就是指程序中的一些特殊的点,在这些点上你无需作任何修改就可以达到改动程序行为的目的。

每个接缝都有一个激活点,在这些点上你可以决定使用哪种行为。

工具

重构:名词,对软件内部结构的一种调整,目的是在不改变软件的外在行为的前提下,提高其可理解性,降低其修改成本。

xUnit 测试框架的关键特性为:

  • 它允许程序员使用开发语言来编写测试
  • 所有测试互不干扰独立运行
  • 一组测试可以集合起来成为一个测试套件(suite),根据需要不断运行

时间紧迫,但必须修改

新生方法

当需要往一个系统中添加特性且这个特性可以用全新的代码来编写时,建议你将这些代码放在一个新的方法中,并在需要用到这个新功能的地方调用这一方法。

任何时候,只要你发现待添加的功能可以写成一块独立的代码,或者暂时还没法用测试来覆盖待修改方法时,我都建议你采用新生方法。这比直接往原方法中添加代码好多了。

优缺点

新生方法技术有优点也有缺点。先来看看缺点。当使用它时,效果上等于暂时放弃了原方法以及它所属的类,也就是暂时不打算将它们置于测试之下和改善它们了。突出的优点在于新旧代码被清楚地隔开。这样即使暂时没法将旧代码置于测试之下,至少还能单独去关注所要作的改动,并在新旧代码之间建立清晰的接口。你会看到所有被影响到的变量,更容易确定新的代码在上下文中是否是正确的。

新生类

两种情况下我们得使用『新生类(Sprout Class)』。第一种情况:所要进行的修改迫使你为某个类添加一个全新的职责。第二种情况:我们想要添加的只是一点小小的功能,可以将它放入一个现有的类中,但问题是我们无法将这个类放入测试用具。

优缺点

新生类技术的主要优点就在于,它让你在进行侵入性较强的修改时有更大的自信去继续开展自己的工作。主要缺点在于它可能会使系统中的概念复杂化。

外覆方法

当需要添加行为时,可以考虑使用不那么『纠缠』的方式。可以使用的技术之一就是新生方法,但还有一项技术有些时候也是很有用的,我把它称为『外覆方法(Wrap Method)』。

一个典型的运用方法是:创建一个与原方法同名的新方法,并在新方法中调用更名后的原方法。

外覆方法还有另一种运用形式,如果只是想增加一个尚未有任何人调用的新方法,就采用这一形式。

要想在添加新特性的同时引入接缝,外覆方法是极好的选择。它只有少数几个缺点。第一,你添加的新特性无法跟旧特性的逻辑『交融』在一起。它们要么在旧特性之前要么在之后完成。事实上这并非坏事,建议你尽量这么做。第二个缺点,也是更为实际的一个缺点就是,你得为原方法中的旧代码起一个新名字。

外覆类

外覆方法的类版本便是『外覆类(Wrap Class)』,两者概念几乎一模一样。如果需要往一个系统中添加行为,我们固然可以将该行为放到一个现有的方法中,但我们同样可以将它放到一个使用了该方法的类当中。

该技术在设计模式里面被称作装饰模式。

漫长的修改

随着代码量的增加,项目就会变得越来越难理解。于是人们也就需要花费越来越多的时间才能弄清应当修改什么。

还有一个非常普遍的因素会导致修改耗时的延长,这个因素就是时滞(lag time),是指从做出修改到得到反馈所经历的时间。

依赖倒置原则:如果你的代码依赖与一个接口,那么这个依赖一般来说是很次要的。除非这个接口发生改变,否则你的代码是无须改变的。

当为了解依赖而往设计中引入了额外的接口和包之后,重新构建整个系统的时间就会稍微变长一点。因为有更多的文件要去编译。但基于需要被重编译的文件而进行的局部重建的平均时耗反而大大缩短了。

添加特性

作者非常推崇 TDD,流程如下:

  1. 编写一个失败测试用例
  2. 让他通过编译
  3. 让测试通过
  4. 消除重复
  5. 重复上述步骤

修改时应当测试哪些方法

倘若你的代码结构良好,则其中的大多数方法的影响结构也会比较简单。实际上,衡量软件好坏的标准之一便是,看看该软件对外部世界的相当复杂的影响能否由代码内的一组相对简单得多的影响所构成。任何改动,只要能够使代码的影响结构图简单化,就能够使其更易理解和维护。

在画影响结构图的时候,你得确保找到了所考察的类的所有客户端,如果你的类有一个基类或派生类,那么得注意一下它们里面是不是还有没有被注意到的客户代码。

影响在代码中的传递有三种基本途径:

  1. 调用方法使用被调用函数的返回值
  2. 修改传参传进来的对象,且后者接下来会被使用到
  3. 修改后面会用到的静态或全局数据

不过有些语言中也有其他途径。例如,在面向切片(aspect-oriented)的语言中,程序员可以编写所谓的『切片』代码,后者能够影响系统中其他地方的代码行为。

修改时应该怎样写测试

特征测试描述了一块代码的实际行为。在编写特征测试的时候如果发现某些结果与我们所期望的不一致,最好弄清它。因为我们遇到的可能是个 bug。但这并不是说我们就不能把该测试放进测试套装中,而是说我们应该将它标记为可疑的,并搞清修正它会带来哪些影响。

当准备在遗留系统中使用一个方法之前,请查看一下是否已有针对它的测试。没有的话就自己写一个,始终保持这一习惯,你的测试就能起到信息传递媒介的作用。别人只要一看到你的测试就能够知道对于某方法他们应该期待什么而不该期待什么,视图使一个类变得可测试这以行为本身往往能够改善代码的质量。

在为代码分支编写测试时,应该考虑除了那个分支被执行之外是否还存在其他能令测试通过的条件。如果不确定的话,可以使用一个感知变量或调试器来确定你的测试是否恰好命中目标。

最有价值的特征测试覆盖某条特定的代码路径并检查这条路径上的每个转换。

棘手的库依赖问题

尽量避免在你的代码中到处出现对库的直接调用。你可能会觉得永远也不会需要去修改这些调用,但最终可能只是自欺欺人。

借助于语言特性来施加设计约束的库设计者们往往是犯了一个错误。他们忘记了根本的一条,那就是好的代码除了要能在产品环境中运行之外,还要能在测试环境中运行。然而针对产品环境而施加在代码上的约束则常常会导致代码在测试环境中寸步难行。

有时候使用编码惯例并不比使用某种限制性的语言特性差。你得为自己的测试考虑考虑。

导出都是 API 调用

从许多方面来讲,到处都是库调用的系统比系统完全自己编写的系统还难对付。其首要原因就是,对于这种系统你很难看出如何才能让代码的结构变得好起来。

剥离并外覆 API 在以下场合表现良好:

  • API 规模相对较小
  • 想要完全分离出对第三方库的依赖
  • 没有现成测试,而且你也没法去编写,因为你没法通过 API 来进行测试

基于职责的提取则在以下场合比较适合:

  • API 较为复杂
  • 手头有支持安全的方法提取的重构工具,或者你觉得不用工具也能安全地完成提取

毫无结构可言

一个开发周期很长的应用会越来越复杂臃肿。一开始的时候它或许还拥有设计良好的架构,然而几年之后,在进度的压力之下,其结构或许就复杂到没人能够真正理解的地步了。

一个残酷的事实是,架构师不是少数人所专有的,而必须是大家的,因为这个角色太重要了。有架构师固然是件好事,但要想发挥架构师的最大作用,关键还是要看团队的成员是否能够清楚架构师到底意味着什么,并能狗感到架构师是跟他们休戚相关的一个角色。每一个接触代码的人都应该了解架构,而其他每一个接触代码的人都应该能够从刚才那个人所学到的东西那儿获益。如果团队里的每个人都有共同的想法,那么整体的力量就会大大增强。

讲述系统故事

在跟团队共事的过程中,我常常会使用一种手法,叫做『讲述系统的故事』。要成功实施这一手法至少需要两个人。像唱双簧那样,一个人开始问,『该系统的架构是怎样的?』然后另一个人就回答,他应该尽量只使用两到三个概念就把系统的架构解释清楚。如果你就是那个负责解释的人,那么就假设另一方对该系统一无所知。你用寥寥数句就解释清楚系统的设计由哪些部分构成以及它们之间是如何进行交互的,你就清楚地解释了这个系统最为本质的东西。接下来再选第二重要的方面来讲述。就这样,一直到你们把有关这个系统的核心设计的所有重要的方面都说明白了为止。

处理大类

庞大的类有哪些问题呢?首先就是容易混淆。

单一职责原则(SRP):每个类应该仅承担一个职责 - 它在系统中的意图应当是单一的,且修改它的原因应该只有一个

探索式方法

  1. 方法分组:寻找相似的方法名。将一个类上的所有方法列出来(别忘了它们的访问权限),找出那些看起来是一伙的
  2. 观察隐藏方法:注意那些私有或受保护的方法。大量私有或受保护的方法往往意味着一个类内部有另一个类迫切地想要独立出来
  3. 寻找可以更改的决定:指已经作出的决定,比如代码中有什么地方(与数据库交互、与另一组对象交互,等等)采用了硬编码吗?你可以设想它们发生变化后的情况吗?
  4. 寻找内部关系:寻找成员变量和方法之间的关系。『这个变量只被这些方法使用吗?』
  5. 寻找主要职责:尝试仅用一句话来描述该类的职责
  6. 当所有方法都行不通时,作一点草稿式重构
  7. 关注当前工作

需要修改大量相同的代码

如果两个方法看上去大致相同,则可以抽取出它们之间的差异成分,通过这种做法,我们往往能够令它们变得完全一样,从而消除掉其中一个。

类名和方法名缩写是问题的来源之一。缩写风格一致的话倒还好,但总的来说我不喜欢这种做法。

当你感到绝望时

对付遗留系统的人们常常希望他们能去做全新的系统。从头开始构建一个系统固然有意思,但坦白地说,全新的系统也有它们自己的问题。

要想在对付遗留代码时保持积极向上的心态,关键是要找到动力。

解依赖技术

  • 接口应传达职责而非实现细节。这样的接口令代码易于阅读和维护
  • 安全第一,一旦测试到位,你便可以更有信心地进行侵入性的改动了
  • 定义补全
  • 封装全局引用
    • 如果若干全局变量总是被一起使用一起修改,则它们应属于同一个类
    • 命名一个类的时候,考虑最终会位于它里面的方法。当然我们应当给它起一个好名字,但并不一定是完美的。别忘了,你总是可以重命名它的
    • 你想到的类名可能已经被用掉了。这时候可以考虑重命名那些使用了该名字的实体,从而将该名字腾出来
    • 从引用一个简单的全局变量到引用一个类成员只是第一步。之后你还需要考虑是否应当使用引入静态设置方法或参数化构造函数,又或者参数化方法
    • 在使用封装全局引用手法时,从数据或小型方法开始着手。稍大一点的方法可以等测试到位之后再移至新类中
    • 要封装对全局自由函数的引用,只需创建一个接口类,然后从它派生出伪类及产品类。产品类中的代码什么都不用做,只需直接委托/调用相应的全局函数即可
  • 暴露静态方法
    • 在没有测试的情况下解依赖时,尽可能对方法进行签名保持。对整个方法进行剪切/复制可以降低引入错误的可能性
  • 提取并重写调用
  • 提取并重写工厂方法
    • 构造函数中固定了的初始化工作可能会给测试带来很大的麻烦
  • 提取并重写获取方法
    • 要对对象的生命周期格外小心,确保你释放测试用对象的方式跟产品代码释放产品用对象的方式是一致的
  • 实现提取
    • 命名是设计的关键部分,好的名字有助于人们理解系统,并令系统更容易对付。反之,糟糕的名字则会影响理解,并给你身后的程序员带来无尽烦恼
  • 接口提取
  • 引入实例委托
  • 引入静态设置方法
  • 连接替换
  • 参数化构造函数
  • 参数化方法
  • 朴素化参数
  • 特性提升
  • 依赖下推
  • 换函数为函数指针
  • 以获取方法替换全局引用
  • 子类化并重写方法
  • 替换实例变量
  • 模板重定义
  • 文本重定义