APOSD 与 Clean Code:三处分歧
A Philosophy of Software Design vs. Clean Code
这场讨论是什么
《A Philosophy of Software Design》(下文简称 APOSD)第 2 版里有两节标题叫"A different opinion",专门反驳 Robert C. Martin("Uncle Bob")的《Clean Code》:一节谈函数长度(9.8 节),一节谈注释(12.6 节)。另外 19.4 节批评了测试驱动开发(TDD),而 Martin 是 TDD 最著名的倡导者之一。
2024 年 9 月到 2025 年 2 月,两位作者就这三点做了一系列线上和线下讨论,整理成一篇很长的对话,公开在 GitHub 上:
- 主要来源:johnousterhout/aposd-vs-clean-code(README 全文,对话体,双方逐段回应)。
- 后续:两人一起上了 Book Overflow 播客,聊这次讨论的过程(YouTube,2025-03-30)。本页不引用播客内容,只列出供你自己去听。
- Ousterhout 在 The Pragmatic Engineer 访谈(2025-04)里也简短回顾过这次讨论。
书里给的读者讨论组(software-design-book)里,同样的话题从 2019 年起就反复出现,Ousterhout 在那里说过一些书和对话里都没有的话,整理在本页靠后的一节。
本页按对话的结构梳理三处分歧:每一处先摆出双方各自的论证(尽量用他们自己的理由,而不是对方转述的版本),再用一组原创 C++ 代码把两种风格放在一起,最后单独给出本站看法。对话里的代码(PrimeGenerator 等)是两位作者的,这里只点名,不搬运;建议你直接去读原文,它本身就是很好的设计评审示范。
读这页之前,最好已经读过原书第 4、9、12、13、19 章。下面凡是"书中"指 APOSD 第 2 版;凡是"对话中"指上面那份 GitHub 文档。
出发点其实一样
对话开头,Ousterhout 先问:你判断一个设计主张值不值得支持,标准是什么?Ousterhout 自己的回答就是书的主线:复杂性主要和信息有关,一是开发者为了完成任务脑子里必须装多少信息,二是这些信息是否容易找到、是否明显(对照第 4 章的"接口 = 使用者必须知道的一切")。
Martin 表示同意,并补了一句:最需要照顾的不是作者,而是以后要读这段代码的人,包括一周后的作者自己。这和书中"为读而设计,而不是为写而设计"(第 18 章)几乎是同一句话。
所以这不是两种价值观的冲突,而是同一个目标下对手段的不同押注。记住这一点,后面的分歧会更容易看清:他们争的是"怎样才算让读者轻松"。
分歧一:方法该多短
Ousterhout 的论证
- 《Clean Code》给出的是强烈、量化、单方向的建议(函数要非常短、if 块只放一行),却几乎没讲"拆过头"是什么样子。
- 方法越拆越小,每个接口背后藏的功能越少,接口反而可能更复杂,模块变浅。
- 拆过头会导致纠缠(entanglement,书中叫连体方法 conjoined methods):读一个方法必须同时读另一个,读者在几个方法之间来回跳,等于在脑子里把它们重新拼回一个大函数。
- "一个方法只做一件事"(One Thing)太模糊,"能起个名字"也拦不住什么:什么都能起名字。反例:线程安全的方法先加锁再干活,这是两件事,但应该放在一起。
- 方法在文件里的排列顺序救不了纠缠。
Martin 的论证
- 书里那些两到四行的函数出自一个具体例子(Martin 和 Kent Beck 写的一个小程序),不是硬性上限;书中的建议本来就是"供参考"。
- Martin 承认确实可能拆过头。判断标准是:如果能从一个方法里有意义地抽出另一个(抽出的部分能起一个描述性的名字,并且比原方法做得少),原方法就做了不止一件事。
- 加锁的例子 Martin 会拆:把临界区单独抽出来,单线程或已持锁时可以直接调用。
- 如果你信任被调用方法的名字,就不必读它的实现也能看懂调用方;把调用替换成实现只会遮住意图。
- 2008 年写书时,Martin 要对抗的是当时到处都是的超长函数;第 2 版写得更平衡。真要犯错,宁可拆多了,因为拆多了还可以内联回去。
对话里双方争论的核心样本是《Clean Code》中的 PrimeGenerator(Martin 说它改编自 Knuth 文学编程论文里的素数程序,在书中是讲"把大函数拆成类"的教学例子)。Ousterhout 指出其中几个小方法层层调用、带有从名字看不出来的副作用,而且只有在调用方按递增顺序传参时才正确;Martin 承认那个副作用"经你指出后,我也不太喜欢",但认为按调用顺序排列方法、加上读者都知道候选数每次加 2,可以缓解这个问题。最终两人都同意那个类内部的方法拆分有问题。
We both value decomposition, and we both avoid entanglement; but we disagree on the relative weighting of those two values.— Robert C. Martin,对话"Method Length Summary"一节(原文)
用一段原创代码对比两种风格
场景:一个追加写日志文件的读取器(和本站项目里的 KV 存储是同一类问题)。日志由"定长头部 + 变长负载"的记录组成;崩溃可能在文件末尾留下写了一半的记录(撕裂的尾部,torn tail),读取时要在那里停下,并记住有效数据的终点,以便写入方截断。两个面板的功能相同,第一个按"小而命名良好的方法"写,第二个按"一个深函数 + 接口注释"写。
std::vector<Record> LogReader::readAll() {
std::vector<Record> records;
while (hasMoreBytes()) {
if (!headerFits()) break;
RecordHeader header = consumeHeader();
if (!payloadFits(header) || !checksumMatches(header)) {
markTornTailBeforeHeader();
break;
}
records.push_back(consumePayload(header));
}
return records;
}
bool LogReader::hasMoreBytes() const { return pos_ < buf_.size(); }
bool LogReader::headerFits() const { return buf_.size() - pos_ >= sizeof(RecordHeader); }
RecordHeader LogReader::consumeHeader() {
RecordHeader h;
std::memcpy(&h, &buf_[pos_], sizeof h);
pos_ += sizeof h;
return h;
}
bool LogReader::payloadFits(const RecordHeader& h) const { return buf_.size() - pos_ >= h.len; }
bool LogReader::checksumMatches(const RecordHeader& h) const {
return crc32(&buf_[pos_], h.len) == h.crc;
}
void LogReader::markTornTailBeforeHeader() { validEnd_ = pos_ - sizeof(RecordHeader); }
Record LogReader::consumePayload(const RecordHeader& h) {
Record r(&buf_[pos_], h.len);
pos_ += h.len;
return r;
}
它的长处是真实的:readAll 本身读起来像一段说明文,循环的骨架一眼可见,这正是 Martin 说的"信任名字就够了"。
它的问题也是真实的:payloadFits 和 checksumMatches 只有在 consumeHeader 已经把 pos_ 推过头部之后才正确;markTornTailBeforeHeader 要减去头部长度,因为它"知道"前面推进过。三个方法通过成员变量 pos_ 隐式耦合,要确认正确性就得把它们一起读。再看第 4 行(假设构造时 validEnd_ 初始化为 buf_.size()):末尾只写了半个头部时直接 break,validEnd_ 仍然指向文件末尾、越过了那半个头部,写入方据此截断就会留下垃圾,这是一个 bug,而它恰好藏在"每个方法单独看都没错"的缝隙里。
// 按顺序解析 buf_ 中所有完整的记录并返回。
// 崩溃可能在末尾留下不完整或校验失败的记录(撕裂的尾部):解析在那里停止,
// 并把 validEnd_ 设为第一个不属于有效记录的字节偏移;写入方重新追加之前
// 应先把文件截断到 validEnd_。记录格式:RecordHeader{len, crc} 紧跟 len 字节负载,
// crc 只覆盖负载。
std::vector<Record> LogReader::readAll() {
std::vector<Record> records;
size_t pos = 0;
while (pos < buf_.size()) {
if (buf_.size() - pos < sizeof(RecordHeader)) break;
RecordHeader h;
std::memcpy(&h, &buf_[pos], sizeof h);
size_t avail = buf_.size() - pos - sizeof h;
const uint8_t* payload = &buf_[pos + sizeof h];
if (avail < h.len || crc32(payload, h.len) != h.crc) break;
records.emplace_back(payload, h.len);
pos += sizeof h + h.len;
}
validEnd_ = pos; // 无论从哪里退出循环,pos 都停在最后一条有效记录之后
return records;
}
不到 20 行代码,所有关于 pos 的推理都在一屏之内;循环无论从哪里退出,之后都只有一处给 validEnd_ 赋值,上一版那个 bug 在这种结构下很难出现。代价是:循环体比风格 A 的 readAll 更"密",第一次读时要看懂偏移计算,而接口注释承担了解释"为什么"的工作。
注意它并没有拒绝抽取:crc32 仍然是独立函数,因为它不依赖调用方的状态,接口(一段字节 → 校验值)远比实现简单。这正是书中 9.7 节认可的那种拆分。
两种风格的分界线不在行数,而在被抽出的部分能不能被单独理解。一个简单的自检:遮住被调用方的实现,能否确信调用方正确?再遮住调用方,能否确信被调用方正确?两个答案都是"能",抽取就是好的(crc32、Martin 举的员工发薪例子);任何一个是"不能",就是在制造连体方法(风格 A 里围绕 pos_ 的那几个)。One Thing 规则告诉你可以在哪里切,深/浅的判断告诉你该不该切。前者擅长生成候选,后者擅长否决候选,两者其实可以配合使用。
另外,Martin 的"拆多了可以再内联"在理论上成立,但现实中内联很少发生:拆开的方法有了名字、有了调用点、有了测试,删掉它需要的勇气比当初抽出它大得多。这一点上本站更认同 Ousterhout 的风险判断。
分歧二:注释是不是失败
书中 12.6 节引用了《Clean Code》"注释总是失败"(comments are always failures)的说法并表示反对。对话里,这是两人分歧最深的一处,Ousterhout 在小结里直说"我们很难找到共识"。
Ousterhout 的论证
- 把注释叫作"失败"会让新人得出"不写最安全"的结论。
- 有大量重要信息根本无法用代码表达;没有注释就没有抽象:只靠方法签名就足以说明怎么用它的情况很少见。
- 按 Ousterhout 自己的经验,缺注释造成的损失是错误注释的 10 到 100 倍;过时注释确实存在,但很少因此耗费多少时间。
- 超长方法名(如书中提到的
isLeastRelevantMultipleOf…)读起来费劲、信息量反而不如一句注释,调用者每次调用都等于重抄一遍文档;方法越拆越小、名字越来越长,恰好说明接口变复杂了。 - "复杂性取决于读者":如果读者说看不懂,作者就该当真,而不是说"多练练就习惯了"。
Martin 的论证
- 《Clean Code》注释一章开头就说恰当的注释极有帮助;Martin 反对的是无谓的注释,以及上世纪七八十年代流行的"注释天然是好东西、默认就该写"。
- 注释用自然语言写,不精确、有歧义、没有编译器检查;Martin 不相信注释会被维护、会被读,所以宁可把信息放进名字,因为名字更难被忽略。
- 命名规则:作用域越小,名字可以越长;私有小方法通常只有一处调用,长名字没有记忆负担。
- 公开 API、给别的团队用的接口确实需要注释;团队内部,好的名字和参数通常就够了。
- Martin 把每条注释都当作可能的错误信息,用来和代码交叉核对;最好的注释告诉你一些出人意料、又能被验证的东西。
两人在注释问题上也有真正的共识:方法内部的注释只在代码不明显时才需要;重复代码的注释没有价值(书中 13.2 节的红旗 );注释和代码都应该接受别人的评审。分歧集中在接口注释和是否信任注释上。Ousterhout 的小结里估计,同样一段代码,自己写的注释行数大约是 Martin 的 5 到 10 倍,Martin 认可这份小结是公平的。
原创对比:名字能承载多少信息
场景:服务端的令牌桶限流器(token bucket),每个 worker 线程持有一个。两个面板是同一个方法的两种写法。
class TokenBucket {
public:
TokenBucket(double tokensPerSecond, double maxBurstTokens);
bool tryConsumeTokensAfterRefillingSince(Clock::time_point now,
double tokensRequested);
private:
void refillTokensForElapsedTimeSinceLastRefill(Clock::time_point now);
bool enoughTokensAvailableFor(double tokensRequested) const;
...
};
名字确实说清了不少:单位是令牌、会先补充再扣减、构造参数的含义。对一个只在本模块内部用的类,这可能已经够了,这正是 Martin 的立场。
但调用者最需要知道的几件事,名字里一件都没有:失败时扣不扣令牌?now 能不能倒退(比如传进来的是 system_clock,被 NTP 往回调了)?能不能多线程共用?这些只能去读实现,而实现又分散在三个方法里。
// 令牌桶限流器。令牌以 rate(个/秒)连续补充,最多攒到 burst 个。
// 非线程安全:每个 worker 线程各持有一个实例。
class TokenBucket {
public:
TokenBucket(double rate, double burst); // 初始为满桶
// 尝试为一次请求扣除 cost 个令牌,成功返回 true。
// 令牌不足时返回 false,且不扣除任何令牌(调用方可以稍后重试)。
// now 必须来自 steady_clock 且单调不减;若比上次调用更早,按"没有经过时间"处理。
bool tryAcquire(Clock::time_point now, double cost = 1.0);
...
};
四行注释交代了三条签名表达不了的契约:失败无副作用、时间来源与单调性、线程模型。它们正是书里说的接口的非正式部分(4.2 节)。
Martin 那条"可交叉验证"的标准在这里也成立:每一句都能对着实现或测试核对,而不是空泛的"获取令牌"。
对 C/C++ 程序员来说,这场争论的天平明显偏向 Ousterhout,原因很具体:C/C++ 接口里有大量名字装不下的契约:所有权(谁释放)、生命周期(返回的指针什么时候失效)、线程安全、错误时状态是否回滚、单位与时钟来源。把这些塞进名字,只会得到 Ousterhout 所说的"超长名字";不写,就只能逼每个调用者去读实现。
但 Martin 的怀疑态度里有一个值得吸收的部分:写可以被核对的注释。陈述不变量、前置条件、单位,而不是复述步骤。这样的注释过时了容易被发现,也常常可以直接变成断言或测试。书中 13.3 节"低层注释增加精确性"讲的就是这类注释。
插曲:两次重写与一次性能回退
对话中段是全文最精彩的部分,建议一定去读原文。简述发生了什么(细节以原文为准):
- Ousterhout 拿出自己课上布置的重写版:一个方法、大量注释。Ousterhout 特别说明这种注释密度对自己来说也非常少见,是因为这个算法本身很难懂;注释写得长,本身就是"难以描述"的信号(书中第 15 章的红旗 )。
- Martin 承认这一版比 Knuth 的原版和自己的版本都好,但说自己读注释时同样卡住了:其中一处说法和代码对不上(有一个首项的特例没写),还有一处用词让 Martin 误解了循环在比较什么,最后是骑了很久的车才想明白算法的。
- Martin 由此得出"两个程序员的故事":两人都以为自己的写法(一方的名字、另一方的注释)能把意图讲清楚,因为作者自己懂;而对不在"盒子"里的人,这些都不够。
- Ousterhout 接受批评、修正了那两条注释,但坚持:解释困难的东西本来就是程序员的责任,放弃尝试是失职。
- Martin 在 IDE 里加了测试、抽了几个方法重写了一版。Ousterhout 测出这一版慢了 3 到 4 倍:原本一个能提前退出的循环被拆成了两个循环,其中一个每次都要走完。Martin 承认问题,很快给出合并后的新版本(Martin 称比 Ousterhout 的版本还快),方法数从最初的 8 个降到了 4 个。
If there is one thing more likely to result in bugs than not understanding code, it's thinking you understand it when you don't.— John Ousterhout,对话"Method Length"一节(原文)
Ousterhout 对这次性能回退的解读,直接连到书中第 21 章(判断什么是重要的):这个算法之所以复杂,全部理由就是性能;只盯着"把方法拆到最小"这种不太重要的事,就容易把真正重要的东西弄坏。
这一段对两种立场都不太客气,也正因如此最有价值:名字和注释都无法替代"换一个人来读"。两位经验极其丰富的作者,都高估了自己代码的明显程度。书中 13.8 节和第 18 章说的"评审者觉得不明显,那就是不明显",在这里被两个人用各自的教训验证了一遍。
分歧三:TDD
这一节有一个很少见的细节:Martin 指出书中 19.4 节对 TDD 的描述不准确(书里写的是"先写好一个类的全部测试,再逐个让它们通过");Ousterhout 当场认错,说下一版会修正,但表示这不改变自己的担忧。(这不是第一次:2023 年 9 月已有读者在讨论组里指出同一处,Ousterhout 当时就道过歉、答应修订,见原帖。)
Martin 给出的定义是自己的"三条定律":没有一个失败的测试就不写产品代码;测试只写到足以失败为止(编译不过也算失败);产品代码只写到足以让当前测试通过为止。再往外一层是"红—绿—重构"循环,每几分钟停下来审视设计。Ousterhout 偏好的替代做法,双方同意叫作bundling(打包):一次写几十到几百行代码(几个方法或一个类),然后为它写全面的单元测试,测试写完之前代码不算"完成"。
Ousterhout 的论证
- Ousterhout 是单元测试的坚定支持者;反对的是"以单个测试为开发单位"。
- 设计的自然单位是类或方法,对应很多个测试;只盯着下一个测试,就只在思考设计问题的一小块。
- "只写刚好通过的代码"压制了提前设计;重构该何时做、做到什么程度几乎没有指引,容易一拖再拖。
- TDD 保证你一开始写出不好的代码,而且这些代码能跑、有测试证明能跑,人性不愿意改能跑的东西。
- Ousterhout 坦承自己没有亲身实践过 TDD,判断依据是"战术式编程很少有好结局"(书中第 3 章),以及一些自己信任的资深工程师的反馈。
Martin 的论证
- TDD 的收益:几乎不用调试;测试是可靠的底层文档;为了可测而解耦;有一套敢托付身家的测试,因而可以放心重构。
- 每个人写代码都是一行一行写的,这也很"战术",并不妨碍设计;一次一个测试为什么就会妨碍?
- 设计当然在编码之前;Martin 会用简化的 UML 记录早期设计,甚至写伪代码放在文本文件里,边做 TDD 边参考。
- 不重视设计的人用什么方法都不会设计;Martin 见过很多 TDD 用得很好的项目。
- Martin 认为一个自律的 bundling 实践者,产出可能和 TDD 实践者难以区分。
两人逐条过了 Martin 列的四项收益:Ousterhout 认为"少调试"来自任何形式的单元测试;测试作为文档远不如注释;"解耦"这一点没有体会过;"放心重构"则完全同意,认为单元测试的价值几乎都在这里。最终双方同意的结论是:单元测试必不可少;用 TDD 也可能得到好的设计;Martin 也认为熟练的 bundling 实践者能得到和 TDD 相当的结果(只是测试覆盖可能略少)。分歧在于风险:Ousterhout 认为两者最好结果相近,但 TDD 的平均和最坏情况差得多;Martin 不同意,认为 TDD 如果有差别也是略好,而且个人偏好和性格的影响更大。Ousterhout 明确说,没有实证数据,这个分歧解决不了。
原创对比:同一个函数的两种诞生方式
场景:解析配置里的时长字符串,如 "90s"、"1h30m"。下面不是"TDD 一定写成这样",而是 Ousterhout 担心的那种中间状态:测试全绿,看起来可以提交了。
// 测试:parse("5s")==5s, parse("2m")==120s, parse("1h")==3600s, parse("1h30m")==5400s
std::chrono::seconds parseDuration(std::string_view s) {
if (s.size() >= 2 && s.back() == 's')
return std::chrono::seconds(std::stoi(std::string(s.substr(0, s.size() - 1))));
if (s.back() == 'm' && s.find('h') != std::string_view::npos) {
auto h = s.find('h');
return std::chrono::hours(std::stoi(std::string(s.substr(0, h))))
+ std::chrono::minutes(std::stoi(std::string(s.substr(h + 1, s.size() - h - 2))));
}
if (s.back() == 'm')
return std::chrono::minutes(std::stoi(std::string(s.substr(0, s.size() - 1))));
if (s.back() == 'h')
return std::chrono::hours(std::stoi(std::string(s.substr(0, s.size() - 1))));
throw std::invalid_argument("bad duration");
}
每一步都是"让下一个测试通过的最少代码",所以每加一种组合就多一个特例分支。"1m30s"、"1h30m15s"、空串都还没处理(空串时 s.back() 是未定义行为)。红—绿—重构里的"重构"一步本该把它改成下一版;Ousterhout 担心的是,四个绿灯之下,很多人会直接提交。
// 解析形如 "1h30m"、"90s"、"2h5s" 的时长:一个或多个 <非负整数><单位> 依次拼接,
// 单位为 h/m/s,同一单位不能重复,必须按 h、m、s 的顺序出现。
// 格式不符(含空串、溢出)时抛 std::invalid_argument,消息里带原始字符串。
std::chrono::seconds parseDuration(std::string_view s) {
static constexpr std::pair<char, int> kUnits[] = {{'h', 3600}, {'m', 60}, {'s', 1}};
auto fail = [&] { return std::invalid_argument("bad duration: '" + std::string(s) + "'"); };
long long total = 0;
size_t i = 0, nextUnit = 0;
if (s.empty()) throw fail();
while (i < s.size()) {
long long n = 0;
size_t start = i;
while (i < s.size() && std::isdigit(static_cast<unsigned char>(s[i]))) {
n = n * 10 + (s[i++] - '0');
if (n > 1'000'000'000) throw fail();
}
if (i == start || i == s.size()) throw fail();
while (nextUnit < 3 && kUnits[nextUnit].first != s[i]) ++nextUnit;
if (nextUnit == 3) throw fail();
total += n * kUnits[nextUnit++].second;
++i;
}
return std::chrono::seconds(total);
}
先把"语法"想清楚(一串 数字+单位,单位有序),特例就不存在了,"1m30s" 和 "2h5s" 不需要额外代码。测试随后成组地写:合法组合、顺序错误、重复单位、空串、溢出。这就是书中 19.2 节说的"开发的增量应该是抽象,而不是功能"。
公平地说:一个认真执行重构步骤的 TDD 实践者完全可能得到同样的代码,Martin 正是这么主张的。两人争的是哪种流程更容易让普通人走到这里。
这一处分歧最缺证据:Ousterhout 自己说没有实践过 TDD,Martin 的证据是自己经历过的项目;Ousterhout 明确说,没有实证数据,这个分歧解决不了。所以本站不判输赢,只指出两点可以立即照做的事(第一点两人的做法本来一致,第二点是本站建议):
- 修 bug 时先写一个能复现它的失败测试:书中 19.4 节明确推荐,这本身也正是 TDD 的做法。
- 在写第一行实现之前,先把接口和接口注释写出来(书中第 15 章"先写注释")。这一步之后,你用 TDD 还是 bundling 去填实现,差别就小多了,因为 Ousterhout 担心的"没有设计"已经不成立。
两人各自的总结与本站看法
在对话结尾,Ousterhout 把自己的所有意见归结为《Clean Code》的两个一般性错误:一是没有聚焦于真正重要的事(把十行方法拆成五行、消灭英文注释、以测试而不是抽象为开发单位,这些都不重要);二是没有平衡取舍(向一个方向给出强烈建议,却没有同样强度的反向提醒,也没讲怎么判断走过了头)。
Martin 的结语则说,两人共享的价值观远多于分歧;Martin 不同意上述结论,但已经把 Ousterhout 的一些想法和整份讨论吸收进了《Clean Code》第 2 版。
| 话题 | 两人都同意 | 仍然分歧 |
|---|---|---|
| 方法长度 | 模块化是好事;可能拆过头;《Clean Code》第 1 版对"拆过头"缺少指引;PrimeGenerator 的内部拆分有问题 | 拆到多细;One Thing 规则是否够用;纠缠能否被拆分的好处抵消;方法排列能否缓解纠缠 |
| 注释 | 方法内部注释只在代码不明显时写;公开 API 需要注释;注释需要评审 | 注释的总体价值;团队内部接口要不要注释;超长名字 vs 短名字加注释;是否默认信任注释 |
| TDD | 单元测试必不可少;TDD 可以产出好设计;bundling 也能达到与 TDD 相当的结果 | TDD 是否压制设计;两种做法的平均与最坏情况谁更差 |
读完全文,本站的总体判断是:Ousterhout 的框架更适合当检查工具,Martin 的规则更适合当起手习惯。深/浅、连体方法、"注释描述代码看不出来的东西"都是双向的判据,能告诉你什么时候做过头;"函数要短""用名字代替注释""先写测试"是单向的推力,在长函数和废话注释泛滥的环境里很有用,但需要一个刹车。书里给的正是刹车。
对本站的读者(C/C++、系统方向):接口契约复杂、性能常常就是需求本身、代码寿命很长,这三点都让 Ousterhout 那一边的论证更贴近日常。但别把这页当成"站队"材料。对话里最该学的,是两个人如何拿具体代码说话、承认自己的错误、把分歧精确地写下来。这本身就是一次示范性的设计评审。
讨论组里的补充:辩论之外,Ousterhout 还说过什么
这一节的材料来自书中给出的读者讨论组,时间从 2019 年到 2026 年(只有"关于测试"一小节例外,来自 Ousterhout 在 X 上的回帖)。帖子是公开的,每条都附了原帖链接;下面全部是转述。请注意两点:讨论组里的话是即兴的回帖,不如书和那份对话文档斟酌;读者一方的说法只代表发帖人自己。
关于方法长度:比书里说得更直白的几句
- "我并不偏爱长方法"。有读者以为书主张"长方法比短方法好"。Ousterhout 的回答是:其他条件相同时,短的通常更好;问题是把方法变短时,其他条件往往并不相同。每多一个方法就多一个接口;拆得太多,一是接口加起来的复杂性超过了省下的,二是会得到必须连着读才懂的方法。还顺带指出:方法越短,调用栈往往越深。(2023-01;2019 年和 2024 年也各说过一次:一、二)
- "拆小是局部优化"。把一个方法变短,多半确实让那一个方法更好懂;而设计要优化的是整个系统好不好懂。所以先做出深的接口,实现太长时再找拆法,并且拆出来的东西也要深、彼此独立。(2019-01)
- 对外不可见的拆分不算成本。Ousterhout 举了自己的做法:把中断处理函数的主体抽成一个独立方法,顶层只留进出中断的管道代码,这样测试时不必真的触发中断。这种拆分在模块外面看不见,不给系统其余部分增加复杂性。(2019-01)这和上面分歧一里
crc32的例子是同一类:好的抽取存在,判据不是行数。 - "只做一件事"该怎么读。在 Ousterhout 看来,这句话说的是调用者看到的抽象单一而紧凑,能用很简单的话描述;而不是方法体里只有一个操作。按前一种读法,一个二十多行、对外只有一个清楚功能的方法就已经"只做一件事"了。(2019-04,针对 Martin 的一篇示范文章,Ousterhout 称其把 25 行代码拆成了 9 个方法)同一串里 Ousterhout 也承认,"深"推到极端同样荒谬:把整个程序塞进一个类。任何有点道理的想法推到极端几乎都是坏主意。
- 课堂上的用法。早在 2019 年初 Ousterhout 就点名过《Clean Code》的 Listing 10-8(从描述看,应当就是后来对话里的
PrimeGenerator):Martin 把它当作"重构之后"的范例,Ousterhout 的课上把它当作"重构之前",让学生先批评再重写。(2019-01)
关于单一职责原则(SRP):书里一个字没提
APOSD 全书没有出现 SRP 或 SOLID。讨论组里 Ousterhout 多次被问到,立场有变化,下面按时间列出其中三次:
| 时间 | Ousterhout 的说法(转述) |
|---|---|
| 2020-07 | 如果 Java 的"类过多症"确实来自对 SRP 的过度执行,那就说明 SRP 大概不是一条好的设计原则。所有强调"把单个东西做小"的方法都会产出大量带接口的东西;要在系统层面看复杂性。小方法更好测,但要测的方法也更多,未必净赚。 |
| 2022-02 | 口气缓和了:合理使用没有问题,一个类的方法和功能之间有内聚是好事,这和"单一职责"相近。但它经不起过度或过细的执行("一个类只加一个特性""一个方法只做一个决定"都是误读)。Ousterhout 不会把它排在设计原则的前列,因为它没有说清为什么照做了系统就更简单,而且容易被误用。 |
| 2023-03 | 把问题归结为单向:SRP 只往一个方向推,没有说什么叫"太过"。"深"则把取舍写在了定义里:接口要尽量简单,但若因此剩不下多少功能,就该回头;功能要尽量多,但若接口因此变大变复杂,也该回头。Ousterhout 同时承认"深"本身也模糊、要靠判断。按 Ousterhout 的说法,还没听谁为 SRP 讲清楚过那个"但是"。 |
这和对话结尾 Ousterhout 对《Clean Code》的总评(没有平衡取舍)是同一个意思,只是早说了两年。也有读者当场反驳:"深"同样可以被推过头,正需要 SRP 这样的原则来平衡;这一条 Ousterhout 没有再回。
关于 TDD:一个更具体的推演
2025 年 5 月,有读者为 TDD 辩护说:先写实现再补测试,写完实现时人会觉得"已经做完了",补测试成了一件扫兴的事;TDD 没有这个问题。Ousterhout 承认这个论点有道理,然后说同样的论点对 TDD 更致命(原帖):
- 让第一个测试通过后,你同样觉得那段代码做完了;写第二个测试时发现它碍事,只好重写;第三、第四个测试又把它打破。自觉的人会一遍遍重写,最后也能到达好的设计。
- 但很多人会在某一轮想:"在这儿打个小补丁,就不用大改了。"每来一个新测试都这么想一次,最后得到一团乱麻。
- 两种担心并不对称:测试不够,随时可以回头补;设计没做好,几乎补不回来。
这比分歧三里"压制设计"的说法多了一步机制,也正是上面 parseDuration 左侧面板演示的那种中间状态。它仍然是推演而不是数据;Ousterhout 没有实践过 TDD 这一点,对话里已经承认。另外,在那份对话的评论帖里有读者提醒:要批评 TDD,应当对照 Kent Beck 自己写的 Canon TDD,其中第一步是先列出要测的场景清单,这一步在两人的对话里没被提到。
关于测试:对话发表后,在 X 上回答的三个追问
这一小节的材料不来自讨论组,来自 Ousterhout 在 X(原 Twitter)上的回帖。2025 年 2 月 Ousterhout 发帖公布了那份对话文档(2025-02-23),随后几天有 TDD 的支持者在帖子下追问。三条回答都很短,但补上了对话里没说的一件事:Ousterhout 自己平时到底怎么写测试。
- 测试是白盒的。一位读者说,只有 TDD 能让测试不依赖实现;后补测试时,人总会偷看实现,然后只验证"某些方法被调用了"。Ousterhout 没有否认"看实现",反而直接说自己的测试就是白盒的:写测试时看着代码,按代码的结构来组织测试,为的是确认每一条路径都测到了(2025-02)。
- 每个测试都要先失败一次。同一位读者追问:后补测试时,会不会故意把代码弄坏,亲眼看到测试在该失败时失败?Ousterhout 的做法不是改坏代码,而是改测试:通常先在测试里断言一个明显错误的预期结果,看它失败,再改成正确的。理由是这逼着自己想清楚到底期望什么行为(2025-02)。
- 为了可测而重构,并不常见。对话里 Ousterhout 说没有体会过"TDD 带来更解耦的设计"。另一位读者表示难以相信:难道从没遇到过不重构、不解耦就没法测的情况?Ousterhout 回答:也许有过,但不记得这是常见问题;更常见的是需要多写一些 mock 代码,而这通常不要求改动被测代码的结构(2025-02)。
本站补充两点。第二条和 TDD 的"红"是同一个动作,只是发生在实现写完之后;两人在"没见过它失败的测试不可信"上并无分歧。第一条则是真正的分歧所在:按代码结构组织的白盒测试覆盖得全,代价是测试和实现绑在一起,实现一重构,测试往往要跟着改;TDD 一方看重的正是测试只认行为。Ousterhout 在这几条回帖里没有回应这个代价。读者可以自己拿分歧三里双方都同意的那一项收益来衡量:测试的主要价值是让人放心重构,那么你的测试在重构之后还能不能原样跑?
关于注释:一句容易被忽略的让步
2020 年回应一篇书评时,Ousterhout 的表述是两段式的:如果能通过重构让代码不再需要注释,完全赞成;需要海量注释的代码多半是坏代码。但总有一些东西无论怎么重构都无法用代码表达,注释就是为它们准备的。(原帖)读这页前半部分时,别把 Ousterhout 读成"注释越多越好"。
读者一方:三条值得带走的意见
下面来自 Ousterhout 2025 年 2 月为那份对话开的评论帖。Ousterhout 本人在帖里没有展开,这些都是读者的看法。
- 替 Martin 补上的最好论据。一位读者指出,对话选了素数生成器这样的数值算法当样本,对 Martin 不利:这类代码需求不会变。换成"歌曲库"这种业务代码,上线后很快会长出"图书库""菜谱库",细粒度的小类小方法更容易顺着开闭原则和依赖倒置去扩展。这位读者说明自己没读过 APOSD,但这个论据本身成立:样本的选择会影响结论。
- 两边都只谈"注释",更准确的对象是"文档"。另一位读者指出,文档注释可以被工具抽成手册,有的语言还能把注释里的示例当测试来跑,所以"注释不受编译器检查"并不总是成立。
- 有些注释其实是类型的缺席。同一位读者拿对话里的
addSongToLibrary(String title, String[] authors, int durationInSeconds)举例:Ousterhout 追问"作者字符串有没有格式要求",要求用注释回答;而如果参数是Author和Duration这样的类型,这个问题根本不必问。
把讨论组和对话放在一起读,Ousterhout 的立场比"长方法、多注释、反 TDD"这种流行的概括要温和得多:短方法在其他条件相同时更好,能重构掉的注释就该重构掉,SRP 合理使用没有问题。真正一以贯之的只有一条:单向的规则需要一个"但是"。这也是读《Clean Code》时最实用的一个提问:这条建议做到什么程度算过头?书里说了吗?
读者的第三条意见对 C++ 尤其有用。上面令牌桶的例子里,"now 必须来自 steady_clock"这条注释,可以直接换成参数类型 std::chrono::steady_clock::time_point,让编译器来保证;剩下"失败时不扣令牌""非线程安全"这两条,类型表达不了,仍然要写。先用类型消掉能消掉的,再用注释写剩下的,两位作者的主张在这里并不冲突。
第一条意见本站认为只对了一半。业务代码确实变得快,但"先拆细以备将来扩展"正是书中 第 6 章反对的那种预判:通用的应该是接口,不是预先切好的碎片。等第二个"库"真的出现时再抽取,通常切得更准。
同事在评审里说:"这个类违反了单一职责原则,应该拆成三个。"按 Ousterhout 在讨论组里的思路,你应该反问什么?
展开对照
不是反驳 SRP 本身(Ousterhout 说合理使用没有问题),而是补上它缺的那个"但是"。可以问三件事:拆开后一共多出几个接口,使用者要多知道什么?三个新类能各自独立读懂吗,还是必须连着读(连体)?拆开之后,原来藏在一个类里的信息会不会变成三个类共享的知识(信息泄漏)?如果三个答案都好看,就拆;否则"职责"的边界多半划错了。换成"深"的说法:每个新类是否仍然用简单的接口提供了足够多的功能。
判断练习
下面每题都没有"按规则套"的标准答案,先写下你的判断和理由,再展开对照。
一个 RPC 处理函数 70 行,依次做五件事:解码请求、鉴权、查缓存、查数据库、编码响应。五段之间只通过几个局部变量传递数据,每段 10~15 行。Code review 里有人要求"拆成五个函数"。你怎么回复?
展开对照
先做上面那个"双向遮挡"测试。如果五段几乎独立(比如鉴权只需要解码出的 userId),抽成五个函数后每个的接口都很小,调用方读起来像目录,那么拆分也说得过去,这时拆不拆都不会出大问题。书中 9.7 节的态度更保守:按顺序执行、彼此相对独立的几段代码留在一个方法里也能逐段读懂,拆开的好处不大;长度本身很少是拆分的理由。
如果"查缓存"和"查数据库"共享一套复杂的回填逻辑(缓存未命中时查库并写回、处理并发填充),那它们应该一起抽成一个深函数 loadUser(),而不是拆成两个互相知道对方细节的函数。重点是按知识分组,而不是按步骤数分组。
团队内部的一个函数:size_t compactSegments(std::vector<Segment>& segs); 只有本模块调用。同事说"名字很清楚,团队都知道它干什么,不用注释"。你会坚持加接口注释吗?如果加,写什么?
展开对照
这正是两人分歧的核心场景(Martin:团队内部通常不用;Ousterhout:没有接口注释就没有抽象)。判断的关键不是"团队熟不熟",而是签名里缺了哪些调用者必须知道的事:返回值是什么(合并掉的段数?回收的字节数?);segs 里的元素会被移动、删除还是原地修改,迭代器是否失效;是否会做磁盘 I/O、会不会阻塞;失败时 segs 处于什么状态。只要其中有一条不能从名字推出,就值得写注释。几个月后的你自己也是"团队外"的读者。
你要修一个 bug:parseDuration("0s") 抛了异常。你会先写测试还是先改代码?这件事上 Ousterhout 和 Martin 的立场一样吗?
展开对照
先写一个会失败的测试,再修。这是书中 19.4 节明确推荐的场景(不先写测试,你无法确定新测试真的触发了那个 bug),也正是 TDD 的做法,两人在这里没有分歧。分歧只在"开发新模块时,开发单位是测试还是抽象"。
一个类里有 void transfer(Account& a, Account& b, Money m):先锁两个账户(按 id 顺序加锁防死锁),再改余额。有人提议把改余额的部分抽成 transferLocked()。什么情况下这个抽取是 Martin 那种"有意义的抽取",什么情况下是 Ousterhout 担心的浅方法?
展开对照
对话里两人恰好争过加锁的例子。Martin 抽出临界区的理由是:有时调用者已经持有锁,或者在单线程场景下不需要锁。所以要问的是:现在是否真有这样的调用者?如果批量转账的代码已经一次性锁住了所有账户,需要一个"已持锁版本",那么 transferLocked() 有真实的第二个用途,并且接口注释可以写明前置条件("调用者必须已按顺序持有两把锁"),这是一个有价值的抽取。
如果没有这样的调用者,只是"加锁和改余额是两件事",那么拆开后多了一个带隐含前置条件的接口,读 transfer 的人还得跳过去确认锁覆盖了哪些操作,这就是浅而且连体的拆分。C++ 里用 std::scoped_lock 同时锁两个互斥量,加锁本身只有一行,留在一起最清楚。
本页前七节对两位作者观点的转述基于 aposd-vs-clean-code 原文(2025 年版本)与 APOSD 第 2 版;两处直接引用均来自该 GitHub 文档。"讨论组里的补充"一节转述自 software-design-book 讨论组的公开帖子(本站查阅于 2026-10-03),帖子内容归各发帖人所有,该节全部为转述;其中"关于测试"一小节转述自 Ousterhout 在 X 上的公开回帖(本站查阅于 2026-10-04)。所有代码为本站原创。