开闭原则带读
把 OCP 从一句口号还原成一个具体动作:在代码里挖一个洞。
八步走完,外加原文没讲的两块——这个动作在 CPU、互联网、Linux、生物演化里的同一副样子,以及它会怎么骗你。
怎么用这份带读
- 八步,每步末尾有一行 ★ 结论。读完那一行不觉得别扭就往下走;觉得别扭就停在那儿——记下断在第几步,换个入口重讲,不补细节。
- 第 3、5、7 步各挂着能跑的 Python 脚本,都已跑过一遍、输出贴在正文里。脚本在同目录
演示/,建议自己改两行再跑。 - 第 1–5 步是原文的逐段带读;第 6–7 步是原文之外的:更广的视角、更深的坑。第 8 步是验收。
1 · 零点:先别看「开放」「封闭」这四个字
对应原文:开头 ~「开闭原则(OCP)」前半节
抽象概念最容易的读法是从定义读起,也是最容易读空的读法。绕开定义,从一个谁都遇到过的处境开始。
需求变了。你手上只有两个动作:
- 改老代码——在已有的函数里加个分支、加个参数、改个判断。
- 加新代码——老的一个字不动,新写一份。
OCP 的全部内容,就是「尽量走第二条」。
为什么?不是因为第二条更优雅,是因为两者的代价增长方式不一样:
| 动作 | 代价 | 随什么增长 |
|---|---|---|
| 加新代码 | 写它 + 测它 | 常数。新东西还没人依赖,写错了只砸自己脚 |
| 改老代码 | 写它 + 测它 + 回归所有调用方 | 正比于依赖它的人数。而且是复利:模块越好用、被依赖越多,改它越贵 |
这个不对称是整篇文章的地基。「对修改封闭」不是道德要求,是成本核算。 越是好用的模块越不该动,恰恰因为它好用。
现在再看原文的定义,它就不是一句口号了:
原文 · Bertrand Meyer 1988
软件实体(模块,类,函数等)应该对于功能扩展是开放的,但对于修改是封闭的。
这里有一个读的时候极容易滑过去、但决定了后面怎么读的细节:这句话的主语是**「软件实体(模块、类、函数)」**,不是「软件」。原文要到第三节讲插件时才把这个区别点破,但它其实从第一句就在了——OCP 从来没说"软件不许进步",它说的是"某一块不许动"。哪一块不许动、哪一块随便动,这才是它真正在分配的东西。
把定义翻译成一个动作:
★ OCP = 在代码里挖一个洞。
洞的形状固定不变 —— 这叫「对修改封闭」;
往洞里塞什么不设限、随时能加 —— 这叫「对扩展开放」。
这个「洞」的比喻会用到最后一步。后面所有东西——CPU 的指令集、插件的事件、回调函数、IP 协议、甚至遗传密码——都只是不同尺寸、挖在不同位置的洞。
2 · 洞挖在哪,是唯一的技术含量
对应原文:CPU 背后的架构思维
原文这一节是全篇最值钱的地方,但它容易被当成一段"举例说明"读过去。它不是举例,它是在演示挖洞这个动作的完整决策过程。
原文
关键要抓住需求的稳定点和变化点。需求的稳定点,往往是系统的核心价值点;而需求的变化点,则往往需要相应去做开放性设计。指令是稳定的,但指令序列是变化的……计算是稳定的,但数据交换是多变的……
我们不必去修改 CPU,但是我们却支持了如此多姿多彩的信息世界。
翻译成我们的说法:洞的形状 = 指令集;往洞里塞的东西 = 指令序列。
而「往洞里塞的那堆东西」,人类给它起了个名字,叫软件。
这句话值得停一下。整个软件行业——你我的全部职业——是某一次挖洞决策的产物。冯·诺依曼把洞挖在了「指令 / 指令序列」这个界面上,于是洞的上面长出了一个几万亿美元的产业,而洞的下面(硅工艺)换了七八代,两边谁都没打扰谁。
试想:如果当年这刀切错了
假设当年觉得"排序是个高频操作",就把 SORT 做成一条 CPU 指令。会怎样?
- 要支持"按多个字段排序" → 改 CPU。
- 要支持"稳定排序" → 改 CPU。
- 要排 10TB 排不进内存的数据 → 改 CPU。
洞挖得太靠上(离业务太近),每一次业务变化都会穿透它砸到核心。反过来,洞挖得太靠下(比如只暴露"控制单个晶体管的电平"),洞是稳定了,但没人能用它拼出软件来。
所以挖洞是在找一个又稳又够用的界面:
- 够稳:上面的东西怎么变,它都不用变。
- 够用:只靠它,上面的东西真能被拼出来。
原文后半段那个「缺页中断」的例子,是同一个动作的第二次演示:CPU 不知道硬盘长什么样、不知道文件系统格式,它只挖了个洞叫「你要的页不在内存里,我叫你一声」。至于叫了以后怎么办,交给操作系统。原文那句注解很关键——
原文
中断机制,我们可以简单把它理解为 CPU 引入的回调函数。
这句是全篇的暗线:中断 = 回调 = 事件 = 插件,它们是同一个东西的不同规模。第 3、4 步把这条线走完。
★ OCP 的全部难度不在「不改」,在「切在哪」。洞的位置一旦定下就是几十年不动的;要改洞的位置,等于推倒重来。
3 · 落到代码:最小的那个洞是一张表
对应原文:插件机制节 · Go 的 image 包
配套脚本:演示/1-洞的形状.py
心智模型讲完了,看它在代码里长什么样。一个导出模块,已支持 csv 和 json,现在要加 xlsx。
写法 A · 没挖洞
def 导出(数据, 格式):
if 格式 == "csv":
return 到csv(数据)
elif 格式 == "json":
return 到json(数据)
elif 格式 == "xlsx": # ← 新需求逼我改这里
return 到xlsx(数据)
写法 B · 挖了洞
导出器 = {} # ← 这就是那个洞
def 注册(名字):
def 装饰(函数):
导出器[名字] = 函数
return 函数
return 装饰
def 导出(数据, 格式):
return 导出器[格式](数据) # 洞的形状:拿名字换一个函数来调
# ——— 以下写在别的文件里,上面那段一个字都不用动 ———
@注册("xlsx")
def 到xlsx(数据): ...
差别不在美观。脚本给两种写法的核心文件算了指纹:
$ python3 演示/1-洞的形状.py
【写法 A · 没挖洞】
核心文件指纹 3c1cb517 → afac4fb6 变了 ✗
含义:所有调用「导出」的地方,理论上都要重新验证一遍
【写法 B · 挖了洞】
核心文件指纹 46a51cac → 46a51cac 没变 ✓
周边文件指纹 427c188f → 1d52b19a 变了(但它没人依赖)
含义:新增的风险被关在新文件里,老调用方一个都不用回归
这就是第 1 步那张代价表的实测版:风险被关进了没人依赖的新文件里。
对照:原文的 Go 例子其实是同一件事
原文
Go 语言中的 image 包……我们可以增加一种格式支持,而无需修改 image 包。
import _ "image/jpeg"
这里面最大的简化,是放弃了插件加载机制。我们自己手工来加载插件。
Go 那个下划线 import _ "image/jpeg" 意思是"我不用这个包里的任何名字,我只要它被加载时产生的副作用"——那个副作用就是这个包在自己的初始化里往 image 的表里注册了一行。
Python 里完全一样:你 import 我的xlsx导出器 的那一刻,模块顶层的 @注册("xlsx") 就执行了,表里多了一行。「导入即注册」在两种语言里是同一个机制。
原文说这叫"放弃了插件加载机制"——意思是没有"扫描某个目录、自动发现插件"这一步,得你手写一行 import。这不是简陋,这是把插件机制里最贵的那部分砍掉了,只留最有用的那部分。
尺度:回调、接口、插件是一根轴上的三个刻度
| 变化点的复杂度 | 洞长什么样 | 例子 |
|---|---|---|
| 一次性、一个点 | 传个函数进去(回调) | sorted(x, key=...)、CPU 的中断 |
| 一族相关行为 | 一个接口 / 一张注册表 | 上面的 导出器、Go 的 image 包 |
| 整块独立业务 | 完整插件机制 | Office / VS Code 的二次开发 |
★ 回调不是插件的"另一种东西",是插件机制退化到最小的样子。原文那句"回调函数或者接口本质上就是一种事件监听机制,所以它是插件机制的特例",说的就是这个。
★ 所以选哪个不是品味问题,是按变化点的大小配尺寸:一个点用回调,一族用接口,一整块业务才上插件。
4 · 插件机制:把洞做大之后,多出来的三样东西
对应原文:插件机制(三部分 · 三类事件 · 成本)
原文说完整插件机制有三部分。带读的关键是理解为什么恰好是这三样,以及为什么第三样最难。
| 部件 | 回答的问题 | 难在哪 |
|---|---|---|
| DOM API(能力暴露) | 插件能干什么 | 最基础。本质是把你已有的功能整理成一套对外可讲的词汇 |
| 加载机制 | 插件怎么进来 | 纯工程问题。扫目录、读注册表——Go 的例子直接把它砍了 |
| 事件监听 | 插件什么时候被叫醒 | 关键且最难。 这一样决定了插件机制成不成立 |
为什么事件最难?因为前两样是你已经有的东西换个说法,事件是你必须凭空做出的一组预测:你要在业务流程里预先埋下若干个"停一下、问问外面有没有人想插话"的点。埋在哪、埋几个,一旦发布就改不动了。
原文
没有事件,插件没有机会介入到业务中去。但是应该提供什么样的事件,提供多少个事件,这非常依赖架构能力。原则来说,在提供的能力相同的情况下,事件当然越少越好。
「越少越好」听起来像审美偏好,其实是硬成本:每一个事件都是一份永久契约。一旦有人的插件挂在上面,你就再也不能删它、不能改它的触发时机、不能改它的参数——甚至改不了它的触发顺序(第 7 步会看到这有多要命)。
追问:原文那三类事件,为什么恰好是三类
原文列了界面操作类、数据变更类、业务流程类,但没解释为什么是这三类。补上这一层:它们对应一个软件被外界感知的三个面。
| 类别 | 对应软件的哪个面 | 典型事件 | 带读要点 |
|---|---|---|---|
| 界面操作类 | 人怎么碰它 | 菜单 / 按钮点击 | 鼠标键盘不给,因为太底层——给了之后插件能截走一切输入,你的核心就再也调不动交互了 |
| 数据变更类 | 它记住了什么 | onSelectionChanged、onDataChanged |
就是 MVC 里 Model 向外发的那条通知。MVC 本身就是一次挖洞:View/Controller 是插进 Model 那个洞里的东西 |
| 业务流程类 | 它在做什么 | 打开文件前 / 后 | 「前」和「后」是两种不同的洞:前能改输入甚至否决,后只能观察。前者贵得多 |
顺带说,这张表其实给了你一个设计自己的事件时的检查表:先问这个事件属于哪一面,再问它是"前"还是"后"。绝大多数需求用"后置的数据变更事件"就够了——那是最便宜的一种洞。
成本:原文最实用的一句话
原文
插件机制本身也是核心系统的一个功能,它本身也需要考虑与核心系统其他功能的耦合度。如果某插件机制没有多少客户……而它本身代码又散落在核心系统的各个角落,那么投入产出就显然不成比例。
★ 插件机制不是核心系统之外的东西,它就是核心系统的一个功能,一样占预算、一样要维护、一样会和别的功能耦合。
★ 判断该不该做,标准朴素得很:这个洞,有几个真实的使用者? 只有一个,就先写死。
5 · 「只读」不是洁癖,是省回归的钱
对应原文:开闭原则节后半 · 「只读」的业务模块
配套脚本:演示/3-爆炸半径.py
原文
与其修改模块的业务,不如实现一个新业务……开闭原则鼓励写"只读"的业务模块,一经设计就不可修改,如果要修改业务就直接废弃它,转而实现新的业务模块。这种"只读"思想……比如基于 Git 的源代码版本管理、基于容器的服务治理都是通过"只读"设计来改善系统的治理难度。
这段第一次读通常会觉得偏激:"改一下不就完了,非要新写一个?"把数字摆出来就不偏激了。
场景:算价模块被 30 处调用,其中 4 处新业务要换算法。
$ python3 演示/3-爆炸半径.py
【路线甲 · 原地改】 def 计算价格(单价, 数量, 用新算法=False)
必须重新验证的调用方:30 处
理由 老函数的代码路径被动过了,谁也不能保证 用新算法=False 时行为一模一样
副作用 这个布尔参数会永久留在签名里;下次再来一种算法就是第二个布尔
【路线乙 · 只读】 计算价格() 一字不改,另写 计算价格_促销()
必须重新验证的调用方:4 处 —— 促销A、促销B、促销C、促销D
理由 老函数字节级没变,26 个老调用方连回归都不用跑
代价 库里多了一个模块;将来老模块没人用了,直接删(这才叫归档)
路线甲 回归面:30 处 路线乙 回归面: 4 处 省下 26 处
省下的不是代码行数,是 26 次回归验证。 这就是"只读"买到的东西。
澄清:两个几乎人人踩的误读
- "只读"不是不许改 bug。 原文写得很清楚:“我们可以修改模块代码的缺陷(Bug),但不要去随意调整模块的业务范畴”。改 bug 是让它更符合原来的承诺;改业务范畴是换了一份承诺。只读锁的是承诺,不是字节。
- “归档"不等于"删除”。 老模块继续躺在那里给老调用方用,只是不再往里加东西了。等最后一个调用方迁走,它自然就能删——那时候删是零风险的。
同源:为什么 git 和容器是同一件事
- git 的 commit 不可变:你从不"修改一个 commit",你产生一个新 commit。于是任何一个 commit hash 永远指向确定的内容——这就是为什么
git bisect能工作。 - 容器镜像不可变:你从不进容器里改,你 build 一个新镜像。于是"线上跑的到底是什么"永远有确切答案。
★ 不可变买到的东西叫「当前状态永远可推理」。省的是排查的钱和验证的钱——而这两笔钱,恰好是软件维护里最大的两笔。
6 · 更广:同一个动作在别处的样子
原文之外 · 从 1972 年到遗传密码
原文说 OCP 是"信息技术架构的基本原则"、不只属于 OOP。这话说小了。把视野再放大一圈,会发现它甚至不只属于信息技术。
6.1 祖宗其实是 1972 年的 Parnas,而且他说得更准
原文说 OCP 由 Meyer 在 1988 年提出——术语确实是他造的。但这个想法早 16 年就被 David Parnas 说过了,还说得更好:《On the Criteria To Be Used in Decomposing Systems into Modules》(1972)。
| 说法 | 它把注意力引向哪 | |
|---|---|---|
| Meyer 1988 | 模块别改 | 引向"纪律"——听起来像一条要遵守的规矩 |
| Parnas 1972 | 按「什么可能会变」来分模块,把每个可能变的决策藏进一个模块里 | 引向"分析"——先做预测,再动刀 |
Parnas 的版本更接近许式伟这篇的立场,也更可执行:模块边界不是按"功能相关"划的,是按"变化相关"划的。这两种划法经常给出完全不同的答案,这也是为什么很多人按"功能"分完包,每次改需求还是要动七八个包。
6.2 叫 OCP 的东西有三个,它们不是一回事
这一条如果不分清,你会在网上读到互相矛盾的解释还以为自己没懂。
| 版本 | 主张 | 手段 |
|---|---|---|
| Meyer 1988(原版) | 模块一经发布就冻结;要改就继承出一个子类改 | 继承。今天基本没人这么用了(继承带来的耦合太重) |
| Robert Martin 1996(教科书版) | 依赖抽象接口,靠多态换实现 | 接口 + 多态 + 依赖倒置。SOLID 里的那个 O |
| 许式伟本篇(架构版) | 划稳定点 / 变化点,变化点交出去 | 回调、接口、插件都算;跟 OOP 没关系 |
三个版本的抽象层级不同:Meyer 管一个类,Martin 管一组类的依赖方向,许式伟管整个系统的切分。用我们的说法——都是挖洞,只是洞的尺寸差了几个数量级。
顺带解决一个常见困惑:为什么 SOLID 里 OCP(O)和 SRP(S)总被一起讲、听着又像在说同一件事?原文答得很准——
原文
单一职责原则强调的是每个模块只负责一个业务……开闭原则强调的是把模块业务的变化点抽离出来,包给其他的模块。它们谈的本质上是同一个问题的两个面。
补一句更好记的:SRP 站在洞里面说"我只干这一件事",OCP 站在洞外面说"不是这件事的都推出去"。同一刀的两侧。
6.3 互联网是一次挖洞,而且大家管那个洞叫「腰」
网络架构里有个成熟的说法叫沙漏模型 / narrow waist,它和 OCP 是同一件事,但它把"洞"这件事量化了:腰越窄,两边越自由。
╲─────────────────────────╱ 上层:随便换
╲ ╱ 变化快 · 增量式生长
╲ ╱
┌───────────────────┐
│ 稳定契约 │ ← 洞的形状,唯一不许动的东西
└───────────────────┘
╱ ╲
╱ ╲ 下层:随便换
╱─────────────────────────╲ 实现换代 · 谁也不打扰谁
| 系统 | 上层(变) | 腰(不变) | 下层(变) |
|---|---|---|---|
| 计算机 | 全部软件 | 指令集 | 硅工艺换代 |
| 互联网 | HTTP · QUIC · 一切应用 | IP 包 | 以太网 · 光纤 · 5G |
| 操作系统 | 所有用户程序 | 系统调用 | 内核实现全部重写过 |
| 生态型软件 | 插件生态 | API + 事件 | 内核实现 |
| 细胞 | 全部蛋白质 | 遗传密码表 | 各类代谢通路 |
五个系统,同一个形状。腰是唯一不许动的东西,上下两头都在剧烈变化——而且正因为腰不动,它们才敢剧烈变化。
IP 那一行尤其值得看:IP 包的格式几乎没变过,但它上面从 FTP 变到了直播和视频会议,下面从电话线变到了光纤和 5G。这两边各自换了好几代,谁都不知道对方换了什么。 这正是原文说 CPU 时那句"我们不必去修改 CPU,但是我们却支持了如此多姿多彩的信息世界"的网络版。
6.4 Linux 内核:OCP 作为一条社会契约
Linux 有一条著名的铁律:“we do not break userspace”——绝不破坏用户态。Linus 为这事在邮件列表上骂过很多人。
但同一个 Linux 内核,内部 API 完全没有稳定性承诺,随时大改,改到出名。
这两件事同时成立,恰好把 OCP 讲透了:
★ OCP 封的是「洞」,不是「代码」。 洞(系统调用界面)死也不动;洞后面的实现(内核内部)随便重写。
★ 反过来说:如果你觉得 OCP 意味着"代码不许改",那你八成把洞挖得太浅了——你把实现细节也暴露成了洞。
6.5 为什么"洞"有价值、又为什么要花钱买
Baldwin & Clark 在《Design Rules》里给了模块化一个经济学解释,正好补上原文只说了一半的成本问题。他们把系统分成两部分:
- 可见的设计规则(visible design rules)——所有人都要遵守、不可单方面更改;
- 隐藏的模块(hidden modules)——只要遵守规则,内部随便折腾。
这就是"洞的形状"和"塞进去的东西"。而他们的核心结论是:模块化的价值本质上是一份期权——每个模块都是一次"可以独立试错、失败了不牵连别人"的权利。你多挖一个洞,就是多买了一份期权。
期权是要花钱买的,这就解释了原文那句成本警告:没人来行权的期权,权利金就是纯损失。第 7 步会看到这笔权利金具体贵在哪。
6.6 最深的一层:稳定 ≠ 不变
Stewart Brand 在《How Buildings Learn》里提出 pace layering:一栋建筑分六层——地基、结构、外墙、管线、隔断、家具,每一层的变化速率差一个数量级。家具天天挪,隔断几年改,结构几十年不动。
他的关键论断是:快的层负责创新,慢的层负责稳住;慢的层不能拖住快的层,快的层也不能反过来逼慢的层跟着动。
套回来看,"稳定点"这个词其实有点误导。CPU 的指令集不是没变过(x86 加了几百条指令),操作系统的系统调用也在加。它们不是不变,是变得比上面那层慢一到两个数量级。
★ 稳定 = 变化率比它上面那层低一个数量级。 找稳定点,实际是在找"变得足够慢、慢到上面的人可以当它不变"的那一层。
★ 再往大了说:任何长期演化的复杂系统,都会自发长出一根窄腰——生物如此(遗传密码几十亿年没换过),互联网如此,CPU 如此。OCP 不是谁发明的编程技巧,是这条规律的人为设计版。
7 · 更深:这条原则会怎么骗你
原文之外 · 五个真实的坑
配套脚本:演示/2-猜错了轴.py、演示/4-你封闭的只是你声明的部分.py
原文是在"讲清楚它有多好",这一节补上另一半:它在什么时候不成立、以及照着做反而更糟的情形。
7.1 洞只对它对准的那一根轴开放
这是最贵的一课。还是那个沿"格式"轴挖了洞的导出模块,来五个新需求:
$ python3 演示/2-猜错了轴.py
已有的洞:沿「格式」轴 —— 拿一个格式名,换一个处理函数
再支持 xlsx 格式 落在「格式」轴 ✓ 加个新文件就行
再支持 parquet 格式 落在「格式」轴 ✓ 加个新文件就行
导出前要记一条审计日志 落在「时机」轴 ✗ 必须改核心 导出()
大文件要异步导出、返回任务号 落在「调用方式」轴 ✗ 必须改核心 导出()
按用户角色决定能导哪些列 落在「权限」轴 ✗ 必须改核心 导出()
五个需求,接住两个。「对扩展开放」从来不是对所有变化开放,只对你猜中的那一根轴开放。 猜错了,你既付了挖洞的钱,又照样得改核心。
这直接推出一条操作原则:挖洞这件事必须是滞后的,不能是预判的。 等同一根轴上的变化真实发生过两三次、你看清它的形状了,再挖。这就是所谓的三次法则(Rule of Three)。
提前挖的洞,多半挖在没人走的方向上——而且它还会把你锁死在一个错误的抽象上,让真正该做的重构更难做。
7.2 洞是负债,不是资产
同一个脚本的后半段演示了"那我再挖一个洞"之后发生什么:加了个"导出前"钩子,核心立刻要回答一串新问题——
★ 结论二:每挖一个洞,核心就要多回答一串问题——
钩子能不能改数据?多个钩子的顺序?抛异常算不算整体失败?
这些答案一旦被人依赖,就是**永久契约**。洞不是免费的,是负债。
注意这三个问题你想不回答都不行——你不写在文档里,代码的实际行为就替你回答了,而且外面的人会依赖那个答案(见 7.3)。
这就是 OCP 与 YAGNI 的真实张力:OCP 说"为变化预留扩展点",YAGNI 说"你不会需要它"。两句都对,因为它们的适用条件不同——
| 情形 | 该听谁 |
|---|---|
| 变化只发生过 1 次,只有 1 个使用者 | YAGNI。写死,别抽象 |
| 同一根轴上变化了 2–3 次,轴向清楚了 | OCP。这时候挖洞,形状是从真实案例里长出来的 |
| 接口已经对外发布、使用者你管不着 | OCP 从严。此时"改"的代价接近无穷 |
7.3 你封闭的,只是你「声明」的那部分
OCP 最大的认知陷阱在这儿:你以为只要不改函数签名就叫"对修改封闭"。现实里有条经验定律,Hyrum 定律:
Hyrum’s Law · Hyrum Wright, Google
接口只要有足够多的使用者,你承诺的行为是什么就不重要了——这个接口所有可观察的行为,都会有人依赖上。
一个配置模块,契约三条(取值 / 取不到就报错 / 列出所有键),做了一次"不改契约"的重构,结果——
$ python3 演示/4-你封闭的只是你声明的部分.py
① 依赖了返回顺序
改之前 ✓ 第一个配置项当默认连接目标
改之后 ✗ AssertionError: 我一直以为第一个是 host,结果是 debug
② 依赖了异常类型
改之前 ✓ 捕获 KeyError,走默认值
改之后 ✗ 配置缺失: 配置项不存在:不存在的键
③ 依赖了错误文案
改之前 ✓ 错误文案匹配成功,触发告警
改之后 ✗ AssertionError: 告警规则匹配不到了:配置项不存在:不存在的键
★ 三个调用方全挂,而你一条声明的契约都没违反。
第三个不是瞎编的——线上告警规则靠正则匹配错误文案,是极常见的做法。此外还有人会依赖你的耗时(给你的接口设了超时)、你的内存占用、你的日志格式。
★ 洞的实际形状,比你画的那个大。 你声明的是洞的轮廓,被依赖的是洞壁上每一道纹路。
★ 两条推论:洞要少、要窄(暴露得越少,被依赖的表面积越小);以及——这也是"只读 + 新模块"比"原地改"安全得多的深层原因:新模块的可观察行为还没被任何人依赖上,它此刻是自由的。
7.4 Meyer 提 OCP 的 1988 年,没有 CI
这一条很少有人讲,但对怎么用这条原则影响最大。
1988 年是什么光景:没有 git(2005),没有普及的自动化测试和 CI,没有 IDE 级的自动重构工具(Fowler《重构》1999)。那个年代,改一个被 30 处调用的函数,是真的没法验证。"别改"在当时几乎是唯一理性的选择。
今天呢?一个有像样测试覆盖的内部模块,改它的代价降了一个数量级。所以:
| 对象 | OCP 该多严 | 为什么 |
|---|---|---|
| 对外发布的 API、公共库、协议、插件接口 | 极严 | 使用者你管不着、改不动、通知不到。这里 Hyrum 定律全额生效 |
| 公司内部跨团队的模块 | 中等 | 能协调,但要排期。加新的通常还是比改老的便宜 |
| 自己团队内、有测试覆盖的代码 | 宽松 | 有安全网。这时候重构(直接改)往往比挖洞更对 |
换句话说:重构是 OCP 的合法对立面。 Fowler 那一整套方法的前提就是"我有测试,所以我敢改"。把 OCP 当成"任何代码都不许改",是在没有必要的地方付 OCP 的钱。
7.5 另一半警告:洞太多,会把内核锁死
原文说"没几个客户的插件机制投入产出失衡"——这是成本的一半。另一半是反过来的:
客户太多的插件机制,会让你再也改不动内核。 Emacs、Eclipse、老版本浏览器扩展 API 都栽在这上面:插件生态越繁荣,内核每一次演进就越要背着整个生态走,最后想大改只能另起炉灶重来一次(浏览器扩展从 XUL 到 WebExtensions 就是这么一次强行断腕)。
★ 洞太少 → 每次需求变化都砸核心。洞太多 → 核心被外面的人焊死,再也演进不动。
★ 所以 OCP 是一个要付费的选项,不是一条免费的美德。它总在问你同一个问题:这一处变化,值不值得你永久放弃一部分自由?
8 · 验收
A · 动手(20 分钟)
- 跑一遍
演示/1-洞的形状.py,然后自己加一种parquet格式——注意你改的是哪个文件。 - 跑
演示/2-猜错了轴.py,往需求列表里加两条你自己项目里真实发生过的需求,标上它们落在哪根轴。看看你现有的扩展点接得住几条。 - 跑
演示/4-你封闭的只是你声明的部分.py,再写一个第 ④ 号调用方,依赖一个没演示过的"可观察行为"(提示:耗时、日志、返回值是不是同一个对象)。
B · 自测七题
Q1 用一句话说出 OCP 的最小定义(不许用"开放""封闭"这两个词)。
答案
在代码里挖一个洞:洞的形状固定不变,往里塞什么不设限。 形状 = 你对外的承诺,塞进去的 = 别人的扩展。
Q2 「对修改封闭」为什么说它是成本核算而不是道德要求?
答案
因为两种动作的代价增长方式不同:改老代码的代价正比于依赖它的人数(而且是复利——越好用越贵),加新代码的代价接近常数。所以不是"改代码可耻",是"改代码的账单会随时间膨胀"。
Q3 CPU 那个例子里,洞的形状是什么?塞进去的是什么?
答案
形状 = 指令集;塞进去的 = 指令序列。而"塞进去的那堆东西",人类给它起的名字叫软件。整个软件行业是这一次挖洞决策的产物。
Q4 为什么说回调函数是插件机制的「特例」?
答案
插件机制三部件是「能力暴露 + 加载机制 + 事件监听」。回调只保留了事件监听这一件,而且"加载"退化成了手工把函数传进去。Go 的 import _ "image/jpeg" 处在中间:保留了注册表,砍掉了自动发现。三者是一根轴上的三个刻度,不是三种东西。
Q5 你给导出模块沿「格式」轴挖了洞。来了个「导出前要记审计日志」的需求,为什么这个洞接不住?
答案
因为扩展点只对它对准的那一根轴开放。审计落在「时机」轴上,不是「格式」轴。这也是为什么挖洞必须滞后——等同一根轴上的变化真实发生过 2–3 次、看清形状了再挖,提前挖的多半挖错方向。
Q6 你一个字没改函数签名,调用方还是全挂了。可能是什么原因?
答案
Hyrum 定律:接口所有可观察的行为都会被人依赖上,不只是你声明的那部分——返回顺序、异常类型、错误文案、耗时、内存占用、日志格式。推论:洞要少要窄;也正因为如此,"新写一个模块"比"原地改"安全得多——新模块的可观察行为还没被任何人依赖,它此刻是自由的。
Q7 什么时候不该挖洞?给两种情形。
答案
① 变化只发生过一次、只有一个使用者——写死,别抽象(YAGNI)。② 自己团队内部、有测试覆盖的代码——有安全网,直接重构往往比挖洞更对。补充第三种:洞已经很多、核心快被生态焊死的时候,该做的是收窄而不是再开。
★ 七题里错哪一道,就回第几步:Q1–Q2 → 第 1 步;Q3 → 第 2 步;Q4 → 第 3、4 步;Q5、Q7 → 第 7 步;Q6 → 第 7.3。
回到原文的思考题
「有没有某个模块,你总是在反复修改它来满足新需求?」
原文给了两个选项:抽扩展点,还是按只读归档重写。但要选之前,先做一件事——把这个模块过去几次改动的 diff 排在一起看。
| diff 长什么样 | 说明什么 | 该做什么 |
|---|---|---|
| 只改过 1 次 | 还看不出轴向 | 什么都别做。 等它再变一次 |
| 几次改动落在同一个位置(同一个 if 链、同一个参数) | 那就是变化轴,形状已经暴露了 | 沿这根轴挖洞。形状照着已发生的案例来,不要发挥 |
| 每次改的地方都不一样,散在各处 | 模块的业务边界本身没想清——这不是缺扩展点,是切错了 | 按只读思想归档,重新划边界写一个新的 |
| 改动总是跟着别的模块一起改 | 它和那个模块本来就该是一个(或中间有条隐藏的洞) | 先合并,再考虑要不要在合并后的边界上挖洞 |
第三行是最容易被误判的一种:反复在改、于是想"我给它加个插件机制吧"。但当改动位置是散的,加扩展点只会把混乱固化成契约——你会得到一个既难改、又扩展不了的东西,两头的坏处都占全。
★ 全文一句话
OCP 不是"不许改代码",是把改的代价局部化——找准变得足够慢的那一层,把它冻成洞的形状,让变得快的东西塞进去。至于这刀什么时候切、切在哪,靠变化真实发生过几次,不靠预判。
附 · 配套脚本
四个都已跑通,输出就是正文里贴的那些。建议改两行再跑——改坏了比读懂了记得牢。
| 脚本 | 对应 | 演示什么 |
|---|---|---|
演示/1-洞的形状.py |
第 3 步 | 挖洞前后,核心文件指纹变没变 |
演示/2-猜错了轴.py |
第 7.1–7.2 | 五个需求,扩展点接住几个;再挖一个洞的代价 |
演示/3-爆炸半径.py |
第 5 步 | 原地改 30 处回归 vs 只读 4 处 |
演示/4-你封闭的只是你声明的部分.py |
第 7.3 | 契约没改,三个调用方全挂 |
相关:笔记-62-重新认识开闭原则 (OCP).md(原文要点提炼)· 原文-62-重新认识开闭原则 (OCP).md
下一讲:63 | 接口设计的准则——那一讲讲的其实就是洞的形状该怎么画,接着这份带读读正好。
