Skip to content

16~20

第16章 修改现有的代码

关键结论

修改现有代码时采取战略性方法是保持系统设计清晰的关键。通过在每次修改中追求最佳可能的设计,而不仅仅是最快的解决方案,系统可以随着时间推移而改进而非恶化。同时,通过遵循将注释放在代码附近、避免重复、使用抽象描述等原则,可以确保文档与代码保持同步,持续提供价值。

虽然这种方法需要更多的短期投入,但它将带来长期收益,使系统更容易理解、维护和扩展。这是软件开发中投资思维的又一体现——当下的额外努力将在未来带来丰厚回报。

修改代码的最佳实践总结

实践 传统做法 战略性做法 优势
修改方法 寻找最小可行变更 考虑最佳系统设计,必要时重构 系统设计随修改改善而非恶化
时间投入 尽快完成当前任务 愿意投入额外时间改善设计 长期加速开发,减少技术债务
注释位置 随意放置,甚至只放在提交日志 紧靠相关代码 确保注释随代码更新
注释组织 集中放置 分散至相关代码处 更容易维护,更可能保持更新
注释详细度 详细描述实现 高级抽象,聚焦设计决策 不易因小变更而过时
文档查重 在多处重复相同信息 一处记录,其他处引用 避免文档不一致

系统设计的演化特性

软件开发是迭代和增量的过程。大型软件系统通过一系列演化阶段发展,每个阶段都会添加新功能并修改现有模块。这意味着系统的设计在不断演进,无法在一开始就完全确定正确的设计。成熟系统的设计更多地取决于系统演化过程中的修改,而非最初的构思。前面章节讨论了如何在初始设计和实现过程中降低复杂度,而本章讨论如何防止复杂度随着系统演化而蔓延。

保持战略性思维

1. 战略性方法 vs 战术性方法

当开发者修改现有代码时,常常不采用战略性思维。典型心态是"我能做出我需要的最小改变是什么?"有些开发者认为这样做是合理的,因为:

  • 他们对要修改的代码不够熟悉
  • 担心更大的修改会带来引入新bug的风险

然而,这种战术性方法最终导致:

  • 每次小改动都引入特殊情况、依赖或其他形式的复杂性
  • 系统设计逐渐恶化
  • 问题随着系统演进而累积

2. 战略性修改代码的方法

如果想维持系统的清晰设计,修改现有代码时必须采取战略性方法:

  • 理想目标:每次修改完成后,系统应该具有这样的结构
  • 抵制快速修复的诱惑:考虑当前系统设计是否仍然是最佳的
  • 必要时重构系统:以获得可能的最佳设计

通过这种方法,系统设计会随着每次修改而改善,而不是恶化。

3. 投资思维

这是投资思维的又一例证:花费额外时间重构和改善系统设计,最终将得到更清晰的系统,加快开发速度,收回重构投入的精力。即使特定修改不需要重构,也应注意可能改进的设计缺陷。

核心原则:每当修改代码,都应尝试至少稍微改善系统设计。如果你没有让设计变得更好,你可能正在让它变得更糟。

4. 实际与理想的平衡

投资思维有时与商业软件开发现实相冲突:

  • 如果"正确"重构需要三个月,而快速修复只需两小时,特别是在紧迫期限下,可能必须采取快速方法
  • 如果重构会造成影响多人和团队的不兼容性,重构可能不切实际

然而,应尽量避免这些妥协:

  • 寻找折中的方案:"几乎一样好"但时间更短的解决方案
  • 请求在当前截止日期后分配时间进行重构
  • 每个开发组织应计划将整体工作的一小部分用于清理和重构,长期来看将得到回报

维护注释的策略

代码变更常常会使现有注释失效。开发者修改代码时容易忘记更新注释,导致注释不再准确。不准确的注释令读者沮丧,过多不准确注释会使读者不信任所有注释。以下是几种保持注释更新的技巧

1. 将注释保留在代码附近

基本原则:注释应位于它们描述的代码附近,使开发者修改代码时能看到它们。注释与关联代码距离越远,正确更新的可能性越小。

最佳实践:

  • 方法接口注释应放在代码文件中,紧靠方法主体
  • 对于C/C++等语言,可以将接口注释放在.h文件中,但这会增加更新难度
  • 实现注释不应全部放在方法顶部,而应分散开来,推至包含相关代码的最窄范围
  • 一个好的方法是在方法顶部提供整体策略概述,具体细节放在各部分代码前
C
//  我们分三个阶段进行:
//  第1阶段:寻找可行候选
//  第2阶段:为每个候选分配得分
//  第3阶段:选择最佳者并移除它

// 第1阶段实现:寻找可行候选
... 代码 ...

// 第2阶段实现:为每个候选分配得分
... 代码 ...

通用规则:注释离描述的代码越远,应该越抽象(减少因代码更改而失效的可能性)。

2. 注释属于代码,而非提交日志

