我的职业生涯有很大一部分时间都在推广 object。 我写过 Thinking in C++ 和 Thinking in Java, 在 C++ Standards Committee 最初八年里担任委员, 也曾在世界各地做 object-oriented programming(OOP)演讲。 所以,当我说自己开始怀疑 object 是否应该成为默认选择时, 这并不是一个局外人的抱怨。
本书这一部分讨论 design pattern, 其中大多数都以 object 和 inheritance 为前提。 不过,在进入它们之前,我想先追问:这些机制究竟有多少是真正需要的? 本章改编自我在 PyCon 2023 的演讲 Rethinking Objects, 以及 StrangeLoop 演讲 Polymorphism Unbound。
语言会为了适应自己的环境而演化。 一个今天看起来古怪的 feature,通常在它诞生时的问题和 hardware 条件下是合理的。 看看 OOP 的起源就知道了。
Simula 在 1960 年代引入 object,是为了建模 simulation: 一个系统是一组彼此交互的事物。 Simula 并不是一切皆 object。 它仍然有 standalone function。 它是一门 compiled、statically typed language, 因此后来被命名为 Liskov Substitution Principle(LSP)的 discipline 与它天然契合。
Smalltalk 走了另一条路:一切都是 object, 你只能通过发送 message 对 object 做操作,而且总是 late-bound。 它是一个极度 dynamic、runtime 的世界; 构建程序的典型方式是寻找最接近的现有 object,再从它 inheritance 以增加 behavior。 这种风格不会对 substitutability 提供任何保证。
C++ 继承了 Simula 的思想。 object 是可选的, 它把 object-oriented programming 和 exception 带进了主流。
Java 则更多继承 Smalltalk。 一切都必须活在 class 里面,即便你真正需要的只是一个 function。 Java 是 statically compiled,因此 substitutability 很重要, 但它又鼓励通过继承 implementation 来复用代码, 这两个方向彼此拉扯。
更新的语言开始远离 inheritance。 Rust、Swift、Go 和 Kotlin 更依赖 data structure,而不是深层 class hierarchy。 它们偏爱 immutability。 Rust 默认让 binding immutable。 Swift 与 Kotlin 通过 let 和 val 鼓励 immutability (Go 没有通用 immutability)。 它们通过 composition 组合 data structure,而不是继承 implementation。 它们也允许代码存在于 class 外部, 因此一个 function 可以服务很多 type,而不必成为每个 type 上的 method。 整个行业其实一直在悄悄从“everything is an object”以及 implementation inheritance 往回走。
Liskov Substitution Principle(LSP) 要求:凡是代码期待 base type object 的地方,都必须能够使用其 subtype object。 subclass 可以增加 behavior,但必须遵守 base class contract。 override 可以接受比 base 更多的输入,却不能更少。 它返回的 result 必须能在 caller 期待 base result 的地方使用, 也不能抛出令人意外的 exception。 只要 subclass 遵守这些规则, 针对 base class 编写的代码就能不做修改地作用于任意 subclass。 这使 polymorphism, 以及 25 · 模板方法(Template Method) 这类 pattern 变得安全。 statically typed compiler 可以检查 override signature 是否保持兼容。 但它无法检查 override 的行为是否符合 base class 声明的语义。 base class 调用 method 时,只能相信每个 subclass 都能代替自己。
Python 没有这样的 compiler, 但工具“能检查什么”和“不能检查什么”的边界仍然落在同一个地方。 type checker 能读取 override signature,并报告已经不再兼容的 override, 尤其当 @override 明确标记了这种意图时。 但没有工具会阅读 signature 背后的 behavior。 subclass 完全可以在 signature 精确匹配的同时破坏 base class contract。 interpreter 会毫无异议地运行违反 LSP 的代码。 这些代码可能会,也可能不会在 runtime 失败:
# lsp_violation.py
from dataclasses import dataclass, field
from typing import ClassVar, override
@dataclass
class Stack:
items: list[int] = field(default_factory=list)
def push(self, item: int) -> None:
self.items.append(item)
@dataclass
class BoundedStack(Stack):
limit: ClassVar[int] = 2
@override
def push(self, item: int) -> None:
if len(self.items) >= self.limit:
raise OverflowError("Stack is full")
super().push(item)
def fill(stack: Stack, count: int) -> int:
for n in range(count):
stack.push(n)
return len(stack.items)
print(fill(Stack(), 5))
#: 5
try:
fill(BoundedStack(), 5)
except OverflowError as e:
print(e)
#: Stack is full
BoundedStack.push() 接受相同 argument,也返回相同 type, 所以 @override 成立,ty 不会报告问题。 fill() 面向 Stack 编写,而 Stack 的 push() 从不拒绝输入; 可一旦把 BoundedStack 传进去,第三个 item 就会抛 exception。 subclass 匹配了 signature,却破坏了 signature 后面的 contract。
substitutability 是 OOP 承诺的第一件、但任何工具都无法验证的事情。 OOP 一共做了四个承诺:encapsulation、 把 behavior 作为 method 与 object 绑定、通过 inheritance 复用, 以及 polymorphism。 后面几节会逐项检查:Python 到底提供了什么,又付出了什么成本。
OOP 的第一个承诺是 encapsulation:隐藏 data, 只允许通过你控制的 method 暴露它。 Python 可以用前导 underscore 加 read-only property 隐藏 field。 但实际效果没有表面上那么好。 一个 getter 如果返回 mutable object,就等于把底层内部对象的 reference 直接交给 caller:
# leaky.py
from dataclasses import dataclass
@dataclass
class Bob:
name: str = "Bob"
class Leaky:
def __init__(self, numbers: list[int]) -> None:
self._numbers = numbers # "Private" by convention
self._bob = Bob()
@property
def numbers(self) -> list[int]:
return self._numbers
@property
def bob(self) -> Bob:
return self._bob
if __name__ == "__main__":
leaky = Leaky([1, 2])
# The same object
print(leaky.numbers is leaky._numbers)
# Both mutate "private" internals through the getters:
leaky.numbers.append(999)
leaky.bob.name = "Ralph"
print(leaky.numbers, leaky.bob)
#: True
#: [1, 2, 999] Bob(name='Ralph')