读前:这一章要回答什么
怎样切分模块,才能让每个开发者在任何时候只需要面对系统复杂性的一小部分?
第 2 章把复杂性拆成三种症状:改一处要动很多地方(变更放大)、要记住的东西太多(认知负担)、不知道自己不知道什么(未知的未知)。从这一章开始,书转向"怎么办",而第一个、也是全书最核心的工具就是模块化设计:用接口把实现的复杂性挡在模块内部。
这一章的全部内容可以压缩成一个比喻和一个判断标准:把模块想成一个矩形,顶边是接口,面积是功能。顶边越短、面积越大,模块越"深",越值得存在。后面几章(信息隐藏、通用模块、分层、把复杂性往下拉)讲的其实都是"怎样把模块做深"。
- 4.2 节"接口里有什么":注意作者对接口的定义有多宽。不只是函数签名,调用顺序、副作用都算。读完试着回答:
strtok的接口有几条? - 4.3 节对"抽象"的定义里有一个词特别关键:unimportant(不重要的)。抽象出错的两种方式都和这个词有关。
- 4.6~4.7 节:作者在这里公开反对一条流行的建议(类和方法要尽量小)。读的时候想想你团队的代码规范,看它站在哪一边。
核心观点
模块 = 接口 + 实现
类、函数、子系统、网络服务都是模块。理想情况下,用一个模块只需懂它的接口,不必懂它的实现。
接口 = 使用者必须知道的一切
包括编译器能检查的正式部分(签名),也包括只能靠注释说明的非正式部分(行为、调用顺序、副作用)。后者通常更大。
抽象 = 省略不重要的细节
多放了不重要的细节,抽象就变重;漏掉了重要的细节,就成了虚假的抽象。
深度 = 收益 / 成本
收益是模块提供的功能,成本是它的接口给系统其他部分带来的复杂度。
浅模块是红旗
接口几乎和实现一样复杂时,学习接口的成本抵消了隐藏实现的好处。
类炎(classitis)
"类越小越好、越多越好"会制造大量浅类,每个类各带一个接口,系统整体反而更复杂。
让常见用法最简单
接口功能再多,只要大多数人只用到其中几个,有效复杂度就只是这几个的复杂度。
心智模型:把模块画成矩形
先用书里的比喻建立直觉:把一个模块画成矩形,顶边长度代表接口的复杂度(使用者要学多少东西),面积代表它实现的功能(替使用者扛下了多少复杂性)。
"接口复杂度"不是签名的字符数,而是使用者为了正确使用必须知道的条目数,包括那些只写在文档里的约束。下面这个小工具把几个模块的接口逐条列出来,按条目数画顶边、按实现规模画高度,右侧列出被藏起来的关注点。切换预设,然后打开"只看常见用法",观察顶边怎么变化。
深度计:接口清单 → 矩形
矩形高度按实现规模的对数画(规模跨了好几个数量级);条目数和规模都是本站为了比较而做的粗略估计,用来建立直觉,不是精确度量。真正重要的问题是:这个模块替使用者藏起来的东西,是否远多于它要求使用者学的东西?
接口远不止签名
书中强调:只要使用者必须知道某个信息才能正确使用模块,这个信息就属于接口,不管它有没有出现在签名里。C 标准库里有一个很好的反面教材:
char *strtok(char *str, const char *delim);
签名只有两个参数,但想正确使用它,你还得知道:
- 第一次调用传字符串,之后要传
NULL才是"继续切同一个字符串"; - 它会把原字符串里的分隔符改写成
'\0',所以不能传字符串字面量; - 它用一个隐藏的静态变量记位置,所以不能嵌套切两个字符串,也不能在多线程里用;
- 连续的分隔符会被当成一个,空字段会被跳过。
四条非正式接口,每一条都曾让很多人踩坑。后来 POSIX 增加了 strtok_r,把隐藏状态变成显式参数,第 3 条就从"必须记住的约束"变成了"签名里看得见的参数"。非正式接口仍然是接口,只是编译器帮不了你。所以第 12、13 章会花很大篇幅讲接口注释。
如果你读过 OSTEP,书中拿来当深模块典范的 Unix 文件 I/O(4.4 节)你应该很熟:open/read/write/lseek/close 五个调用背后,是 OSTEP 第 39~43 章讲的 inode、目录、缓存、日志、崩溃一致性。把两本书对照着看,你会对"接口有多短、背后有多深"有切身的体会。
一个原创例子:类炎与深类
下面的场景在业务代码里很常见:读取 INI 配置。左边按"每个类只做一件事"拆成了 5 个类;右边只有一个类。先只看调用方的代码,体会使用者要学多少东西。
auto text = FileLoader().load("app.ini");
auto lines = LineSplitter().split(text);
auto clean = CommentStripper('#', ';').strip(lines); // 注释符号由调用者决定?
auto pairs = KeyValueParser('=').parse(clean);
auto table = SectionGrouper().group(pairs);
int port = std::stoi(table["server"]["port"]); // 不存在时?非数字时?
调用者要学 5 个类的接口,要知道它们必须按这个顺序串起来(这也是接口的一部分),还要自己处理类型转换和缺失值。每个类单独看都很"整洁",合起来却很重。
// 一份已解析的配置。load 失败(文件不存在、语法错误)时抛 ConfigError,
// 错误信息里带文件名和行号。
class Config {
public:
static Config load(const std::string& path);
std::string getString(std::string_view key, std::string_view def = "") const;
int getInt(std::string_view key, int def) const; // 值不是整数时抛 ConfigError
bool getBool(std::string_view key, bool def) const; // 接受 true/false/yes/no/1/0
};
auto cfg = Config::load("app.ini");
int port = cfg.getInt("server.port", 8080);
换行符差异、注释语法、空白、分节规则、类型转换、带行号的错误报告,全都藏到了 Config 里面。实现可能比原来那 5 个类加起来还长,但这不要紧:复杂性在模块内部只需要被理解一次,在接口上却要被每个使用者理解一次。
注意"改之后"的实现并不比"改之前"简单,可能更长。深模块的意思不是"代码少",而是"使用者需要知道的少"。这一点第 8 章会推得更远:宁可实现复杂,也要让接口简单。
浅类与浅方法:没有收益的接口
浅的可以是一个方法,也可以是整个类。书里(4.5 节)举的是学生项目中一个只有一行的私有方法。下面这个类的每个方法都是这样的浅方法,合起来就是一个浅类。它常见于"不许直接用标准容器,要包一层有业务含义的类型"的规范:
// 订单里的商品列表
class ItemList {
public:
void addItem(const Item& item) { items_.push_back(item); }
void removeLastItem() { items_.pop_back(); }
size_t getItemCount() const { return items_.size(); }
const Item& getItem(size_t i) const { return items_[i]; }
private:
std::vector<Item> items_;
};
四个方法各自把 std::vector 的一个操作换了个名字。使用者要多学一套名字,可 vector 的规矩一条也没少:空列表上调用 removeLastItem 是未定义行为,getItem 不检查越界,这些照样得知道;原本现成的迭代器、范围 for 和标准算法反而用不了了。它增加了一个要学的接口,却没有替谁隐藏任何东西。要么直接用 std::vector<Item>,要么让这个类真正承担职责(比如合并同一商品的数量、维护"每单最多 100 件"的约束),它才值得存在。
这和下文"链表天生就浅"不是一回事:链表自己提供了基本功能,只是杠杆小;ItemList 是在一个现成的接口外面又包了一层,自己没有添加任何功能。
边界与反方:什么时候别生搬硬套
深度是比值。一个 3000 行、带 80 个公开方法的类不一定深,可能只是一堆浅方法装在一起(看训练题里那个满是 getter/setter 的 HttpRequest)。反过来,一个只有一行声明的函数可能非常深。判断时只问一个问题:它替使用者藏起来的,是否远多于它要求使用者学的?
作者自己承认,链表这类数据结构天生就浅:插入、删除几行代码就能写完,接口几乎没法比实现更简单。它们仍然有用,只是对抗复杂性的杠杆很小。别为了"变深"去强行给它加功能。
很多代码规范要求"方法不超过 N 行",有的 N 小到 10。作者认为这种规则会批量制造浅方法。注意作者反对的是把长度本身当目标,并不是提倡写长函数。什么时候拆、什么时候合,第 9 章给了更细的判断标准(例如:拆开后两段代码还能不能分开理解)。这场争论在本站的专题:APOSD 与 Clean Code 里有完整对照。
书中批评 Java 多年来打开文件都要叠三层对象、缓冲还得自己加(4.7 节),重点不是"选项不好",而是罕用的选项不该挡在常用路径上。好的做法是:默认就是大多数人想要的行为,罕见需求通过可选参数或单独的入口提供,大多数人根本不需要知道它们存在。
以下不在书里,来自书中给出的读者讨论组。4.7 节对 Java I/O 提了两条意见:缓冲不是默认的;读序列化对象还要再套一个类。2019 年有读者指出,后一个类可以叠在任何输入流上(压缩流、内存流、网络流),本身就是"较通用"的模块。Ousterhout 回帖承认,对这个类的批评更有争议,并说自己在意的主要不是执行效率,而是使用者要面对的复杂性(2019-06)。读这个例子时,只取"缓冲应当默认"那一条就够了。
2022 年又有两位读者替分层辩护:缓冲和数据来源是正交的,分成三层,每层都可以单独替换。Ousterhout 同意"正交"这个前提,给出的设计是把通用的缓冲模块垫在各种来源的下面、供它们内部使用,而不是叠在上面、让使用者自己套:通用机制仍然只有一份,但绝大多数使用者不必知道它存在(2022-10、2022-11)。对"只是少打几个字"的说法,Ousterhout 的回答是:复杂性正是由一大堆"看起来没那么糟"的决定堆出来的;而且忘了套缓冲时程序照样能跑,只是很慢。
同一批讨论里有两点 Ousterhout 没有回应。一是交互式输入(逐字符、按行读终端)恰恰不想要标准缓冲;二是管道和依赖背压的自定义流,默认缓冲会改变它们的行为。它们不推翻"常见情况应当默认做对",但提醒你:"默认"替使用者做的决定越多,就越要留一个看得见的出口,并在接口注释里写明默认行为是什么。
有读者追问"深度到底怎么量"。Ousterhout 在讨论组里的回答有三层(均不在书里):
- 深度是用来讨论和比较的定性说法,不值得纠结精确定义,更不该去量化(2024-05)。本页的"深度计"只是帮你建立直觉,别把那个比值当指标用。
- 判断深度看的是模块提供的功能,这应当从接口文档上看得出来;实现更长不等于更深,也可能只是实现得差。
- 有用的抽象不一定深。栈很有用,但很浅,远不如 Unix 文件 I/O 那样的接口;Ousterhout 猜测它的价值更多在于提供了一个心智模型,让别的代码得以组织得干净,而不是藏起了多少实现复杂性(2018-10)。这和上面"浅模块有时无法避免"是同一个意思,只是把"为什么仍然值得用"说出来了。
还有一个 Ousterhout 自己承认没有答案的问题:一个只是先做一点登记、再转调深方法的包装方法,它的接口和那个深方法一样"深",可它本身对控制复杂性没什么贡献。Ousterhout 提出也许该把深度看成一组接口的属性而不是单个方法的属性,但这样一来怎么判断单个方法好不好,目前没有简单的答案。
书中用"五个基本调用"说明 Unix 文件 I/O 很深(4.4 节)。2025 年有读者问:要保证数据落盘还得用 fsync,漏掉它是不是低估了接口的复杂度?Ousterhout 的回答把第 2 章的定义用到了接口上(2025-03,不在书里):接口的复杂度要按多少人、多经常需要知道来算。fsync 多数程序用不到,所以不给多数人增加负担;但如果你写的代码大多都要用它,那对你来说它就是 I/O 接口的基本组成部分,评估复杂度时应当算进去。Ousterhout 自述写过上千个程序,只有很少几个需要它;这是个人经验,不是统计。
读过 OSTEP 的读者可以把这段和对照专题连起来看:同一个接口,对写普通工具的人是深的,对写存储引擎的人要浅得多。"深"不是模块自身的常数,而是相对于使用者而言的。
练一练
下面三道题来自训练场。先自己判断再看解析,答题记录会进入间隔复习。
再加两道,取材于读者讨论组(对应上面"边界与反方"里的"讨论组"一栏):
动手:项目任务
本站的 minikv 是一个能跑、有测试、但按"战术式"风格写成的 C++ 键值存储。读完本章和第 5 章后,去做任务 m01:在它的日志读写代码里找出一串浅类、一个参数过多的 open,以及被好几个类同时知道的记录格式,把它们重构成更深的模块,测试保持全绿。
任务 m01:把浅类合成深模块,把泄漏的记录格式收进一个地方。详见 任务页。
cd project/minikv && make test自测题
init() 才能调用它",这条约束在本书的框架里属于什么?小结与读后一句话
- 模块化的目标是减少模块间的依赖;接口就是"别人用你时必须知道的一切",非正式部分往往比签名大。
- 好的抽象只省略不重要的细节;漏掉重要细节会得到虚假的抽象。
- 深模块用简单的接口提供强大的功能;浅模块(接口≈实现)是红旗。
- "类和方法越小越好"会导致类炎;该追求的是深度,不是小。
- 让常见用法最简单:罕用功能可以有,但不要挡在常用路径上。
接下来的第 5 章回答一个自然的问题:怎样才能把模块做深?答案是信息隐藏。同时它也会给出一个和深度密切相关的红旗:信息泄漏。
读后一句话:用你自己的话写下这一章最重要的一个观点,最好结合你写过的某段代码。