| name | oop-thinking |
| description | OOP 思考框架——用自然语言的名词/动词组织代码,与点语法无关。提炼自 Sandi Metz 的 POOD 与 99 Bottles of OOP |
OOP 思考框架
本 skill 提炼自 Sandi Metz 的两本书:
- Practical Object-Oriented Design, 2nd Edition(简称 POOD)——理论框架
- 99 Bottles of OOP(简称 99 Bottles)——实践方法
核心定位:这不是写 OOP 代码的语法指南,而是写任何代码时都应该运用的思考方式。
无论你用的是 Lisp、TypeScript、Python、C 还是 Go,OOP 思考都能帮助你组织出可维护、可变化
的代码。
核心哲学
设计的目的是降低变更成本
"The purpose of design is to allow you to do design later, and its primary goal is to reduce the cost of change."
— POOD Ch.1
写代码时,始终面对两个问题:今天要实现的功能、未来会到来的变更。好的设计不是预
测未来需要什么,而是保留适应未来的选项。它不猜测,它给你留移动空间。
你写的每一行代码都是对未来的承诺。如果今天的代码让明天的变更变得困难,那今天
的代码就是坏的设计——不管它多"优雅"。
面向对象的核心是消息,不是对象
"The foundation of an object-oriented system is the message."
— POOD Ch.2
对象是容器,消息才是设计的基本单位。思考代码时,先问:
- 谁需要什么消息?(接收方的视角)
- 谁会发送这个消息?(发送方的视角)
- 消息的合约是什么?(输入、输出、副作用)
好的设计让消息流动自然——调用方不需要知道接收方的实现细节,只需要知道它能
响应什么消息。
OOP 思维的语法无关性
OOP 思考不依赖 object.method() 的点语法。它的本质是用自然语言中的名词和
动词来组织代码——"mechanic 准备 bike"这个关系,不同语法只是不同写法:
| 关系 | OO 语言 | Lisp / Scheme | C |
|---|
| mechanic 准备 bike | mechanic.prepare_bicycle(bike) | (mechanic-prepare-bicycle mechanic bike) | mechanic_prepare_bicycle(mechanic, bike) |
| trip 知道自己的 bikes | trip.bicycles() | (trip-bicycles trip) | trip_bicycles(trip) |
| wheel 计算直径 | wheel.diameter() | (wheel-diameter wheel) | wheel_diameter(wheel) |
三种语法形式不同,但表述的概念结构相同——"谁"对"什么东西"做"什么动作"。
判断标准:当一段代码可以用"主语-动词-宾语"大声朗读、非程序员也能大致听懂时,
它就是好的 OOP 设计。 如果你需要念完一整段操作步骤才能说清发生了什么,
说明职责划分有问题。
TRUE 代码标准
写出的代码应当是 TRUE 的(POOD Ch.2):
- Transparent(透明):变更的后果在代码中显而易见——无论在被修改的代码里,
还是在依赖它的远端代码里
- Reasonable(合理):任何变更的代价与收益成正比——小需求只需小改动
- Usable(可用):现有代码可以在新的、未预期的上下文中被复用
- Exemplary(范例性):代码本身鼓励维护者也延续这些品质
TRUE 的起点是单一职责:每个类、每个方法只做最小可行的有用的事。
SOLID 原则(作为思考检查清单)
写代码时,用以下原则自检(POOD Ch.1):
| 原则 | 含义 | 自检问题 |
|---|
| Single Responsibility | 每个类/模块只有一个变化理由 | 能用一句话描述这个模块的职责吗?句子里有"和"就说明做太多了 |
| Open/Closed | 对扩展开放,对修改关闭 | 加新功能需要改已有代码吗?需要改 = 不 closed |
| Liskov Substitution | 子类可以替换父类而不破坏程序 | 子类的行为会让调用方"惊喜"吗?惊喜 = 违约 |
| Interface Segregation | 接口应当小而专注 | 调用方被迫依赖它不需要的方法吗? |
| Dependency Inversion | 依赖抽象而非具体 | 高层模块依赖了低层模块的名字吗? |
Shameless Green:抵制过早抽象
"The failure here is not bad intention—it's insufficient patience."
— 99 Bottles Ch.1
四种代码方案(99 Bottles Ch.1):
| 方案 | 特点 | 问题 |
|---|
| Incomprehensibly Concise | 极简、机智 | 不可读,变更成本爆炸 |
| Speculatively General | 预判未来的抽象 | 抽象大概率是错的;复杂度白付 |
| Concretely Abstract | 正确的直觉,过早抽象 | 方法名和抽象层级不匹配,改起来依然贵 |
| Shameless Green | 简单直白,不羞耻 | 看起来不酷,但变更容易、理解快 |
教训:先用最简单的方式把代码写对(Shameless Green),等变更到来时再抽象。
抽象应该从代码中"浮现"出来,而不是被塞进去。
关键判断:
- 如果未来的改造成本和今天的改造成本一样,推迟决策
- 如果有两个以上调用场景,并且差异是规律性的,考虑抽象
- 如果只有两个调用场景,容忍重复——等第三个场景出现再抽象
Flocking Rules:机械式地发现抽象
"Complex behavior emerges from the repeated application of simple rules."
— 99 Bottles Ch.3
当你需要从具体的代码中抽取出抽象时,不要靠"灵感"。用 Flocking Rules,
这是可通过反复执行而产出抽象机械式流程:
1. 选择最相似的东西
2. 找到它们之间最小的差异
3. 做出消除该差异的最简单修改:
a. 先让新代码能解析(parse)
b. 再让它解析且执行(parse + execute)
c. 再让它解析、执行且使用结果(parse + execute + use)
d. 删除不再使用的旧代码
每次只改一行。每次改完跑测试。测试失败就回退,换更小的改动。
Flocking Rules 的四个步骤详解
当面对多个 switch case / if-else 分支 / 相似函数时:
- parse the new code — 新代码能通过语法检查即可,不要求正确运行
- parse and execute it — 新代码能运行不报错
- parse, execute and use its result — 新代码产生正确结果
- delete unused code — 删除被替代的旧代码
这四个步骤把小步重构变成了流水线:每步只引入最小的不确定性,测试在每步都保持
绿色。
消息思维
问"要什么"而不是告诉"怎么做"
"Ask for 'what' instead of telling 'how'."
— POOD Ch.4
坏消息: 发送方知道接收方的所有实现细节,接收方的任何变化都会破坏发送方。
# OO 语法
trip.clean_bicycle(bike)
trip.pump_tires(bike)
trip.check_brakes(bike)
# Lisp 语法
(trip-clean-bicycle trip bike)
(trip-pump-tires trip bike)
(trip-check-brakes trip bike)
好消息: 发送方只信任接收方知道怎么做。接收方的实现可以随意变化。
# OO 语法
mechanic.prepare_bicycle(bike)
# Lisp 语法
(mechanic-prepare-bicycle mechanic bike)
在好消息版本中,命名本身承载了"主语-动词-宾语"结构——mechanic + prepare_bicycle + bike。
代码就是可读的领域文档。在坏消息版本中,读起来像操作手册步骤列表——
发送方在指挥接收方"做这个、做那个",而不是委托一个任务。
判断标准: 看消息链——如果发送方需要序列化地告诉接收方每一步做什么,就说明
职责放错了地方。行为应该和数据放在一起。
对象的上下文
一个对象知道的关于其他对象的所有事情,构成了它的上下文。上下文越小,对象
就越可复用、越容易测试。设计的目标是创造上下文无关的对象——它只需要知道
消息名和参数结构,不需要知道接收方的类型和来历。
序列图(思维工具)
当你搞不清对象之间该怎么通信时,画一张简单的序列图:
发送方 接收方
│ │
│──bicycles──────────│
│ │
│ for each bicycle: │
│──prepare(bike)────>│
│ │ (内部处理)
│<──done─────────────│
序列图帮你把注意力从"有什么对象"转移到"对象之间传递什么消息"。
依赖管理
依赖注入
"Referring to another class by its name creates a major sticky spot."
— POOD Ch.3
坏代码: 在方法内部 new 或直接引用具体类名
gear_inches() {
return ratio * new Wheel(rim, tire).diameter()
}
好代码: 通过构造器/参数注入依赖
constructor(chainring, cog, wheel) {
this.wheel = wheel
}
gear_inches() {
return ratio * this.wheel.diameter()
}
注入的不是"Wheel 的实例",而是"一个能响应 diameter 消息的对象"。
这个思维转变解放了你的设计——测试时可以注入假的 wheel,未来可以注入其他
实现了 diameter 的东西。
依赖方向
依赖应该指向更稳定的方向:
- 抽象比具体更稳定 → 依赖抽象
- 核心业务逻辑比边缘功能更稳定 → 边缘依赖核心
- 被修改频率低的方向更稳定 → 修改频繁的依赖修改少的
自检问题: 如果 A 依赖 B,A 修改的频率是否高于 B?如果不是,考虑反转依赖。
依赖隔离
如果无法消除依赖(如调用第三方库),至少隔离它:
- 把外部依赖包裹在薄薄的适配层里
- 让适配层承担所有第三方 API 知识
- 让核心代码只依赖自己的适配层
这样当第三方库变化时,只有适配层需要修改。
接口设计
显式公共接口
"It is more important that a well-defined interface exists than that it be perfect."
— POOD Ch.4
公共接口是其他对象依赖的合约。设计公共接口时:
- 最小化:暴露的方法越少,未来改变的自由度越大
- 明确化:清楚地标记哪些是公共的、哪些是内部的
- 稳定化:公共接口一旦建立就尽量不要改;内部实现随意
公共接口 vs 私有接口
| 公共接口 | 私有接口 |
|---|
| 可见性 | 对外暴露 | 仅内部可见 |
| 稳定性 | 稳定,改变需谨慎 | 可任意重构 |
| 测试 | 必须测试 | 不直接测试(通过公共接口覆盖) |
| 数量 | 越少越好 | 不限 |
Law of Demeter(迪米特法则)
"Talk only to your immediate friends."
a.b.c.d 这种消息链是坏味道。它意味着 a 知道 b、b 知道 c、c 知道 d——链条上的
任何改动都影响 a。
修复方式: 不是让 a 知道更少,而是让 b 提供更高级别的行为。把 a.b.c.d 变成
a.something_meaningful,让 b 内部负责处理 c.d。
继承 vs 组合
(POOD Ch.6-8)
继承
继承是"is-a"关系——子类是父类的一种特殊化。
用继承的场景:
- 子类和父类是真正的"is-a"关系
- 父类提供了模板方法(子类填空)
- 使用的是一个稳定、狭窄的抽象
- 子类可以用在任何需要父类的地方(Liskov 替换)
继承的风险:
- 子类自动获得父类的一切——即使它不需要
- 父类修改可能无意中破坏子类
- 深层继承难以理解和修改
- 继承在编译时确定,运行时无法改变
规则: 继承层级不超过 1-2 层。使用模板方法模式,让父类定义骨架,子类填空。
父类提供"钩子消息"(hook messages),让子类可选地参与行为。
组合
组合是"has-a"关系——对象由其他对象组装而成。
用组合的场景:
- "has-a"关系——小对象组装成大对象
- 被组合的对象独立变化
- 需要在运行时切换行为
- 需要多个职责的灵活组合
组合的优势:
- 每个部分只做一件事
- 部件可以独立测试、独立替换
- 职责清晰,边界明确
如何选择
| 问题 | 答案指导方向 |
|---|
| 子类是父类的特殊化还是变体? | 特殊化→继承;变体→组合 |
| 子类需要父类的所有行为吗? | 不是 → 组合 |
| 子类和父类会同时被修改吗? | 是 → 组合(分开演进) |
| 运行时需要切换吗? | 是 → 组合 |
默认用组合。只有当你确信"is-a"且结构足够稳定时,才选继承。
鸭子类型和角色
(POOD Ch.5, Ch.7)
"If it quacks like a duck..."
鸭子类型是消息思维的自然延伸:你不关心对象的类,只关心它响应什么消息。
def prepare(preparers)
preparers.each { |preparer| preparer.prepare_bicycle(bike) }
end
角色(Role)是一组消息的合约——不通过类继承,而是通过实现了相同的一组消息
来共享行为。角色是接口的抽象:一组对象扮演同一个角色,意味着它们都响应同一
组消息。
何时定义角色:
- 多个不同类的对象响应同一组消息
- 有相同的行为模式但类层次不同
- 测试时需要注入不同实现的假对象
代码 Smell 识别
(99 Bottles Ch.3)
代码 smell 不是 bug——它是暗示设计问题的结构特征。以下是常见 smell 及对应的
修复策略:
| Smell | 表现 | 修复方向 |
|---|
| Duplicated Code | 相似代码出现在两个以上位置 | Flocking Rules → 提取共性 |
| Switch Statements | 基于类型的 switch/if-else 散落各处 | 用多态替换条件 |
| Long Method | 方法超过 5-10 行 | 提取更小的方法,每个只做一件事 |
| Large Class | 类超过 100 行或有 10+ 个公共方法 | 按职责拆分为多个类 |
| Primitive Obsession | 用基础类型(string/number)表示领域概念 | 封装成值对象 |
| Feature Envy | 方法对一个类的数据访问超过对自己类的访问 | 把方法挪到它真正关心的类里 |
| Inappropriate Intimacy | 两个类知道彼此太多内部细节 | 提取公共接口,减少直接访问 |
| Shotgun Surgery | 一个变更需要同时修改多个类 | 把相关职责集中到一个类 |
| Divergent Change | 一个类因为不同原因被修改 | 按变更原因拆分类 |
| Data Clumps | 一组数据总是一起出现 | 封装成新的类 |
处理 smell 的优先级
当你面对多个 smell 时,问自己:
- 最阻碍当前任务的 smell 是哪个?
- 从最小的、最孤立的 smell 开始
- 每次只修复一个 smell,修复完跑测试
- 不要追求完美——解决当前问题的 smell 就够了
测试设计的思考
(POOD Ch.9)
测试的最终目的
测试不只是保证正确性——它降低代码变更的成本。好的测试让你敢于重构。
应该测试什么
| 测试类型 | 测什么 | 不测什么 |
|---|
| 入站消息(公共接口) | 对给定输入返回正确输出 | — |
| 出站命令消息(对外有副作用) | 是否发送了正确的消息 | — |
| 出站查询消息(对外无副作用) | — | 不测(发消息本身不是目的,结果才重要) |
| 私有方法 | — | 不直接测(通过公共接口覆盖) |
| 角色(鸭子类型) | 所有扮演该角色的对象都遵守合约 | — |
测试与耦合
"Tests are the first reuse of code."
— POOD Ch.9
如果测试某个对象需要创建大量其他对象,说明你的设计耦合太紧——代码被
测试揭示出了设计问题。测试的痛苦是设计问题的信号。
AI 编程应用指南
以下是 AI 写代码时应当应用的具体行动准则:
写新代码时
-
先从 Shameless Green 开始:用最简单直白的方式实现功能。不要预判未来。
简单的代码即使要改,改起来也快。复杂的"抽象"一旦错了,改起来代价巨大。
-
优先用消息思维:不要想"我有哪些类",而是想"这些对象之间传递什么消息"。
先设计消息,再根据消息需要的接收者来创建对象。
-
问"要什么"不告诉"怎么做":方法名应该描述需求(prepare_bicycle),
而不是实现步骤(clean_bicycle + pump_tires + check_brakes)。
-
确保 TRUE:写完代码后自检——透明吗?合理吗?可复用吗?是好的范例吗?
-
用自然语言朗读检查设计:写完关键调用后,试着用"主语-动词-宾语"句式朗读。
如果读起来像操作流程图而非自然语句("先做这个、再做那个"),说明职责划分有问题。
修改已有代码时
-
用 Flocking Rules 重构:面对重复代码或 switch 语句,不要试图一步到位写出
抽象。遵循:找最相似 → 找最小差异 → 消除差异 → 测试通过 → 重复。
-
一次只修一个 smell:不要同时重构多个问题。选一个最小的 smell,
用 Flocking Rules 消除,测试通过后再看下一个。
-
依赖注入是默认选择:不要在方法内部创建依赖对象。通过构造器或参数传入。
设计决策时
-
推迟决策:当信息不足以做出正确判断时,先不做。等变更到来时,
新信息自然会告诉你该怎么设计。
-
默认组合,谨慎继承:用"has-a"而非"is-a"。只有当你确信子类是父类的
特殊化 + 结构足够稳定时,才使用继承。
-
测试痛苦 = 设计问题:如果写测试时需要创建大量 mock 或大量 setup,
停下来反思——是不是类之间的耦合太紧?是不是类做了太多事?