读前:这一章要回答什么

写一个新模块时,该为眼前这几个用法量身定制,还是做得"通用一点"?通用到什么程度算刚好?

第 5 章说,深模块来自信息隐藏。这一章换一个入口回到同一个目标:通用的接口天然藏得住更多信息。作者的说法相当激进,认为过度专用化(over-specialization)可能是软件复杂性最大的单一来源。

这和很多人的直觉相反。"只做今天需要的,以后真要复用再重构"听起来既务实又符合增量开发。作者坦白自己教课之初也这么想,看了几届学生的项目后改了主意:通用一点的设计几乎总是更好,哪怕根本没有被复用。这一章就是作者给出的理由和分寸。

后半章把"专用"往下推到更细的粒度:专用代码不可能消灭,但可以和通用代码干净地分开;方法体里的特殊情况(special case),很多也可以通过重新设计正常路径来消掉。

  1. 6.1 节:注意"有点通用"(somewhat general-purpose)的精确定义,它把功能和接口分开说。读完试着用一句话回答:按这个定义,要不要现在就实现将来可能用到的功能?
  2. 6.4 节最后一段:书中把一个专用方法称为"虚假的抽象"。想一想:为什么藏起某些细节反而让使用者更难拿到他们需要的信息?这和第 4 章、第 5.9 节是一条线。
  3. 6.7 节结尾的 "Note":把通用代码和专用代码分开,是针对同一个机制说的。读完你应该能解释,为什么某些专用的撤销代码放在文本类里反而是对的。

核心观点

专用化制造复杂性

为某个具体用法量身定制的接口,往往方法多、每个方法都浅,还把上层的概念带进了下层。

"有点通用"

功能只做今天需要的,接口不绑死在今天的用法上。"有点"两个字很重要:通用到今天反而难用,就过头了。

通用 → 藏得更多

下层只提供基本操作,上层的具体用法留在上层。两边可以各自独立地改,谁也不必知道对方的细节。

三个自问

覆盖当前全部需求的最简接口是什么?这个方法会在几种场合用到?用它满足今天的需求方便吗?

谁需要知道什么,什么时候

使用者确实需要的细节,应该明确而显眼;藏进一个专用方法里,只会制造模糊。

专用代码往上推,或往下推

具体功能留在顶层;设备差异这类专用细节压到底层的驱动里,中间的核心保持通用。

消灭特殊情况

把正常路径设计成能自然处理边界情况,少写 if。第 10 章的异常处理和第 20 章的性能都会回到这一点。

心智模型:专用知识住在哪一层

书中的贯穿例子是学生写的图形界面文本编辑器:有的小组给文本类设计了一组和界面按键一一对应的方法,有的小组只给了"在某个位置插入""删除一段范围"这样的基本操作(6.2~6.3 节)。本站换一个 C++ 程序员更熟悉的场景:TCP 连接的接收缓冲区。上层有 HTTP、Redis 协议、内部 RPC 三种解析器,都要从同一个缓冲区里取数据。

专用 有点通用 过度通用 协议解析器(几乎什么都不做) RecvBuffer readHttpLine() readRedisBulk() readFrame32() … 每加一个协议,改这里 HTTP / Redis / RPC协议知识留在上层 RecvBuffer find / peek / consume 只懂"字节和偏移" 藏住:分段存储、扩容、拷贝 每个解析器都写一遍逐字节循环 RecvBuffer at(i) / pop() 接口最小, 但替上层做的事也最少 红:上层概念漏进了下层 绿:各层只懂自己的事 黄:下层太薄,复杂性堆在调用方
同一个接收缓冲区的三种接口。本站自绘示意。
the module's functionality should reflect your current needs, but its interface should not.— 第 6.1 节

光看图还不够具体。下面把三种接口和三个现有用法、外加一个"明天才来的新需求"逐一摆出来。切换设计和用例,对照右边的调用方代码,再看下方三个自问的结论。

交互

三个自问:接收缓冲区的三种接口

左边是 RecvBuffer 的公开接口,右边是上层协议解析器为了某个用例要写的代码(NeedMore 表示数据还没收全,等下次再来)。
接口设计
用例

RecvBuffer 的接口

