三种看不见的耦合,和一次搬了家的依赖
《编程思想》系列第 3/10 篇。这一季的立论在序章。
我以前有个很省事的判断:一个模块 import 得越多,耦合就越高。直接调用改成事件之后,两个 import 消失,构造函数也短了一截,看上去很像解耦。
把这段代码放回 1974 年那张耦合等级表,结论却反了过来。原来的直接调用处在最松的一档;换成事件后,两端开始共同依赖事件名、payload 形状、执行顺序和失败语义。其中几项还离开了类型系统。
上一篇讲内聚,问模块内部的元素该不该待在一起。这一篇讲模块之间:当 import 数量不再可靠,还能从哪里看出两个模块绑得有多紧?
藏在参数里的耦合
先从一眼能看见的耦合说起:用参数指挥对方走哪条内部分支。
async function submitRefund(req: RefundRequest, dryRun: boolean): Promise<RefundResult>
// 调用处
await submitRefund(req, true)
调用处那个 true 泄露了两件事:submitRefund 内部存在几条路径,dryRun 会跳过其中一些步骤。读代码的人只有知道这些内部安排,才能判断这次调用会做什么。这个参数同时传递了退款数据和执行指令。
返回值又添了一层麻烦。dryRun 为真时没有真的提交,RefundResult.status 该填什么,金额字段又代表预估还是结果?一个类型承担了两种语义,调用方只能结合那个布尔值再猜一次。
把两条路径拆开以后,函数名和返回类型各自说明意图:
async function previewRefund(req: RefundRequest): Promise<RefundPreview>
async function submitRefund(req: RefundRequest): Promise<RefundResult>
到这里可以把定义收进来:耦合量的是两个模块之间互相了解多少。了解得越多,绑得越紧,改一个牵动另一个的概率也越高。dryRun 之所以扎眼,就是因为调用方被迫了解了 submitRefund 的内部安排。
这里还需要区分控制参数和普通配置。formatAmount(m, { showCurrency: true }) 也传布尔值,但它描述输出长什么样,没有要求调用方选择一套业务流程。双方仍然共享参数含义,这属于普通的数据耦合;dryRun 额外暴露了被调方的控制流,才落进控制耦合。
耦合的六档从哪来
控制耦合这个名字出自 Stevens、Myers、Constantine 1974 年发在 IBM Systems Journal 上的《Structured Design》[1],和上一篇的内聚六档来自同一套工作。今天常见的耦合表从紧到松排成六档:
| 档 | 名称 | 两个模块之间连着什么 |
|---|---|---|
| 1 | 内容 content | 一个模块直接读写另一个的内部实现 |
| 2 | 公共 common | 共享一份全局数据 |
| 3 | 外部 external | 共同依赖一个双方之外的格式或协议 |
| 4 | 控制 control | 一个模块传标志位去决定另一个走哪条分支 |
| 5 | 标记 stamp | 传了一整个结构体,只用其中几个字段 |
| 6 | 数据 data | 只传对方需要的数据 |
dryRun 例子落在第四档。拆成两个操作后,请求里只留下各自需要的数据,连接方式往第六档移动。
这张表经历过后来的整理。关于谱系,我核到的二手转述是:1974 年原文里有一档「混合耦合」,后来从教材中消失;「外部」和「标记」是后补的。原文正文没有拿到,这段沿革只按转述处理。六档表本身仍然有用。真正值得记的是档位顺序:两个模块为了配合,需要知道多少对方或第三方的细节。
生产代码里,耦合藏在 import 之外
前面的耦合写在函数签名里,编译器和 IDE 都能看见。生产代码里更难处理的部分,往往只在运行时、共享状态或一个看似中立的中间层里出现。import 变少时,这些连接不会跟着消失。
换成事件总线后,耦合从编译期挪到运行期
class RefundService {
constructor(
private ledger: RefundLedger,
private notifier: Notifier,
) {}
async settle(refundId: string) {
await this.ledger.markSettled(refundId)
await this.notifier.refundSettled(refundId)
}
}
RefundService 明确认识两个类型。换成事件总线之后:
class RefundService {
constructor(private bus: EventBus) {}
async settle(refundId: string) {
await this.bus.emit('refund.settled', { refundId })
}
}
现在它只认识 EventBus,import 少了两行,构造函数也短了。账仍然要记,只是改由订阅 refund.settled 的 handler 完成。
编译器能看见 emit,也能检查 EventBus 暴露出来的类型。假如事件名只是一个字符串,它无法据此列出全部订阅方,也不知道订阅方的执行顺序和失败传播规则。IDE 可以搜到相同字符串,却很难证明搜索结果就是完整的业务链路。
按等级表逐条看,第一版的 RefundService 和 RefundLedger 只传 refundId,属于第六档的数据耦合。第二版的发布方和订阅方共同依赖事件名与 payload 形状,属于第三档的外部耦合。单看这条业务连接,它从可检索的编译期依赖,变成了需要额外追踪的运行期约定。
事件总线仍然有合适的场景:跨进程、跨团队,或者订阅方确实不该被发布方知道。判断业务依赖强弱有个笨办法:暂时拿掉订阅方,看主业务是否仍然成立。埋点和审计日志缺席会少一份数据,通常可以异步订阅;记账缺席会让账目错误,这条链路就需要更强的完成与失败保证。这个检查只能判断业务依赖的强弱,跨进程带来的部署边界、吞吐和可用性还要另外权衡。
共享可变状态:无需 import 也互相影响
// shared-cache.ts
export const refundCache = new Map<string, Refund>()
对账任务写它,风控读它。两个模块可能都 import shared-cache.ts,彼此之间却没有直接引用。全文搜索能列出这份 Map 的使用者,却无法说明哪些读取必须发生在哪次写入之后。
例如,风控先读到一笔退款还是 Submitted,随后准备放行;放行完成之前,对账任务把同一笔退款改成了 Failed。Map 没有返回旧值,问题出在读取状态和使用结果分成了两个时刻。风控继续拿先前的判断往下走,业务决定已经过期。
这类连接落在第二档的公共耦合,只比直接读写对方内部实现松一点。它难查的地方也更具体:工具找得到共享对象,找不到读写所有权、先后顺序和原子性要求。
风控需要的是查询能力,可以只给它一个只读接口,再由共享状态之外的模块提供实现。这样依赖重新变得可检索,风控也拿不到写入口。这个接口该由谁定义,是第 7 篇和第 8 篇的题目。
中间层为什么不是解耦
多个窗体直接调用多个数据层方法,依赖图是网状;中间放一个 Controller,图会变成星形。形状清楚了一些,但 Controller 如果堆满按调用方分支的 switch,原来散在各处的了解量会全部集中到这里。
这时改一个数据层方法,Controller 要改;某个窗体多一种情况,Controller 也要加分支。原本互不相识的 Form1 和 Form3 开始频繁修改同一个文件。需要同时打开和验证的代码没有减少,依赖只是搬了家。
判断这层是否真的形成了边界,可以试着改动它背后的实现。如果调用方不受影响,而且中间层的接口仍然用稳定的业务词汇表达同一件事,这层吸收了变化;如果每次改动都要给 switch 添一个调用方分支,它只是所有细节的集散地。这个前提在第 7 篇讲依赖倒置时还会回来。
依赖指向更稳定的一侧,不能成环
找到这些连接之后,还要决定它们朝哪边指。我最后留下三条规则,范围有限,但每条都能直接检查。
先说环。A 依赖 B、B 依赖 C、C 又依赖 A,这三个模块在变更影响上已经很接近一个模块:动一处,三处都要重新验证。环是少数能由工具直接查出的结构问题,madge 和 dependency-cruiser 都能做这类检查。
再说方向。依赖要指向更稳定的一侧。这里的稳定,指被依赖得多、自己依赖得少,因而改动成本更高。Money 可以被四个渠道依赖,却不该了解任何一个具体渠道;反过来连接,加一种渠道就要动 Money,序章列出的四条不变量里有两条还住在这个类型里。
最后是可见性。本篇的几种连接可以按「改动时工具能帮到什么程度」重新排一次:
| 连接方式 | 改动约定时会发生什么 |
|---|---|
| 构造函数参数 | 编译器报错,错误位置基本就是调用方清单 |
| 有类型的事件约定 | 名称和 payload 对不上会报错,订阅顺序与失败传播仍要运行时核对 |
| 全局可变对象 | 全文搜索找得到使用者,读写关系和时序仍要自己还原 |
| 事件名字符串 | 搜得到字符串,很难证明搜索结果覆盖了完整语义 |
后几种连接都有用途。它们的共同代价,是把一部分「谁依赖谁」从编译器交给测试、文档、运行时检查,或者人的记忆。配套机制缺一项,维护者就要多记一项。
这三条管的是依赖的形状、方向和可见性,判断不了一条依赖该不该存在。那个问题仍然要回到上一篇:这两个模块会不会因为同一个变化而一起改。
把 import 数量当耦合指标,两次都错了
我原本把「引入中间层」当成解耦的标准答案。写 Controller 那个例子时再按改动范围检查,网状变星形只改变了依赖图的形状;中心仍然堆着同一批分支,需要同时打开的文件一个也没少。
我也把直接调用换成事件当作降低耦合,证据就是 import 变少。按等级表逐条核对后,RefundService 和 RefundLedger 原本只传对方需要的数据;换成事件,两端开始共享字符串、payload 和运行时约定。这个改动在某些部署边界上可能值得做,但「import 少了,所以耦合更低」这个结论已经站不住。
两次误判都来自同一个省事的替代指标。import 数量数的是源文件里的符号,耦合表问的是理解和修改一条连接需要知道多少东西。碰到事件总线、共享状态和中间层时,两者会给出不同答案。
现在再看一条依赖,我会先假设它的约定发生变化:编译器能不能列出所有受影响的位置?剩下那些必须靠搜索、文档和记忆才能补齐的部分,就是这条连接没有写在 import 里的成本。
下一篇讲单一职责,回到内聚那一侧。