读前:这一章要回答什么

给定两段功能,应该放在同一个地方实现,还是分开?什么时候该拆一个方法,什么时候该把两个方法合起来?

这是软件设计里最基本、也最天天碰到的问题:缓冲该不该放进文件流类里?一个请求解析函数该不该拆成十个?它在函数、类、服务各个层次上都会出现。前面几章其实已经零散地回答过一部分:第 5 章说共享知识的代码该放在一起,第 6 章说通用和专用要分开,第 7 章说没有新抽象的层不该存在。第 9 章把这些标准收拢起来,再补上"方法该多长"这个最容易引起争论的问题。

这一章也是全书最直接和流行建议唱反调的地方:作者认为开发者普遍把方法拆得太碎了,并在 9.8 节点名反驳了《Clean Code》关于函数长度的主张。读这一章时,你很可能会发现自己团队的代码规范站在作者的对面。

  1. 章首的两个清单:一个列出"拆分本身带来的复杂性",一个列出"两段代码相关的迹象"。第二个清单里"一起使用"那一条有一个限定条件,作者用块缓存和哈希表举例说明,别漏掉。
  2. 9.5、9.6 节是一对正反例:一个是"合起来反而更糟",一个是"拆开反而更糟"。读的时候对照章首的清单,看作者是用哪几条判断出来的。
  3. 9.7 节的图 9.3:除了原方法,图里画了三种拆法,只有两种是合理的,其中一种作者说"不常见"。记住"抽出子任务"成立的两个条件,它们正是"连体方法"这面红旗的反面。

核心观点

拆分不是免费的

更多组件、更多接口、更多协调代码,相关代码被分开,有时还得复制。组件越小,单个越简单,整体未必越简单。

相关的才放在一起

共享信息、双向地一起使用、概念上同属一类、看一个离不开另一个:满足这些,合起来往往更好。

合并的三个理由

共享信息;合并后接口更简单(甚至能把某些事自动做掉);能消除重复。

重复是缺抽象的信号

一段不算简单的代码反复出现,说明你还没找到合适的抽象。

通用和专用要分开

通用机制只提供那一个机制;为某个用途定制的代码放到那个用途的模块里。

方法:做一件事,并做完整

接口简单、实现深,长一点没关系。长度本身很少是拆分的理由。

两种合理的拆法

抽出能独立读懂的子任务(最好);或把做了几件无关之事的方法拆成几个接口更简单的方法(少见)。

连体方法

不读另一个方法的实现就看不懂这一个,说明拆错了。深度比长度重要。

先算账:拆分的成本和"相关"的迹象

很多人默认"小就是好":组件越小,每个越简单。作者提醒,拆分这个动作本身就会带来原来没有的复杂性:

拆分的成本具体表现
数量组件越多越难记住、越难找到想要的那个;每个新组件通常还带来一个新接口。
管理代码原来用一个对象就够了,现在要创建、连接、协调好几个。
分离相关的代码被放进不同的类、不同的文件,看不到彼此。组件真正独立时这是好事;有依赖时,读代码的人只能来回翻,甚至意识不到依赖存在而写出 bug。
重复原来只有一份的代码,拆开后可能每个组件都需要一份。

所以合并的收益取决于两段代码有多相关。书中给了四个迹象,用自己的话记:

  1. 共享信息:比如两段代码都依赖同一种文件格式的语法。
  2. 一起使用:用了一个几乎一定会用另一个。但这条只有双向成立才有说服力。作者的反例是块缓存和哈希表:块缓存几乎总会用到哈希表,可哈希表在无数和块缓存无关的场合都会用,所以它们应该分开。
  3. 概念重叠:能被一个简单的上层类别同时概括,例如子串查找和大小写转换都属于"字符串处理"。
  4. 离开另一个就看不懂:读一段时总要去看另一段。

什么时候合在一起

共享信息:合(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 节

"连体"不只发生在方法之间:任何两段物理上分开、却只能对照着读的代码,都属于这种情况。下面的交互把同一个"导入订单"功能写成三种结构,并把代码行之间的依赖画出来:某一行用到的值是在哪一行产生的。

交互

依赖线:连体方法长什么样

同一个功能的三种写法。右侧弧线连接"产生某个值的行"和"用到它的行"。红线跨越了函数边界,而且走的是成员变量这种看不见的通道:读其中一个函数时,你必须同时打开另一个。
函数内部的依赖跨函数的隐藏依赖(成员变量)调用(参数进、结果出)
0
函数个数(接口数)
0
跨函数的隐藏依赖
0
读懂一个函数最多要连带读几个

代码是本站为说明结构而写的简化版。注意第三种写法也拆出了一个函数,但它和第二种的区别不在函数个数,而在于函数之间只通过参数和返回值交流: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 的块体最好只有一行(通常是一次函数调用),因此函数里不该出现嵌套结构,缩进不超过一两层。作者的回应可以概括为三点:

  1. 作者同意短函数一般比长函数好懂。但函数缩到几十行以内之后,再缩短对可读性的帮助就很小了。
  2. 真正该问的是:拆分有没有降低系统整体的复杂性?读几个短函数并弄清它们怎么配合,真的比读一个长一点的函数容易吗?函数越多,要写文档、要学的接口就越多。
  3. 函数拆得太小就失去了独立性,变成必须放在一起读的连体函数。这时还不如保留那个大一点的函数,让相关代码待在一处。
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

自测题

下列哪一项不是作者列出的"拆分本身带来的复杂性"?
书中列的是数量(含接口)、管理代码、分离、重复四项。测试难度不在其中。
块缓存几乎总要用哈希表。按本章的标准,它们应该:
这正是作者在章首用的反例。单向的"一起使用"不足以说明两者该合并。
三处代码都有一段 2 行的相同片段,而且都要读写调用处的 4 个局部变量。按 9.3 节,最可能的结论是:
抽方法最有效的情况是片段长、签名简单。片段只有一两行,或和很多局部变量纠缠时,替换成方法调用没有多大好处。(本站补充:如果这几个变量本来就是一个整体,更好的做法可能是把它们变成一个类型。)
一个通用的 HTTP 客户端库里有这么一段:if (host == "payments.internal") timeout = 30s;。这属于:
通用机制里出现了只服务某个具体用途的代码。支付服务的超时要求应该由支付相关的代码在调用时指定(客户端提供通用的"按请求设置超时"即可)。
你从一个方法里抽出了一个子方法。下面哪种情况说明这次拆分多半是个错误?
需要在父子之间来回翻才能看懂,就是"连体方法"。好的子任务应该让两边各自独立可读;被别处复用反而是好迹象。
关于"把一个方法拆成两个调用者可见的方法",哪项说法正确?
作者最推荐的是"抽出子任务"。拆成调用者可见的多个方法并不常见,如果调用者得把它们全调一遍、还要传状态,就是拆坏了。
作者对《Clean Code》"函数要非常短"的回应,下列哪项最准确?
作者没有走向另一个极端,顺序是:先把函数做深,再设法让它短到容易读;不要为了长度牺牲深度。

小结与读后一句话

全书"模块设计"这一部分到这里结束。第 10 章进入一个具体而常被忽视的复杂性来源:异常。异常也是接口的一部分,作者会给出一个听起来很激进的建议:把错误定义为不存在。

读后一句话:用你自己的话写下这一章最重要的一个观点,最好结合你写过的某段代码。