修改代码的常见错误是将详细信息放入源代码仓库的提交消息中,而不是记录在代码里。虽然提交消息可以通过扫描仓库日志浏览,但:

  • 需要信息的开发者不太可能想到查看仓库日志
  • 即使查看也难以找到正确的日志消息

关键问题:编写提交消息时,问自己开发者将来是否需要这些信息。如果是,应在代码中记录。

例如,描述导致代码变更的细微问题的提交消息,如果没有在代码中记录,未来开发者可能会撤销该变更而不知道他们重新引入了bug。

3. 避免注释重复

保持注释最新的另一技术是避免重复。文档重复会使开发者难以找到并更新所有相关副本。

最佳做法:

  • 每个设计决策只记录一次
  • 如果代码中多处受特定决策影响,找到一个最明显的单一位置放置文档
  • 例如,变量相关的复杂行为可记录在变量声明旁的注释中

当没有"明显"位置时:

  • 创建designNotes文件
  • 或选择最佳可用位置,在其他地方添加引用:

    Text Only
    // 关于下面代码的解释,请参见xyz中的注释
    
  • 这样即使主注释移动或删除,开发者至少能发现引用过时

4. 避免模块间重复文档

  • 不要在一个模块中记录另一个模块的设计决策
  • 不要在方法调用前添加解释被调用方法内部工作的注释
  • 利用开发工具自动提供相关文档(如悬停显示方法接口注释)

5. 利用外部文档

如果信息已在程序外记录,不要在程序内重复,只需引用外部文档:

Java
// 实现HTTP协议,详见:https://tools.ietf.org/html/rfc2616
Java
// 实现Foo命令,详细信息请参阅用户手册

6. 检查代码差异

确保文档保持更新的好方法是在提交前花几分钟检查所有更改,确保文档正确反映每个变更。这种预提交扫描还能发现其他问题,如意外保留的调试代码或未修复的TODO项。

7. 更高级的注释更易维护

更高级、更抽象的注释通常更易于维护,因为它们不反映代码细节,不会受到小的代码变更影响,只有整体行为变化才会影响这些注释。

第17章 一致性

关键结论

一致性是投资思维的又一例证。确保一致性需要一些额外工作:

  • 决定约定的工作
  • 创建自动检查器的工作
  • 寻找类似情况并在新代码中模仿的工作
  • 在代码审查中教育团队的工作

然而,这项投资的回报是代码变得更加明显。开发者将能够更快、更准确地理解代码行为,使他们能够更高效地工作,减少错误

通过一致性,我们不仅能提高当前代码的质量,还能为团队建立长期的工程文化基础,使系统能够健康地演进和扩展。

一致性的实践价值

一致性领域 不一致时的问题 一致带来的好处
命名 同一概念使用不同名称造成混淆 减少学习曲线,提高代码可读性
代码格式 风格混乱,难以阅读 提高可读性,减少低级错误
接口设计 每个接口需要单独学习 学习一次,应用多处
错误处理 需要记忆多种错误处理方式 统一处理模式,减少特殊情况
架构模式 同类问题使用不同解决方案 更快理解系统,减少实现错误

一致性的价值

一致性是降低系统复杂性并使其行为更加明显的强大工具。在一个一致的系统中:

  • 相似的事情以相似的方式完成
  • 不同的事情以不同的方式完成

一致性能够创造认知杠杆:一旦你学会了如何在一个地方完成某事,你就可以立即理解使用相同方法的其他地方。相反,如果系统没有以一致的方式实现,开发者必须分别学习每种情况,这将花费更多时间。

一致性的主要好处:

  1. 减少学习成本:开发者只需学习一次模式,就能应用到所有类似场景
  2. 减少错误:在一致的系统中,基于熟悉情况做出的假设更可能是安全的
  3. 提高开发速度:开发者可以更快速地工作,出错更少

一致性的应用范围

一致性可以应用于系统的多个层面:

1. 名称一致性

如第14章所述,以一致的方式使用名称有很多好处:

  • 相同概念使用相同名称
  • 不同概念使用不同名称
  • 保持术语的一致使用

2. 编码风格一致性

现代风格指南涵盖多种方面:

  • 缩进和空格使用
  • 花括号放置
  • 声明顺序
  • 命名规范
  • 注释格式
  • 语言特性的限制

一致的编码风格能使代码更易读,减少某些类型的错误。

3. 接口一致性

具有多个实现的接口是一致性的良好示例:

  • 一旦理解接口的一种实现,其他实现更易理解
  • 因为你已经知道它必须提供哪些功能
  • 接口成为理解系统行为的快捷方式

4. 设计模式一致性

设计模式是特定常见问题的普遍接受的解决方案,例如:

  • MVC(模型-视图-控制器)模式用于用户界面设计
  • 工厂模式用于对象创建
  • 观察者模式用于事件处理

使用现有设计模式的优势:

  • 实现更快速
  • 更可能正常工作
  • 代码对读者更明显

5. 不变量一致性

