第1章介绍过,软件开发是一个迭代且渐进的过程。一个大型软件系统会经历一系列演化阶段,每个阶段都会增加新的能力并修改现有模块。这意味着系统设计始终处于不断演进之中。系统在最初阶段不可能就拥有完美的设计;一个成熟系统的设计,更多是由其演化过程中所做出的修改决定的,而不是由最初的设计构想决定的。前面的章节讨论了如何在最初的设计与实现阶段压缩复杂度;本章将讨论如何在系统不断演进的过程中,防止复杂度再次悄悄滋生。
第3章介绍了战术式编程(tactical programming)与战略式编程(strategic programming)之间的区别:在战术式编程中,首要目标是尽快让功能运行起来,即使这会带来额外的复杂度;而在战略式编程中,最重要的目标是构建优秀的系统设计。战术式方法很快就会导致系统设计变得混乱。如果你希望拥有一个易于维护和扩展的系统,那么“能运行”并不是一个足够高的标准;你必须优先考虑设计,并以战略性的方式思考问题。这一原则同样适用于修改现有代码时。
遗憾的是,当开发者进入现有代码库中修复 Bug 或增加新功能时,通常不会以战略性的方式思考。一个典型的思维模式是:“我能做出的、满足需求的最小改动是什么?”有时开发者会为这种做法辩护,因为他们对所修改的代码并不熟悉;他们担心较大的改动会带来更高的引入新 Bug 的风险。然而,这种思维最终会导致战术式编程。每一次这样的微小修改,都会引入一些特殊情况、依赖关系或其他形式的复杂度。结果就是,系统设计会变得稍微糟糕一点,而这些问题会随着系统的不断演进逐步累积。
如果你想保持系统设计的整洁,那么在修改现有代码时必须采取战略性的方式。理想情况下,当你完成一次修改后,系统应该呈现出这样一种结构:如果一开始就知道需要这个变更,那么你本来就会设计成这个样子。 为了实现这一目标,你必须抵制“快速修补”的诱惑。相反,应当思考:在考虑这项变更之后,当前的系统设计是否仍然是最佳方案?如果不是,就应当重构系统,使其最终达到尽可能优秀的设计。有了这种做法,系统设计会随着每一次修改而持续改善。
这也是第15页介绍的“投资思维(investment mindset)”的一个例子:如果你愿意额外投入一点时间来重构并改进系统设计,最终会得到一个更加整洁的系统。这会加快后续开发速度,你投入在重构上的成本也会得到回报。即使你当前的改动本身并不要求重构,你仍然应该留意那些能够顺手修复的设计缺陷。每当你修改代码时,都应尝试顺便让系统设计至少变得更好一点。如果你没有让设计变得更好,那么你很可能正在让它变得更糟。
正如第3章所讨论的那样,投资思维有时会与商业软件开发的现实产生冲突。如果按照“正确方式”进行重构需要三个月,而采用一个快速且粗糙的方案只需要两小时,那么你可能不得不选择后者,尤其是在截止日期非常紧张的时候。或者,如果重构会造成兼容性问题,并影响许多其他人员和团队,那么这种重构也许并不现实。
尽管如此,你仍然应该尽可能抵制这些妥协。问问自己:“在当前约束条件下,为了创造一个整洁的系统设计,我是否已经做到了最好?”也许存在一种替代方案,其效果几乎与三个月的重构一样好,却只需要几天时间就能完成;又或者,如果当前无法承担大规模重构的成本,可以争取在当前截止日期之后安排时间继续完成它。每个软件开发组织都应该规划将总工作量中的一小部分投入到清理和重构中,因为从长期来看,这些工作终将获得回报。
当你修改现有代码时,很有可能会使一些已有注释失效。修改代码时很容易忘记同步更新注释,从而导致注释内容不再准确。过时的注释会让读者感到困扰;如果这样的注释太多,读者甚至会开始不再信任所有注释。幸运的是,只要保持一点纪律性并遵循几个指导原则,就能够在不付出太大代价的情况下保持注释始终更新。本节以及后续部分将介绍一些具体技巧。
确保注释得到更新的最佳方法,就是将注释放在它所描述代码的附近。 这样开发者在修改代码时就能够看到这些注释。注释离其对应代码越远,它被正确更新的可能性就越低。例如,对于一个方法而言,接口注释最理想的位置是在代码文件中,紧挨着方法实现的位置。这样,任何对该方法的修改都会涉及这部分代码,开发者也更有可能看到这些接口注释,并在必要时更新它们。
在像 C 和 C++ 这样将代码文件与头文件分离的语言中,另一种做法是把接口注释放在头文件(.h)中的方法声明旁边。然而,这种方式实际上让注释离代码变得很远:开发者在修改方法实现时看不到这些注释,而且还需要额外打开另一个文件才能找到并更新它们。有人可能会认为接口注释应该放在头文件中,这样用户无需查看实现代码就能了解如何使用某个抽象。然而,用户本来就不应该通过阅读代码文件或头文件来获取这些信息;他们应该从由 Doxygen 或 Javadoc 等工具生成的文档中获取。此外,许多 IDE 也会自动提取并展示这些文档,例如在输入方法名称时显示其说明信息。既然有这些工具存在,那么文档就应该放在最方便开发者维护的位置——也就是代码所在的位置。
在编写实现注释(implementation comments)时,不要把整个方法的所有注释都堆放在方法开头。应该将注释分散开来,把每条注释尽量下移到能够覆盖其所描述代码的最小作用范围内。例如,如果一个方法包含三个主要阶段,不要只在方法开头写一条注释来描述所有阶段的细节。相反,应该为每个阶段分别编写注释,并将其放置在该阶段第一行代码的正上方。
(接上页)
// 整个实现分为三个阶段:
// 阶段1:寻找可行候选项
// 阶段2:为每个候选项分配评分
// 阶段3:选择最佳候选项,并将其移除
每个阶段的额外细节,则可以记录在对应阶段代码的正上方。
一般来说,注释离它所描述的代码越远,它就应该越抽象(这样可以降低代码修改导致注释失效的可能性)。
修改代码时,一个常见错误是:把关于此次修改的大量细节信息写进版本控制系统的提交信息(commit message)里,却没有把这些信息记录在代码中。
虽然将来可以通过浏览仓库历史记录来查看提交信息,但真正需要这些信息的开发者往往不会想到去翻阅提交历史。即使他们真的去查看日志,从中找到正确的那条提交记录也会十分费时。
在编写提交信息时,问问自己:未来的开发者是否会需要使用这些信息?如果答案是肯定的,那么应该把这些信息记录到代码中。
例如,某次提交信息中详细描述了一个促使代码修改的微妙问题。如果这些内容没有记录在代码里,那么以后某位开发者可能会撤销这项修改,却没有意识到自己重新引入了一个 Bug。
如果你希望同时在提交信息中保留这份说明,也完全没问题,但最重要的是先把它写进代码中。这体现了一个原则:文档应该放在开发者最有可能看到的地方;而提交日志通常并不是那个地方。
保持注释始终更新的第二个技巧,是避免重复。
如果文档内容被复制到了多个地方,那么开发者就更难找到并更新所有相关副本。相反,应当让每项设计决策只被记录一次。
如果某个设计决策影响了代码中的多个位置,不要在每个位置都重复记录同样的说明。应当找到一个最合适、最明显的位置来保存这份文档。
例如,假设某个复杂行为与一个变量有关,而这个变量又会在多个地方被使用。那么你可以把关于该行为的说明写在变量声明旁边。对于那些因为难以理解变量使用方式而查阅代码的开发者来说,这正是他们最有可能查看的位置。
如果不存在一个“显而易见”的位置来存放某份文档,那么可以按照第13.7节介绍的方法创建一个 designNotes 文件,并把文档放在那里。
或者,在所有可选位置中挑选一个最合适的地方作为主文档位置,并在其他相关位置添加简短说明,引导读者前往该中心位置。例如:
关于下面这段代码的解释,请参见 xyz 中的注释。
如果将来主注释被移动或删除,这种引用关系失效了,那么开发者很容易发现问题,因为他们在指定位置找不到相应注释;此时可以利用版本控制历史来查找发生了什么,并更新引用。
相反,如果文档被复制到多个地方,而其中部分副本没有同步更新,那么开发者将无法意识到自己正在阅读过时的信息。
不要在一个模块里重复记录另一个模块的设计决策。
例如,不要在某个方法调用之前写一大段注释来解释被调用方法内部会发生什么。如果读者想知道这些内容,他们应该查看该方法的接口注释。
优秀的开发工具通常会自动提供这些信息。例如,当开发者选中方法名或者将鼠标悬停在其上时,IDE 会显示该方法的接口文档。
应当努力让开发者容易找到正确的文档,但不要通过复制文档内容来实现这一目标。
如果某些信息已经在程序之外的地方有文档记录,那么不要再在程序内部重复记录;只需要引用外部文档即可。
例如,如果你编写了一个实现 HTTP 协议的类,就没有必要在代码中再详细解释 HTTP 协议。网上已经有大量相关文档;你只需要在代码中添加一条简短注释,并附上其中一个文档的网址即可。
另一个例子是:某个程序实现了一组命令,而每个命令都由一个方法负责实现。如果这些命令已经在用户手册中有详细说明,那么就没有必要在每个命令方法中再次重复这些说明。
相反,可以在每个命令方法的接口注释中加入类似下面这样的简短说明:
// 实现 Foo 命令;详细信息请参阅用户手册。
让读者能够方便地找到理解代码所需的全部文档非常重要,但这并不意味着你必须把所有文档都重新写进代码中。
确保文档保持最新的一个有效方法,是在将修改提交到版本控制系统之前,花几分钟时间检查此次提交的所有变更内容(diff)。
确认每一处代码修改都已经正确反映在相应文档中。
这种提交前检查不仅能够帮助维护文档,还能发现其他问题,例如:
关于维护文档,最后再补充一个观点:如果注释描述的是更高层次、更抽象的内容,而不是代码细节,那么它们通常更容易维护。这类注释不会反映代码实现层面的细节,因此不会受到微小代码修改的影响;只有整体行为发生变化时,它们才需要更新。
当然,正如第13章所讨论的那样,有些注释必须足够详细和精确。但是总体而言,最有价值的注释(那些不会只是简单重复代码内容的注释),往往也是最容易维护的注释。