读前:这一章要回答什么
系统里每多出一层(一个类、一个包装、一个参数),调用者到底多得到了什么?怎样判断一条层边界值不值得存在?
第 4~6 章从"一个模块"的角度讨论深度:接口要短、要藏住知识、要通用一些。第 7 章换个角度,看上下相邻的模块:软件是一层叠一层搭起来的,高层用低层提供的设施。作者的判断标准很简单:在设计良好的系统里,沿着一次操作往下走,每经过一次方法调用,抽象就换一次。如果相邻两层说的是同一套话,这一层多半没有挣回它自己的成本。
这一章列出了"抽象没换"的几种具体表现:透传方法、浅装饰器、照搬内部表示的接口、透传变量,并给出对应的改法。它也是第 9 章"合在一起还是分开"的前奏:本章讨论的是上下两层之间的边界,第 9 章讨论的是任意两段代码之间的边界。
- 7.1 节:看到透传方法时,作者建议你问自己一个问题(关于两个类各自负责什么)。记住这个问题,然后看图 7.1 的三种改法,想一想你项目里那个"什么都转交"的类该用哪一种。
- 7.3 节:作者在写装饰器之前会先问四个问题。读的时候注意第一个问题里的三个条件,并回头对照第 4 章的 Java I/O 例子:作者为什么认为缓冲流"本该合并"?
- 7.5 节:作者推荐 context 对象,但紧接着用一整段列它的缺点,最后一句话很值得玩味。读完想想:你见过的
AppContext/ServiceLocator有没有变成书里说的那种"杂物袋"?
核心观点
每层换一种抽象
好的分层里,上层说"文件里的字节",下层说"固定大小的块",再下层说"设备上的扇区"。每一层都替上层省掉一类细节。
抽象相似是警告
相邻两层提供差不多的抽象,说明类的划分可能出了问题。这种情况最常见的表现就是透传方法。
透传方法
只是把参数转交给一个签名相同或相近的方法。它让类变浅、制造依赖,并暴露出职责划分不清。
签名相同不一定是错
分派器(负责"选择")和同一接口的多种实现(如各种设备驱动)签名都相同,但每个都贡献了不同的功能。
装饰器容易浅
为一点新功能包一整层,带来大量转交样板。写之前先问:能不能直接加进底层、放进用它的地方、并入已有的包装,或者独立实现?
接口 ≠ 内部表示
接口照抄存储结构,类就不会深。接口和表示之间的差别,正是这个类提供的价值。
透传变量
一个值穿过一长串用不到它的方法。共享对象、全局变量、context 对象都能消除它,各有代价;作者最常用 context。
净收益测试
每个接口、参数、函数、类都要人去学。它消除的复杂性必须多于它带来的,否则不如不要。
心智模型:顺着一次调用往下走
判断分层好不好,最直接的办法是挑一个操作,沿着调用栈往下走,在每一层问一句:这一层用什么"词汇"描述这件事?下面两条调用链做的是同一件事(保存一个键值对),左边每层都换了说法,右边四层说的是同一句话。
书中用文件系统(文件 → 块缓存 → 设备驱动)和 TCP(可靠字节流 → 尽力而为的有界数据包)说明左边这种情况。注意右边的问题不在于"层多",而在于层与层之间没有任何东西被藏起来。如果 service 那一层负责鉴权和事务,它就有了自己的抽象,不再是传话筒。
OSTEP 的文件系统实现一章就是一次完整的"顺着调用往下走":read(fd, buf, n) 先变成 inode 里的块号,再变成对块缓存的访问,最后变成对磁盘的请求。每一层的数据结构和词汇都不一样,这正是本章开头说的好分层。
透传方法:职责没分清的信号
透传方法(pass-through method)的问题有三层:它给类增加了接口却没增加功能,所以类变浅;它和下层方法签名绑在一起,下层一改它就得跟着改;最重要的是,它说明两个类的职责有重叠,没人说得清某项功能到底归谁。作者给出的判断原则是:
the interface to a piece of functionality should be in the same class that implements the functionality.— 第 7.1 节
书中的例子是学生编辑器项目里的一个文档类,15 个公开方法里 13 个是透传(7.1 节)。下面换一个服务端的例子:一个支持断点续传的上传会话,内部用一个临时文件存数据。切换标签看现状和三种改法:
// 一次上传会话
class UploadSession {
public:
void append(ByteView data) { file_.append(data); }
void sync() { file_.sync(); }
uint64_t size() const { return file_.size(); }
std::string path() const { return file_.path(); }
bool expired(TimePoint now) const { return now > deadline_; }
private:
TempFile file_;
TimePoint deadline_;
};
// 调用方:断点续传的逻辑散在 HTTP 处理函数里
if (req.offset != s.size()) return resp.conflict(s.size());
s.append(req.body);
if (s.size() - lastSynced > (8u << 20)) { s.sync(); lastSynced = s.size(); }
5 个方法里 4 个是透传。真正属于"上传"的逻辑(偏移校验、定期落盘)反而写在调用方。问一句"UploadSession 和 TempFile 各自负责什么",答不上来。
struct UploadSession {
TempFile& file(); // 调用者直接对文件读写
bool expired(TimePoint now) const;
};
s.file().append(req.body);
去掉中间那层"传话",让 UploadSession 只负责它真正拥有的东西(过期时间)。适合上层确实没有自己的文件相关职责的情况。缺点:调用方依然要自己处理续传逻辑。
// 一次可续传的上传。内部用临时文件,调用方看不到。
class UploadSession {
public:
// 接收一段数据。offset 必须等于已收到的字节数,否则不写入,
// 返回已收到的字节数,供客户端从那里重发。每 8MiB 自动落盘一次。
std::optional<uint64_t> receive(uint64_t offset, ByteView data);
// 收完:校验摘要,原子地移动到 finalPath。失败抛 UploadError。
void finish(const Digest& expected, const std::string& finalPath);
bool expired(TimePoint now) const;
private:
TempFile file_;
// ……
};
这是通常最好的改法:UploadSession 提供一种不同的抽象("续传协议"),TempFile 只管文件。偏移校验、落盘节奏、原子提交都收进了会话里,调用方的 HTTP 代码变成两三行。
// TempFile 只被上传会话使用,两者分不开时干脆合成一个类
class Upload {
public:
std::optional<uint64_t> receive(uint64_t offset, ByteView data);
void finish(const Digest& expected, const std::string& finalPath);
private:
int fd_;
std::string tmpPath_;
uint64_t received_ = 0, synced_ = 0;
};
如果两个类的职责实在拆不开(TempFile 没有别的使用者,逻辑又互相缠绕),合并往往最干净。书中的学生正是把三个职责交织的类合成了两个。
三种改法的共同点:改完以后,每个类都有一组不同且自洽的职责,类之间不再需要互相转交。选哪一种,取决于上层有没有属于它自己的、和下层不同的抽象可以提供。
签名相同什么时候没问题
作者在 7.2 节划了一条清楚的线:签名相同不要紧,要紧的是每个方法都贡献了有用且不同的功能。两类常见的合理情况:
- 分派器(dispatcher):根据参数选择交给哪个方法处理,然后把参数转过去。签名和目标方法一样,但"选择"本身就是功能,有时还相当复杂(例如 Web 服务器按一组规则匹配 URL)。
- 同一接口的多种实现:比如各种磁盘驱动。它们处在同一层、彼此不调用,学会一个就会用其他的,反而降低了认知负担。
装饰器:最容易制造透传的模式
装饰器(decorator,也叫 wrapper)接过一个已有对象,提供相同或相近的 API,在转交时顺便加点功能。它的初衷是把特殊扩展和通用核心分开,但作者认为装饰器类往往很浅:为一点新功能写一大堆转交,用多了就是一堆浅类(第 4 章的 Java I/O 就是这么来的)。写装饰器之前,用自己的话记住这四个替代方案:
- 直接加进底层类:新功能比较通用、和底层逻辑相关、或者大多数使用者都会用时。
- 和用途放在一起:新功能只服务某个具体场景时,放进那个场景的代码里。
- 并入已有的装饰器:一个深一点的包装,好过好几个浅包装。
- 独立实现:它真的需要包住原对象的全部功能吗?书中的例子是滚动条,多半可以和窗口分开实现。
作者承认包装器偶尔有用:系统要用一个改不了接口的外部类,又要求它符合系统自己的接口,这时用一层包装来翻译。但也说这种情况很少见。
接口不该照抄内部表示
"不同层,不同抽象"在单个类内部也成立:接口的抽象应该和内部表示不同。如果接口只是把存储结构原样暴露出去,类就不会深,原本该由类处理的逻辑会散落到每个调用方。书中的例子是编辑器的文本类:内部按行存储,接口却应该按字符和任意范围来插入、删除(7.4 节,第 6、8 章也反复用到它)。下面是一个服务端常见的版本:
class TokenBucket {
public:
double tokens() const; void setTokens(double t);
TimePoint lastRefill() const; void setLastRefill(TimePoint t);
double ratePerSec() const;
double capacity() const;
};
// 每个需要限流的地方都有一份这样的代码
auto now = Clock::now();
double dt = std::chrono::duration<double>(now - tb.lastRefill()).count();
tb.setTokens(std::min(tb.capacity(), tb.tokens() + dt * tb.ratePerSec()));
tb.setLastRefill(now);
if (tb.tokens() < 1) return reject(req);
tb.setTokens(tb.tokens() - 1);
接口就是内部的四个字段。补充令牌的公式散在每个调用方(这也是第 5 章说的信息泄漏),而且"读、算、写"分成了好几次调用,多线程下根本没法保证正确。
// 令牌桶限流器,线程安全。
class RateLimiter {
public:
RateLimiter(double ratePerSec, double burst);
// 有足够令牌时扣除 cost 个并返回 true;否则不扣除,返回 false。
bool tryAcquire(double cost = 1);
};
if (!limiter.tryAcquire()) return reject(req);
内部仍然是令牌数加时间戳,但接口说的是另一件事:"现在能不能放行"。补充公式、时钟、加锁都藏进了类里。接口和表示之间的差距,就是这个类的价值。代价:如果有人需要"还要等多久",得专门加一个方法,而不是自己拿字段去算。
透传变量:一个值穿过整条调用链
透传方法是"方法在层之间重复",透传变量(pass-through variable)是"参数在层之间重复":一个值从入口一路往下传,路径上的大多数函数都用不到它,却都得在签名里写上它。后果是两个:中间每一层都被迫知道它的存在;以后再加一个这样的变量,整条路径上的签名都要改一遍。
书中的例子是一个数据中心服务的证书参数(7.5 节,图 7.2)。下面换成一个键值存储:只有最底层的 LogWriter 关心 fsync 策略,但它是从 main 里解析出来的。切换四种做法,再打开"再加一个变量",看看每种做法要改多少地方。
透传变量的四种处理方式
作者最常用的是 context 对象,但对它的评价相当克制:context 里的变量仍有全局变量的大部分缺点(看不出某个字段为什么在、在哪里被用),不加约束会变成制造隐蔽依赖的杂物袋,还可能有线程安全问题(最好让字段不可变)。然后作者说:
Unfortunately, I haven't found a better solution than contexts.— 第 7.5 节
把本章收拢成一张账:净收益
7.6 节把整章归结为一句话:每个设计元素(接口、参数、函数、类、定义)都要让开发者多学一样东西,它必须消除比这更多的复杂性才划算。透传方法、浅装饰器、透传变量的共同问题,都是"让人学了,却没替人藏住什么"。拿本页出现过的例子记一笔账:
| 设计元素 | 使用者多学了什么 | 替使用者藏住了什么 | 账面 |
|---|---|---|---|
service.put(key, value) 透传层 | 一个类、一组和下层相同的方法 | 无 | 亏 |
| 按 URL 规则选处理函数的分派器 | 一个入口函数 | 有哪些路由、匹配顺序、统一的权限检查 | 赚 |
| 只加了两个计数器的装饰器 | 一个类、叠放顺序、9 个方法(其中 7 个纯转交) | 两次自增 | 亏(计数放进存储或调用方) |
| 适配第三方 C SDK 的包装 | 一个实现了已有接口的类 | 参数转换、错误码映射、手动释放 | 赚(但这种场景少见) |
RateLimiter::tryAcquire | 一个方法 | 补充公式、时钟、加锁 | 赚 |
穿过 4 层的 fsync 参数 | 4 个签名各多一个参数 | 无(中间层都用不到) | 亏 |
这张表的用法:下次你想加一层包装、一个 Manager、一个"为了以后扩展"的接口时,先在脑子里把这一行填出来。第三列写不出东西,就别加。
边界与反方
很多团队规定 Controller → Service → Repository 必须逐层调用,于是 Service 里塞满了一对一的转交。按本章的标准,这种层没有提供不同的抽象,是纯成本。本站补充:问题不在分层规范本身,而在某一层没有属于自己的职责。如果 Service 层真的负责事务边界、权限、跨仓储的业务规则,它就是一层不同的抽象;如果只是规范要求"必须经过",就该问问这条规范值不值得。
作者对装饰器的流行持保留态度,但没有说"永远别用":适配改不了的外部接口是合理用途,只是少见。本站补充:C++ 里常见的 RAII 守卫(如加锁守卫)虽然也叫"包装",但它们并不复制被包对象的 API,不属于本章批评的对象。判断时别看名字,看它是否原样暴露了下层的接口,却只多做了一点点事。
context 对象解决了透传变量,却引入了"隐式依赖":一个类从 context 里取了什么,看签名看不出来。作者自己也承认它远不理想。实践中要守住几条纪律:每个系统实例一个;建好后只读;只放真正全局的东西(配置、共享子系统、计数器),不要把某个模块的私有状态也塞进去。
本章的结论不是"层越少越好",而是每一层都要挣回自己的成本。一个真正藏住了东西的层(块缓存、协议、令牌桶)值得存在,哪怕它让调用链变长。反过来,把所有东西压进一层同样会失败,第 8 章的"别做过头"和第 9 章的拆分与合并会把这条线画得更细。
以下不在书里,来自书中给出的读者讨论组。7.5 节列了三种办法(共享对象、全局变量、context),并承认都不理想。2022 年两位读者各提了一种新办法,Ousterhout 的评价截然不同(2022-09):
- 在入口处把对象链预先接好:最底层的对象在构造时拿到那个值,再被传给上一层对象的构造函数,依此类推。Ousterhout 不赞成:这样入口代码必须知道整条调用链谁调谁,中间哪一层改了结构,入口就得跟着改,链越深要知道的越多。抽象被打破了。
- 绑定在调用栈上的作用域式 context:顶层用一个作用域对象"设置"这个值,离开作用域时自动恢复;最底层直接向它要;中间各层完全不知情,内层还可以临时覆盖(方便测试)。Ousterhout 说这接近最优,因为只有真正关心这个值的两方(顶层的提供者和底层的使用者)需要知道它,并认为 C++ 用构造函数、析构函数加静态成员就能实现。不过也说明这只是第一反应,还要再想想弱点;之后没有再发言。
四年前,Ousterhout 在另一串里为 context 辩护时用过一个区分(2018-08):A 调用 B 是"有意思的依赖",不知道它就看不懂 A,所以应当显式;透传变量连接的来源和使用者之间则是"没意思的依赖",不知道它也能看懂两头,没有必要专门写出来。那一串争的是依赖注入,Ousterhout 自己说没太弄懂依赖注入,讨论最后搁置了,所以别把它读成对依赖注入的定评。
同一串里读者指出、Ousterhout 没有回应的几点:底层的任何代码都能"够到"这个值,提供者不知道谁在用,时间久了容易共享过度;哪些函数要求"必须在某个作用域内调用"成了看不见的前置条件,写进接口注释又会一层层往上传;提出这个办法的读者也承认自己没在实际项目里用过。本站补充一点:用静态成员保存当前值在多线程下是错的,C++ 里至少要用 thread_local,而且值不会自动跟到线程池里的另一个线程上去。所以它和 context 一样,是"几种都不理想的办法"里的一种,不是定论。
2026 年有读者问:遵守"Tell, Don't Ask"(让对象自己做事,别把它的数据取出来替它做)就会写出一个只转交的小方法,这不正是本章的红旗吗?Ousterhout 的回答有三步(2026-03,不在书里):这个方法确实又浅又是透传,它唯一的价值是按订单号找到订单;如果这样的调用没几处,干脆不要这个方法,在调用处多写一步;如果很多处都需要,留着它也可以容忍。然后退一步看:为什么到处都有人拿着"客户 + 订单号"却想操作订单?在更高的层次就把它们换成订单对象,这个方法就不需要存在了。一处别扭,有时最好的修法是改另一处。顺带一提,Ousterhout 表示总体上赞同"Tell, Don't Ask",认为它会导向更深的类。
有读者问 7.5 节为什么没把单例列为办法之一。Ousterhout 回答(2024-09,不在书里):单例大体上等同于全局变量;说不清当初为什么没提这一点,已经记下,下次修订时考虑补上。可能的原因是自己本来就不太喜欢单例:坚持"一个类只能有一个实例"是一条不必要的限制;比如单元测试常常要反复创建和销毁对象,单例不喜欢被销毁,测试会因此变复杂。
练一练
下面三道题来自训练场:一道识别透传、一道"看起来像透传其实不是"、一道透传变量的取舍。
再加两道,取材于读者讨论组(对应上面"边界与反方"里的"讨论组"一栏):
动手:项目任务
minikv 里有一个典型的"传话层":存储引擎外面套了一层几乎只做转交的类,还有一个写入选项从命令处理层一路穿过好几层方法的签名,只在最底层的日志写入处才被用到。这个任务要你先用本章的问题判断那一层有没有自己的职责,选一种改法消除透传方法,再决定透传变量用哪种方式处理,并说出你接受了什么代价。
任务 m03:消除 minikv 中的透传方法和透传变量。详见 任务页。
cd project/minikv && make test自测题
小结与读后一句话
- 好的分层里,每过一层抽象就换一次;相邻层抽象相似是警告。
- 透传方法让类变浅、制造依赖、暴露职责不清;改法:让调用者直接用下层、重新分配职责、或合并。
- 签名相同本身没问题,只要每个方法都贡献了不同的功能(分派器、多种实现)。
- 装饰器容易浅:先考虑加进底层、放进用途、并入已有包装、独立实现。
- 接口不该照抄内部表示,两者的差别就是类的价值。
- 透传变量可以用共享对象、全局变量、context 对象消除;context 最常用,但并不理想。
- 一切都归结为净收益:每个设计元素都要消除比它带来的更多的复杂性。
第 8 章接着回答一个相关的问题:当复杂性无法消除时,该由哪一层来承担?作者的回答是"往下拉":宁可让模块的实现复杂,也别把问题推给上层。
读后一句话:用你自己的话写下这一章最重要的一个观点,最好结合你写过的某段代码。