第 16 章 修改现有的代码
第 1 章介绍了软件开发的迭代性和增量性。大型软件系统通过一系列阶段逐步演进,每个阶段都会增加新功能、修改现有模块,因此系统的设计也在不断变化。我们不可能一开始就构思出正确的设计;与最初的构想相比,演进过程中所作的变更对成熟系统的设计影响更大。前面的章节讨论了如何在初始设计和实现时减少复杂性,本章则讨论如何防止复杂性随着系统演进而逐渐积累。
本章概览修改现有代码时,怎样不让复杂性积累?
16.1 保持战略式的思考
第 3 章介绍了战术式编程与战略式编程的区别:前者主要追求尽快让代码工作,即使会增加复杂性;后者则把出色的系统设计放在首位。战术式方法很快就会使系统设计陷入混乱。要让系统易于维护和扩展,仅仅“能工作”还不够,必须优先考虑设计,从战略角度思考。修改现有代码时也不例外。
不幸的是,开发人员修改现有代码(例如修复缺陷或添加功能)时,通常不会从战略角度思考。典型的心态是:“要达到这次修改的目的,最少需要改动哪些代码?”有时,他们觉得这样做合理,是因为对要修改的代码没有把握,担心改动越大,风险越大,会引入新的缺陷。但这就变成了战术式编程:每次最小改动都增加一些特殊情况、依赖关系或其他形式的复杂性,让设计变差一点。系统每演进一步,问题就多积累一些。
要保持系统设计整洁,修改现有代码时就必须采用战略式方法。理想情况下,每次修改完成后,系统结构都应如同一开始设计时就考虑了这次变更。 为此,必须克制匆忙修补的冲动,重新考虑:面对这次变更,现有设计是否仍然最佳?如果不是,就重构系统,最终获得最佳设计。这样,每次修改都能改善系统设计。
这也体现了第 3.2 节介绍的投资思维:多花一点时间重构、改进设计,就能得到更整洁的系统,后续开发也会更快,从而收回重构投入的精力。即使当前变更不需要重构,也应留意代码中可以修复的设计缺陷。每次修改代码,都尽量找机会改进一点设计。如果没有让设计变好,你就很可能正在让它变坏。
如第 3 章所述,投资思维有时会与商业软件开发的现实发生冲突。如果按“正确的方式”重构需要三个月,而快速但不够整洁的修补只需两个小时,你可能不得不选择后者,尤其是在期限紧迫时。又或者,重构会带来不兼容的变更,影响许多其他人员和团队,那么重构也可能不切实际。
尽管如此,仍应尽量避免这些妥协。问问自己:“在当前限制下,为了让系统设计保持整洁,我是否已经做到了最好?”也许有其他方案,只需几天就能完成,设计却几乎与花 3 个月重构一样整洁。又或者,现在没条件大规模重构,可以请负责人安排时间,在当前任务的截止日期之后再做。每个开发组织都应留出一小部分工作时间用于清理和重构;从长远看,这些投入一定值得。
16.2 维护注释:将注释保留在代码附近
修改现有代码时,一些原有注释很可能随之失效。开发人员又容易忘记同步更新,导致注释不再准确。过时的注释让读者失望;数量一多,读者甚至会开始怀疑所有注释。好在只要养成习惯、遵循一些规则,不必投入太多精力,也能让注释保持最新。本节及后续几节介绍一些具体技巧。
确保注释及时更新的最佳方法,是把它放在所描述的代码附近,让开发人员修改代码时能够看到。注释离相关代码越远,被正确更新的可能性就越小。例如,方法的接口注释最好放在代码文件中,紧靠方法体。修改方法时必然会接触这部分代码,因此开发人员很可能看到接口注释,并在需要时更新。
对于 C 和 C++ 这类代码文件与头文件分开的语言,也可以把接口注释放在 .h 文件中的方法声明旁边。但这样离实现代码较远,开发人员修改方法体时看不到注释,还要另行打开文件,找到注释再更新,增加了额外工作。有人会认为,接口注释应该放在头文件中,让使用者不看实现代码就能了解如何使用这个抽象。然而,使用者应该既不必读实现代码,也不必读头文件,而是查阅 Doxygen 或 Javadoc 等工具生成的文档。此外,许多 IDE 会自动提取并展示文档,例如输入方法名称时显示相应说明。既然有这些工具,文档就应放在最方便开发人员修改代码时维护的位置。
编写实现注释时,不要把整个方法的注释都堆在开头,而应拆开,把每段注释尽可能下移到能涵盖其所述全部代码的最小范围内。例如,方法有三个主要阶段时,不要在开头用一大段注释详述所有阶段,而应为每个阶段单独写注释,放在该阶段第一行代码的正上方。不过,在方法实现的开头概述整体策略也可能有帮助,例如:
// We proceed in three phases:
// Phase 1: Find feasible candidates
// Phase 2: Assign each candidate a score
// Phase 3: Choose the best, and remove it
更多的细节可以在各个阶段代码的正上方记录。
通常,注释离所描述的代码越远,就应该越抽象,这样可以降低注释因代码变更而失效的可能性。
译者图解注释应覆盖多大范围?
16.3 注释属于代码,而不是提交日志
修改代码时,一个常见错误是只在源代码仓库的提交消息中记录变更详情,却不在代码中说明。虽然以后可以翻阅仓库日志查看提交消息,但需要这些信息的开发人员未必知道要去查日志。即使想到了,找到相关记录也可能很费事。
编写提交消息时,问问自己:未来的开发人员是否还需要这些信息?如果需要,就应该在代码中记录。例如,某次变更是为了解决一个微妙的问题,而原因只写在提交消息中,后来的开发人员就可能撤销这次变更,却没有意识到这会重新引入缺陷。在提交消息中也保留一份说明当然可以,但最重要的是把它写进代码。这体现了一个原则:文档应放在开发人员最可能看到的地方,提交日志很少是这样的地方。
16.4 维护注释:避免重复
让注释保持最新的第二个方法是避免重复。文档一旦重复,开发人员就很难找到并更新所有副本。因此,每个设计决策应尽量只记录一次。如果多个位置受同一个决策影响,不要到处重复注释,而要把说明放在最容易找到的位置。例如,某个变量有难以理解的行为,影响多个使用它的位置,就可以在变量声明旁加以说明。开发人员读不懂使用该变量的代码时,自然会到声明处查看。
如果没有一个开发人员自然会去查找的位置,可以创建一个 designNotes 文件,如第 13.7 节所述;或者从现有位置中选出最合适的一处放置文档。其他地方只需用简短注释指向那里,例如:“查看 xyz 中的注释以理解下面的代码。”如果主要注释被移动或删除,引用失效就很容易被发现,因为开发人员在指定位置找不到它。他们可以查阅版本控制历史,弄清注释的去向,再更新引用。反过来,如果到处都是重复文档,而部分副本没有更新,开发人员可能根本不知道自己看到的是过时信息。
不要在另一个模块中重复记录某个模块的设计决策。例如,不要在方法调用前添加注释,以解释被调用方法中发生的事情。如果读者想知道,他们应该查看该方法的接口注释。好的开发工具通常会自动提供此信息,例如,如果你选择了方法的名称或将鼠标悬停在该方法的名称上,则将显示该方法的接口注释。尽量让开发人员容易找到合适的文档,但是不要通过重复文档来做到这一点。
如果信息已经记录在程序之外,就不要在程序内部重复记录,只需引用外部文档。 例如,编写实现 HTTP 协议的类时,无需在代码中再次描述 HTTP 协议。网上已有许多相关文档,只需添加简短注释,附上其中一份文档的 URL 即可。用户手册中已经说明的特性也是如此。假设程序实现了一组命令,每个命令由一个方法负责实现。如果用户手册已经描述了这些命令,就不必在代码中重复,只需在相应方法的接口注释中加上这样的简短说明:
// Implements the Foo command; see the user manual for details.
让读者能轻松找到理解代码所需的所有文档是很重要的,但这并不意味着你必须编写所有这些文档。
译者图解说明复制与引用,失效方式不同
16.5 维护注释:检查待提交的变更
还有一个让文档保持最新的好方法:提交到版本控制系统之前,花几分钟检查本次提交的所有变更,确认文档准确反映了每项变更。这样的提交前检查还可能发现其他问题,例如遗留的调试代码,或尚未完成的 TODO 项目。
16.6 更高层级的注释更易于维护
关于文档维护,最后还有一点:比代码更高层级、更抽象的注释,更容易维护。它们不描述代码细节,因此细微的代码变更不会影响它们,只有整体行为变化时才需要更新。当然,正如第 13 章所讨论的,有些注释确实需要详细而精确。但总的来说,最有用、并非简单重复代码的注释,也最容易维护。