读前:这一章要回答什么
面对一个流行的做法(面向对象、敏捷、TDD、设计模式……),用什么标准判断它是在减少还是增加系统的复杂性?
前 18 章建立了一套词汇:深模块、信息泄漏、战术与战略编程、通用与专用、一目了然。这一章是一次应用练习:作者拿这套词汇去评价几十年来流行的几种做法,结论有褒有贬,有些相当尖锐(比如对 TDD)。
这一章最值得带走的不是某个具体结论,而是结尾那个方法:遇到任何新范式,都从复杂性的角度去质问它。所以读的时候,别急着同意或反对作者,而是看作者每一个判断引用了前面哪一条原则。如果你不同意某个结论,也试着用同样的词汇说出理由。本章的训练题里有不少"同意还是不同意"的判断题,就是为此准备的。
- 19.1 节区分了两种继承。注意作者对实现继承的评价是两面的:它减少了什么复杂性(用第 2 章的哪个术语),又制造了什么复杂性(用第 5 章的哪个红旗)?
- 19.2 节和 19.4 节都落到同一条原则上(原则 15)。读的时候想清楚"增量是功能"和"增量是抽象"的区别,作者对敏捷和 TDD 的批评都从这里来。
- 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 接口既能读写磁盘文件、也能读写网络套接字"说明接口继承的杠杆作用。用依赖关系画出来,两种继承的区别一目了然:
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 节
这条原则有两半,常被只记住一半。前一半是"可以推迟":在某个功能需要之前,完全不必考虑这个抽象,这和 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 说自己非常看重测试,尤其是单元测试;书不展开,是有意把范围限定在设计上(2020-02)。当时还说测试"大体上不影响设计",被读者指出小尺度上会影响之后,表示同意,承认原话说得太简略。
- 单元测试会不会把结构冻住。有读者说单元测试以类为单位,稍大的重构就要改成百上千行测试,等于把原有结构冻住了。Ousterhout 的回答(2023-04):设计得好的单元测试只针对一个类,不太受别的类改动影响;如果你的大多数改动都要跨很多类,这本身就是设计不对的信号;测试真被大面积波及时,改动通常很机械,自己很少要花超过几分钟,而且这往往说明测试对无关的部分过于敏感,可以趁机整理;没有单元测试的代价远高于维护它们的代价。
- 19.4 节对 TDD 的描述有误。2023 年有读者指出书里把 TDD 写成"先写完全部测试再写实现"并不准确。Ousterhout 道歉,说已记下、下次修订时改正(2023-09);2021 年的第 2 版里还是原来的写法。对"先把接口整个定下来,再用 TDD 填实现"这个折中,Ousterhout 认为有帮助,但每个模块的实现仍是战术式地长出来的。同一帖里描述了自己的次序:先写够一块实现,直到对模块内部的结构有把握(常常要返工),再给这一块写测试;理由是测试很重要,而把结构做对更重要。修 bug 时例外,总是先写一个能复现它的测试。
- TDD 的推演。2025 年 Ousterhout 给过一个更具体的推演:每个新测试都可能打破为前面的测试写的代码,反复返工几轮后,很多人会开始打补丁;并且认为测试不够可以回头补,设计没做好几乎补不回来。详见专题页。
- 工厂。书里谈设计模式时没有单独讨论工厂(19.5 节)。2021 年 Ousterhout 在讨论组里主动发问:读某个开源库的代码时见到层层工厂,它们到底有什么用?对读者给的理由,Ousterhout 否定了两条:构造函数抛异常时没有人能拿到半成品对象,所以这不是用工厂的理由;想防止对象在初始化完成后再被改,可以在类里设一个内部标志,或者干脆把选项设计成随时可改,类的实现复杂一点,使用者简单一点,好过引入一个工厂类(2021-12)。接受了一条:要创建的对象的具体类型得在运行时根据数据决定时,构造函数做不到,工厂方法是唯一可行的办法(2022-06)。这和 19.5 节的态度一致:模式在它解决的那个问题真的出现时才用。
关于单一职责原则(书里没有提),Ousterhout 的三次表态也在专题页。
练一练
下面三道题来自训练场。判断题请先想清楚"作者会怎么说",再想"我同不同意,为什么"。
再加一道,取材于读者讨论组(对应上面"边界与反方"里的"讨论组"一栏):
自测题
小结与读后一句话
- OOP 的机制可以帮助好设计,但不保证好设计。
- 接口继承提供杠杆:实现越多,接口越深。实现继承减少重复但制造依赖和信息泄漏,先考虑组合。
- 敏捷的增量开发是对的,但增量应该是抽象而不是功能(原则 15)。
- 坚决支持单元测试(让重构安全);不支持 TDD(战术编程),修 bug 时先写测试除外。
- 设计模式适合时就用,最大的风险是滥用;getter/setter 是浅方法,最好不暴露成员变量。
- 评价任何新范式:问它是否真的减少了复杂性。
下一章(为性能而设计)讨论最常被拿来反对"简洁设计"的理由:性能。作者会论证,简洁的设计和高性能通常并不冲突。
读后一句话:选一个你团队里流行的做法(某个框架、某条规范、某种流程),用本书的词汇评价它:它在哪里减少了复杂性,又在哪里增加了复杂性?