不变量是变量或结构的一个始终为真的属性:

  • 例如,确保文本数据结构中每行都以换行符结束
  • 不变量减少了代码中必须考虑的特殊情况
  • 使推导代码行为更容易

确保一致性的策略

一致性很难维持,尤其是当许多人长时间共同开发一个项目时:

  • 一个小组可能不了解另一小组建立的约定
  • 新人不知道规则,可能无意中违反约定
  • 可能创建与现有约定冲突的新约定

1. 文档化约定

关键做法

  • 创建文档列出最重要的整体约定,如编码风格指南
  • 将文档放在开发者易于看到的地方(项目Wiki上的显眼位置)
  • 鼓励新加入的人阅读文档
  • 鼓励现有成员定期回顾

对于更局部的约定(如不变量),在代码中找到合适位置记录它们。

重要提示:如果不将约定写下来,其他人不太可能遵循它们。

2. 强制执行约定

仅有文档不足以确保一致性:

  • 开发人员难以记住所有约定
  • 需要自动化工具检查违规情况
  • 确保代码在通过检查器前不能提交到仓库

案例研究:行终止符问题

  • 问题:不同操作系统使用不同行终止符(Unix使用\n,Windows使用\r\n)
  • 后果:看起来每行都被修改,难以追踪真正的变更
  • 解决方案:创建提交前自动执行的脚本,检查并阻止含回车符的文件提交
  • 效果:立即消除问题,帮助培训新开发者

代码审查是另一个强制约定的机会:

  • 代码审查者越严格,团队学习约定越快
  • 代码质量也会越高

3. "入乡随俗"原则

最重要的约定是"在罗马做如罗马人":

  • 处理新文件时,观察现有代码的结构
  • 注意任何可能的约定并遵循它
  • 做设计决策时,考虑项目其他地方是否已有类似决策
  • 找到现有例子,在新代码中使用相同方法

4. 不要改变现有约定

抵制"改善"现有约定的冲动:

  • 有"更好的想法"不足以引入不一致
  • 一致性胜于不一致的价值几乎总是大于一种方法胜过另一种方法的价值

在引入不一致行为前,问自己两个问题:

  • 你是否有旧约定建立时不可用的重要新信息?
  • 新方法是否好到值得花时间更新所有旧用法?

如果两个回答都是"是",则可以进行升级,但应确保:

  • 完成后不留旧约定的痕迹
  • 意识到其他开发者可能不知道新约定,将来可能重新引入旧方法

重要观点:重新考虑已建立的约定很少是开发者时间的良好利用。

一致性的界限

一致性不仅意味着相似的事情应该以相似的方式完成,也意味着不同的事情应该以不同的方式完成。

警告:过度热衷于一致性可能导致问题

  • 不要强行将不同事物纳入相同方法
  • 不要对真正不同的事物使用相同变量名
  • 不要为不适合的任务强行使用现有设计模式

一致性只有在开发者确信"如果看起来像X,它确实是X"时才有价值。

第18章 代码应该是显而易见的

关键结论

编写显而易见的代码是降低软件复杂性的关键策略。通过良好的命名、一致性、合理的空白使用和适当的注释,可以大大提高代码的可读性。重要的是要记住,显而易见性是由读者决定的,因此接受反馈并主动寻求代码审查是至关重要的

最终,编写显而易见的代码是一种投资:虽然可能需要更多的前期思考和努力,但会为所有接触代码的人节省大量时间并减少错误,包括未来的你自己。"易于阅读比易于编写"的理念应指导我们的代码设计决策。

显而易见代码的评判标准

评判标准 显而易见代码的特性 不够显而易见代码的特性
阅读速度 可以快速阅读,一目了然 需要反复阅读和推敲
理解准确性 初步理解通常准确 容易产生误解
所需注释量 需要更少的注释 需要更多注释解释
代码结构 结构清晰,有逻辑分段 混乱,缺乏明确结构
名称选择 精确、有意义的名称 模糊、通用的名称
符合预期 遵循惯例和模式 有意外行为或格式

晦涩难懂与显而易见性

晦涩难懂是造成软件复杂性的两个主要原因之一。当系统的重要信息对新开发人员不够明显时,就会出现这种模糊性。解决晦涩问题的方法是编写显而易见的代码

1. 什么是显而易见的代码?

显而易见的代码意味着:

  • 读者可以快速阅读代码,无需深思熟虑
  • 读者对代码行为或含义的第一印象通常是正确的
  • 读者不需要花费大量时间或精力就能获取所需信息

相反,如果代码不够明显:

  • 读者必须花费大量时间和精力理解它
  • 降低效率
  • 增加误解和错误的可能性
  • 需要更多注释来解释

关键认识:"显而易见"是由读者决定的,而非代码作者。发现别人代码中的晦涩比发现自己代码中的问题更容易。因此确定代码是否显而易见的最佳方法是通过代码审查。如果阅读你代码的人说它不明显,那么它就是不明显的,无论对你自己来说多么清晰。

