面向对象编程
过程式程序有一个结构性问题,不是语法,不是性能,而是数据的裸露。
int balance = 1000;
int account_id = 42;
withdraw(&balance, 200);
deposit(&balance, 500);
audit_log(account_id, balance);balance 漂浮在空中,任何函数都可以读它、改它。当系统规模增大,没人知道”谁在什么时候改了这个值”——这不是 bug,这是结构性的认知崩溃。
OOP 的起点是一个朴素的洞察:每一块状态都需要有一个负责人。 对象就是这个负责人——它保管状态,定义别人能对它做什么,并对状态的完整性负责。
把这个洞察推到极致:程序不是一系列指令,而是一个由相互通信的自治个体组成的社会。每个个体都有自己的状态和边界,通过消息协作,而不是共享数据。
一、对象是什么
三要素:状态、行为、身份
对象不只是”数据 + 方法的容器”。它有三个递进的要素:
- 状态(State):我拥有什么——对象内部的可变数据,只有我自己能管理
- 行为(Behavior):我能做什么——通过消息或接口暴露给外部的能力
- 身份(Identity):我是谁——即使两个对象状态完全相同,它们仍然是两个不同的个体
身份是 OOP 区别于纯函数式数据的关键。{balance: 1000} 是数据;而一个银行账户对象,即使余额相同,仍然是可区分的个体,有自己的历史和责任。
Alan Kay 的原始定义:消息传递
OOP 这个词是 Alan Kay 在 Smalltalk 时代创造的,但他后来说:
“OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things.”(对我来说,OOP 只意味着消息传递、状态的局部保持与保护,以及所有事物的极端迟绑定。)
他认为最重要的不是 class,而是消息传递:对象之间通过消息通信,发送方完全不知道接收方如何处理。
" Smalltalk:发送消息,不是调用方法 "
anAccount withdraw: 200.| 维度 | 消息传递(Smalltalk) | 方法调用(Java/C++) |
|---|---|---|
| 发送方 | 不知道接收方如何响应 | 编译时绑定到具体方法 |
| 接收方 | 自由决定如何响应,甚至可以不响应 | 必须实现对应方法 |
| 灵活性 | 支持 doesNotUnderstand 等动态机制 | 类型不匹配则编译失败 |
| 性能 | 需要运行时查找 | 可以静态优化 |
主流语言实现的是方法调用,而不是真正的消息传递——这是 Kay 认为”OOP 走歪了”的地方。
两种对象模型:类式 vs 原型式
类式 OOP(Java/C++/Python/TypeScript):类是模板,对象是实例,继承在类之间发生。
原型式 OOP(JavaScript/Self):对象直接从对象克隆,无需类,属性查找沿原型链向上搜索。ES6 的 class 只是原型链的语法糖——底层仍然是对象到对象的委托。
原型模型更接近 Kay 的理念:对象是第一等公民,类不是必须的。
二、封装如何降低复杂度
认知模型匹配
OOP 之所以在大型系统中占主导地位,不是因为它理论上最优,而是因为它匹配人类认知的自然结构。
人类理解世界的方式是名词 + 动词:银行账户(对象)、取款(消息)、余额(状态)。把这套认知模型直接映射到代码结构,降低了从需求到实现的翻译成本。这也是为什么 OOP 在领域建模中特别强大——业务实体(User、Order、Product)天然适合对象表示,业务规则天然适合方法约束。
封装的三个层次
封装不只是 private 关键字。它有三个递进的层次:
| 层次 | 机制 | 本质 |
|---|---|---|
| 物理封装 | private/pub/# 等访问控制 | 编译器或运行时强制的访问门禁 |
| 逻辑封装 | 模块/包级别信息隐藏 | 明确哪些是内部实现,哪些是公开接口 |
| 语义封装 | 接口契约(行为封装) | 调用方只关心”能做什么”,不关心”怎么做到的” |
三层封装合在一起,实现了 David Parnas 1972 年论文中的核心主张:隐藏的不是数据,而是设计决策。
一个 BankAccount 类不只是把 balance 藏起来——它隐藏的是”余额如何计算”、“透支规则是什么”、“日志记录在哪里”这些决策。这些决策的修改不会影响外部调用方,只要接口契约不变。
接口契约:承诺,而非实现
真正的封装意味着:你只需要知道对象能做什么,不需要知道它怎么做到的。
account = BankAccount(owner="Alice")
account.withdraw(200) # 你不需要知道 balance 在哪里,怎么存储的这让大型系统得以分工:实现团队和使用团队只需要对齐接口契约,不需要共享实现细节。这是 OOP 在工程组织上最大的价值。
三、状态是 OOP 的荣耀与原罪
OOP 的答案:局部化,而非消除
过程式程序的问题是状态无人负责,FP 的答案是消除可变状态,OOP 的答案是把可变状态锁在对象边界内,指定对象为责任人。
class BankAccount:
def __init__(self, balance: int):
self._balance = balance
def withdraw(self, amount: int) -> None:
if amount > self._balance:
raise InsufficientFundsError()
self._balance -= amount_balance 不再裸露在外,只有 BankAccount 自己能修改它,且只能通过定义好的入口。
原罪:共享可变状态
OOP 局部化了状态,但没有消除可变性。当多个对象需要协作,当线程需要共享资源,封装的边界就开始失效:
account = BankAccount(1000)
# 线程 A:account.withdraw(800)
# 线程 B:account.withdraw(800)
# 两者都通过了余额检查,余额变成 -600这是 OOP 被批判的核心:
- Joe Armstrong(Erlang 之父):“OOP 假设每个对象有一个共享的可变状态。这在分布式系统中是根本错误的。”
- Rich Hickey(Clojure 之父):可变状态是并发 bug 的根源,对象封装只是把问题藏起来,没有解决问题。
自我修正:不可变对象与值对象
面对这些批判,OOP 语言逐渐引入了不可变性:
from dataclasses import dataclass
@dataclass(frozen=True) # 不可变值对象
class Money:
amount: int
currency: str不可变对象没有状态变化,可以安全地在线程间共享。现代 OOP 的最佳实践已经是:尽量用不可变对象,只在必须时允许可变状态,并明确标记它——这是 OOP 向 FP 借鉴的部分。
四、从对象到系统
继承:三种用途,一个陷阱
继承有三种用途:
- 代码复用:子类重用父类的实现(最常见,也最容易滥用)
- 接口契约:子类承诺实现父类定义的行为规范
- 类型层次:建立”is-a”关系,支持多态替换
核心陷阱是脆弱基类(Fragile Base Class):父类修改内部实现,子类行为意外改变。继承暴露了实现细节,创造了最深度的耦合。
Liskov 替换悖论:Square 在几何上是 Rectangle 的子类,但在 OOP 中无法安全替换:
class Rectangle:
def set_width(self, w): self._width = w
def set_height(self, h): self._height = h
class Square(Rectangle):
def set_width(self, w):
self._width = w
self._height = w # 正方形宽高必须相等,破坏了 Rectangle 的行为契约
def stretch(rect: Rectangle):
rect.set_width(rect.width * 2)
assert rect.area() == rect.width * 2 * rect.height # Square 让这个断言失败几何上的”is-a”和行为上的”可替换”是两件事。继承绑定的应该是后者,不是前者。
组合优于继承
解决继承陷阱的答案是组合:has-a 替代 is-a,接口替代实现复用。
# 继承:紧耦合,LoggingAccount 依赖 BankAccount 的内部实现
class LoggingAccount(BankAccount):
def withdraw(self, amount):
super().withdraw(amount)
self._log(amount)
# 组合:松耦合,持有依赖而不是继承它
class BankAccount:
def __init__(self, logger: Logger):
self._logger = logger
def withdraw(self, amount):
self._balance -= amount
self._logger.log(amount)Rust 走得更远——彻底删除继承,只保留 trait + 组合,证明继承不是 OOP 的必要条件,只是一种实现手段。
多态:同一契约,不同实现
多态是继承真正有价值的部分——它允许用统一的接口处理不同的实现:
| 类型 | 机制 | 特点 | 典型语言 |
|---|---|---|---|
| 子类型多态 | 继承 + 重写 | 运行时分派,is-a 关系 | Java、C++、Python |
| 参数多态 | 泛型/模板 | 编译时展开,类型参数化 | Rust、Haskell |
| 特设多态 | 重载/trait | 同一接口,不同类型实现 | Rust trait、Haskell Type Class |
| 鸭子类型 | 结构匹配 | 运行时检查行为,无需声明 | Python、Go |
SOLID:组合哲学的工程总结
SOLID 五条原则是”如何用组合而非继承构建系统”的工程化总结:
| 原则 | 核心思想 | 呼应上文 |
|---|---|---|
| SRP(单一职责) | 一个类只做一件事 | 对象是单一状态的负责人 |
| OCP(开闭原则) | 扩展开放,修改关闭 | 用接口和组合实现扩展,不改原有代码 |
| LSP(里氏替换) | 子类必须可以替换父类 | Square/Rectangle 悖论的直接答案 |
| ISP(接口隔离) | 小而专一的接口优于大而全 | 接口契约越精确,耦合越低 |
| DIP(依赖反转) | 依赖抽象而非具体实现 | 组合时依赖接口,不依赖具体类 |
五、多语对照:不同哲学的工程落地
类与对象定义
| 维度 | Python | TypeScript | Rust |
|---|---|---|---|
| 定义方式 | class Dog: | class Dog {} | struct Dog + impl Dog |
| 构造函数 | __init__(self) | constructor() | fn new() -> Self(关联函数) |
| 类的地位 | 类是一等对象,可运行时修改 | 类是函数的语法糖 | 无 class 关键字 |
继承与组合
| 维度 | Python | TypeScript | Rust |
|---|---|---|---|
| 继承模型 | 单/多继承(C3 线性化) | extends(单继承) | 无继承,trait 替代 |
| 多继承 | 支持,解决菱形问题 | 不支持,用 mixin 模拟 | 不支持,多 trait 组合 |
| 组合方式 | 属性持有 | 属性持有 + mixin | struct 嵌套 + trait 实现 |
接口与多态
| 维度 | Python | TypeScript | Rust |
|---|---|---|---|
| 接口机制 | duck typing + ABC + Protocol | interface | trait |
| 类型策略 | 结构化(鸭子类型) | 结构化类型 | 标称类型 |
| 多态分派 | 运行时查找 | 运行时(可优化) | 静态分派 / vtable 动态分派 |
可见性控制
| 维度 | Python | TypeScript | Rust |
|---|---|---|---|
| 私有 | _ / __(约定) | private / #(真私有) | 默认私有 |
| 公开 | 默认公开 | public | pub |
| 边界强度 | 约定,无强制 | 编译时检查 | 编译器强制 |
三门语言的哲学倾向:
- Python:信任程序员。鸭子类型让接口契约靠约定维护,灵活性优先,安全靠自律。
- TypeScript:结构化类型适配 JavaScript 生态。形状即身份,接口不需要显式声明实现关系。
- Rust:编译器替代继承。
trait + 组合实现了 OOP 所有价值,并消除了脆弱基类和共享可变状态两个核心问题。
延伸阅读
- Kay, Alan. “The Early History of Smalltalk.” (1993) — OOP 定义
- Gamma et al. Design Patterns: Elements of Reusable Object-Oriented Software. (1994) — 设计模式经典
- Bloch, Joshua. Effective Java. (3rd ed., 2018) — 实践指南
- Rust Book Ch10 — Trait 与泛型替代继承
关联 meta 维度
- meta: 类型系统 — 对象的类型契约与身份认定
- meta: 模块与可见性 — 封装边界的工程化(逻辑封装层)
- 02 函数式编程 — OOP 和 FP 的对立与互补