读前:这一章要回答什么
给定两段功能,应该放在同一个地方实现,还是分开?什么时候该拆一个方法,什么时候该把两个方法合起来?
这是软件设计里最基本、也最天天碰到的问题:缓冲该不该放进文件流类里?一个请求解析函数该不该拆成十个?它在函数、类、服务各个层次上都会出现。前面几章其实已经零散地回答过一部分:第 5 章说共享知识的代码该放在一起,第 6 章说通用和专用要分开,第 7 章说没有新抽象的层不该存在。第 9 章把这些标准收拢起来,再补上"方法该多长"这个最容易引起争论的问题。
这一章也是全书最直接和流行建议唱反调的地方:作者认为开发者普遍把方法拆得太碎了,并在 9.8 节点名反驳了《Clean Code》关于函数长度的主张。读这一章时,你很可能会发现自己团队的代码规范站在作者的对面。
- 章首的两个清单:一个列出"拆分本身带来的复杂性",一个列出"两段代码相关的迹象"。第二个清单里"一起使用"那一条有一个限定条件,作者用块缓存和哈希表举例说明,别漏掉。
- 9.5、9.6 节是一对正反例:一个是"合起来反而更糟",一个是"拆开反而更糟"。读的时候对照章首的清单,看作者是用哪几条判断出来的。
- 9.7 节的图 9.3:除了原方法,图里画了三种拆法,只有两种是合理的,其中一种作者说"不常见"。记住"抽出子任务"成立的两个条件,它们正是"连体方法"这面红旗的反面。
核心观点
拆分不是免费的
更多组件、更多接口、更多协调代码,相关代码被分开,有时还得复制。组件越小,单个越简单,整体未必越简单。
相关的才放在一起
共享信息、双向地一起使用、概念上同属一类、看一个离不开另一个:满足这些,合起来往往更好。
合并的三个理由
共享信息;合并后接口更简单(甚至能把某些事自动做掉);能消除重复。
重复是缺抽象的信号
一段不算简单的代码反复出现,说明你还没找到合适的抽象。
通用和专用要分开
通用机制只提供那一个机制;为某个用途定制的代码放到那个用途的模块里。
方法:做一件事,并做完整
接口简单、实现深,长一点没关系。长度本身很少是拆分的理由。
两种合理的拆法
抽出能独立读懂的子任务(最好);或把做了几件无关之事的方法拆成几个接口更简单的方法(少见)。
连体方法
不读另一个方法的实现就看不懂这一个,说明拆错了。深度比长度重要。
先算账:拆分的成本和"相关"的迹象
很多人默认"小就是好":组件越小,每个越简单。作者提醒,拆分这个动作本身就会带来原来没有的复杂性:
| 拆分的成本 | 具体表现 |
|---|---|
| 数量 | 组件越多越难记住、越难找到想要的那个;每个新组件通常还带来一个新接口。 |
| 管理代码 | 原来用一个对象就够了,现在要创建、连接、协调好几个。 |
| 分离 | 相关的代码被放进不同的类、不同的文件,看不到彼此。组件真正独立时这是好事;有依赖时,读代码的人只能来回翻,甚至意识不到依赖存在而写出 bug。 |
| 重复 | 原来只有一份的代码,拆开后可能每个组件都需要一份。 |
所以合并的收益取决于两段代码有多相关。书中给了四个迹象,用自己的话记:
- 共享信息:比如两段代码都依赖同一种文件格式的语法。
- 一起使用:用了一个几乎一定会用另一个。但这条只有双向成立才有说服力。作者的反例是块缓存和哈希表:块缓存几乎总会用到哈希表,可哈希表在无数和块缓存无关的场合都会用,所以它们应该分开。
- 概念重叠:能被一个简单的上层类别同时概括,例如子串查找和大小写转换都属于"字符串处理"。
- 离开另一个就看不懂:读一段时总要去看另一段。
什么时候合在一起
共享信息:合(9.1、9.2 节)
书中回到第 5 章的 HTTP 服务器项目:读取请求和解析请求被放在两个类里,可"读取"为了知道请求在哪结束,不得不做掉大半解析工作。合并后代码更短,中间那个"把原始字符串从一个方法交给另一个方法"的接口也消失了。9.2 节再加一个 Java 的例子:如果文件流和缓冲流合成一个类、默认带缓冲,大多数使用者根本不必知道缓冲的存在。下面是同一类问题的一个常见版本:
// report_export.cpp
std::string toCsvField(std::string_view s); // 含 , " \n 时加引号," 写两遍
// data_import.cpp(另一个团队写的)
std::vector<std::string> splitCsvLine(std::string_view line);
// 以引号开头的字段读到单独的引号为止;不支持字段内换行
// 调用方必须自己保证:先按行切,再调用 splitCsvLine
for (auto& line : splitLines(text)) rows.push_back(splitCsvLine(line));
"什么字符要加引号、引号如何转义、字段里能否换行"是同一份知识,却分在两个文件里,而且已经不一致:导出会给带换行的字段加引号,导入却按行切分,一读就错。调用方还要知道"先按行切"这条规则。
// csv.h —— 引号、转义、换行规则只在 csv.cpp 里定义一次
class CsvWriter {
public:
explicit CsvWriter(std::ostream& out, char sep = ',');
void writeRow(std::span<const std::string_view> fields);
};
class CsvReader {
public:
explicit CsvReader(std::istream& in, char sep = ',');
// 读下一条记录;字段内可以有换行。到达末尾返回 false。
bool readRow(std::vector<std::string>& fields);
};
读和写放在同一个模块里,规则只定义一次,两边不会再走样。接口也更简单了:调用方不用再自己按行切,"一条记录可能跨行"这件事被藏了起来。代价是两个团队要共用一个模块,修改要更小心。
消除重复:合(9.3 节)
同一段代码反复出现时,有两种合法的去重方式。第一种是把它抽成一个方法:片段越长、新方法的签名越简单,收益越大;如果片段只有一两行,或者和周围的一堆局部变量纠缠在一起(抽出来要传一串引用参数),收益就很小,甚至得不偿失。第二种是调整结构,让这段代码只需要在一个地方执行。书中的例子是按类型处理网络包、多处重复同一条日志语句,用 goto 跳到函数末尾统一处理。下面是 C 程序员最熟悉的版本:出错时的清理代码。
int copyFile(const char *src, const char *dst) {
int in = open(src, O_RDONLY);
if (in < 0) return -1;
int out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (out < 0) { close(in); return -1; }
char *buf = malloc(BUFSZ);
if (!buf) { close(out); close(in); return -1; }
for (;;) {
ssize_t n = read(in, buf, BUFSZ);
if (n < 0) { free(buf); close(out); close(in); return -1; }
if (n == 0) break;
if (writeAll(out, buf, n) < 0) { free(buf); close(out); close(in); return -1; }
}
free(buf); close(out); close(in);
return 0;
}
每多一种资源,之前的每个出错分支都要补一句释放;漏一处就是泄漏。
int copyFile(const char *src, const char *dst) {
int rc = -1, in = -1, out = -1;
char *buf = NULL;
if ((in = open(src, O_RDONLY)) < 0) goto done;
if ((out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644)) < 0) goto done;
if (!(buf = malloc(BUFSZ))) goto done;
for (;;) {
ssize_t n = read(in, buf, BUFSZ);
if (n < 0) goto done;
if (n == 0) break;
if (writeAll(out, buf, n) < 0) goto done;
}
rc = 0;
done:
free(buf);
if (out >= 0) close(out);
if (in >= 0) close(in);
return rc;
}
清理只写一次。作者承认 goto 名声不好、滥用会让代码没法读,但用来跳出嵌套代码、汇合到统一的清理正合适。(本站补充:Linux 内核里大量使用这种写法。)
// UniqueFd:析构时 close;openOrThrow 失败时抛 std::system_error
void copyFile(const std::string& src, const std::string& dst) {
UniqueFd in = openOrThrow(src, O_RDONLY);
UniqueFd out = openOrThrow(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);
std::vector<char> buf(kBufSize);
while (size_t n = readOrThrow(in, buf)) writeAllOrThrow(out, {buf.data(), n});
}
本站补充:在 C++ 里,"清理只在一处执行"由析构函数在语言层面保证,连出口标签都不需要了。思路和 goto done 相同:把重复的代码变成只执行一次的代码。
什么时候分开:通用与专用
9.4 节重申了第 6 章的原则:如果一个模块提供的是一个可用于多种用途的机制,它就只应该提供这个机制:既不该包含为某个具体用途定制的代码,也不该再塞进别的通用机制。为特定用途写的代码,应该放到和那个用途相关的模块里。违反它的代码,就是"通用与专用混杂":
const std::string* TtlCache::get(std::string_view key) {
auto it = map_.find(key);
if (it == map_.end() || it->second.expiresAt < clock_.now())
return nullptr;
// 登录会话每次访问续期 30 分钟,管理员会话不续期
if (key.starts_with("sess:") && !looksLikeAdmin(it->second.value))
it->second.expiresAt = clock_.now() + 30min;
return &it->second.value;
}
十几个服务共用的缓存里,混进了只属于登录会话的规则:键前缀、角色判断、续期时长。会话规则每改一次,都得改这个公共模块。
// ttl_cache.h —— 只提供通用机制
class TtlCache {
public:
const std::string* get(std::string_view key);
void put(std::string key, std::string value, Duration ttl);
bool extend(std::string_view key, Duration ttl); // 给已有条目续期
};
// session_store.cpp —— 会话的规则都在这里
std::optional<Session> SessionStore::lookup(std::string_view id) {
const std::string* raw = cache_.get(sessionKey(id));
if (!raw) return std::nullopt;
Session s = Session::decode(*raw);
if (!s.isAdmin) cache_.extend(sessionKey(id), kIdleTimeout);
return s;
}
缓存只多了一个通用的 extend,别的用途(比如滑动过期的限流记录)也能用;"谁续期、续多久"留在会话模块。改会话规则不再碰缓存。
书中用两个学生项目的例子说明"合"与"分"都可能做错。9.5 节:编辑器的插入光标和选区看似总是一起被操作,有个团队把它们合进一个对象,结果上层仍然得分别对待两者,实现反而更绕;拆开后(用一个通用的"位置"类表示两者)使用和实现都更简单。9.6 节:一个项目把每种错误日志都抽成一个专门类里的单行方法,每个方法只被调用一次、却要配一大段注释,读的人得在调用处和日志方法之间来回翻;直接把日志写在发现错误的地方更好。前者是"不够相关却合在一起",后者是"紧密相关却被拆开"。
方法:拆还是合
同样的问题落到方法层面,就是最常见的争论:长方法要不要拆?作者的立场很明确:长方法通常确实更难懂,但长度本身很少是拆分的好理由,开发者普遍拆得太多。一个方法如果做了一件事并且做完整、接口简单、实现深,长一点没关系。比如一个方法由五段各 20 行的代码依次组成:如果各段相对独立,可以一段一段读,拆开没多大好处;如果各段之间交互复杂,就更应该放在一起,让读者一次看全。
拆分只有在整体抽象变得更干净时才值得。下图把方法画成第 4 章的矩形(顶边是接口,面积是功能),上方是调用者:
- 抽出子任务(最好的方式):父方法调用子方法,调用者看到的接口不变。成立的条件是子任务能干净地分开:读子方法的人不需要知道父方法,读父方法的人不需要理解子方法的实现。通常这意味着子方法比较通用,别处也可能用得上。
- 拆成两个调用者可见的方法:原方法同时做了几件不太相关的事,接口因此过于复杂。拆完后每个方法的接口都应该比原来简单,而且大多数调用者只需调用其中一个。作者说这种拆法并不常见。
- 反例:拆完后调用者必须把几个方法全部调一遍,还要在它们之间传递状态。这只是把复杂性推给了调用者。
反过来,合并方法也常常让系统更简单:把两个浅方法换成一个深方法、消除重复、去掉方法之间的依赖或中间数据结构、把分散在几处的知识收到一处、或者得到更简单的接口。判断拆错了的信号就是第三面红旗:
It should be possible to understand each method independently.— 第 9.7 节
"连体"不只发生在方法之间:任何两段物理上分开、却只能对照着读的代码,都属于这种情况。下面的交互把同一个"导入订单"功能写成三种结构,并把代码行之间的依赖画出来:某一行用到的值是在哪一行产生的。
依赖线:连体方法长什么样
代码是本站为说明结构而写的简化版。注意第三种写法也拆出了一个函数,但它和第二种的区别不在函数个数,而在于函数之间只通过参数和返回值交流:parseCents 可以完全不看调用者就读懂,调用者也不需要知道它的实现。
一张随身清单
把本章的标准压成几个问题,下次想拆或想合时逐条过一遍(括号里是对应的小节):
| 问自己 | 答"是"时倾向于 |
|---|---|
| 两段代码依赖同一个设计决定(格式、协议、规则)吗?(9.1) | 合 |
| 合起来以后,调用者要学的接口会变少,或者某件事能自动完成吗?(9.2) | 合 |
| 同一段不短的代码出现了好几次,而抽出来的签名会很简单吗?(9.3) | 合(抽成方法,或让它只在一处执行) |
| "一起使用"只在一个方向上成立吗?(章首) | 分 |
| 某段代码只为通用机制的某一种用途服务吗?(9.4) | 分(放到那个用途的模块) |
| 想抽出的那段,能不看父方法就读懂,父方法也不必看它的实现吗?(9.7) | 可以抽出子任务 |
| 拆完后调用者得依次调用好几个方法、来回传状态吗?(9.7) | 别拆 |
| 拆的唯一理由是"太长了"吗?(9.7、9.8) | 先别拆,看它是否深、是否好读 |
与 Clean Code 的分歧
9.8 节是全书少有的点名反驳。Robert Martin 在《Clean Code》里主张函数要非常短:短到十行都算长,if、else、while 的块体最好只有一行(通常是一次函数调用),因此函数里不该出现嵌套结构,缩进不超过一两层。作者的回应可以概括为三点:
- 作者同意短函数一般比长函数好懂。但函数缩到几十行以内之后,再缩短对可读性的帮助就很小了。
- 真正该问的是:拆分有没有降低系统整体的复杂性?读几个短函数并弄清它们怎么配合,真的比读一个长一点的函数容易吗?函数越多,要写文档、要学的接口就越多。
- 函数拆得太小就失去了独立性,变成必须放在一起读的连体函数。这时还不如保留那个大一点的函数,让相关代码待在一处。
Depth is more important than length: first make functions deep, then try to make them short enough to be easily read.— 第 9.8 节
这场争论远不止函数长度(两本书在注释上也有分歧,见第 12 章)。本站的专题:APOSD 与 Clean Code 做了完整对照,包括两本书各自的论据和你在团队里可以怎么取舍。
边界与反方
这一章不是"越大越好"的许可证。作者在章首就说了:组件真正独立时,分离是好事,它让人一次只关注一件事。9.5 节的光标与选区就是"合错了"的例子。判断标准始终是那四个相关迹象,而不是哪种结构看起来更整洁。最后一节的结论是:选择信息隐藏最好、依赖最少、接口最深的结构。
作者只在"片段长、签名简单"时推荐抽方法。本站补充:业界常说"错误的抽象比重复更糟"。两段代码今天长得一样,背后可能是两个以后会各自演变的需求;硬抽成一个函数,再给它加上几个布尔参数来区分调用者,就同时得到了复杂签名和通用专用混杂。先确认它们共享的是同一个设计决定,再合并。
作者为 goto 辩护的范围很窄:用来跳出嵌套代码、汇合到统一的清理。这不是"goto 没问题"的一般结论。本站补充:在 C++ 里优先用 RAII,它从语言层面保证了"清理只执行一次",并且对异常也成立。
很多团队的静态检查会限制函数行数或圈复杂度。本章的观点并不要求你删掉这些检查,而是把它们当成提示而不是目标:超标时先问"这个函数是不是做了几件事""有没有能独立读懂的子任务",答案是否定的,就接受它长。另一方面,几百行的函数只有在"签名简单、容易读"时才算好,这个前提别忘了。
以下不在书里,来自书中给出的读者讨论组。9.7 节常被读成"长方法更好"。Ousterhout 在讨论组里至少三次否认过:其他条件相同时,短方法通常更好;问题在于拆短时其他条件往往不同,每多一个方法就多一个接口。拆长方法是降低复杂性的手段,不是目的。Ousterhout 把"把方法拆小"称为局部优化,而设计要优化的是整个系统;还举过自己为了便于测试而拆分中断处理函数的例子,说明对模块外不可见的拆分不算成本。这些说法和出处整理在专题页的"讨论组里的补充"里,这里不重复。
有读者问怎么看 Go 社区的一句谚语"少量复制好过少量依赖"。Ousterhout 给了一个判据(2024-02,不在书里):其中一份要改时,另一份是不是也得跟着改?如果是,依赖还在,只是复制之后变得不明显了,这比原来更糟。
讨论里有读者举了一个修 bug 的场景:一个表示"高龄门槛"的常量被到处引用,现在某一处的门槛要调低;几乎没人敢改常量本身,都会在这一处另起一个值。Ousterhout 同意在那种处境下分开是最稳妥的,但不同意由此得出"复制更好":真正的病根是这个常量的含义从来没写清楚,各处对它做了不同的假设;复制只是一个构造得不好的代码库里害处较小的选择。Ousterhout 还采纳了另一位读者的区分:两段代码是逻辑上同一件事,还是只是碰巧写得一样。前者必须合并,后者不该合并。这和上面"消除重复也可能适得其反"说的是同一条线,判据更好用。
2026 年有读者贴了同一个小任务(给 Markdown 标题自动编号)的两种实现:一个函数写完,或者拆成两个类加若干小方法,请大家评。Ousterhout 选了前者,评审拆分版时用的判据值得记下来(2026-03,不在书里):
- 在调用处看不懂,抽取就没有收益。读到一行方法调用,如果从名字和文档看不出它做什么,就得去读方法体;既然还得读方法体,把代码搬出去图什么?
- 名字里看不出的副作用会把方法和调用者绑在一起。某个方法一边给标题加编号,一边悄悄累积计数状态,于是它只有在"对每个标题、按顺序、恰好调用一次"时才正确,和调用它的那个循环成了连体。
- "必须把它们同时装进脑子"就是没有抽象。如果要看懂其中任何一个类,都得把主循环和两个类一起读完,那这些类并没有提供模块化。
Ousterhout 还补了一句:不知道将来的扩展是什么,就无从判断哪一版更容易扩展;先用简单的版本,需要时再重构。提出拆分版的读者随后用"再加一个目录功能"来反驳,这一点 Ousterhout 没有再回,所以"哪版更经得起扩展"在那一串里是没有结论的。
有读者觉得 9.2(Java 把缓冲和文件输入流分开是错的,该合并)和 9.4(通用代码与专用代码该分开)互相打架。Ousterhout 的解法是:通用的缓冲机制完全可以是一个独立的模块,但它应当被文件流在内部使用,而不是作为一层交给使用者去套(2022-10,不在书里)。也就是说,分开的是实现,合起来的是使用者看到的接口。详见第 4 章"边界与反方"里的同一个例子。
有读者问:本章讲的不就是"高内聚、低耦合"吗,为什么全章不提这两个词?Ousterhout 回答说只是疏漏:两者足够相近,本该在这一章里提到,已经记下,下次修订时补上(2022-11)。2021 年的第 2 版里没有。所以读这一章时可以放心地把它和你已经知道的"内聚/耦合"对上:本章的贡献是给出了怎么判断两段代码相关的具体迹象。
练一练
下面三道题来自训练场:一道连体方法、一道"看起来重复其实没问题"、一道拆成浅方法的接口对比。
再加一道,取材于读者讨论组(对应上面"边界与反方"里的"讨论组"一栏):
动手:项目任务
minikv 里正好集齐了本章的三面红旗:几个命令处理函数里反复出现同一段参数检查和错误回复的代码;一个切词函数把结果写进成员变量,几个命令处理函数再从这些成员变量里读,不看前者就看不懂后者;还有一个通用的组件里夹带了只服务某一条命令的特殊逻辑。这个任务要你先用依赖线的眼光找出它们,再决定哪里该合、哪里该分,并说明每个决定依据的是本章的哪一条标准。
任务 m05:消除 minikv 中的重复代码、连体方法和通用/专用混杂。详见 任务页。
cd project/minikv && make test自测题
if (host == "payments.internal") timeout = 30s;。这属于:小结与读后一句话
- 拆分有成本:数量、接口、管理代码、分离、重复。目标是系统整体的复杂性更低,而不是每个组件更小。
- 相关的迹象:共享信息、双向地一起使用、概念重叠、离开另一个就看不懂。
- 该合:共享信息、合并后接口更简单、能消除重复(片段长且签名简单时抽方法,或让代码只在一处执行)。
- 该分:通用机制和专用代码;真正互相独立的部分。
- 方法:做一件事并做完整;长度很少是拆分的理由。合理的拆法是抽出能独立读懂的子任务,或(少见地)拆成几个接口更简单的方法。
- 三面红旗:重复、通用与专用混杂、连体方法。深度比长度重要。
全书"模块设计"这一部分到这里结束。第 10 章进入一个具体而常被忽视的复杂性来源:异常。异常也是接口的一部分,作者会给出一个听起来很激进的建议:把错误定义为不存在。
读后一句话:用你自己的话写下这一章最重要的一个观点,最好结合你写过的某段代码。