读前:这一章要回答什么

面对一个流行的做法(面向对象、敏捷、TDD、设计模式……),用什么标准判断它是在减少还是增加系统的复杂性?

前 18 章建立了一套词汇:深模块、信息泄漏、战术与战略编程、通用与专用、一目了然。这一章是一次应用练习:作者拿这套词汇去评价几十年来流行的几种做法,结论有褒有贬,有些相当尖锐(比如对 TDD)。

这一章最值得带走的不是某个具体结论,而是结尾那个方法:遇到任何新范式,都从复杂性的角度去质问它。所以读的时候,别急着同意或反对作者,而是看作者每一个判断引用了前面哪一条原则。如果你不同意某个结论,也试着用同样的词汇说出理由。本章的训练题里有不少"同意还是不同意"的判断题,就是为此准备的。

  1. 19.1 节区分了两种继承。注意作者对实现继承的评价是两面的:它减少了什么复杂性(用第 2 章的哪个术语),又制造了什么复杂性(用第 5 章的哪个红旗)?
  2. 19.2 节和 19.4 节都落到同一条原则上(原则 15)。读的时候想清楚"增量是功能"和"增量是抽象"的区别,作者对敏捷和 TDD 的批评都从这里来。
  3. 19.4 节的最后一段:作者在什么情况下赞成先写测试?给的理由是什么?这个理由本身值得记住。

核心观点

OOP 的机制不保证好设计

私有成员能帮助信息隐藏,但类如果浅、接口复杂、暴露内部状态,用了 OOP 照样复杂。

接口继承:一个接口,多种实现

实现越多,接口越深。为了容纳多种实现,接口必须抓住共同的本质、避开各自的差异,这正是抽象。

实现继承:要谨慎

它减少了重复(变更放大),却让父类和子类互相依赖;共享的成员变量造成信息泄漏。先考虑组合。

敏捷:增量是对的,功能导向有风险

迭代式开发和本书的主张一致;但只盯着功能、推迟设计,容易滑向战术编程。

增量应该是抽象

可以等到某个功能需要时再考虑一个抽象;一旦需要,就花时间把它设计干净、做得适度通用。

单元测试:坚决支持

好的测试套件让人敢重构;没有测试,人人只做最小改动,复杂性越积越多。

TDD:不支持(修 bug 除外)

它让注意力集中在"让下一个测试通过",没有明显的时机做设计。但修 bug 时先写一个能复现它的测试,是最好的做法。

设计模式与 getter/setter

模式适合时很难做得更好,最大的风险是滥用。getter/setter 是浅方法,最好的做法是根本不暴露成员变量。

一张记分卡

先看全貌。每一行都试着用前面学过的原则说出理由,而不是只记结论:

做法作者的判断对应前文的概念(本站整理)
接口继承有帮助深模块(第 4 章):同一个接口服务多种实现,学一次用多处
实现继承谨慎使用减少变更放大(第 2 章),但造成信息泄漏(第 5 章)和父子类之间的依赖
敏捷开发增量好,功能导向有风险战术与战略编程(第 3 章);通用模块更深(第 6 章)
单元测试强烈支持持续投资(第 3 章)、修改代码时保持战略(第 16 章):测试让重构变得安全
测试驱动开发不支持(修 bug 除外)战术编程(第 3 章);设计两次(第 11 章)需要的"设计时间"在 TDD 里没有位置
设计模式适合时好,怕滥用一致性(第 17 章);过犹不及(17.3 节)
getter/setter尽量避免浅模块(第 4 章);信息隐藏(第 5 章)

继承:两种继承,两种依赖结构

书中用"同一个 I/O 接口既能读写磁盘文件、也能读写网络套接字"说明接口继承的杠杆作用。用依赖关系画出来,两种继承的区别一目了然:

接口继承 BlobBackend 只有纯虚函数 + 契约 LocalDisk S3Backend InMemory 依赖只指向接口,实现之间互不相干 改 S3Backend 不用看 LocalDisk 实现越多,接口越"深" 实现继承(共享状态) Connection protected: rbuf_ state_ 默认实现 + 虚函数钩子 TlsConnection HttpConnection 读写 rbuf_ 改 state_ 父子之间双向依赖 改父类要检查所有子类; 写子类要读父类的实现
左边的箭头只有一个方向;右边的双向箭头来自共享的 protected 成员变量。本站自绘示意。

