读前:这一章要回答什么

一个设计决定应该"住"在哪个模块里?怎样发现它已经悄悄住进了好几个模块?

第 4 章给出了目标:模块要深,接口要比实现简单得多。但它没有说怎么做到。从这一章开始,书用连续几章讲"造深模块的技术",第一个、也是最基础的一个,就是信息隐藏(information hiding):让每个模块把少数几个设计决定关在自己的实现里,接口不透露它们。

它的反面叫信息泄漏(information leakage):同一个设计决定出现在好几个模块里。回想第 2 章,复杂性的根源之一是依赖,症状之一是"改一处要动很多地方"。信息泄漏正是制造这种依赖最常见的方式,而泄漏最常见的成因,是一种几乎人人都会的切分方式:按时间顺序分解(temporal decomposition)。

这一章篇幅不长,书中的主要例子是学生实现的 HTTP 服务器,但它给出的三个红旗(泄漏、按时间分解、过度暴露 overexposure)会贯穿全书。本站的训练题也以这一章为重点。

  1. 5.1 节末尾那段 "Note":把变量声明成 private 不等于信息隐藏。读完想一想你最近写的一组 getter/setter:它们藏住了什么,又暴露了什么?
  2. 5.3 节的最后一段:作者并没有说"执行顺序不重要"。注意作者允许按阶段切分模块的那个条件,本页"边界与反方"一节会用到它。
  3. 5.5 节:作者说把类"稍微变大"常常能改善信息隐藏,并给了两个理由。读的时候把它和第 4 章的类炎对照起来看,再留意作者马上补充的那句"也可能做过头"。

核心观点

信息隐藏 = 把设计决定关起来

数据结构、算法、页大小这样的底层常量,乃至"大多数文件都很小"这样的高层假设,都可以只存在于一个模块的实现里。好处有两个:接口更简单(使用者要记的少),改这个决定只动一个模块(系统好演进)。

private ≠ 隐藏

私有变量配上一对 getter/setter,它是什么、怎么用,照样暴露给了所有调用者,和公开变量差不多。访问控制只是工具,隐藏的是知识。

泄漏 = 同一个决定出现在多处

改这个决定,所有知道它的模块都得一起改。写进接口的信息,按定义就已经泄漏了;所以接口越简单,往往信息藏得越好。

后门泄漏更阴险

后门泄漏(back-door leakage):两个类都懂同一种文件格式,但谁的接口里都没写。改格式时编译器不会提醒你,读头文件也看不出来,这正是第 2 章说的"未知的未知"。

按时间顺序分解

按"先读、再改、后写"切模块。可大多数设计决定会在程序生命中的好几个时刻用到,于是同一份知识被复制进好几个阶段。

把接口的层次提高

与其提供三个"步骤方法"让调用者按顺序调用,不如提供一个方法把整件事做完。类会稍微变大,但更深。

默认就做对的事

几乎人人都要的行为应该自动发生;罕见需求另走单独的入口。逼着所有人先了解罕用功能,就是过度暴露(overexposure)。

心智模型:知识住在哪里

判断一个切分好不好,先别看类图上的方框,而是把系统里的设计决定一条条列出来,问每一条:有几个模块必须知道它?理想答案是一个。下图是同一个"读段文件、处理、写回"程序的两种切法。

按时间顺序切 执行时间 → 读 改 写 格式 格式 脱敏规则 校验工具 格式 一个决定,三个家:改格式要改三处 按知识切 读阶段 脱敏 写阶段 校验工具 → store.verify() SegmentStore 格式 Redactor 脱敏规则 同一个类在不同时刻被用到,格式只有一个家
横轴是执行时间。左边每个阶段一个类,格式知识跟着阶段被复制;右边按知识划分,"读"和"写"这两个时刻都去用同一个类。本站自绘示意。
When designing modules, focus on the knowledge that's needed to perform each task, not the order in which tasks occur.— 第 5.3 节

下面这个小工具把上图展开成一张"知识归属表":行是设计决定,列是模块,格子表示"这个模块必须知道这个决定"。点击某一行,假装你要改这个决定,看看要动几个模块;再勾上"只看头文件",体会后门泄漏为什么难以发现。

交互

谁知道这个决定?

