读前:这一章要回答什么

系统里每多出一层(一个类、一个包装、一个参数),调用者到底多得到了什么?怎样判断一条层边界值不值得存在?

第 4~6 章从"一个模块"的角度讨论深度:接口要短、要藏住知识、要通用一些。第 7 章换个角度,看上下相邻的模块:软件是一层叠一层搭起来的,高层用低层提供的设施。作者的判断标准很简单:在设计良好的系统里,沿着一次操作往下走,每经过一次方法调用,抽象就换一次。如果相邻两层说的是同一套话,这一层多半没有挣回它自己的成本。

这一章列出了"抽象没换"的几种具体表现:透传方法、浅装饰器、照搬内部表示的接口、透传变量,并给出对应的改法。它也是第 9 章"合在一起还是分开"的前奏:本章讨论的是上下两层之间的边界,第 9 章讨论的是任意两段代码之间的边界。

  1. 7.1 节:看到透传方法时,作者建议你问自己一个问题(关于两个类各自负责什么)。记住这个问题,然后看图 7.1 的三种改法,想一想你项目里那个"什么都转交"的类该用哪一种。
  2. 7.3 节:作者在写装饰器之前会先问四个问题。读的时候注意第一个问题里的三个条件,并回头对照第 4 章的 Java I/O 例子:作者为什么认为缓冲流"本该合并"?
  3. 7.5 节:作者推荐 context 对象,但紧接着用一整段列它的缺点,最后一句话很值得玩味。读完想想:你见过的 AppContext / ServiceLocator 有没有变成书里说的那种"杂物袋"?

核心观点

每层换一种抽象

好的分层里,上层说"文件里的字节",下层说"固定大小的块",再下层说"设备上的扇区"。每一层都替上层省掉一类细节。

抽象相似是警告

相邻两层提供差不多的抽象,说明类的划分可能出了问题。这种情况最常见的表现就是透传方法。

透传方法

只是把参数转交给一个签名相同或相近的方法。它让类变浅、制造依赖,并暴露出职责划分不清。

签名相同不一定是错

分派器(负责"选择")和同一接口的多种实现(如各种设备驱动)签名都相同,但每个都贡献了不同的功能。

装饰器容易浅

为一点新功能包一整层,带来大量转交样板。写之前先问:能不能直接加进底层、放进用它的地方、并入已有的包装,或者独立实现?

接口 ≠ 内部表示

接口照抄存储结构,类就不会深。接口和表示之间的差别,正是这个类提供的价值。

透传变量

一个值穿过一长串用不到它的方法。共享对象、全局变量、context 对象都能消除它,各有代价;作者最常用 context。

净收益测试

每个接口、参数、函数、类都要人去学。它消除的复杂性必须多于它带来的,否则不如不要。

心智模型:顺着一次调用往下走

判断分层好不好,最直接的办法是挑一个操作,沿着调用栈往下走,在每一层问一句:这一层用什么"词汇"描述这件事?下面两条调用链做的是同一件事(保存一个键值对),左边每层都换了说法,右边四层说的是同一句话。

每过一层,换一种抽象 四层说的是同一句话 db.put("user:42", doc) 键值对 wal.append(record) 带长度和校验和的日志记录 blocks.write(blockNo, buf) 固定大小(4KB)的块,带缓存 pwrite(fd, buf, 4096, off) 文件里的字节偏移 handler.put(key, value) 键值对 service.put(key, value) 键值对(原样转交) manager.put(key, value) 键值对(原样转交) storeImpl.put(key, value) 键值对:所有活都在这一层
左:每一层都替上层藏起一类细节(日志格式、块缓存、文件偏移)。右:三层只是在传话,每一层都是额外要学、要维护的接口。本站自绘示意。

书中用文件系统(文件 → 块缓存 → 设备驱动)和 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 节划了一条清楚的线:签名相同不要紧,要紧的是每个方法都贡献了有用且不同的功能。两类常见的合理情况:

装饰器:最容易制造透传的模式

装饰器(decorator,也叫 wrapper)接过一个已有对象,提供相同或相近的 API,在转交时顺便加点功能。它的初衷是把特殊扩展和通用核心分开,但作者认为装饰器类往往很浅:为一点新功能写一大堆转交,用多了就是一堆浅类(第 4 章的 Java I/O 就是这么来的)。写装饰器之前,用自己的话记住这四个替代方案:

  1. 直接加进底层类:新功能比较通用、和底层逻辑相关、或者大多数使用者都会用时。
  2. 和用途放在一起:新功能只服务某个具体场景时,放进那个场景的代码里。
  3. 并入已有的装饰器:一个深一点的包装,好过好几个浅包装。
  4. 独立实现:它真的需要包住原对象的全部功能吗?书中的例子是滚动条,多半可以和窗口分开实现。

作者承认包装器偶尔有用:系统要用一个改不了接口的外部类,又要求它符合系统自己的接口,这时用一层包装来翻译。但也说这种情况很少见。

接口不该照抄内部表示

"不同层,不同抽象"在单个类内部也成立:接口的抽象应该和内部表示不同。如果接口只是把存储结构原样暴露出去,类就不会深,原本该由类处理的逻辑会散落到每个调用方。书中的例子是编辑器的文本类:内部按行存储,接口却应该按字符和任意范围来插入、删除(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不知道它
0
用不到却得知道的层
0
新增变量要改的方法签名
—
同一进程跑两个实例

作者最常用的是 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 辩护时用过一个区分(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

自测题

沿着一次操作在调用栈里往下走,好的分层应该表现为:
本章的核心规则:不同的层提供不同的抽象。签名一致恰恰是警告信号;层数多少不是标准,每层是否藏住了东西才是。
一个类里大部分方法都是透传。作者建议你先问什么问题?
透传方法说明职责划分不清,所以要从职责入手。用宏减少样板只是把症状藏起来,接口和依赖仍然在。
下列哪种情况,方法签名和它调用的方法相同,不算问题?
C 是分派器:"选择谁来处理"本身就是功能。其余三种都没有贡献新功能(或者新功能极少,却配了一大堆转交)。
一个编辑器文本类内部按行存储。下面哪种接口最符合 7.4 节的观点?
接口和内部表示的差别就是类提供的价值。按行的接口让调用者在各处自己拆行、合并行,代码重复且分散。
作者反对用全局变量消除透传变量,最主要的理由是:
7.5 节举的正是这个问题。context 对象每个实例一份,就能让多个实例共存于同一进程。
关于 context 对象的用法,哪一项符合书中的建议?
每个方法都传 context,它就成了新的透传变量;做成单例就退化成全局变量;什么都往里放,就成了作者警告的杂物袋。

小结与读后一句话

第 8 章接着回答一个相关的问题:当复杂性无法消除时,该由哪一层来承担?作者的回答是"往下拉":宁可让模块的实现复杂,也别把问题推给上层。

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