面向对象综合实践
一、新增一种蚂蚁,要不要改老代码
- 这一篇对应官方 Lecture 25(Object Examples),把这些构件组合成一个完整系统。
塔防游戏里已经有采集蚁、投掷蚁、忍者蚁……现在产品说要加一种「会挖地道的蚁」。
打开代码准备改动主循环,结果发现一行都不用改,只要新写一个子类。
这并非巧合,而是模板方法模式:基类把流程写死,子类只填其中变化的一步。这一篇就讲这个「多态在系统层面」的用法。
二、模板方法:基类定流程,子类填差异
1 | class Insect: |
输出:
1 | 1 |
三段逻辑各自说明一层:
NotImplementedError是「抽象方法」的简化做法。基类把action声明出来,但故意不实现,强制子类提供实现。Bee忘了写,一旦调用就报错——这是有意为之,好过静默执行一个错误的行为。HarvesterAnt只重写变化的那一步。armor、place、reduce_armor全部从Insect继承,一个字都不用重写。Ant与HarvesterAnt形成两层。h.action在HarvesterAnt命中,h.armor一路爬到Insect才命中——查找链在这里被完整地走了一遍。
flowchart TB
I["<b>Insect</b><br/>armor / place<br/>action(抛 NotImplementedError)<br/>reduce_armor"]
A["<b>Ant</b><br/>food_cost = 0<br/>action(通用行为)"]
H["<b>HarvesterAnt</b><br/>action(采集 +1)"]
B["<b>Bee</b><br/>(没实现 action)"]
I --> A
I --> B
A --> H
H -.->|"h.armor 沿链上爬"| I
新增一种蚂蚁 = 加一个子类,主循环、gamestate、伤害结算全都不用动。这就是「设计模式」在 61A 里的真实价值。
三、双向引用:最容易写出不一致状态的地方
Insect 有 self.place,Place 又有 self.insects——两边互相指着。这带来一个隐蔽的 bug 源:
| 动作 | 必须同时做两件事 | 漏一边的后果 |
|---|---|---|
| 蚂蚁进场 | insect.place = p 且 p.insects.append(insect) |
蚂蚁知道自己在哪,战场里却没这只蚂蚁 |
| 蚂蚁离场 | 两边都清 | 战场里还留着这只蚂蚁,伤害还在结算 |
只改一边,就会出现「在战场上但战场里没有它」这类幽灵状态。 这类 bug 不会报错,只会让游戏行为异常——这一类项目中最常见的缺陷之一就在这里。
判断方法:任何改变「谁在谁那儿」的操作,问一句「另一边同步了吗」。
四、继承链上的属性查找
1 | class Account: |
输出:
1 | 0.03 |
__mro__ 就是查找顺序的那条链。SuperSavings 自己没有 interest,往上第一格 Savings 有 0.03,命中即停,不再看 Account。把 __mro__ 打出来,继承链查找就从「推演」变成「读表」。
五、默认参数在定义时求值
这是个非常隐蔽的问题,藏在 __init__ 的默认参数里:
1 | class Account: |
输出:
1 | 0.03 |
因为默认参数在函数定义时求值一次,rate=interest 当场就固定成 0.03。之后你把 Savings.interest 改成 0.99,s.rate 依然是 0.03。
这与类属性遮蔽是两回事:遮蔽是「查找时先命中实例」,这里是「值在定义时就被固定下来」。两条规则叠加时,最容易出问题。
六、闭包状态与生成器状态
1 | def counter(): |
输出:
1 | 1 2 1 |
两组输出各对应一条规则:
c1和c2各有独立帧。counter()每调一次造一份新的n,所以c2()从头开始,得到1。gen(3)调两次是生成器对象,各能跑;而同一个g跑两遍,第二遍是空的(一次性耗尽)。
判据:counter() / gen(3) 每调用一次 = 新状态;同一个对象重复用 = 复用旧状态。
七、面向对象与状态管理的要点
flowchart TB
R["面向对象与状态管理的要点"]
R --> A["继承链属性查找"]
R --> B["闭包与可变状态"]
R --> C["生成器状态"]
R --> D["复杂度与记忆化"]
A --> A1["打印 __mro__,首个命中即停"]
A --> A2["super() 与 self 的传递"]
B --> B1["mutation 与 reassignment"]
B --> B2["默认参数在定义时求值"]
C --> C1["调用不等于执行"]
C --> C2["一次性,耗尽不回头"]
C --> C3["yield from 是平铺"]
D --> D1["list 与 set 的成员检查"]
D --> D2["fib 加 memo"]
四组需要掌握的内容:
- 继承链上的属性遮蔽与方法覆盖——先打印
__mro__,再逐层查找。 nonlocal与可变对象的环境图——分清 mutation 与 reassignment。- 生成器的暂停与恢复,含多层
yield from。 - 复杂度分析——判断 Big-O,并为指数级算法加记忆化。
三处最容易出错的地方:
- 这个属性是先在实例上找,还是先在类上找?(写
a.x = v之前先判断清楚) - 这个生成器是刚创建,还是已经消耗过?
- 这个默认参数是在定义时固定下来的,还是在调用时读取的?
八、小结
| 概念 | 一句话 | 证据 |
|---|---|---|
| 模板方法 | 基类定流程,子类填差异 | 只重写 action |
| 抽象方法 | 基类抛 NotImplementedError |
Bee().action 直接报错 |
| 双向引用 | 两边必须同步维护 | 否则出现幽灵状态 |
__mro__ |
查找顺序的那条链 | ['SuperSavings', 'Savings', 'Account', 'object'] |
| 默认参数 | 定义时求值一次 | 改类属性不影响 rate |
| 帧隔离 | 每次调用造新状态 | c1(), c1(), c2() → 1 2 1 |
| 生成器一次性 | 同一对象不能重跑 | list(g), list(g) → [] |
三条能带走的:
- 新增功能优先加子类,而不是改基类。 模板方法模式的价值就在于「不修改既有代码」。
__mro__是继承查找的可视化。 推演受阻时直接打印出来,比在脑中逐层追溯可靠。- 三处容易忽视的机制:默认参数在定义时求值、生成器一次性、双向引用需同步维护。 三者在出错时都不会抛异常,只会让行为变得难以解释。