场景(本站原创):一个访问日志归档工具,读入一批段文件(segment),把其中的个人信息脱敏,再写成新的段文件;另有一个 fsck 工具检查段文件是否损坏。表头的 Reader / Writer 即下文代码里的 SegmentReader / SegmentWriter。
主人 唯一该知道它的模块 后门 知道它,但接口上看不出来 接口 接口里就写着它 配置 有意暴露的可调参数
0
改这个决定要动的模块
0
全表后门泄漏处数
0
只有一个家的决定

表里的归属是按下文"改之前 / 改之后"两段代码逐条标出来的。真实项目里没人给你画这张表,这正是本章要训练的能力:看到一段代码,能说出它依赖哪些别处也在用的知识。

一个原创例子:先读、再改、后写

把上面的场景写成代码。很多人第一次写这类工具,会顺着脑子里的流程图写出左边的结构,书里叫它按时间顺序分解。

class SegmentReader {            // 第一步:读
public:
    explicit SegmentReader(const std::string& dir);   // 按文件名顺序打开 seg-*.log
    std::optional<LogRecord> next();
};
class Redactor {                 // 第二步:改
public:
    void apply(LogRecord& r) const;
};
class SegmentWriter {            // 第三步:写
public:
    SegmentWriter(const std::string& dir, const char* namePattern, size_t rotateBytes);
    void append(const LogRecord& r);   // 实现:writeLE32(len); writeLE32(crc32(payload)); ...
};

// segment_reader.cpp
std::optional<LogRecord> SegmentReader::next() {
    uint32_t len = readLE32(in_);
    uint32_t crc = readLE32(in_);
    std::string payload = readBytes(in_, len);
    if (crc32(payload) != crc) throw CorruptSegment(path_);
    return decodeRecord(payload);    // 和 writer 里的 encodeRecord 必须严格互逆
}
// tools/fsck.cpp 里还有第三份:同样的 readLE32、同样的 crc32 比较……

第 13 行(写)、第 18~21 行(读)以及 fsck 里的第三份,各自"知道"帧格式和校验算法,但三个类的接口里都没有提到它们,这是后门泄漏。第 12 行更直接:文件命名模式和轮转阈值写进了构造函数签名,调用者被迫知道段文件怎么命名,这是接口泄漏。

// 一个目录下按顺序排列的一组段文件。帧格式、校验算法、记录编码、
// 文件命名与轮转规则,只在这个类的实现里出现。
class SegmentStore {
public:
    struct Options { size_t segmentBytes = 64 << 20; };  // 不同部署可能要调
    explicit SegmentStore(std::string dir, Options opt = {});

    class Scanner { public: std::optional<LogRecord> next(); /* … */ };
    Scanner scan() const;            // 按写入顺序遍历;损坏时抛 CorruptSegment(带文件名和偏移)
    void append(const LogRecord& r); // 满了自动换新段
    VerifyReport verify() const;     // 给 fsck 用
};

// main:顺序仍然在这里,但只剩顺序
SegmentStore in(srcDir), out(dstDir);
Redactor redact(rules);
for (auto it = in.scan(); auto rec = it.next(); ) {
    redact.apply(*rec);
    out.append(*rec);
}

读和写发生在不同的时刻,但它们依赖同一份知识,所以放进同一个类。fsck 工具变成一行 store.verify()。Redactor 仍然是单独的"一个阶段",这没问题,因为脱敏规则和段文件格式毫无关系。

好在哪里:把长度字段改成 varint、把 CRC32 换成 CRC32C、给文件头加版本号,现在都只改一个类;编码和解码挨在一起,一个往返测试(写进去、读出来、比较)就能锁住它们互逆。代价:SegmentStore 比原来任何一个类都大,读和写两条路径挤在一个文件里;如果哪天它大到难以理解,就需要第 9 章的判断标准来决定怎么再拆,但拆的依据仍然是知识,不是时间顺序。

书中 5.1 节列举的"可以藏在模块里的信息",有几条你在 OSTEP 里亲手见过:文件的逻辑块怎么映射到磁盘物理块(OSTEP 第 40 章的 inode 与间接块),线程在多核上怎么调度(第 10 章)。文件系统从直接指针改成 extent、调度器从 O(1) 换成 CFS,上层程序一行都不用改,这就是"改设计只动一个模块"的大规模实例。

private 不等于隐藏

第二个例子更隐蔽,因为它"看起来封装得很好":成员变量是私有的,访问都走方法。