使代码更显而易见的技术

1. 选择好名字(如第14章所述)

精确而有意义的名称可以:

  • 阐明代码的行为
  • 减少对文档的需求
  • 避免读者为了推断含义而阅读整段代码

2. 保持一致性(如第17章所述)

如果相似的事情总是以相似的方式完成:

  • 读者可以识别已见过的模式
  • 立即得出安全结论
  • 无需详细分析代码

3. 明智地使用空白

代码的格式化方式对易读性有重大影响:

  • 参数文档中的空白

=== 不良示例(缺乏空白)

Text Only
```java
/**
* ... @param numThreads The number of threads that this manager should
* spin up in order to manage ongoing connections. The MessageManager
* spins up at least one thread for every open connection, so this
* should be at least equal to the number of connections you expect
* to be open at once. This should be a multiple of that number if
* you expect to send a lot of messages in a short amount of time.
* @param handler Used as a callback in order to handle incoming
* messages on this MessageManager's open connections. See
* {@code MessageHandler} and {@code handleMessage} for details.
*/
```

=== 良好示例(适当空白)

Text Only
```java
/**
* @param numThreads
*           The number of threads that this manager should spin up in
*           order to manage ongoing connections. The MessageManager spins
*           up at least one thread for every open connection, so this
*           should be at least equal to the number of connections you
*           expect to be open at once. This should be a multiple of that
*           number if you expect to send a lot of messages in a short
*           amount of time.
* @param handler
*           Used as a callback in order to handle incoming messages on
*           this MessageManager's open connections. See
*           {@code MessageHandler} and {@code handleMessage} for details.
*/
```
  • 代码块间的空行

空行可以用来分隔方法内的主要代码块:

C
void* Buffer::allocAux(size_t numBytes)
{
    // Round up the length to a multiple of 8 bytes, to ensure alignment.
    uint32_t numBytes32 = (downCast<uint32_t>(numBytes) + 7) & ~0x7;
    assert(numBytes32 != 0);

    // If there is enough memory at firstAvailable, use that. Work down
    // from the top, because this memory is guaranteed to be aligned
    // (memory at the bottom may have been used for variable-size chunks).
    if (availableLength >= numBytes32) {
        availableLength -= numBytes32;
        return firstAvailable + availableLength;
    }

    // Next, see if there is extra space at the end of the last chunk.
    if (extraAppendBytes >= numBytes32) {
        extraAppendBytes -= numBytes32;
        return lastChunk->data + lastChunk->length + extraAppendBytes;
    }

    // Must create a new space allocation; allocate space within it.
    uint32_t allocatedLength;
    firstAvailable = getNewAllocation(numBytes32, &allocatedLength);
    availableLength = allocatedLength - numBytes32;
    return firstAvailable + availableLength;
}

这种方法尤其有效,如果每个空行后的第一行是描述下一个代码块的注释。空行使注释更加显眼。

  • 语句内的空白