作者对实现继承的评价是两面的:没有它,同一段方法实现可能要在几个子类里各写一份,改一处要改几处(变更放大);有了它,父类的成员变量往往被父类和子类同时读写,这就是信息泄漏,最坏的情况是改任何一个类都要了解整棵继承树。作者的建议有两步:先看组合(用小的辅助类实现共享的功能)能不能达到同样效果;实在要用实现继承,就让父类完全管理自己的状态,子类只读或者只通过父类的方法访问。

原创例子一:从共享状态的继承树到组合

class Connection {
public:
    void onReadable();             // 读入 rbuf_,然后调用 parse()
    virtual ~Connection() = default;
protected:
    Buffer rbuf_;                  // 子类可以直接读写
    State  state_ = State::Open;   // 子类可以直接改
    virtual void parse() = 0;
    int fd_;
};

class TlsConnection : public Connection {
    SSL* ssl_;
protected:
    void parse() override {
        // 必须知道:父类在 rbuf_ 尾部留了 4 字节哨兵,不能消费
        auto plain = decrypt(ssl_, rbuf_.data(), rbuf_.size() - 4);
        rbuf_.consume(rbuf_.size() - 4);
        if (sslWantsClose(ssl_)) state_ = State::Closing;
        deliver(plain);
    }
};

第 17~19 行是信息泄漏:子类依赖父类缓冲区的内部约定(4 字节哨兵),还直接改父类的状态机。改 Connection 的人必须检查每一个子类;写子类的人必须读懂父类的实现。再加一层 HttpsConnection : TlsConnection,就要同时懂三个类。

// 把字节流转换成应用层消息。只看到输入的字节,不接触连接的状态。
class Codec {
public:
    virtual ~Codec() = default;
    // 消费 in 中能处理的前缀,返回消费的字节数;需要关闭时返回 Close。
    virtual DecodeResult decode(std::span<const std::byte> in, MessageSink& out) = 0;
};

class Connection final {
public:
    Connection(int fd, std::unique_ptr<Codec> codec);
    void onReadable();   // 读入、调用 codec_->decode、按结果消费和关闭
private:
    Buffer rbuf_;        // 只有 Connection 自己碰
    State  state_;
    std::unique_ptr<Codec> codec_;
    int fd_;
};

class TlsCodec : public Codec { /* 只管解密,不知道哨兵和状态机 */ };

缓冲区的约定和状态机都留在 Connection 内部;Codec 是一个窄接口(接口继承),TLS、HTTP、测试用的假编解码器都实现它。TLS 套 HTTP 可以写成一个 Codec 包着另一个,不需要继承树。

代价:Codec 的接口需要设计,DecodeResult 要能表达"消费了多少""要不要关闭",这比直接改 state_ 多想一步。但这一步就是在把父子类之间原来隐式的约定,变成一个写得出来的接口。

敏捷与原则 15:增量应该是抽象

作者赞同敏捷的核心:迭代式、增量式开发。第 1 章就说过,复杂系统在一开始不可能想清楚最好的设计。作者担心的是另一面:敏捷倾向于让开发者盯着功能,并鼓励推迟设计决定以尽快交付可运行的软件。书中特别点名反驳了一种敏捷建议:先实现一个最小的专用机制,等确认需要时再重构成通用的。作者认为这种论证有一定道理,但它反对的正是投资式的做法。

the increments of development should be abstractions, not features.— 第 19.2 节
需求:三个迭代分别要导出订单、用户、报表的 CSV 增量 = 功能 exportOrdersCsv() exportUsersCsv() exportReportCsv() 每次都"先写专用的、以后再抽象";转义规则写了三份,第三份漏了换行符。"以后"一直没来。 增量 = 抽象 CsvWriter第一次需要时设计好 用 CsvWriter 导出用户 用 CsvWriter 导出报表 第 1 迭代多花半天,把转义、表头、流式写出一次做对; 之后每个功能都只是"选哪些列"。增量仍然很小,只是每次加的是一个抽象。
注意下面一行并没有在第 1 迭代就做"支持 Excel、PDF 的导出框架":抽象是在功能需要它时才引入的,但一引入就做干净。本站自绘示意。

这条原则有两半,常被只记住一半。前一半是"可以推迟":在某个功能需要之前,完全不必考虑这个抽象,这和 YAGNI 并不冲突。后一半是"一旦需要就投资":按第 6 章的建议把它做得适度通用,而不是先写一个专用版本、指望以后重构。