class RouteTable {
public:
    // 按前缀长度从长到短排好序;调用者可以从头扫描,第一个匹配的就是最长前缀
    const std::vector<Route>& routes() const { return routes_; }
    void setRoutes(std::vector<Route> r) { routes_ = std::move(r); }  // 调用者负责先排好序
private:
    std::vector<Route> routes_;
};

routes_ 是私有的,但"它是一个按前缀长度排序的 vector"这件事,以及"最长前缀匹配怎么做",全都写在了接口注释里,每个调用者都依赖它。哪天为了性能换成前缀树(trie),所有调用者都得改。

class RouteTable {
public:
    // 最长前缀匹配;没有匹配的路由时返回 nullptr。
    const Route* lookup(IpAddr dst) const;
    void add(const Route& r);          // 前缀已存在时替换
    bool remove(const Prefix& p);      // 不存在时返回 false
private:
    std::vector<Route> routes_;        // 现在:排序数组;以后:随便换
};

表示方式和匹配算法都缩回了类里。顺带,lookup 让调用者少写一个循环,接口更深了。书中 5.6 节 HTTP 请求的 getParams() 返回内部 Map 是同一个问题,作者还指出了另一层风险:调用者可能改动返回的 Map,从而破坏对象的内部状态。

默认值:部分隐藏

不是所有信息都能完全藏住。书中把"只有少数调用者需要、而且走单独方法访问"的信息称为部分隐藏(partial information hiding):常见路径上看不见它,所以依赖少得多。默认值就是典型。书中 5.7 节举的反例是:HTTP 响应的协议版本本来可以从请求里推出来,却要求调用者显式传入。作者说,调用者多半不知道该填什么;就算填了,也很可能造成库和调用者之间的信息泄漏。

The best features are the ones you get without even knowing they exist.— 第 5.7 节

放到 C++ 里,常见的例子是:一个日志库默认就带缓冲和按天轮转,想关掉的人去调 setRotation();一个 RPC 框架回复时从请求对象里自动带上相关 id 和编码方式,而不是让每个处理函数自己抄一遍。训练题里有这样的对比。

类的内部也一样

信息隐藏不只用在类的公开接口上。作者建议:每个私有方法也各自封装一点知识;并且尽量减少每个成员变量被用到的地方。一个成员变量被二十个方法直接读写,就是类内部的一次"泄漏":改它的含义,二十处都得重新检查。

评审时怎么嗅出泄漏

本站补充几条经验线索(不是书里的清单,只是帮你把"对泄漏敏感"落到具体动作上):

边界与反方:什么时候别生搬硬套

信息隐藏只对模块外不需要的信息有意义。作者的例子是性能配置参数:如果不同的使用场景确实需要不同的取值,就必须放进接口让人去调。书中给出的优先顺序是:最好是模块自己调好;做不到,就老老实实暴露出来。上面 SegmentStore::Options::segmentBytes 就属于这一类。把需要的信息藏起来,得到的是第 4 章说的虚假的抽象。

执行顺序总要在某处体现(例子里是 main)。作者反对的是让顺序决定模块结构,同时也留了一个口子:如果各个阶段用到的信息完全不同,按阶段切分是可以的。下载图片、生成缩略图、上传对象存储,三步各管各的知识(HTTP 重试、图像解码、分片上传),分成三个模块并不泄漏。判断标准始终是"知识在哪",不是"像不像流水线"。

修泄漏的办法,书里给了两种:几个类都小、又和泄漏的知识紧密相关时,把它们合并;或者把那份知识抽出来,单独放进一个新类。第二种有个前提:新类要能提供远比知识本身简单的接口。如果你抽出一个 FrameCodec,接口里却列着 headerSize()、lengthOffset()、checksumOffset(),调用者还是得懂格式,只是把后门泄漏换成了接口泄漏。

作者在 5.5 节明确说,把类稍微变大常常能改善信息隐藏,这和"类越小越好"的流行建议直接冲突。但作者紧接着承认这也能做过头,比如整个应用只有一个类。合并的理由应该是"这些代码共享同一份知识",而不是"合在一起更省事"。什么时候该拆,第 9 章会给出更系统的判断(本站专题:APOSD 与 Clean Code 有完整对照)。