调用方代码

    0
    RecvBuffer 公开方法
    0
    这个用例的调用方代码(行)
    —
    新需求要改 RecvBuffer 吗(选第 4 个用例)

    行数不含注释,只为比较量级。注意"有点通用"那一列:调用方代码比专用设计长几行,这一点作者也承认(6.3 节),但这几行把"读到哪里为止、行尾怎么算"明明白白摆在协议解析器里,而那正是协议解析器的开发者需要知道的东西。

    专用方法为什么是虚假的抽象

    专用设计里的 readHttpLine() 看起来替 HTTP 解析器藏住了"怎么找行尾"。可写 HTTP 解析器的人恰恰必须知道:只认 \r\n,还是也接受单独的 \n?行太长时返回什么?于是还是得翻进 RecvBuffer 的实现去确认。书中对编辑器里那个专用删除方法下了同样的判断(6.4 节):它假装隐藏了信息,实际上使用者需要这些信息,放进下层只是让它更难找。

    One of the most important elements of software design is determining who needs to know what, and when.— 第 6.4 节

    反过来,在"有点通用"的设计里,协议解析器自己调用 find("\r\n"),规则写在它该在的地方;RecvBuffer 藏住的是协议解析器确实不关心的东西:数据是存在一块连续内存还是一串分段里、什么时候扩容、peek 跨段时要不要拷贝。这才是信息隐藏。

    把通用机制从专用细节里拆出来

    书中 6.7 节用编辑器的多级撤销(undo)说明了一个更细的拆分:通用机制、具体细节、组合策略各住一处。下面是本站的一个服务端例子,结构上是同一回事:一个连接类里长出了"顺便帮别人管定时器"的代码。

    class Connection {
        enum class TimerKind { Idle, RequestDeadline, AuthSessionExpiry, RateLimitWindow };
        struct Timer { TimerKind kind; Clock::time_point at; std::string sessionId; };
        std::vector<Timer> timers_;
    public:
        void addAuthSessionTimer(const std::string& sid, Clock::time_point at);  // 给 AuthService 用
        void addRateLimitTimer(Clock::time_point at);                            // 给 RateLimiter 用
        void onTick(Clock::time_point now) {
            for (const Timer& t : takeDue(now)) {
                switch (t.kind) {
                case TimerKind::Idle:
                    close("idle"); break;
                case TimerKind::RequestDeadline:
                    failPending(Status::Timeout); break;
                case TimerKind::AuthSessionExpiry:
                    auth_->onSessionExpired(t.sessionId); break;
                case TimerKind::RateLimitWindow:
                    limiter_->resetWindow(id_); break;
                }
            }
        }
    };

    "按到期时间排序、到点触发"是通用机制;认证会话过期、限流窗口重置是别人家的专用细节,却都写进了 Connection(第 2、6~7、15~18 行)。Connection 因此要知道 auth_ 和 limiter_,那两个模块也要知道 Connection 的专用方法。再加一种定时器,就得改枚举、加方法、加分支。

    // 通用机制:只负责"到点调用回调",不知道每个定时器是干什么的。
    // 回调在事件循环线程里执行;取消已触发或不存在的 id 是无害的空操作。
    class TimerQueue {
    public:
        using Id = uint64_t;
        Id schedule(Clock::time_point at, std::function<void()> fn);
        void cancel(Id id);
        void runDue(Clock::time_point now);   // 事件循环每轮调用一次
    };
    
    // 具体细节:每个模块自己定义自己的定时器
    void Connection::armIdleTimer() {
        timers_.cancel(idleTimer_);
        idleTimer_ = timers_.schedule(Clock::now() + opts_.idleTimeout,
                                      [this] { close("idle"); });
    }
    void AuthService::startSession(const Session& s) {
        expiry_[s.id] = timers_.schedule(s.expiresAt, [this, id = s.id] { expire(id); });
    }
    
    // 组合策略:各种超时设多长、断线时取消哪些定时器,由顶层组装代码决定
    Server::Server(const Config& cfg)
        : conns_(timers_, {.idleTimeout = cfg.idleTimeout}),
          auth_(timers_, {.sessionTtl = cfg.sessionTtl}) {}

    TimerQueue 可以原封不动地用在任何事件循环里;每种定时器只被一个模块理解;"超时设多长"这类策略集中在顶层配置。三部分可以各自理解、各自修改。

    组合策略:Server 配置(idleTimeout、sessionTtl……、断线时取消哪些) Connection:空闲断开 AuthService:会话过期 RateLimiter:窗口重置 通用机制:TimerQueue(schedule / cancel / runDue),不认识任何具体定时器
    中间一层是具体细节,各自住在最懂它的模块里。本站自绘示意。

    代价:回调捕获了 this,于是"对象销毁前必须取消自己的定时器"成了一条新的接口约束,TimerQueue 的接口注释和各模块的析构函数都要处理它。另外,调试时"到底有哪些定时器在排队"不再能从一个枚举里一眼看出来。这些代价通常远小于原来的纠缠,但值得写进注释。

    注意 Connection::armIdleTimer 里那个回调:它是专门服务"定时器"这个机制的专用代码,却住在 Connection 里。这正是 6.7 节末尾那段 Note 的意思:分开通用与专用,是针对同一个机制而言的。空闲断开和连接的其他代码关系紧密,放在一起是对的;不该放在一起的,是它和 TimerQueue 的通用代码。

    往下推:驱动程序

    专用代码也可以往下推。书中的例子是操作系统的设备驱动(6.6 节):核心只认识"读一个块""写一个块"这样的通用操作,每种设备的专用命令集压到各自的驱动里。新设备只要能实现这个通用接口,就能接入,核心代码不用改。

    OSTEP 第 36 章讲设备驱动时也提到了这种抽象的代价:通用块接口让文件系统不知道底层是什么设备,但也让它用不上某些设备的特殊能力(OSTEP 举的是 SCSI 丰富的错误报告)。这和 6.5 节"别通用到今天不好用"是同一个权衡的两面。

    消灭特殊情况

    专用化不只出现在类和方法的设计上,也出现在方法体里,形式是特殊情况的 if。书中的例子是编辑器里"当前有没有选区"的状态变量(6.8 节):改成"选区总是存在,没选东西时它是空的",大量判断就消失了。C 程序员最熟悉的同类技巧是带哨兵(sentinel)节点的双向链表:

    struct node { struct node *prev, *next; };
    struct list { struct node *head, *tail; };
    
    void list_remove(struct list *l, struct node *n) {
        if (n->prev) n->prev->next = n->next; else l->head = n->next;
        if (n->next) n->next->prev = n->prev; else l->tail = n->prev;
    }
    void list_push_back(struct list *l, struct node *n) {
        n->next = NULL; n->prev = l->tail;
        if (l->tail) l->tail->next = n; else l->head = n;
        l->tail = n;
    }

    每个修改操作都要区分"是不是头""是不是尾""链表是不是空的"。每多一种操作(插到某节点前、拼接两个链表),这些分支就再写一遍,漏掉一个就是空指针。

    /* 环形链表,head 是哨兵节点,不存数据。
     * 空链表:head.next == head.prev == &head。 */
    struct list { struct node head; };
    
    void list_init(struct list *l) { l->head.next = l->head.prev = &l->head; }
    
    void list_remove(struct node *n) {
        n->prev->next = n->next;
        n->next->prev = n->prev;
    }
    void list_push_back(struct list *l, struct node *n) {
        n->prev = l->head.prev;  n->next = &l->head;
        l->head.prev->next = n;  l->head.prev = n;
    }

    每个节点的前后永远有邻居(最坏情况下就是哨兵本身),"空""头""尾"不再是特殊情况,list_remove 甚至不需要知道节点属于哪个链表。Linux 内核的 list_head 就是这样设计的。代价:遍历的终止条件从"遇到 NULL"变成"回到 &head",新手容易写错;需要判断"是否为空"的地方仍然要判断,只是只剩真正需要的那几处。

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

    作者专门强调"有点"二字。交互里的"过度通用"设计接口最小、也完全通用,但每个协议解析器都要写一堆逐字节循环,而且大块数据逐字节处理也慢。书里对应的例子是只支持单字符插入删除的文本类(6.5 节),结论是应该直接支持对一段范围的操作。判断方法就是第三个自问:为了今天的需求,调用方要写很多额外代码吗?要,就说明接口没提供合适的功能。

    "You aren't gonna need it"反对的是提前实现用不上的功能。作者的"有点通用"同样要求功能只反映当前需求,分歧主要在接口的形状:作者认为,即使只服务今天的用法,通用的接口也更简单、实现代码更少。本站补充:两者真正的分歧在"先写专用的,等出现第二个用法再重构成通用的"这条路线(6.1 节作者自己起初也是这么教的)。作者后来改主意的理由并不是"将来一定会复用",而是"即使不复用,通用一点的接口现在就更简单、实现代码更少"。既然如此,就没有理由先走专用这一步。第 19 章讨论敏捷开发时还会回到这个话题。

    第一个自问说的是"在不减少能力的前提下减少方法数"。但如果靠给一个方法加五个布尔参数、把三个方法硬并成一个,每个调用者都得弄懂所有参数的组合,接口其实更复杂了(6.5 节)。通用接口的方法应该少,每一个也都应该简单。

    应用的顶层功能天生就是专用的,没必要、也不可能"通用化"一切。这一章的要求是把专用代码往上或往下推,让它和通用代码分开(6.6 节)。如果你发现自己在给一个只有一处调用的业务流程抽象"可扩展框架",那是走到了另一个极端。第 9 章会把"通用与专用混杂"正式列为一个红旗。

    以下不在书里,来自书中给出的读者讨论组,是 Ousterhout 对本章的两处补充。

    通用会不会更慢?有读者说通用的算法和数据结构往往比专用的慢。Ousterhout 的回答(2023-04):这种情况有,但不常见;关键是"做成通用"指的是接口,而不是功能。功能只实现今天需要的(把功能做过头才会明显拖慢性能),然后给它配一个通用的接口;性能要求可以照常考虑进去,在满足功能和性能的前提下取最通用的接口,结果几乎总是相当通用。

    为什么容易做成专用的?2020 年讨论"自顶向下还是自底向上"时,Ousterhout 说学生项目里常见这样的毛病:做一个模块时,把接口设计得只贴合马上要用它的那个应用,结果信息泄漏、系统变脆。原因是知道得太多:脑子里一直想着最终的应用,很难不让那些具体需求渗进接口(2020-03)。Ousterhout 由此说,好设计的关键之一是知道什么时候不该用上自己脑子里已有的信息。这正是 6.2 节那个文本编辑器例子里发生的事,只是换了个角度讲:不是设计者不懂通用,而是太清楚眼前的需求。

    练一练

    下面三道题来自训练场:一道识别专用接口,一道"看起来通用但其实过头了",一道消除特殊情况。本章题库共 21 题,去训练场做完整一组。

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

    动手:项目任务

    minikv 的 Store 为每条命令各写了一个专用方法(set、del、incr、append、getRange),每个都重复一遍"组记录、写日志、记垃圾字节、更新索引";INCR 的整数解析、GETRANGE 的切片这些命令语义也落在了存储层。结果是加一条命令要同时改 Server、Database、Store 三处。任务 m02 要求你用这一章的三个自问审视 src/store.cpp 和 src/database.cpp,设计一个"有点通用"的存储核心,把命令语义推回上层。动手之前先读第 11 章"设计两次":至少画出两套截然不同的接口再比较,这是 m02 的设计步骤。

    任务 m02:把一组专用方法换成一个有点通用的核心接口。详见 任务页。

    cd project/minikv && make test

    自测题

    按书中的定义,"有点通用"(somewhat general-purpose)的模块指的是:
    功能和接口要分开看:功能只做今天需要的(这一点和"别提前实现"一致),接口不绑死在今天的具体用法上。C 是"通用过头"。
    作者认为通用的接口比专用的好,即使:
    作者最意外的发现是:即使只按专用方式使用,通用的设计也更简单、更深,实现代码也更少。复用只是额外的收益。B、C、D 都和书中描述的通用设计的特点相反。
    一个方法只在整个代码库里被调用一次,而且名字直接对应一个界面按钮。按 6.5 节,这提示什么?
    "这个方法会在几种场合用到?"如果答案是一种,那是专用化的警告信号。书中还特别指出,把类的方法和用户可见的功能一一对应,往往会把上层概念泄漏进下层。
    为了把 exportCsv()、exportJson()、exportForEmail() 三个方法"通用化",有人合成了 exportData(bool json, bool gzip, bool forEmail, bool legacy, int indent)。评价:
    作者明确提醒:减少方法数只有在每个方法本身保持简单时才有意义。靠堆参数合并方法,得到的往往是一个更难用的接口。更好的方向是找出真正的通用核心(例如"把记录序列化成某种格式写到某个流"),让格式成为一个可替换的部件。
    在本页的定时器例子里,"空闲多久算超时、断线时取消哪些定时器"属于哪一部分?
    三分法:通用机制管"到点调用";具体细节由各模块的回调实现;怎么设、怎么组合属于策略,放在最了解整体行为的顶层代码。书中的撤销例子里,"把哪些动作分成一组"同样由上层决定。
    关于"消灭特殊情况",下列哪项最符合 6.8 节的意思?
    关键是让常规代码自然覆盖边界,而不是把检查挪到别处。A 只是把特殊情况推给了调用者,第 10 章会专门讨论怎样减少这类异常。

    小结与读后一句话

    第 7 章看系统的纵向结构:相邻两层如果提供几乎一样的抽象(比如一个方法只是把参数原样转交给下一层),说明分层出了问题。本章的"专用往上推、往下推",到那里会变成"每一层都应该提供不同的抽象"。

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