读前:这一章要回答什么
写一个新模块时,该为眼前这几个用法量身定制,还是做得"通用一点"?通用到什么程度算刚好?
第 5 章说,深模块来自信息隐藏。这一章换一个入口回到同一个目标:通用的接口天然藏得住更多信息。作者的说法相当激进,认为过度专用化(over-specialization)可能是软件复杂性最大的单一来源。
这和很多人的直觉相反。"只做今天需要的,以后真要复用再重构"听起来既务实又符合增量开发。作者坦白自己教课之初也这么想,看了几届学生的项目后改了主意:通用一点的设计几乎总是更好,哪怕根本没有被复用。这一章就是作者给出的理由和分寸。
后半章把"专用"往下推到更细的粒度:专用代码不可能消灭,但可以和通用代码干净地分开;方法体里的特殊情况(special case),很多也可以通过重新设计正常路径来消掉。
- 6.1 节:注意"有点通用"(somewhat general-purpose)的精确定义,它把功能和接口分开说。读完试着用一句话回答:按这个定义,要不要现在就实现将来可能用到的功能?
- 6.4 节最后一段:书中把一个专用方法称为"虚假的抽象"。想一想:为什么藏起某些细节反而让使用者更难拿到他们需要的信息?这和第 4 章、第 5.9 节是一条线。
- 6.7 节结尾的 "Note":把通用代码和专用代码分开,是针对同一个机制说的。读完你应该能解释,为什么某些专用的撤销代码放在文本类里反而是对的。
核心观点
专用化制造复杂性
为某个具体用法量身定制的接口,往往方法多、每个方法都浅,还把上层的概念带进了下层。
"有点通用"
功能只做今天需要的,接口不绑死在今天的用法上。"有点"两个字很重要:通用到今天反而难用,就过头了。
通用 → 藏得更多
下层只提供基本操作,上层的具体用法留在上层。两边可以各自独立地改,谁也不必知道对方的细节。
三个自问
覆盖当前全部需求的最简接口是什么?这个方法会在几种场合用到?用它满足今天的需求方便吗?
谁需要知道什么,什么时候
使用者确实需要的细节,应该明确而显眼;藏进一个专用方法里,只会制造模糊。
专用代码往上推,或往下推
具体功能留在顶层;设备差异这类专用细节压到底层的驱动里,中间的核心保持通用。
消灭特殊情况
把正常路径设计成能自然处理边界情况,少写 if。第 10 章的异常处理和第 20 章的性能都会回到这一点。
心智模型:专用知识住在哪一层
书中的贯穿例子是学生写的图形界面文本编辑器:有的小组给文本类设计了一组和界面按键一一对应的方法,有的小组只给了"在某个位置插入""删除一段范围"这样的基本操作(6.2~6.3 节)。本站换一个 C++ 程序员更熟悉的场景:TCP 连接的接收缓冲区。上层有 HTTP、Redis 协议、内部 RPC 三种解析器,都要从同一个缓冲区里取数据。
the module's functionality should reflect your current needs, but its interface should not.— 第 6.1 节
光看图还不够具体。下面把三种接口和三个现有用法、外加一个"明天才来的新需求"逐一摆出来。切换设计和用例,对照右边的调用方代码,再看下方三个自问的结论。
三个自问:接收缓冲区的三种接口
RecvBuffer 的公开接口,右边是上层协议解析器为了某个用例要写的代码(NeedMore 表示数据还没收全,等下次再来)。RecvBuffer 的接口
调用方代码
行数不含注释,只为比较量级。注意"有点通用"那一列:调用方代码比专用设计长几行,这一点作者也承认(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 可以原封不动地用在任何事件循环里;每种定时器只被一个模块理解;"超时设多长"这类策略集中在顶层配置。三部分可以各自理解、各自修改。
代价:回调捕获了 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自测题
exportCsv()、exportJson()、exportForEmail() 三个方法"通用化",有人合成了 exportData(bool json, bool gzip, bool forEmail, bool legacy, int indent)。评价:小结与读后一句话
- 过度专用化是复杂性的主要来源之一;通用一点的接口更简单、更深,哪怕不被复用。
- "有点通用":功能反映当前需求,接口不反映。别通用到今天难用。
- 三个自问:覆盖当前需求的最简接口?会用在几种场合?今天用起来方便吗?
- 使用者需要的细节要明确地交给他们;藏进专用方法就成了虚假的抽象。
- 专用代码往上推(顶层功能)或往下推(驱动),和通用代码分开;分开的单位是"同一个机制"。
- 方法体里的特殊情况,尽量靠重新设计正常路径来消除。
第 7 章看系统的纵向结构:相邻两层如果提供几乎一样的抽象(比如一个方法只是把参数原样转交给下一层),说明分层出了问题。本章的"专用往上推、往下推",到那里会变成"每一层都应该提供不同的抽象"。
读后一句话:用你自己的话写下这一章最重要的一个观点,最好结合你写过的某段代码。