以下不在书里,来自书中给出的读者讨论组。2023 年有读者推介一种做法:所有功能都写成由事件触发的函数,函数之间不互相调用,只读写一份共享的事件记录;据称这样函数彼此独立、不存在契约。Ousterhout 的反驳很适合当作本章的反面教材(2023-06):函数之间其实在通过那份共享记录通信,后面的函数会用到前面的函数写下的东西,也必然期待在里面找到特定的内容,这就是契约;每个事件都可能受到之前所有事件的影响。这相当于一个程序里所有变量都是全局变量,没有任何隐藏信息的手段。

Ousterhout 的概括是:没有接口,和让接口变得不明显,是两回事;这种做法属于后者。读到"我们的模块之间零耦合,只通过消息/数据库/配置中心交互"这类说法时,可以用同一个问题检验:共享的那份数据里,谁在假设谁写了什么?假设还在,依赖就还在,只是从签名里搬到了看不见的地方。

练一练

下面三道题来自训练场:一道按时间顺序分解,一道"看起来像但其实没问题",一道默认值设计。本章题库共 22 题,去训练场做完整一组。

再加一道,取材于读者讨论组(对应上面"边界与反方"里的"讨论组"一栏):

动手:项目任务

本站的 minikv 是一个能跑、有测试、但按"战术式"风格写成的 C++ 键值存储。它的日志代码恰好按"写、读、解析"的时间顺序切成了 LogWriter、LogReader、RecordParser 三个类,记录格式这一个设计决定三个类各知道一份,还漏进了 store.cpp。去找这些痕迹:LogReader::next 里那个没有解释的 skip,store.cpp 里反复出现的"记录头大小 + 键长 + 值长",以及 Store::load() 里把几个类串起来的胶水代码。然后把格式收进一个深模块,测试保持全绿。这个任务和第 4 章共用(第 4 章那一半是浅类和参数过多的 open)。

任务 m01:把浅类合成深模块,把泄漏的记录格式收进一个地方。详见 任务页。

cd project/minikv && make test

自测题

一个类的所有成员变量都是 private,每个变量都有对应的 getter 和 setter。按本章的观点:
private 能防止外部直接访问,是信息隐藏的工具,但不是信息隐藏本身。getter/setter 把变量的本质原样摆在接口上,改它的表示方式时调用者照样要改。
为什么书中认为"后门泄漏"比"通过接口泄漏"更糟?
接口上的泄漏至少看得见,编译器和代码评审能帮你找到所有依赖方。后门泄漏(比如两个类各自懂同一种文件格式)只能靠记忆或全文搜索,是第 2 章"未知的未知"的典型来源。
一个配置热更新功能被拆成 Fetcher(下载)、Parser(解析)、Applier(生效)三个类。以下哪种情况最能说明它犯了"按时间顺序分解"的毛病?
按顺序调用本身不是问题,顺序总得在某处体现。问题在于同一份知识(文件头格式)因为在两个时刻都要用,被复制进了两个阶段。B 是 Applier 自己该有的知识,不算泄漏。
一个缓存模块的性能高度依赖容量,而不同服务的最佳容量相差百倍,模块也没法自动判断。按 5.9 节,应该:
信息隐藏只适用于模块外不需要的信息。这里不同使用场景确实需要不同的值,就必须暴露。作者更推荐的是模块能自动调整;做不到时,老实暴露。B 是一种后门泄漏:依赖仍然存在,只是看不见了。
发现两个类都知道同一种二进制格式。你抽出一个 FormatCodec 类,接口是 headerSize()、lengthFieldOffset()、checksumFieldOffset()。这次重构:
抽新类只有在新类能给出一个远比知识本身简单的接口时才有效,例如 encode(record) / decode(bytes)。这个接口只是把格式的细节换了个地方公开,两个类照样依赖它。
关于"把类稍微变大",下列哪项符合书中 5.5 节的意思?
作者给的两个理由:把某项能力的所有代码聚到一起,以及提高接口层次(一个方法完成整个计算)。作者同时提醒,整个应用一个类显然过头了,第 9 章讨论什么时候该拆。

小结与读后一句话

第 6 章换一个角度看同一件事:模块该为今天的具体需求量身定制,还是做得"有点通用"?作者的答案会让很多信奉"先满足需求再说"的人意外,而给出的主要理由正是:通用的接口能藏住更多信息。

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