读前:这一章要回答什么

怎样切分模块,才能让每个开发者在任何时候只需要面对系统复杂性的一小部分?

第 2 章把复杂性拆成三种症状:改一处要动很多地方(变更放大)、要记住的东西太多(认知负担)、不知道自己不知道什么(未知的未知)。从这一章开始,书转向"怎么办",而第一个、也是全书最核心的工具就是模块化设计:用接口把实现的复杂性挡在模块内部。

这一章的全部内容可以压缩成一个比喻和一个判断标准:把模块想成一个矩形,顶边是接口,面积是功能。顶边越短、面积越大,模块越"深",越值得存在。后面几章(信息隐藏、通用模块、分层、把复杂性往下拉)讲的其实都是"怎样把模块做深"。

  1. 4.2 节"接口里有什么":注意作者对接口的定义有多宽。不只是函数签名,调用顺序、副作用都算。读完试着回答:strtok 的接口有几条?
  2. 4.3 节对"抽象"的定义里有一个词特别关键:unimportant(不重要的)。抽象出错的两种方式都和这个词有关。
  3. 4.6~4.7 节:作者在这里公开反对一条流行的建议(类和方法要尽量小)。读的时候想想你团队的代码规范,看它站在哪一边。

核心观点

模块 = 接口 + 实现

类、函数、子系统、网络服务都是模块。理想情况下,用一个模块只需懂它的接口,不必懂它的实现。

接口 = 使用者必须知道的一切

包括编译器能检查的正式部分(签名),也包括只能靠注释说明的非正式部分(行为、调用顺序、副作用)。后者通常更大。

抽象 = 省略不重要的细节

多放了不重要的细节,抽象就变重;漏掉了重要的细节,就成了虚假的抽象。

深度 = 收益 / 成本

收益是模块提供的功能,成本是它的接口给系统其他部分带来的复杂度。

浅模块是红旗

接口几乎和实现一样复杂时,学习接口的成本抵消了隐藏实现的好处。

类炎(classitis)

"类越小越好、越多越好"会制造大量浅类,每个类各带一个接口,系统整体反而更复杂。

让常见用法最简单

接口功能再多,只要大多数人只用到其中几个,有效复杂度就只是这几个的复杂度。

心智模型:把模块画成矩形

先用书里的比喻建立直觉:把一个模块画成矩形,顶边长度代表接口的复杂度(使用者要学多少东西),面积代表它实现的功能(替使用者扛下了多少复杂性)。

深模块 接口短,背后功能多 ← 接口 实现 浅模块 实现 接口长,背后没藏多少东西 使用者学接口的成本 ≈ 自己写一遍的成本
同样的面积(功能),顶边(接口)越短越好。本站自绘示意。

"接口复杂度"不是签名的字符数,而是使用者为了正确使用必须知道的条目数,包括那些只写在文档里的约束。下面这个小工具把几个模块的接口逐条列出来,按条目数画顶边、按实现规模画高度,右侧列出被藏起来的关注点。切换预设,然后打开"只看常见用法",观察顶边怎么变化。

交互

深度计:接口清单 → 矩形

左边是使用者必须知道的条目(正式 = 签名里能看到;非正式 = 只能靠文档;罕用 = 只有少数人需要)。点击条目可以划掉它,看看去掉某个接口条目后模块变深了多少。
    0
    接口条目(顶边)
    0
    实现规模(粗估)
    —
    矩形形状

    矩形高度按实现规模的对数画(规模跨了好几个数量级);条目数和规模都是本站为了比较而做的粗略估计,用来建立直觉,不是精确度量。真正重要的问题是:这个模块替使用者藏起来的东西,是否远多于它要求使用者学的东西?

    接口远不止签名

    书中强调:只要使用者必须知道某个信息才能正确使用模块,这个信息就属于接口,不管它有没有出现在签名里。C 标准库里有一个很好的反面教材:

    char *strtok(char *str, const char *delim);

    签名只有两个参数,但想正确使用它,你还得知道:

    1. 第一次调用传字符串,之后要传 NULL 才是"继续切同一个字符串";
    2. 它会把原字符串里的分隔符改写成 '\0',所以不能传字符串字面量;
    3. 它用一个隐藏的静态变量记位置,所以不能嵌套切两个字符串,也不能在多线程里用;
    4. 连续的分隔符会被当成一个,空字段会被跳过。

    四条非正式接口,每一条都曾让很多人踩坑。后来 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 在讨论组里的回答有三层(均不在书里):

    还有一个 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

    自测题

    下列关于"模块"的说法,哪一项符合本书的用法?
    书中对模块的定义非常宽:只要有接口和实现之分就算。所以"模块应该深"这条原则对方法、类、子系统、HTTP 服务同样适用。
    一个方法要求"必须先调用 init() 才能调用它",这条约束在本书的框架里属于什么?
    使用者必须知道才能正确使用,所以它是接口;编译器不检查它,只能靠文档说明,所以它是非正式部分。这样的约束越多,接口越"宽",后面几章会讨论如何消除它们。
    "深度"在本章的比喻里怎么看?
    深度是收益与成本之比:功能是收益,接口是成本。行数、方法数、继承层次都不是深度。
    垃圾回收器为什么被书中视为深模块的例子?
    引入垃圾回收后,"释放对象"这项接口从整个系统中消失了,系统的总接口反而变小,同时它在内部隐藏了大量复杂的实现。C++ 里的 RAII 和智能指针也是类似思路。
    关于"类炎"(classitis),下列哪种说法最准确?
    类炎的问题不在单个类,而在总量:类小了,每个类提供的功能就少,所以需要更多类,接口的总量随之膨胀,代码也因为每个类的样板而变得啰嗦。
    一个缓存库对外宣称"写入后即可读到",但没有说明数据只在内存里,进程崩溃会丢失。按本章的说法,这是:
    只有真正不重要的细节才能省略。对需要持久性的调用者来说,"会不会丢"非常重要,省略它会让抽象看起来简单,实际却让使用者无法正确使用。

    小结与读后一句话

    接下来的第 5 章回答一个自然的问题:怎样才能把模块做深?答案是信息隐藏。同时它也会给出一个和深度密切相关的红旗:信息泄漏。

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