单元测试与 TDD:作者为什么态度不同

作者支持单元测试的理由和第 16 章一脉相承:没有测试,重构就很危险,于是大家只做最小改动,复杂性越积越多,设计错误得不到纠正;有了测试,人们才敢做结构性的改进。作者还认为单元测试比系统测试覆盖得更全,更可能发现 bug。书中举了 Tcl 把解释器换成字节码编译器的经历:一次几乎触及整个核心引擎的改动,靠原有的单元测试套件,发布 alpha 版后只冒出了一个 bug。

但对 TDD(先写测试、再写刚好让测试通过的代码),作者的评价很尖锐:

This is tactical programming pure and simple, with all of its disadvantages.— 第 19.4 节

理由是:TDD 太增量了,在任何时候,最省事的都是"再糊一点代码让下一个测试通过",没有一个明显的时间点用来做设计。这和原则 15 是同一件事:一旦发现需要一个抽象,就应该一次设计完整(至少设计出一组足够全面的核心功能),而不是一点一点地长出来。

作者赞成先写测试的一个场合是修 bug:先写一个因为这个 bug 而失败的测试,再修,再看它通过。理由很实在:如果先修再写测试,你没法确定这个测试真的触发了 bug,它通过了也说明不了问题。

// 第 1 步:先写测试,确认它在修复前失败。
// bug:路径里有连续的斜杠时,splitPath 会产生空的路径分量。
TEST(SplitPath, CollapsesRepeatedSlashes) {
    EXPECT_EQ(splitPath("/a//b/"), (std::vector<std::string>{"a", "b"}));
}
// 第 2 步:运行,看到它失败(得到 {"a", "", "b"})。
// 第 3 步:修复 splitPath,再运行,看到它通过。

第 6 行是关键的一步:亲眼看到测试因为这个 bug而失败,才证明它真的覆盖了这个 bug。

设计模式与 getter/setter

作者对设计模式的态度是"适合就用":模式之所以流行,是因为它们干净地解决了常见问题;一个模式适合时,你很难想出更好的办法。最大的风险是滥用:把不适合的问题硬塞进某个模式。在 C++ 代码里,最常见的滥用形式是"只有一种实现的抽象工厂":

// 整个项目里只有一种压缩算法,以后也没有计划换
class CompressorFactory { public: virtual std::unique_ptr<Compressor> create() = 0; };
class ZstdCompressorFactory : public CompressorFactory { /* return make_unique<Zstd…>() */ };
class CompressionStrategyContext { /* 持有一个 Compressor,转发 compress() */ };

// 调用者要先认识三个类,才能压缩一块数据。对比:
std::vector<std::byte> compress(std::span<const std::byte> in);

每个类都是浅的,CompressionStrategyContext 还是一个透传类。用了三个模式名,没有隐藏任何东西。作者的话是:设计模式是好的,不等于设计模式越多越好。

getter/setter 是一个被模式化之后滥用的典型。支持它的理由是:以后可以在读写时顺带做别的事(更新相关的值、通知监听者、检查约束),而不用改接口。作者承认,如果必须暴露成员变量,用 getter/setter 比直接公开好;但更好的做法是根本不暴露,因为暴露的变量就是实现的一部分,违反信息隐藏,而 getter/setter 本身是只有一行的浅方法,只会把接口弄乱。

class RetryPolicy {
public:
    int  getMaxAttempts() const;   void setMaxAttempts(int);
    int  getBaseDelayMs() const;   void setBaseDelayMs(int);
    double getMultiplier() const;  void setMultiplier(double);
    int  getMaxDelayMs() const;    void setMaxDelayMs(int);
};

// 每个调用者自己算退避时间(三个调用者,三种写法):
int delay = std::min(p.getMaxDelayMs(),
    int(p.getBaseDelayMs() * std::pow(p.getMultiplier(), attempt)));

八个浅方法;真正的知识(怎么算退避)散落在调用者手里。

class RetryPolicy {
public:
    static RetryPolicy standard();            // 绝大多数调用者用这个
    // 第 attempt 次失败(从 1 开始)后应等待多久;
    // 超过最大重试次数时返回 std::nullopt,表示放弃。
    std::optional<std::chrono::milliseconds> nextDelay(int attempt) const;
};

