读前:这一章要回答什么
一个设计决定应该"住"在哪个模块里?怎样发现它已经悄悄住进了好几个模块?
第 4 章给出了目标:模块要深,接口要比实现简单得多。但它没有说怎么做到。从这一章开始,书用连续几章讲"造深模块的技术",第一个、也是最基础的一个,就是信息隐藏(information hiding):让每个模块把少数几个设计决定关在自己的实现里,接口不透露它们。
它的反面叫信息泄漏(information leakage):同一个设计决定出现在好几个模块里。回想第 2 章,复杂性的根源之一是依赖,症状之一是"改一处要动很多地方"。信息泄漏正是制造这种依赖最常见的方式,而泄漏最常见的成因,是一种几乎人人都会的切分方式:按时间顺序分解(temporal decomposition)。
这一章篇幅不长,书中的主要例子是学生实现的 HTTP 服务器,但它给出的三个红旗(泄漏、按时间分解、过度暴露 overexposure)会贯穿全书。本站的训练题也以这一章为重点。
- 5.1 节末尾那段 "Note":把变量声明成
private不等于信息隐藏。读完想一想你最近写的一组 getter/setter:它们藏住了什么,又暴露了什么? - 5.3 节的最后一段:作者并没有说"执行顺序不重要"。注意作者允许按阶段切分模块的那个条件,本页"边界与反方"一节会用到它。
- 5.5 节:作者说把类"稍微变大"常常能改善信息隐藏,并给了两个理由。读的时候把它和第 4 章的类炎对照起来看,再留意作者马上补充的那句"也可能做过头"。
核心观点
信息隐藏 = 把设计决定关起来
数据结构、算法、页大小这样的底层常量,乃至"大多数文件都很小"这样的高层假设,都可以只存在于一个模块的实现里。好处有两个:接口更简单(使用者要记的少),改这个决定只动一个模块(系统好演进)。
private ≠ 隐藏
私有变量配上一对 getter/setter,它是什么、怎么用,照样暴露给了所有调用者,和公开变量差不多。访问控制只是工具,隐藏的是知识。
泄漏 = 同一个决定出现在多处
改这个决定,所有知道它的模块都得一起改。写进接口的信息,按定义就已经泄漏了;所以接口越简单,往往信息藏得越好。
后门泄漏更阴险
后门泄漏(back-door leakage):两个类都懂同一种文件格式,但谁的接口里都没写。改格式时编译器不会提醒你,读头文件也看不出来,这正是第 2 章说的"未知的未知"。
按时间顺序分解
按"先读、再改、后写"切模块。可大多数设计决定会在程序生命中的好几个时刻用到,于是同一份知识被复制进好几个阶段。
把接口的层次提高
与其提供三个"步骤方法"让调用者按顺序调用,不如提供一个方法把整件事做完。类会稍微变大,但更深。
默认就做对的事
几乎人人都要的行为应该自动发生;罕见需求另走单独的入口。逼着所有人先了解罕用功能,就是过度暴露(overexposure)。
心智模型:知识住在哪里
判断一个切分好不好,先别看类图上的方框,而是把系统里的设计决定一条条列出来,问每一条:有几个模块必须知道它?理想答案是一个。下图是同一个"读段文件、处理、写回"程序的两种切法。
When designing modules, focus on the knowledge that's needed to perform each task, not the order in which tasks occur.— 第 5.3 节
下面这个小工具把上图展开成一张"知识归属表":行是设计决定,列是模块,格子表示"这个模块必须知道这个决定"。点击某一行,假装你要改这个决定,看看要动几个模块;再勾上"只看头文件",体会后门泄漏为什么难以发现。
谁知道这个决定?
SegmentReader / SegmentWriter。表里的归属是按下文"改之前 / 改之后"两段代码逐条标出来的。真实项目里没人给你画这张表,这正是本章要训练的能力:看到一段代码,能说出它依赖哪些别处也在用的知识。
一个原创例子:先读、再改、后写
把上面的场景写成代码。很多人第一次写这类工具,会顺着脑子里的流程图写出左边的结构,书里叫它按时间顺序分解。
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 和编码方式,而不是让每个处理函数自己抄一遍。训练题里有这样的对比。
类的内部也一样
信息隐藏不只用在类的公开接口上。作者建议:每个私有方法也各自封装一点知识;并且尽量减少每个成员变量被用到的地方。一个成员变量被二十个方法直接读写,就是类内部的一次"泄漏":改它的含义,二十处都得重新检查。
评审时怎么嗅出泄漏
本站补充几条经验线索(不是书里的清单,只是帮你把"对泄漏敏感"落到具体动作上):
- 成对出现、却分住两处的代码:
Encoder/Decoder、Exporter/Importer、写缓存/删缓存。它们几乎必然共享一份格式知识。 - 同一个"魔法值"出现在多个文件:
30min、"v2"、64 << 20、0xA55A。全文搜索一下,搜到几处就是几个依赖方。 - 调用者必须按固定顺序调好几个方法,或者必须把上一个方法的某个返回字段原样传给下一个:时间顺序分解的典型痕迹。
- getter 返回容器或内部结构的引用:表示方式已经成了接口的一部分。
- 改一个需求时,你下意识地问"还有哪里也要改?":这个问题本身就说明知识不只住在一处。
边界与反方:什么时候别生搬硬套
信息隐藏只对模块外不需要的信息有意义。作者的例子是性能配置参数:如果不同的使用场景确实需要不同的取值,就必须放进接口让人去调。书中给出的优先顺序是:最好是模块自己调好;做不到,就老老实实暴露出来。上面 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 把变量的本质原样摆在接口上,改它的表示方式时调用者照样要改。Fetcher(下载)、Parser(解析)、Applier(生效)三个类。以下哪种情况最能说明它犯了"按时间顺序分解"的毛病?Applier 自己该有的知识,不算泄漏。FormatCodec 类,接口是 headerSize()、lengthFieldOffset()、checksumFieldOffset()。这次重构:encode(record) / decode(bytes)。这个接口只是把格式的细节换了个地方公开,两个类照样依赖它。小结与读后一句话
- 信息隐藏:每个模块把少数几个设计决定关在实现里。接口变简单,改设计只动一处。
private不等于隐藏;getter/setter 常常把表示方式原样暴露出去。- 信息泄漏:同一个决定出现在多个模块里。后门泄漏看不见,比接口泄漏更危险。
- 按时间顺序分解是泄漏最常见的成因:按"知识"切模块,而不是按"先后"。
- 默认就做对的事;罕用功能走单独入口,别让常见用法被迫了解它们。
- 但模块外需要的信息必须暴露,藏起来就成了虚假的抽象。
第 6 章换一个角度看同一件事:模块该为今天的具体需求量身定制,还是做得"有点通用"?作者的答案会让很多信奉"先满足需求再说"的人意外,而给出的主要理由正是:通用的接口能藏住更多信息。
读后一句话:用你自己的话写下这一章最重要的一个观点,最好结合你写过的某段代码。