C
// 不含空格:
for(int pass=1;pass>=0&&!empty;pass--){

// 含适当空格:
for (int pass = 1; pass >= 0 && !empty; pass--) {

4. 适当使用注释

当无法避免非显而易见的代码时,使用注释来补充缺失的信息:

  • 站在读者的角度思考
  • 预测可能造成困惑的点
  • 提供消除困惑的信息

使代码不那么显而易见的因素

1. 事件驱动编程

事件驱动编程使控制流程难以跟踪:

  • 事件处理函数从不被直接调用,而是通过事件模块间接调用
  • 即使找到事件模块中的调用点,也难以确定具体会调用哪个函数
  • 这取决于运行时注册的处理程序

为弥补这种模糊性,在每个处理函数的接口注释中明确说明它何时被调用:

Java
/**
 * This method is invoked in the dispatch thread by a transport if a
 * transport-level error prevents an RPC from completing.
 */
void Transport::RpcNotifier::failed() {
    ...
}

2. 通用容器

许多语言提供通用容器类(如Java的Pair或C++的std::pair),但这些会导致代码不明显:

  • 分组元素具有通用名称,模糊了实际含义
  • 调用者必须使用如result.getKey()result.getValue()等不明确的方法
Java
// 不明显的代码
return new Pair<Integer, Boolean>(currentTerm, false);

更好的做法是定义专门的类或结构:

  • 为元素使用有意义的名称
  • 在声明中提供额外文档

这说明了一个通用原则:软件应该设计得易于阅读而非易于编写。通用容器对编码者方便,但会给所有后续读者带来困惑。

3. 声明和分配使用不同类型

Java
// 声明为List,但分配为ArrayList
private List<Message> incomingMessageList;
...
incomingMessageList = new ArrayList<Message>();

这合法但容易误导仅看到声明而未看到分配的读者。实际类型可能影响变量的使用方式(如性能、线程安全性等),最好使声明与分配的类型匹配。

4. 违反读者期望的代码

Java
public static void main(String[] args) {
    ...
    new RaftClient(myAddress, serverAddresses);
}

大多数应用程序在main返回时退出,读者可能会做此假设。但在这个例子中,RaftClient构造函数创建了额外线程,即使main线程结束应用也会继续运行。

这种行为应该:

  • 在RaftClient构造函数的接口注释中记录
  • 在main末尾添加简短注释,说明应用将在其他线程中继续执行

最显而易见的代码是符合读者预期的代码;如果不符合,则需要特别说明以避免混淆。

显而易见性的信息角度

从信息角度看显而易见性:如果代码不明显,通常意味着读者缺少关于代码的重要信息

要使代码显而易见,必须确保读者始终拥有理解所需的信息。可通过三种方式实现:

  1. 减少所需信息量

    • 使用抽象等设计技术
    • 消除特殊情况
    • 简化代码结构
  2. 利用读者已有的知识

    • 遵循编程惯例
    • 符合开发者的预期
    • 使用常见的设计模式
  3. 在代码中直接呈现重要信息

    • 使用好的命名
    • 添加战略性注释
    • 明智地格式化代码

第19章 软件发展趋势

关键结论

面对新软件开发范式的提案,应从复杂性角度质疑:该提案是否真正有助于降低大型软件系统的复杂性

许多提案表面上看起来不错,但深入研究会发现其中一些实际上使复杂性恶化而非改善。评估任何软件设计趋势或模式时,都应以其对降低复杂性的贡献作为关键标准。

软件趋势评估总结

趋势/模式 对复杂性的积极影响 潜在风险与陷阱 最佳实践建议
面向对象编程 提供信息隐藏机制
接口继承创造认知杠杆
实现继承减少代码重复
实现继承创建类间依赖
导致信息泄露
可能需要了解整个类层次结构
谨慎使用实现继承
考虑基于组合的替代方案
分离父类与子类状态
敏捷开发 增量迭代开发符合现实
允许基于经验调整设计
可能导致战术编程
过度关注功能而非抽象
复杂性快速积累
开发增量应为抽象而非功能
需要抽象时投入时间设计
单元测试 促进重构
给开发者重构信心
改善整体设计
更容易发现问题
无明显风险
(不测试风险更大)
建立良好测试套件
使重构成为可能
测试驱动开发 确保代码有测试覆盖
修复缺陷时有价值
过度关注功能而非设计
过于增量
促进战术编程
修复缺陷时先写测试
但不必所有开发都用TDD
设计模式 提供经过验证的解决方案
无需重新发明轮子
过度应用
强行套用不适合的模式
适合时使用
不要为用而用
定制方案可能更好
Getter/Setter 允许在未来添加功能而不改变接口 暴露实现细节
违反信息隐藏
增加接口复杂性
尽量避免暴露实现数据
不应成为默认实践

软件设计应以降低复杂性为核心目标,而不是盲目追随趋势。每种方法都有其适用场景,关键是理解其对复杂性的影响,在适当场景明智使用。

本章通过审视过去几十年来软件开发中的几种流行趋势和模式,来说明本书中讨论的设计原则。作者分析了这些趋势与复杂性管理的关系,并评估它们是否提供了对抗软件复杂性的杠杆作用。

面向对象编程与继承

面向对象编程(OOP)是过去30-40年中软件开发领域最重要的新思想之一,引入了类、继承、私有方法和实例变量等概念。如果谨慎使用,这些机制可以帮助创造更好的软件设计。

1. 两种形式的继承

a. 接口继承

  • 父类定义方法签名但不实现
  • 子类必须实现这些方法,不同子类可以有不同实现
  • 例如:I/O接口定义读写操作,磁盘文件和网络套接字分别实现

优势

  • 同一接口用于多种目的,提供了抵抗复杂性的杠杆作用
  • 解决一个问题的知识可用于解决其他问题
  • 从深度角度看:接口的不同实现越多,接口越深
  • 好的接口捕获所有底层实现的本质特征,同时避免实现间差异

b. 实现继承

  • 父类不仅定义方法签名,还提供默认实现
  • 子类可以继承父类实现或通过新方法覆盖

优势

  • 减少重复代码,避免在多个子类中复制相同实现
  • 减少第2章描述的变更放大问题

缺点

  • 在父类与子类间创建依赖关系
  • 父类的实例变量可能同时被父类和子类访问
  • 导致继承层次中类之间的信息泄露
  • 修改一个类时难以不考虑其他类
  • 可能需要完全了解整个类层次结构才能修改任何类

2. 实现继承的建议

  • 谨慎使用实现继承
  • 考虑基于组合的替代方案

    • 使用小型辅助类实现共享功能
    • 原始类建立在辅助类功能上,而非继承父类
  • 如必须使用实现继承,尝试分离父类与子类的状态

    • 某些实例变量完全由父类方法管理
    • 子类仅以只读方式或通过父类方法使用
    • 在类层次结构中应用信息隐藏原则

重要提示:面向对象机制可以辅助实现清晰设计,但本身不能保证好的设计。如果类很浅、接口复杂、或允许外部访问内部状态,仍会导致高复杂度。

敏捷开发

敏捷开发是1990年代末出现的软件开发方法,关注如何使开发更轻量化、灵活和渐进,于2001年正式定义。它主要关注软件开发的过程(团队组织、进度管理、单元测试、客户交互等),而非软件设计。

1. 敏捷开发的关键特性

增量式和迭代式开发

  • 软件系统通过一系列迭代开发
  • 每个迭代添加并评估新功能
  • 每个迭代包括设计、测试和客户反馈

这与本书提倡的增量方法相似:在项目开始时难以确定最佳设计,最好通过递增方式开发系统,每个增量添加新抽象并基于经验重构现有抽象。

2. 敏捷开发的风险

敏捷开发可能导致战术编程:

  • 倾向让开发者关注功能而非抽象
  • 鼓励推迟设计决策以尽快产出工作软件
  • 一些敏捷实践者认为不应立即实现通用机制,应先实现最小特定功能,后期需要时再重构为通用解决方案

这些观点在一定程度上合理,但与投资思维相悖,鼓励战术编程风格,可能导致复杂性快速积累。

3. 作者建议

递增式开发是好主意,但软件开发的增量应该是抽象而非功能。可以推迟考虑特定抽象直到功能需要它,但一旦需要,就应投入时间进行清晰设计并使其具有一定通用性。

单元测试

过去开发者很少编写测试,如有测试通常由独立QA团队完成。敏捷开发的一个原则是测试应与开发紧密集成,程序员应为自己代码编写测试,这一做法现已广泛采用。

1. 两类测试

a.单元测试

  • 通常由开发者编写
  • 小且集中:每个测试通常验证单个方法中的小段代码
  • 可独立运行,无需设置生产环境
  • 常与测试覆盖工具一起运行,确保代码全覆盖

b. 系统测试(集成测试)

  • 确保应用不同部分协同工作
  • 通常涉及在生产环境中运行整个应用
  • 更可能由QA或测试团队编写

2. 测试对软件设计的重要性

测试(尤其是单元测试)通过促进重构在软件设计中发挥重要作用:

  • 无测试套件的风险

    • 进行重大结构变更很危险
    • 难以发现缺陷
    • 问题可能直到部署后才被发现,此时修复成本高昂
    • 开发者避免重构,尽量减少代码变更
    • 复杂性积累,设计错误得不到纠正
  • 良好测试套件的优势

    • 开发者重构时更有信心
    • 测试套件能发现大多数引入的缺陷
    • 鼓励系统结构改进,带来更好设计
    • 单元测试提供高代码覆盖率,更可能发现问题

案例:Tcl脚本语言开发中,用字节码编译器替换解释器是影响核心引擎几乎每个部分的重大变更。良好的单元测试套件使开发团队能发现新引擎中的缺陷,alpha版本发布后仅出现一个缺陷。

测试驱动开发

测试驱动开发(TDD)是一种软件开发方法,程序员先编写单元测试,再编写代码:

  • 基于预期行为先为新类编写单元测试
  • 此时测试均不通过(因无代码实现)
  • 逐个编写代码使每个测试通过
  • 所有测试通过后,类就完成了

1. 作者的观点

作者虽然是单元测试的坚定支持者,但不推崇测试驱动开发:

  • 核心问题:TDD关注使特定功能工作,而非寻找最佳设计
  • 这是纯粹的战术编程,有诸多缺点
  • TDD过于增量:容易仅为通过测试而快速实现下一个功能
  • 没有明显时机进行设计,容易导致混乱结果

作者重申:开发单位应是抽象而非功能。发现需要抽象时,应一次性设计(至少提供相当全面的核心功能集),而非随时间零散创建。这更可能产生干净、各部分契合良好的设计。

2. 先写测试的合理场景

修复缺陷时先编写测试是有意义的:

  1. 在修复前,编写一个因该缺陷而失败的单元测试
  2. 修复缺陷并确保单元测试通过
  3. 这能确保真正修复了问题

如果先修复缺陷再写测试,新测试可能不会触发原始缺陷,无法验证问题是否真正解决。

设计模式

设计模式是解决特定类型问题(如迭代器或观察者)的常用方法,由《设计模式:可复用面向对象软件的基础》一书推广,现已广泛应用于面向对象软件开发。

1. 设计模式的价值

设计模式是设计的替代方案:不必从头设计新机制,而是应用已知设计模式:

  • 设计模式产生是因为能解决常见问题,提供公认的清晰解决方案
  • 如果设计模式在特定场景表现良好,通常很难想出更好的替代方案

2. 设计模式的风险

最大风险是过度应用

  • 不是每个问题都能用现有设计模式清晰解决
  • 当定制方法更简洁时,不要强行将问题套入设计模式
  • 使用设计模式不会自动改善软件系统,只有在设计模式适合时才会
  • 设计模式好并不意味着更多设计模式更好

Getter和Setter方法

在Java编程社区,getter和setter是流行的设计模式,与类的实例变量关联:

  • 命名形如getFoosetFoo(Foo是变量名)
  • getter返回变量当前值
  • setter修改变量值

1. Getter和Setter的争议

支持观点

  • 允许在获取和设置时执行额外功能
  • 可在变量更改时更新相关值
  • 可通知监听器变化
  • 可对值实施约束
  • 即使初始不需要这些功能,后续可在不改变接口的情况下添加

作者观点

  • 虽然必须暴露实例变量时使用getter/setter有意义,但更好的做法是不暴露实例变量
  • 暴露的实例变量意味着类实现的部分在外部可见,违反信息隐藏原则,增加接口复杂性
  • getter/setter是浅层方法(通常只有一行),使类接口混乱却提供很少功能
  • 应尽量避免getter/setter(或任何暴露实现数据的做法)

设计模式的风险:开发者认为模式好就尽可能多地使用,导致Java中getter/setter的过度使用。

第20章 设计性能

关键结论

优化阶段 关键策略 避免的陷阱
一般开发 使用"自然高效"且简洁的设计
了解哪些操作本质上昂贵
在不增加复杂性时选择高效方法
过度优化每个语句
忽略性能直至太迟
基于直觉进行修改
性能分析 创建微基准测试
深入测量而非仅顶层性能
识别关键性能瓶颈
肤浅的性能分析
没有基线测量
不验证修改效果
代码优化 首选"根本性"更改
围绕关键路径设计
从关键路径中移除特殊情况
增加无效复杂性
保留太多层抽象
忽略简洁性
验证与迭代 优化后重新测量
无改善则恢复更改
持续寻找简洁性
保留未提供速度提升的复杂设计
满足于小幅改进
停止寻找更简洁方案

性能优化不应该与代码质量改进相对立,而应该相辅相成。通过围绕关键路径重新设计,常常能同时实现更简洁的代码和更好的性能,这是软件设计的双赢局面。

性能与设计的平衡

到目前为止,本书的软件设计讨论主要集中在复杂性上,目标是让软件尽可能简单易懂。但对于需要高性能的系统,性能考虑应如何影响设计过程?本章讨论如何在不牺牲简洁设计的前提下实现高性能。

核心观点:简单性不仅能改善系统设计,通常也能使系统运行更快。清晰的设计和高性能是兼容的,而非相互矛盾的目标。

如何思考性能

1. 性能优化的平衡

在正常开发过程中应该多关注性能?这存在两个极端:

  • 过度优化每个语句

    • 会减慢开发速度
    • 引入不必要的复杂性
    • 许多"优化"实际上不会提升性能
  • 完全忽略性能问题

    • 导致整个代码库充满低效率问题
    • 系统可能比必要的慢5-10倍
    • 陷入"千刀万剐"的困境,单个改进难有显著影响

2. 最佳方法

采用介于两极端之间的方法,利用性能的基本知识选择"自然高效"且干净简单的设计方案。

关键是了解哪些操作本质上是昂贵的。以下是当今相对昂贵的操作示例:

  • 网络通信

    • 数据中心内的往返消息可能需要10-50微秒(数万条指令时间)
    • 广域网往返可能需要10-100毫秒
  • 二级存储I/O

    • 磁盘I/O操作通常需要5-10毫秒(数百万条指令时间)
    • 闪存存储需要10-100微秒
    • 新型非易失性存储器约为1微秒(约2000条指令时间)
  • 动态内存分配

    • C中的malloc,C++或Java中的new
    • 涉及分配、释放和垃圾回收的显著开销
  • 缓存未命中

    • 从DRAM获取数据到处理器缓存需要数百条指令时间
    • 在许多程序中,整体性能受缓存未命中影响巨大

3. 学习性能特性的方法

了解哪些操作昂贵的最佳方法是运行微基准测试(测量单个操作成本的小程序):

  • 创建微基准测试框架,使添加新测试变得容易
  • 收集众多微基准,了解系统各部分性能特征
  • 用于理解现有库性能和衡量新组件性能

4. 高效设计的决策原则

一旦了解什么昂贵什么便宜,就可以在可能的情况下选择廉价操作:

  • 当简单方法和高效方法同样简洁时,选择高效方法

    • 例如:对于需键值查找的大量对象,哈希表比有序映射快5-10倍
    • 例如:在C/C++中分配结构数组时,直接存储结构而非指针更高效
  • 当高效方法增加复杂性时,需权衡

    • 如果只增加少量复杂性且复杂性被隐藏,可能值得采用
    • 如果显著增加复杂性或使接口复杂化,可能先采用简单方法,后续优化
    • 如果有明确证据表明性能至关重要,可立即实施高效方法

实例:RAMCloud项目优先考虑低延迟。虽然增加复杂性,但决定使用特殊网络硬件绕过内核,因为之前测量表明基于内核的网络无法满足需求。解决这一关键问题后,系统其他部分设计变得更简单。

修改前的测量原则

当系统仍然太慢,即使已按上述方式设计,不要急于根据直觉进行性能调整:

关键警告:程序员对性能的直觉通常不可靠,即使是有经验的开发者也是如此。

1. 正确的优化步骤

a. 先测量现有行为,有两个目的:

  • 识别性能调整最有影响的地方
  • 提供基线,确保更改后性能确实提升

b. 深入分析而非仅测量顶层性能:

  • 详细识别影响整体性能的因素
  • 找出系统当前花费大量时间的少数特定位置
  • 为这些位置准备改进方案

c. 验证改进效果

  • 更改后重新测量,确保性能实际改善
  • 如果更改未产生明显差异,恢复更改(除非简化了系统)
  • 保留复杂性只有在提供显著速度提升时才有意义

围绕关键路径进行设计

当你已经分析性能并确定影响整体系统性能的慢代码,最佳解决方案是进行"根本性"更改,例如:

  • 引入缓存
  • 使用不同算法(如平衡树替代列表)
  • 架构级改变(如RAMCloud绕过内核进行网络通信)

1. 当无法进行根本性修复时

有时无法找到根本性解决方案,必须重新设计现有代码以提升性能。这时关键思想是围绕关键路径设计代码

a. 确定关键路径

  • 问自己:在常见情况下执行所需任务的最小代码量是什么?
  • 忽略现有代码结构和特殊情况
  • 想象将所有相关代码放在单个方法中
  • 只考虑关键路径所需的数据和最便利的数据结构
  • 这代表代码可能的最简单、最快状态——"理想"

b. 设计接近理想的新结构

  • 寻找尽可能接近理想同时保持清晰结构的设计
  • 应用本书前面章节的设计原则,同时保持理想代码基本完整
  • 根据需要添加少量额外代码,以实现干净抽象

c. 从关键路径中移除特殊情况

  • 代码缓慢常因需处理多种情况,代码结构化简化处理所有不同情况
  • 每个特殊情况以额外条件语句/方法调用形式向关键路径添加代码
  • 重新设计时,最小化必须检查的特殊情况数量
  • 理想情况:开头单个if语句检测所有特殊情况
  • 正常情况只需这一测试,然后无需额外特殊情况测试执行关键路径
  • 特殊情况处理可放在关键路径外,优化简洁性而非性能

案例研究:RAMCloud缓冲区优化

1. Buffer类背景

RAMCloud使用Buffer对象管理可变长度内存数组(如RPC请求和响应消息),设计目标是减少内存复制和动态分配开销:

  • Buffer存储看似线性的字节数组,实际可划分为多个不连续内存块
  • 每个块可以是外部的(调用方拥有)或内部的(Buffer拥有)
  • Buffer包含小型内置分配空间,用尽后创建额外分配
  • 优势:允许在不复制大量数据的情况下组装消息

2. 优化动机

随着Buffer在系统中使用越来越广泛(每个RPC至少创建四个Buffer),其性能对整体系统影响显著。团队决定优化Buffer类,重点关注最常见操作:为少量新数据分配内部块空间。

3. 原始代码问题

a. 多层检查特殊情况

  • 多次检查是否有足够空间
  • 路径上总计测试6种不同条件
  • 先分配新空间,再检查是否可与最后一块合并

b. 太多浅层

  • 关键路径有三层方法调用
  • 每个调用需额外时间,结果需调用者检查
  • 三个方法签名相同,提供基本相同抽象
  • 中间层几乎只是传递方法

4. 重构方法与结果

团队重构Buffer类,围绕性能关键路径设计:

  • 确定常见操作的关键路径,确定必须执行的最少代码
  • 围绕关键路径设计整个类
  • 应用本书设计原则简化类:消除浅层,创建更深内部抽象

具体改进:

  • 引入新实例变量extraAppendBytes,简化关键路径
  • 将整个路径处理在单一方法中
  • 使用单一测试排除所有特殊情况

重构后:

  • 代码更快且更易读,避免了浅层抽象
  • 重构后的类比原始版本小20%(1476行vs.1886行)
  • 性能提升约2倍:
    • 使用内部存储将1字节字符串附加到Buffer的时间从8.8ns降至4.75ns
    • 构建新Buffer、附加小块并销毁的时间从24ns降至12ns

性能与简洁设计的关系

本章的核心经验是:简洁设计与高性能是兼容的。Buffx`er类重写在简化设计并减少20%代码量的同时,将性能提高了2倍。

  • 复杂代码通常更慢,因为它执行多余或冗余工作
  • 简洁干净的代码往往足够快,减少性能优化需求
  • 需要优化的少数情况下,关键仍是简洁:
    • 找出对性能最重要的关键路径
    • 使它们尽可能简单