if (auto d = policy.nextDelay(attempt)) sleepFor(*d);
else return lastError;

一个深方法替代了八个浅方法,退避公式和抖动只在一处。代价:需要"特殊策略"的少数调用者要通过构造参数定制,而不是随手 set。

边界与反方

TDD 的支持者会说:红—绿—重构这个循环里,第三步就是设计的时机,作者忽略了它。本站补充:这个争论的关键不在"测试先写还是后写",而在设计发生在什么粒度。作者反对的是"抽象一个测试一个测试地长出来";如果你在写第一个测试之前已经把接口想清楚(例如先写接口注释,见第 15 章),再用测试驱动实现,作者的批评就不太适用了。两种立场的完整对照见专题:APOSD 与 Clean Code。注意:作者在这一点上的立场本身就有争议,本站只负责把它讲准确。

作者说的是"谨慎使用",并承认它能减少重复。本站补充两个合理的场景:框架提供的基类只暴露少量受保护的钩子方法、自己的状态全部私有(这正是作者说的"父类完全管理自己的状态");C++ 里用 CRTP 或混入(mixin)复用一小段无状态的逻辑。判断标准始终是:子类需要知道父类的多少实现细节?

作者反对的不是"现在不做",而是"现在做一个专用的,以后再改成通用的"。前者作者明确同意(功能需要之前可以完全不考虑这个抽象);后者的问题在于"以后"往往不会来,专用代码一份份复制下去。本站补充:适度通用的标准见第 6 章,问"满足当前需求的最简单接口是什么",而不是"未来所有可能的需求是什么",这样就不会滑到另一个极端(过度设计)。

作者反对的是暴露实现:类本来有自己的职责,成员变量是它实现职责的手段。本站补充:C++ 里一个只承载数据、没有不变式的聚合体(如 struct Point { double x, y; }、上一章的 Location),数据本身就是它的接口,直接用公开字段反而最诚实,给它套 getter/setter 只会增加浅方法。一旦字段之间出现了约束(比如 begin <= end),就该把它变成有行为的类。

以下不在书里,来自书中给出的读者讨论组,是 Ousterhout 对本章三个话题的补充。

关于单一职责原则(书里没有提),Ousterhout 的三次表态也在专题页。

练一练

下面三道题来自训练场。判断题请先想清楚"作者会怎么说",再想"我同不同意,为什么"。

再加一道,取材于读者讨论组(对应上面"边界与反方"里的"讨论组"一栏):

自测题

作者说"一个接口的实现越多,这个接口就越深"。原因是:
接口继承的杠杆在于复用同一个接口:学会用它操作一种实现,就会用它操作所有实现。能容纳多种实现,说明接口只保留了本质,这正是抽象的核心。
关于实现继承,下列哪个说法符合作者的观点?
作者承认实现继承能减少变更放大,但共享的成员变量会在父子类之间泄漏信息。protected 变量恰恰是泄漏的通道。
原则 15 说"软件开发的增量应该是抽象,而不是功能"。下列哪种做法最符合它?
原则的两半:抽象可以等到功能需要时再引入(所以不是 C);但一旦需要,就投入时间一次设计好(所以不是 D)。
作者在什么情况下赞成先写测试?
先写测试并看到它失败,才能确认这个测试真的触发了 bug;如果先修再写,测试通过可能只是因为它根本没碰到 bug。
作者为什么认为单元测试对设计很重要?
作者的论证是:测试让人敢做结构性改进。A 恰恰接近 TDD 支持者的说法,作者并不认同。
关于 getter/setter,下列哪一项最准确地表达了作者的观点?
getter/setter 是浅方法,暴露的仍然是实现。作者认为它们被滥用,部分原因是人们以为"既然是模式,就应该尽量多用"。
这一章结尾给出的、可以用于评价任何新范式的方法是:
很多提议表面上听起来不错,深入看却可能让复杂性更糟。用复杂性(及前面各章的词汇)去检验,是作者希望读者带走的方法。

小结与读后一句话

下一章(为性能而设计)讨论最常被拿来反对"简洁设计"的理由:性能。作者会论证,简洁的设计和高性能通常并不冲突。

读后一句话:选一个你团队里流行的做法(某个框架、某条规范、某种流程),用本书的词汇评价它:它在哪里减少了复杂性,又在哪里增加了复杂性?