项目三:Ants Vs. SomeBeesok 166 项通过
一个塔防游戏,也是这门课里唯一一次「在别人写好的两千行程序里做外科手术」。真正难的从来不是写出十几种蚂蚁,而是搞清楚每一次 reduce_health 背后,哪个对象改了、哪个列表被动了、谁还指着已经死掉的东西。
0. 这份作业在练什么
前面所有的 lab 和 homework,你面对的都是一个空函数体:签名给好了,doctest 给好了,你把中间那几行填上就完事。Ants 是第一次不一样的作业——它给你一个已经能跑的、九百多行的程序,让你在它内部动手术。这意味着两件事:
第一,你必须先读懂别人的代码,才能写自己的代码。项目的第一题(Problem 0)压根不让你写任何东西,只让你回答九个关于 ants.py 的问题。这不是走过场:如果你不知道 Insect.reduce_health 在血量归零时会顺手把这只虫子从它所在的 Place 里移除,那么 Problem 5 的 FireAnt 你一定会写错,而且错得莫名其妙。
第二,这门课的 OOP 部分在这里被榨干了。类属性(class attribute)与实例属性(instance attribute)的区别、继承(inheritance)、方法重写(override)、用 super() 调用父类实现、多态(polymorphism)——不是在讲义里看看例子,而是每一题都必须选对其中一个。选错了照样能通过某几个测试,但一定会在另外几个测试上炸掉。
- 类属性 vs 实例属性:
food_cost、damage、name、implemented是类属性(同一种蚂蚁共享);health、place、cooldown、ant_contained是实例属性(每只蚂蚁自己一份)。 - 继承是为了不重复:ShortThrower / LongThrower 只改两个数字就完成了;FireAnt / QueenAnt 用
super().reduce_health(...)复用父类逻辑再加自己的那一点。整个项目几乎不需要复制粘贴任何一段逻辑。 - 可变对象与别名:
place.bees是一个会被别人改的列表。一边遍历一边让元素消失,是这个项目最经典的 bug 来源。 - 链式结构的遍历:
Place不放在列表里,只能靠entrance(往蜂巢方向)和exit(往家门方向)一格一格走。所有「往前找 / 往后找」的题目本质上都是同一个 while 循环。
五个核心类,以及它们互相指的方向
动手之前必须在脑子里建立这张图。整局游戏就是一条(或几条)由 Place 串成的隧道:
AntHomeBase <-- tunnel_0_0 <-- tunnel_0_1 <-- ... <-- tunnel_0_8 <-- Hive
(家) ^ (蜂巢)
|
蚂蚁站在这些格子里 蜜蜂从右边的 Hive 出发,一路向左走
每个 Place 对象上挂着四个关键属性:
| 属性 | 类型 | 含义 |
|---|---|---|
exit | Place 或 None | 往左(家的方向)相邻的格子。蜜蜂前进就是走到 exit。隧道最左端的 exit 是 AntHomeBase。 |
entrance | Place 或 None | 往右(蜂巢方向)相邻的格子。ThrowerAnt「往前看」就是沿 entrance 走。隧道最右端的 entrance 是 Hive。 |
ant | Ant 或 None | 这一格里的蚂蚁,最多一只(Problem 8 之后,容器蚂蚁可以再装一只,但 place.ant 指的永远是容器)。 |
bees | list[Bee] | 这一格里的蜜蜂,可以有任意多只。空的时候是 [],不是 None。 |
虫子这边,继承树是这样的(省略了选做题的几个类):
Insect health / place / reduce_health / action
├─ Ant food_cost / implemented / is_container / add_to
│ ├─ HarvesterAnt
│ ├─ ThrowerAnt nearest_bee / throw_at / lower_bound / upper_bound
│ │ ├─ ShortThrower
│ │ ├─ LongThrower
│ │ ├─ ScubaThrower
│ │ └─ QueenAnt
│ ├─ FireAnt
│ ├─ WallAnt
│ ├─ HungryAnt
│ └─ ContainerAnt ant_contained / can_contain / store_ant
│ ├─ ProtectorAnt
│ └─ TankAnt
└─ Bee sting / move_to / blocked
└─ Wasp
└─ Boss
还有两个特殊的 Place 子类:Hive(is_hive = True,蜜蜂的出发地)和 AntHomeBase(蜜蜂一旦被加进来就抛 AntsLoseException)。Problem 10 你会亲手再加一个 Water。
一个回合到底发生了什么
看懂 GameState.simulate 这个生成器,后面很多「为什么测试是这个结果」就有答案了:
beehive.strategy(self):按预定的进攻计划,把这一时刻该出场的蜜蜂从 Hive 移到随机一条隧道的入口。yield None:把控制权交回 GUI,玩家在这段时间放蚂蚁(deploy_ant 会检查食物够不够,然后扣钱)。self.ants_take_actions():遍历所有还活着的蚂蚁,各自调用 ant.action(gamestate)。蚂蚁先动。self.time += 1:时间推进。注意时间是在蚂蚁行动之后、蜜蜂行动之前加的,这个细节在选做题 SlowThrower 里会咬人。self.bees_take_actions(num_bees):遍历 active_bees 的副本,每只蜜蜂要么蜇挡路的蚂蚁,要么往 exit 走一格。走到 AntHomeBase 就抛异常,蚂蚁输。整个项目里你要写的每一个 action 方法,都是在回答同一个问题:「轮到这只蚂蚁了,它对世界做了什么改动?」——改动只有三种:改某个 gamestate 的字段(HarvesterAnt 加食物)、调用某只虫子的 reduce_health(所有攻击型蚂蚁)、或者改自己的实例属性(HungryAnt 的 cooldown)。想不出怎么写的时候,先问自己「我要改的是这三样里的哪一样」。
做之前应该已经掌握什么
把 Lecture 13 的「实例属性查找顺序」和 Lecture 14 的「super() 到底找到了谁」两件事弄清楚,比熟悉游戏规则重要得多。特别是这一条:当你写 self.damage 时,Python 先在实例的 __dict__ 里找,找不到才去类里找,再找不到才去父类里找。 项目里有好几个测试专门在实例上塞一个 ant.damage = 49,来验证你有没有把 3 这个数字硬编码进 FireAnt。
1. Problem 0:读完 ants.py 之后的九个概念题
题目要什么
这一题零分,运行 python3 ok -q 00 -u 会连续问九道选择题。它不检查代码,只检查你有没有真的把 ants.py 读进去。答案解锁后明文存在 proj/ants/tests/00.py 里,但直接看答案等于白做——下面把每一题的依据在源码的哪一行都标出来,这才是这题的价值。
逐题过一遍
Q1. Insect 的 health 属性意味着什么?会变吗?
答案:它表示这只虫子剩余的血量,归零时虫子被淘汰。干扰项说「health 是保护罩,归零后才能被伤害」,正好反了。依据在 Insect.reduce_health:
def reduce_health(self, damage_taken: float):
self.health -= damage_taken
if self.health <= 0:
self.zero_health_callback()
if self.place is not None:
self.place.remove_insect(self)
这五行是整个项目最重要的五行,请务必背下来。它说明了两件事:(a) 血量减到 0 或负数就触发死亡;(b) 死亡会立刻把自己从 place 里摘掉,同时 self.place 被置为 None(在 Insect.remove_from 里)。后面 FireAnt、TankAnt、NinjaAnt 三题的坑,全部来自 (b)。
Q2. 下面哪个是 Insect 的类属性?(选项:damage / health / place / bees)
答案:damage。health 和 place 在 __init__ 里用 self.xxx = ... 赋值,是实例属性;bees 根本不是 Insect 的属性,它属于 Place。而 damage = 0 写在 class Insect: 的类体里,是类属性。
Q3. Ant 的 health 是实例属性还是类属性?为什么?
实例属性——每只蚂蚁需要自己的一份血量。注意干扰项「实例属性,因为每只蚂蚁初始血量都不一样」是错的:初始血量其实由类决定(WallAnt 都是 4),但受伤之后必须各算各的。这道题在考「什么时候必须用实例属性」的判据:不是「初值不同」,而是「改一个不能影响其他的」。
Q4. ThrowerAnt.damage 是实例属性还是类属性?
类属性——同一个子类的所有蚂蚁伤害相同。注意另一个干扰项「类属性,所有蚂蚁伤害都相同」也是错的:ThrowerAnt.damage = 1 而 FireAnt.damage = 3,它们并不相同,只是「同子类内部相同」。
Q5. Ant 和 Bee 共同继承自哪个类? Insect。
Q6. Ant 实例和 Bee 实例有什么共同点?(选最准确的)
答案是「都有 health、damage、place 属性,以及 reduce_health 和 action 方法」。有个干扰项少写了 health 和 place,要逐字比对才能排除。这一题在暗示项目的核心设计:正因为蚂蚁和蜜蜂都有 reduce_health,攻击代码才不用关心打的是谁。 选做题 LaserAnt 会连友军一起打,靠的就是这个统一接口。
Q7. Problem 8 之前,一个 Place 最多能有几只虫子?
「一只蚂蚁 + 任意多只蜜蜂」。这直接对应 Place.__init__ 里 self.ant = None(单数)与 self.bees = [](列表)的类型差异。
Q8. 蜜蜂一个回合做什么?
「如果自己这一格有蚂蚁挡路就蜇它,否则往下一格走」——是二选一,不是「先蜇再走」,也不是「先走再蜇」。看 Bee.action 里的 if ... elif ... 结构就明白了。
Q9. 什么时候算蚂蚁输?
「任意一只蜜蜂走到隧道尽头,或者 QueenAnt 被打死」——是「或」不是「且」。前者由 AntHomeBase.add_insect 抛 AntsLoseException 实现,后者要你在 Problem 12 里亲手补上。
很多人在这一题上随便乱选,靠 ok 的「答错了再试」机制蒙过去。代价会在 Problem 5 出现:不知道「死亡即从 place 移除」,你写出的 FireAnt 会在 self.place.bees 上抛 AttributeError: 'NoneType' object has no attribute 'bees',而且你完全不知道 self.place 为什么变成了 None。
2. Problem 1:食物开销与 HarvesterAnt
题目要什么
两件事,都很短:
Part A:基类 Ant 的 food_cost 是 0,也就是现在放蚂蚁不要钱,游戏毫无难度。给 HarvesterAnt 和 ThrowerAnt 各自重写这个类属性:
| 类 | Food Cost | 初始血量 |
|---|---|---|
HarvesterAnt | 2 | 1 |
ThrowerAnt | 3 | 1 |
Part B:既然放蚂蚁要花钱,就得有赚钱的手段。实现 HarvesterAnt.action,让它每回合把 gamestate.food 加 1。
解锁题里有一个小陷阱值得注意:food_cost 的作用是放置时一次性扣除,不是「每回合每只蚂蚁吃掉 food_cost 的食物」。看 GameState.deploy_ant 就知道:
ant: Ant = ant_type()
self.places[place_name].add_insect(ant)
self.food -= ant.food_cost
怎么想到的
Part A 的唯一难点是搞清楚「重写类属性」在语法上到底长什么样。很多刚接触 OOP 的人第一反应是写在 __init__ 里:
def __init__(self):
self.food_cost = 2 # 别这么写
这样在功能上似乎也能跑(harvester.food_cost 确实是 2),但它是错的,而且解锁题第二问就在考这个:food_cost 应该是类属性,因为「同一子类的所有蚂蚁放置价格相同」。更要命的是,GameState.deploy_ant 在检查食物够不够时写的是 ant_type.food_cost——直接问类,而不是问某个实例。如果你只在实例上设了值,类上的 food_cost 仍然是继承来的 0,检查就形同虚设。
所以正确写法是直接写在类体里,跟 name = 'Harvester' 并排。
Part B 的思维路径更短,但有一个必须想清楚的点:action 为什么要接收 gamestate 参数? 因为「食物」不属于任何一只蚂蚁,它是整局游戏的全局状态。蚂蚁自己身上没有通往 GameState 的引用(Ant 只知道自己的 place),所以每回合调用 action 时游戏把自己作为参数递过来。这是一种很常见的设计:不让子对象持有父对象的引用,而是在需要时把上下文当参数传进去,可以避免循环引用带来的一堆麻烦。
看到 gamestate.food += 1 这行,要意识到它做的是就地修改一个可变对象的属性,不是返回一个新的 gamestate。action 方法的返回值永远是 None——它的全部意义都在副作用(side effect)里。这跟前半学期「函数应该返回值而不是打印」的训诫看似矛盾,其实不矛盾:那里说的是不要用 print 冒充 return,这里是有意为之的状态变更。
代码
class HarvesterAnt(Ant):
"""HarvesterAnt produces 1 additional food per turn for the colony."""
name = 'Harvester'
implemented = True
# OVERRIDE CLASS ATTRIBUTES HERE
food_cost = 2
def action(self, gamestate: GameState):
"""Produce 1 additional food for the colony.
gamestate -- The GameState, used to access game state information.
"""
# BEGIN Problem 1
gamestate.food += 1
# END Problem 1
class ThrowerAnt(Ant):
"""ThrowerAnt throws a leaf each turn at the nearest Bee in its range."""
name = 'Thrower'
implemented = True
damage = 1
# ADD/OVERRIDE CLASS ATTRIBUTES HERE
food_cost = 3
逐行看:
food_cost = 2直接写在类体里,缩进与name、implemented一致。它遮蔽(shadow)了Ant.food_cost = 0,而不是修改它——Ant.food_cost依然是 0,解锁题里>>> Ant.food_cost的期望输出就是0。gamestate.food += 1这一行不能写成self.gamestate.food += 1(蚂蚁没有这个属性),也不能写成return gamestate.food + 1(调用方ants_take_actions根本不看返回值)。- 方法体里没有
super().action(gamestate)。父类Insect.action是个空方法,调不调都一样,但写上去会让人误以为父类有事要做。
验证
拿解锁题里的用例手动追一遍:
>>> gamestate.food = 4
>>> harvester = HarvesterAnt()
>>> gamestate.food
4
>>> harvester.action(gamestate)
>>> gamestate.food
5
>>> harvester.action(gamestate)
>>> gamestate.food
6
HarvesterAnt() 求值:Ant.__init__ 被调用(HarvesterAnt 没有自己的 __init__),默认 health=1,它转手调 Insect.__init__(1),于是新对象上出现三个实例属性 health=1、full_health=1、place=None,外加一个 id。注意这一步没有扣任何食物——解锁题的注释专门提醒了这一点:造一只蚂蚁不花钱,只有通过 deploy_ant 把它摆进棋盘才花钱。
harvester.action(gamestate) 求值:Python 先在 harvester.__dict__ 里找 action,没有;再去 HarvesterAnt.__dict__ 找,找到了我们写的那个函数;于是以 self=harvester, gamestate=gamestate 调用它。函数体执行 gamestate.food += 1,等价于 gamestate.food = gamestate.food + 1,即 4 + 1 = 5 写回 gamestate 的 food 属性。函数走到末尾,隐式返回 None,所以 REPL 什么也不打印。
第二次调用重复上述过程,5 + 1 = 6。因为 food 是 gamestate 这个共享的可变对象上的属性,两次调用的效果会累积。如果 food 是传值进来的整数,就绝不可能有这个效果。
把 implemented 忘了。 这一题里 HarvesterAnt 和 ThrowerAnt 的 implemented = True 是起始代码就写好的,但从 Problem 4 起每个新蚂蚁都要你自己加。ant_types() 函数会过滤掉 implemented 为 False 的类,而 ok 的测试 setup 里普遍有一句 GameState(beehive, ant_types(), layout, dimensions)——忘了置 True,那个蚂蚁在测试里根本不存在,你会收到一个八竿子打不着的 KeyError。
3. Problem 2:Place.__init__ 里的 entrance
题目要什么
现在的 Place 只知道自己的 exit(左边的邻居),不知道 entrance(右边的邻居)。可 ThrowerAnt 要「往前找蜜蜂」,前方指的正是 entrance 的方向。所以要给 Place 补上 entrance。
题面把逻辑说得很死,照抄就行:
- 新建的
Place,它自己的entrance一律先设成None。 - 如果这个新
Place有exit,那么把那个 exit 的entrance设成自己。
一个 Place 只需要记录一个 entrance——即使布局是多条隧道汇聚到同一个家门口,也不用记多个。
怎么想到的
看到这题,第一个念头几乎肯定是「那就给构造器加个参数呗」:
def __init__(self, name, exit=None, entrance=None): # 死路
...
题面提前把这条路堵死了,理由说得很妙:先有鸡还是先有蛋。要造 tunnel_0_1,你得先有它的 entrance,也就是 tunnel_0_2;要造 tunnel_0_2,你得先有它的 exit,也就是 tunnel_0_1。两边互相等对方先出生,谁也造不出来。
看看布局函数是怎么造隧道的,突破口就在里面:
def wet_layout(queen, register_place, tunnels=3, length=9, moat_frequency=3):
for tunnel in range(tunnels):
exit = queen
for step in range(length):
...
exit = Place('tunnel_{0}_{1}'.format(tunnel, step), exit)
register_place(exit, step == length - 1)
它从家门口(queen,其实是 AntHomeBase)出发,由左往右一格一格造。造 tunnel_0_1 的时候,tunnel_0_0 已经存在了,被当作 exit 传进来。
关键的一跃就在这儿:既然新格子造出来的那一刻,它的 exit 已经存在,那就让新格子反过来去通知那个 exit:「我是你的 entrance」。 不是「我需要知道谁在我右边」,而是「我知道谁在我左边,所以我去告诉他,他右边站着我」。信息的流向反了过来,鸡蛋问题就消失了。
__init__ 的第一个参数 self 绑定的是正在被创建的那个对象。所以在 __init__ 内部写 self.exit.entrance = self,含义是「把我的 exit 的 entrance 指针,指向我自己」。这是整个项目里第一次真正用到「self 是一个可以被交给别人的值」这件事——它不只是用来读写自己的属性。
还有个细节:为什么要判断 if self.exit is not None?因为隧道最左端那个 Place……其实不会出现这种情况(它的 exit 是 AntHomeBase)。但解锁题的第一个用例是 Place('place_0')——不带 exit 单独造一个格子。没有守卫的话 None.entrance = ... 会抛 AttributeError: 'NoneType' object has no attribute 'entrance'。这是典型的「测试比游戏本身更严苛」,写守卫是零成本的保险。
代码
def __init__(self, name: str, exit: Place | None = None):
"""Create a Place with the given NAME and EXIT.
name -- A string; the name of this Place.
exit -- The Place reached by exiting this Place (may be None).
"""
self.name = name
self.exit = exit
self.bees: list[Bee] = []
self.ant: Ant | None = None
self.entrance: Place | None = None
# Phase 1: Add an entrance to the exit
# BEGIN Problem 2
# A Place is always built after the Place it exits into, so the exit
# cannot know its entrance until we get here: we are that entrance.
if self.exit is not None:
self.exit.entrance = self
# END Problem 2
self.entrance = None必须写在前面且无条件执行。每个Place都得有这个属性,哪怕它右边暂时没人——否则后面place.entrance会抛AttributeError。if self.exit is not None用is not而不是!=:这里比的是「是不是同一个对象」而非「值相不相等」,而且Place没定义__eq__,用!=虽然结果一样但语义不准。self.exit.entrance = self只有这一行是真正的答案。注意不能写成self.entrance = self.exit——那是把左邻居当成了右邻居,方向完全反了,Problem 3 会立刻挂掉。
验证
用解锁题的第一个用例,逐句追踪对象状态:
>>> place0 = Place('place_0')
>>> print(place0.exit)
None
>>> print(place0.entrance)
None
>>> place1 = Place('place_1', place0)
>>> place1.exit is place0
True
>>> place0.entrance is place1
True
第 1 句:Place('place_0')
name = 'place_0'
exit = None (用了默认值)
bees = []
ant = None
entrance = None
if None is not None → False,跳过。
结果:place0 ─┬─ exit: None
└─ entrance: None
第 5 句:Place('place_1', place0)
name = 'place_1'
exit = place0 (形参 exit 绑到了 place0 这个对象)
entrance = None
if place0 is not None → True,执行 self.exit.entrance = self
self.exit 求值 → place0 这个对象
self 求值 → 正在创建的 place1
于是 place0 的 entrance 属性被改写为 place1
结果:
place1 ──exit──> place0
place0 ──entrance──> place1
place1.entrance 仍是 None(它右边还没人)
place0.exit 仍是 None(它左边还是没人)
再看第二个用例,它检查真实布局里的链条:tile_1 是蜜蜂入口(最右格),tile_1.entrance is gamestate.beehive 应为 True。这个 entrance 不是我们在 __init__ 里设的,而是 GameState.configure 里的 register_place 单独补的:
def register_place(place: Place, is_bee_entrance: bool):
self.places[place.name] = place
if is_bee_entrance:
place.entrance = beehive
self.bee_entrances.append(place)
也就是说,最右端那格的 entrance 被事后覆盖成了 Hive。ThrowerAnt 一路往右找蜜蜂,最终一定会走到 Hive——这正是 Problem 3 里必须检查 is_hive 的原因。
写成 Place.entrance = self。 这是把类属性改掉了,效果是所有 Place 共享同一个 entrance,最后一个被创建的格子会覆盖全部。这种 bug 通过测试的概率是零,但排查起来很痛苦,因为它看上去「只差一个词」。记住:改某个具体对象的属性,主语必须是那个对象的引用(self.exit),不能是类名。
4. Problem 3:nearest_bee 沿着隧道往前找
题目要什么
ThrowerAnt 要扔叶子,得先知道扔谁。起始代码里的 nearest_bee 只会看自己脚下这一格,要改成:
- 从 ThrowerAnt 自己所在的
Place开始; - 如果这一格有蜜蜂,随机返回其中一只;
- 否则沿
entrance走到前面那格,重复; - 一路找不到就返回
None; - 蜂巢(Hive)里的蜜蜂永远不算。
四个边界情况全都有测试盯着,一个都不能漏:(1) 蜜蜂就在自己脚下这格 → 要打;(2) 蜜蜂在隧道最尽头的 tunnel_0_8 → 要打;(3) 蜜蜂在 Hive 里 → 绝不能打;(4) 同一格有两只蜜蜂 → 要真的随机选,测试会扔一千次然后统计分布。
怎么想到的
「沿着 entrance 一直往前走,直到找到为止」——这句话有两种写法,递归和迭代。递归写起来是这样:
def nearest_bee(self): # 示意,不是最终版
def search(place):
if place is None or place.is_hive:
return None
target = random_bee(place.bees)
if target is not None:
return target
return search(place.entrance)
return search(self.place)
它是对的,也很好读。但 Problem 4 马上要加距离限制,需要在递归里再拖一个 distance 参数,写起来会开始别扭。而且这条隧道最多九格,用不着递归的表达力。所以直接写 while 循环,用两个变量往前挪:place 走到哪了,distance 走了几步。
循环的终止条件是这题最容易写错的地方。 第一版很多人会写 while place is not None:,结果测试「Bees in the Hive can never be attacked」立刻挂掉——因为 tunnel_0_8.entrance 就是 Hive,而 Hive 也有 bees 列表,循环会大摇大摆地走进去把蜂巢里待命的蜜蜂给打了。
那能不能改成 while place is not None and place.bees == []?不行,这只是把「有没有蜜蜂」的判断挪到了条件里,蜂巢照样会被访问。正确做法是把 is_hive 直接写进终止条件:while place is not None and not place.is_hive:。is_hive 是 Place 的类属性,默认 False,Hive 类里重写成 True——又是一次类属性重写,跟 Problem 1 一模一样的手法。
那 place is not None 这半边还有必要吗?在标准布局里,往 entrance 方向走一定会撞到 Hive 而停下,走不到 None。但如果这只蚂蚁被摆在一个孤立的 Place 上(测试里确实会这么干),entrance 就是 None。留着它,零成本。
题面提示用 random_bee(place.bees),而它的定义是「列表非空就 random.choice,否则(隐式)返回 None」。这意味着你不需要自己判断 place.bees 是不是空——直接调用,拿到 None 就说明这格没蜜蜂,继续往前。少写一个 if len(place.bees) > 0,逻辑立刻干净一大截。
代码
本仓库里的这段代码是已经并入 Problem 4 的最终版(源文件里的标记就是 BEGIN Problem 3 and 4)。先整段看,再把属于 Problem 3 的部分挑出来:
def nearest_bee(self) -> Bee | None:
"""Return a random Bee from the nearest Place (excluding the Hive) that contains Bees and is reachable from
the ThrowerAnt's Place by following entrances.
This method returns None if there is no such Bee (or none in range).
"""
if not self.place:
return None # An Ant that is not in a Place has no nearest Bee
# BEGIN Problem 3 and 4
# Walk forward through the tunnel, counting how many entrances we
# followed, and stop at the Hive (bees there cannot be attacked).
place, distance = self.place, 0
while place is not None and not place.is_hive:
if self.lower_bound <= distance <= self.upper_bound:
target = random_bee(place.bees)
if target is not None:
return target
place = place.entrance
distance += 1
return None
# END Problem 3 and 4
只做 Problem 3 的话,把中间那个 if self.lower_bound <= distance <= self.upper_bound: 去掉、distance 也可以不要,剩下的就是纯 Problem 3 的答案。逐行说明:
if not self.place: return None是起始代码给的,别删。一只不在任何格子上的蚂蚁(比如刚被FireAnt反伤炸掉、place已置None)没有「最近的蜜蜂」可言。place, distance = self.place, 0:用局部变量place作游标,而不是直接改self.place。测试里专门有两句>>> thrower.place is ant_place # Don't change self.place!就是防这个——如果你写self.place = self.place.entrance,蚂蚁会在找蜜蜂的过程中把自己「传送」到隧道尽头。while place is not None and not place.is_hive::两个守卫,前者防走出隧道,后者防走进蜂巢。and有短路(short-circuit)语义,所以place为None时不会去求place.is_hive,顺序不能颠倒。target = random_bee(place.bees)后面必须再判一次if target is not None。不能写成return random_bee(place.bees)——那样第一格没蜜蜂就直接返回None,根本走不到第二格。place = place.entrance:往前挪一格。这是整个项目里反复出现的模式,Problem 12 的 QueenAnt 是它的镜像(place = place.exit)。- 循环正常结束(走到
None或撞到Hive)说明全程没找到,return None。
验证
用测试里那个最有代表性的用例。布局是 dry_layout,一条隧道九格;thrower 站在 ant_place,也就是 tunnel_0_0(最靠近家的那格)。三只蜜蜂:near_bee(2 血)在 tunnel_0_3,far_bee(3 血)在 tunnel_0_6,hive_bee(4 血)在 Hive。
>>> thrower.nearest_bee() is hive_bee
False
>>> nearest_bee = thrower.nearest_bee()
>>> nearest_bee is near_bee
True
>>> thrower.action(gamestate) # Attack! ThrowerAnts do 1 damage
>>> near_bee.health
1
>>> far_bee.health
3
self.place = tunnel_0_0,于是 place = tunnel_0_0, distance = 0 第 1 轮:place = tunnel_0_0 place is not None ✓ not place.is_hive ✓(普通 Place,is_hive 为 False) 0 <= 0 <= inf ✓ random_bee([]) → None(这格没蜜蜂) place ← tunnel_0_1, distance ← 1 第 2 轮:place = tunnel_0_1 同上,空 → 前进 distance ← 2 第 3 轮:place = tunnel_0_2 同上,空 → 前进 distance ← 3 第 4 轮:place = tunnel_0_3 place.bees = [near_bee] random_bee([near_bee]) → near_bee(只有一个候选) target is not None ✓ → return near_bee ★ 循环在此结束 于是 far_bee 所在的 tunnel_0_6 和 Hive 都没被访问到, hive_bee 与 far_bee 毫发无伤。
接着 thrower.action(gamestate) 调 self.throw_at(self.nearest_bee()),即 throw_at(near_bee),执行 near_bee.reduce_health(self.damage)。self.damage 沿 MRO 找到 ThrowerAnt.damage = 1,所以 near_bee.health 从 2 变成 1;1 > 0,不触发死亡逻辑,蜜蜂留在原地。far_bee.health 自然还是 3。
再验一个反例:如果循环条件漏了 not place.is_hive,那么在只有 hive_bee 存在时,循环会一路空转到 distance = 9,此时 place 是 Hive,place.bees 里躺着 hive_bee,函数会把它返回。测试 thrower.nearest_bee() is bee → False 当场变成 True,失败信息是 expected False but got True。
用 place.bees[0] 代替 random_bee(place.bees)。 前八个测试都会过,第九个「Testing ThrowerAnt chooses a random target」会挂:它在同一格放两只 1001 血的蜜蜂,扔一千次,期望第一只剩下约 501 血,容差 95。取 [0] 的话第一只会被打成 1,然后死掉从列表里消失——abs(1 - 501) < 95 为 False,直接暴露。
5. Problem 4:ShortThrower 与 LongThrower——用两个数字换掉两份代码
题目要什么
两个便宜但有射程限制的投手:
| 类 | Food Cost | 初始血量 | 射程(走过多少次 entrance) |
|---|---|---|---|
ThrowerAnt | 3 | 1 | 0 到 ∞,不限 |
ShortThrower | 2 | 1 | 至多 3 |
LongThrower | 2 | 1 | 至少 5 |
两个必须点透的细节:
其一,恰好 4 格远的蜜蜂谁都打不到。 ShortThrower 上限 3,LongThrower 下限 5,中间那个 4 是死角。这不是 bug,是设计。
其二,「太近」不等于「停下」。 题面明说:如果 LongThrower 前方 2 格有一只蜜蜂、7 格有另一只,它应该跳过近的那只去打远的,而不是「看见近的就放弃这一回合」。这一条决定了循环该怎么写。
题面还给了硬性约束:属性必须叫 lower_bound 和 upper_bound(测试直接按名字读),而且 ShortThrower、LongThrower、ThrowerAnt 三者之间不许有任何重复代码。
怎么想到的
先说一条走不通的路,因为绝大多数人都会先想到它:在子类里各自重写 nearest_bee。
class ShortThrower(ThrowerAnt): # 别这么写
def nearest_bee(self):
place, distance = self.place, 0
while place is not None and not place.is_hive and distance <= 3:
...
它能通过测试,但把 nearest_bee 那十行逻辑抄了三遍。以后要改(比如加个「不打 Boss」的规则),三个地方都得改,漏一个就出 bug。题面点名禁止这种写法。
正确的思路是反过来问:这三个类的行为差在哪一个点上? 差别只有一处——「距离多少的格子算数」。既然差别是数据而不是逻辑,那就应该把数据抽出来做成类属性,让唯一的一份逻辑去读它。这正是继承最典型的用法:父类写算法,子类填参数。
于是 nearest_bee 里加一行判断:只有当 lower_bound <= distance <= upper_bound 时,才考虑这一格的蜜蜂。而 ThrowerAnt 自己要保持「不限射程」的老行为,就把区间开到最大:lower_bound = 0,upper_bound = float('inf')。
为什么 upper_bound 要用 float('inf') 而不是一个大数字比如 100?因为隧道长度理论上没上限,而且 float('inf') 表达的是「我根本不关心上界」这个意图,读代码的人一眼就懂。它是个真正的浮点数,可以直接跟 int 比大小,3 <= float('inf') 求值为 True。
回到「太近不等于停下」那条。判断放在循环里面而不是循环条件上,正是为了这件事:
while place is not None and not place.is_hive:
if self.lower_bound <= distance <= self.upper_bound: # 在这一格能打吗
...
place = place.entrance # 无论能不能打,都继续往前
distance += 1
如果错误地写成 while ... and distance <= self.upper_bound and distance >= self.lower_bound:,LongThrower 在 distance = 0 时条件就为假,循环一次都不执行,永远打不到任何东西。上界属于「走到这就该停」,下界属于「这格跳过但要继续走」——两者性质不同,不能塞进同一个条件。
self.lower_bound <= distance <= self.upper_bound 用的是 Python 的链式比较(chained comparison),等价于 (self.lower_bound <= distance) and (distance <= self.upper_bound),而且 distance 只求值一次。这里的 self.lower_bound 会按 MRO 查找:ShortThrower 实例上没有、ShortThrower 类里也没有(它只重写了 upper_bound),于是找到 ThrowerAnt.lower_bound = 0。子类不需要重写的属性会自动继承下来——这就是为什么 ShortThrower 只写一行就够了。
代码
class ThrowerAnt(Ant):
"""ThrowerAnt throws a leaf each turn at the nearest Bee in its range."""
name = 'Thrower'
implemented = True
damage = 1
# ADD/OVERRIDE CLASS ATTRIBUTES HERE
food_cost = 3
# A plain ThrowerAnt can hit a Bee at any distance, including its own place.
lower_bound = 0
upper_bound = float('inf')
class ShortThrower(ThrowerAnt):
"""A ThrowerAnt that only throws leaves at Bees at most 3 places away."""
name = 'Short'
food_cost = 2
# OVERRIDE CLASS ATTRIBUTES HERE
upper_bound = 3
# BEGIN Problem 4
implemented = True # Change to True to view in the GUI
# END Problem 4
class LongThrower(ThrowerAnt):
"""A ThrowerAnt that only throws leaves at Bees at least 5 places away."""
name = 'Long'
food_cost = 2
# OVERRIDE CLASS ATTRIBUTES HERE
lower_bound = 5
# BEGIN Problem 4
implemented = True # Change to True to view in the GUI
# END Problem 4
整整两个新蚂蚁,加起来写了六行有效代码,其中四行是 name、food_cost、implemented 这种登记信息,真正的行为差异只有 upper_bound = 3 和 lower_bound = 5 两行。如果你的答案比这长得多,说明抽象层次找错了。
注意 ShortThrower 里没有 damage = 1,也没有 __init__。前者继承自 ThrowerAnt,后者继承自 Ant(默认 health=1,正好符合表格要求)。写了反而是噪音。
验证
设一条九格隧道,LongThrower 站在 tunnel_0_0,一只蜜蜂在 tunnel_0_2(距离 2,太近),另一只在 tunnel_0_7(距离 7,在射程内)。
distance: 0 1 2 3 4 5 6 7 8 格子: _0 _1 _2 _3 _4 _5 _6 _7 _8 → Hive 蜜蜂: A B d=0: 5 <= 0 ? 否 → 跳过判断,前进 d=1: 5 <= 1 ? 否 → 跳过,前进 d=2: 5 <= 2 ? 否 → 跳过(★ 蜜蜂 A 就在这里,但不打),前进 d=3: 否 → 前进 d=4: 否 → 前进 d=5: 5 <= 5 <= inf ✓ → random_bee([]) → None,前进 d=6: 在范围内 → None,前进 d=7: 在范围内 → random_bee([B]) → B ★ 返回 B → 蜜蜂 A 血量不变,蜜蜂 B 掉 1 血。
同样的布局,蜜蜂 A 在 d=2,蜜蜂 B 在 d=7。 d=0: 0 <= 0 <= 3 ✓ → 空,前进 d=1: ✓ → 空,前进 d=2: ✓ → random_bee([A]) → A ★ 返回 A → 蜜蜂 A 掉 1 血。 若把 A 挪到 d=4: d=0..3 全在范围内但都是空的; d=4: 0 <= 4 ? ✓,但 4 <= 3 ? ✗ → 跳过; d=5..8 更远,同样跳过; 撞到 Hive,循环结束 → return None throw_at(None) 里的 if target is not None 为假,什么也不做。 → 恰好 4 格远的蜜蜂,ShortThrower 打不到; LongThrower 的下界是 5,也打不到。死角成立。
还有一个容易被忽视的检查:Problem 4 做完之后必须重跑 Problem 3 的测试(题面明确要求 python3 ok -q 03 再跑一遍)。因为你改的是 ThrowerAnt.nearest_bee 本身,如果 lower_bound/upper_bound 的默认值给错了(比如 lower_bound = 1),普通 ThrowerAnt 就再也打不到脚下这格的蜜蜂了,而 Problem 4 自己的测试未必能发现。
把 upper_bound 写成 float('int') 或 float(inf)。 前者抛 ValueError: could not convert string to float: 'int',后者抛 NameError: name 'inf' is not defined。参数是字符串 'inf',引号不能少。另外 math.inf 也可以,但 ants.py 没 import math,用 float('inf') 更省事。
6. Problem 5:FireAnt——顺序、别名与「死后仍要办的事」
题目要什么
FireAnt(5 食物,3 血)挨打时会烧到同格的所有蜜蜂:
- 被扣
damage_taken点血时,对同格所有蜜蜂造成damage_taken点反伤; - 如果这一下把它打死了,额外再对所有蜜蜂造成
self.damage点伤害(默认 3); - 实现方式是重写
FireAnt.reduce_health,并且必须在里面调用父类的reduce_health来完成扣血与死亡移除。
题面里有三条警告,每一条都对应一个真实的坑:
- 不要调用
self.reduce_health——那是你自己,会无限递归,最终RecursionError: maximum recursion depth exceeded。 - 蚂蚁血量归零会被移出
Place,所以调用父类方法之后self.place可能已经是None。 - 伤害蜜蜂会让它从
place.bees里消失,边遍历边删会跳过元素,必须遍历副本。
怎么想到的
先把最朴素的版本写出来,然后一个一个地被测试打脸——这是理解这题最快的路径。
第一版:
def reduce_health(self, damage_taken): # 第一版,三处错
self.health -= damage_taken
reflected = damage_taken
if self.health <= 0:
reflected += self.damage
for bee in self.place.bees:
bee.reduce_health(reflected)
错误一:自己扣血,没走父类。 self.health -= damage_taken 只改了数字,没有触发 zero_health_callback(),也没有把蚂蚁从 place 里移除。测试里有一条极其阴险的检查:
>>> Insect.zero_health_callback = lambda x: print("insect died")
>>> bee.action(gamestate) # 打三次,FireAnt 3 血
>>> bee.action(gamestate)
>>> bee.action(gamestate)
insect died
insect died
两个 "insect died":一个是 FireAnt 自己死,一个是被反伤烧死的蜜蜂。你不调父类,这行输出就少一个。还有另一条测试直接把 Ant.reduce_health 替换成 lambda self, damage_taken: print("reduced"),你不调用它就打印不出来。「必须复用父类实现」这件事,是被测试硬性验证的,不是建议。
错误二:self.place 在父类调用之后可能变 None。 把第一行换成 super().reduce_health(damage_taken) 之后,如果这一击致命,Insect.reduce_health 会执行 self.place.remove_insect(self),而 Ant.remove_from 最后一句 Insect.remove_from(self, place) 会把 self.place = None。于是下一行的 self.place.bees 抛 AttributeError: 'NoneType' object has no attribute 'bees'。
解法:在调用父类之前,先把 place 用局部变量存起来。这是整题最核心的一个动作。注意 place 这个局部变量指向的还是同一个 Place 对象——FireAnt 从它的 ant 槽里被摘掉了,但那个 Place 本身和它的 bees 列表都还在,蜜蜂们也还在里面。
错误三:边遍历边删。 bee.reduce_health(...) 若把蜜蜂打死,会执行 place.bees.remove(self)(见 Bee.remove_from)。for 循环内部是靠一个递增的下标去取元素的,你在下标 0 删掉一个元素,原本的下标 1 就变成了下标 0,而循环下一轮去取下标 1——中间那只被跳过了。测试放了 100 只 3 血蜜蜂,期望全灭;用原列表遍历的话只会烧掉一半左右,len(place.bees) 返回 50 而不是 0。
解法:for bee in list(place.bees)。list(...) 造了一个新的列表对象,里面装着同样那些蜜蜂的引用。原列表怎么被删都不影响遍历,而我们操作的仍是同一批蜜蜂对象。place.bees[:] 效果相同。
还有一个顺序问题:if self.health <= 0 要判在父类调用之前还是之后? 必须在之后。父类调用之前 self.health 还是旧值,判不出这一击是否致命。所以顺序是:存 place → 记下基础反伤 → 调父类扣血 → 检查是否死了、死了就加上 self.damage → 统一对蜜蜂结算。
「反伤只结算一次,但金额分两步算出来」。很多人会写成「先烧一遍 damage_taken,如果死了再烧一遍 self.damage」——烧两遍。这在数值上对大多数情况等价,但对 Boss 不等价:Boss.reduce_health 会把单次伤害截断到 damage_cap = 8。烧两遍 5 点 = 10 点实际伤害,烧一遍 10 点 = 8 点实际伤害。所以必须先把总额加好,再一次性调用。
代码
def reduce_health(self, damage_taken: float):
"""Reduce health by DAMAGE_TAKEN, and remove the FireAnt from its place if it
has no health remaining.
Make sure to reduce the health of each bee in the current place, and apply
the additional damage if the fire ant dies.
"""
# BEGIN Problem 5
# Remember the place: losing all health removes this ant from it.
place = self.place
reflected = damage_taken
super().reduce_health(damage_taken)
if self.health <= 0:
reflected += self.damage # A dying FireAnt goes out with a bang
if place is not None:
# Copy the list: damaged bees may be removed from place.bees.
for bee in list(place.bees):
bee.reduce_health(reflected)
# END Problem 5
逐行:
place = self.place:必须是第一行。在super()调用之后再取就晚了。reflected = damage_taken:反伤的基数就是它自己挨的伤害。用一个新名字而不是直接改参数,是为了后面那行+=读起来有意义。super().reduce_health(damage_taken):super()在FireAnt的 MRO 里往上找,下一个是Ant;Ant没有定义reduce_health,继续找到Insect.reduce_health,执行扣血 + 死亡移除。写成Ant.reduce_health(self, damage_taken)也能过测试(测试正是这么替换的),但super()更稳健。if self.health <= 0: reflected += self.damage:注意是self.damage而不是字面量3。有测试专门写ant.damage = 49(在实例上设属性),期望蜜蜂掉 50 血(1 点普通反伤 + 49 点死亡伤害)。硬编码 3 会直接失败。if place is not None:处理蚂蚁本来就不在任何格子上的情况(测试里fire = FireAnt()造出来还没放进棋盘就被戳一下)。for bee in list(place.bees):副本遍历。
验证
用测试里的「General FireAnt Test」:bee = Bee(10) 和 ant = FireAnt(1) 同在 tunnel_0_4,然后 bee.action(gamestate)。
>>> bee.action(gamestate) # Attack the FireAnt
>>> bee.health
6
>>> ant.health
0
>>> place.ant is None
True
初始状态:
tunnel_0_4.ant = ant (FireAnt, health=1, damage=3)
tunnel_0_4.bees = [bee] (Bee, health=10, damage=1)
ant.place = bee.place = tunnel_0_4
bee.action(gamestate)
self.blocked() → place.ant 是 ant 且 ant.blocks_path 为 True → True
→ self.sting(ant) → ant.reduce_health(1) # Bee.damage = 1
进入 FireAnt.reduce_health(damage_taken=1)
1) place = self.place → place 指向 tunnel_0_4
2) reflected = 1
3) super().reduce_health(1) → Insect.reduce_health
self.health = 1 - 1 = 0
0 <= 0 为真:
zero_health_callback() (空方法)
self.place 不是 None → tunnel_0_4.remove_insect(ant)
→ Ant.remove_from:place.ant is ant → place.ant = None
→ Insect.remove_from:ant.place = None
★ 此刻 self.place 已是 None,但局部变量 place 仍指着 tunnel_0_4
4) self.health <= 0 为真 → reflected = 1 + 3 = 4
5) place 不是 None → list(place.bees) 得到新列表 [bee]
bee.reduce_health(4)
bee.health = 10 - 4 = 6
6 > 0,蜜蜂活着,不移除
最终:ant.health = 0, ant.place = None, tunnel_0_4.ant = None
bee.health = 6, 仍在 tunnel_0_4
再验一个「不死」的情形,测试里是 ant.reduce_health(0.1),FireAnt 满血 3:
>>> ant.reduce_health(0.1) # Poke the FireAnt
>>> bee.health # Bee should only get slightly damaged
9.9
>>> ant.health
2.9
>>> place.ant is ant
True
推演:reflected = 0.1;父类把 health 变成 2.9;2.9 <= 0 为假,不加 3;蜜蜂掉 0.1 血变 9.9。如果你把 if 判在父类调用之前,用的是旧血量 3,结论同样是「没死」,这个用例侥幸能过——但上一个用例(1 血被打 1 点)就会算成「没死」,蜜蜂只掉 1 血变 9,直接失败。两个用例合起来,唯一能同时满足的顺序只有「先扣血、后判死」。
把死亡额外伤害只打给「还活着」的蜜蜂,或者反过来漏掉刚死的。 其实不用操心:bee.reduce_health 内部自己会判断,血量掉到 0 以下就移除。你只管对副本里的每一只调用一次,剩下的交给它。这就是 Problem 0 第六问「蚂蚁和蜜蜂共享 reduce_health 接口」的价值——攻击方永远不需要知道被打的是谁、还剩多少血。
7. Problem 6:WallAnt——什么都不做,也需要显式地写出来
题目要什么
WallAnt(4 食物,4 血)每回合什么也不干,纯粹用来挡路和挨打。跟之前几题不同,这次连 class 语句都没给你,整个类要从零写。要求:类属性 name = 'Wall'(GUI 靠这个找贴图)、implemented = True、food_cost = 4,并且 __init__ 要让它开局 4 血。
怎么想到的
这题真正的考点是解锁题里那两问:
Ant 的子类从哪里继承
action方法? —— 从Insect类。
如果 Ant 的子类不重写action,默认行为是什么? —— 什么都不做。
翻回 ants.py 看 Insect.action 的定义:
def action(self, gamestate: GameState):
"""The action performed each turn."""
函数体只有一个 docstring,没有任何语句,隐式返回 None。Ant 没有重写它,WallAnt 也不重写,于是 wall.action(gamestate) 沿 MRO 一路找到 Insect.action,调用它,什么也不发生。所以「什么都不做」的正确实现是「一行都不写」。 主动写一个 def action(self, gamestate): pass 不算错,但它传达了一个错误信号:让人以为你在这里刻意屏蔽了父类的某种行为。
那 __init__ 为什么必须写?因为血量是实例属性,它不在类体里,只能在构造时确定。Ant.__init__ 的签名是 def __init__(self, health: int = 1),默认 1 血。如果 WallAnt 不写 __init__,WallAnt() 就会用这个默认值,造出一只 1 血的墙——四分之一的血量,还贵一倍。
那能不能干脆写个类属性 health = 4?不能。 这正是 Problem 0 第三问考的东西:health 必须是实例属性,因为每只墙蚂蚁挨的打不一样。如果你写成类属性,第一次 self.health -= damage_taken 时 Python 会在实例上新建一个 health 属性(增强赋值先读后写,读到的是类属性 4,写的是实例属性),表面上看起来能跑,但类属性 WallAnt.health 永远是 4,而且新造的墙拿到的初始值仍然是这个类属性——碰巧也对。真正暴露问题的是别的路径:Insect.__init__ 里还会设 self.full_health = health,你的类属性绕过了它,full_health 会是 1,GUI 的血条会画错。把值通过 __init__ 一路传下去,才是这套代码约定的做法。
def __init__(self, health=4): super().__init__(health) 这个模式在项目里出现了五次(FireAnt、WallAnt、HungryAnt、ProtectorAnt、TankAnt)。它的含义是:「我的默认血量跟父类不同,但除此之外的初始化逻辑我一点也不想改」。 把 health 留成参数而不是写死 super().__init__(4),是因为测试会用 FireAnt(health=1) 这种写法直接指定血量。
代码
class WallAnt(Ant):
"""WallAnt does nothing each turn, but it has plenty of health to spare."""
name = 'Wall'
food_cost = 4
implemented = True
def __init__(self, health: int = 4):
super().__init__(health)
class WallAnt(Ant):直接继承Ant,不是ThrowerAnt(它不扔东西),也不是Insect(它是蚂蚁,需要food_cost、add_to等一整套)。name = 'Wall':题面强调必须是这个字符串,因为GameState.__init__用OrderedDict((a.name, a) for a in ant_types())把名字当键,GUI 按名字请求static/下的图片。写成'WallAnt'测试会挂在KeyError: 'Wall'。- 没有
action,没有damage。Insect.damage = 0继承下来正合适。
验证
>>> wall = WallAnt()
>>> wall.health
4
>>> WallAnt.food_cost
4
WallAnt() 求值:
查找 WallAnt.__init__ → 找到我们写的那个,health 取默认值 4
super().__init__(4) → Ant.__init__(self, 4)
super().__init__(4) → Insect.__init__(self, 4)
self.health = 4
self.full_health = 4
self.place = None
self.id = Insect.next_id; Insect.next_id += 1
回到 Ant.__init__:self.doubled = False (Problem 12 用)
构造结束
wall.action(gamestate) 求值:
wall.__dict__ 里没有 action
WallAnt.__dict__ 里没有 action
Ant.__dict__ 里没有 action
Insect.__dict__ 找到 action → 调用 → 函数体为空 → 返回 None
一个回合就这么过去了。
放进游戏里试试更直观:一只 1 血的 ThrowerAnt 被普通蜜蜂(1 伤害)蜇一下就没了,而 WallAnt 能扛四下。对付黄蜂(Wasp,2 伤害)则只能扛两下——因为 Wasp.damage = 2 重写了 Bee.damage = 1,又一次类属性重写。
8. Problem 7:HungryAnt——第一个带「自己的状态」的蚂蚁
题目要什么
HungryAnt(4 食物,1 血)整只吞掉同格的一只蜜蜂,然后花 3 回合咀嚼:
- 类属性
chew_cooldown = 3,实例属性cooldown初始为 0; action逻辑:先检查是不是在咀嚼;是就把cooldown减 1,这回合什么也不吃;否则从同格随机挑一只蜜蜂,把它的血打到 0,然后把cooldown设成 3;- 同格没蜜蜂就什么也不做(而且不进入冷却);
- 类属性
name = 'Hungry'、implemented = True;__init__要给 1 血并初始化cooldown。
解锁题问得很直接:cooldown 该是实例属性还是类属性? 实例属性——每只 HungryAnt 各嚼各的,一只在吃另一只不该跟着进冷却。而 chew_cooldown 是类属性——所有 HungryAnt 的咀嚼时长都是 3。同一题里同时出现两种属性,就是为了逼你分辨它们。
怎么想到的
「吃掉一只蜜蜂」怎么写?题面给了提示:把蜜蜂的血量减去它自己的血量。 也就是 target.reduce_health(target.health)。
为什么不直接 target.health = 0?因为那样只改了个数字,不会触发 reduce_health 里的死亡流程——蜜蜂血是 0 了,却还赖在 place.bees 列表里,也没从 active_bees 里被清掉,游戏会认为它还活着,永远不结束。凡是要「让某只虫子掉血」,唯一正确的入口就是 reduce_health。 这条规则贯穿整个项目。
那为什么不写成 target.reduce_health(9999)?功能上等价,但对 Boss 不等价:Boss.reduce_health 会把伤害截到 8。不过用 target.health 同样会被截——所以对 Boss 来说 HungryAnt 本来就吃不掉,这是游戏设计。用 target.health 而不是魔数,至少表达了「我要造成刚好足够的伤害」这个意图。
冷却的顺序是这题唯一的逻辑陷阱。 考虑一只刚吃完的 HungryAnt,cooldown = 3。接下来几回合应该怎么走?
| 回合 | 进入 action 时 cooldown | 做了什么 | 结束时 cooldown |
|---|---|---|---|
| T | 0 | 吃掉一只蜜蜂 | 3 |
| T+1 | 3 | 咀嚼,减 1 | 2 |
| T+2 | 2 | 咀嚼,减 1 | 1 |
| T+3 | 1 | 咀嚼,减 1 | 0 |
| T+4 | 0 | 再吃一只 | 3 |
吃完之后整整三个回合不能吃,第四个回合恢复。如果你写成「先吃,再减 cooldown」,或者用 if self.cooldown >= 0,节奏就会差一拍。这个差一拍(off-by-one)在测试里有专门的用例逐回合核对。
「吃了才进冷却」——self.cooldown = self.chew_cooldown 必须写在 if target is not None: 的里面。如果写在外面,一只面前没有蜜蜂的 HungryAnt 会白白进入 3 回合冷却,等蜜蜂来了却动不了。状态变更要跟触发它的事件绑在同一个分支里,这是一条通用的经验。
代码
class HungryAnt(Ant):
"""HungryAnt eats a Bee whole, then spends a few turns chewing."""
name = 'Hungry'
food_cost = 4
chew_cooldown = 3 # Turns spent chewing after a meal
implemented = True
def __init__(self, health: int = 1):
super().__init__(health)
self.cooldown = 0 # Turns left before this ant can eat again
def action(self, gamestate: GameState):
"""Eat a random Bee in this place, unless still chewing."""
if self.cooldown > 0:
self.cooldown -= 1
else:
target = random_bee(self.place.bees)
if target is not None:
# Eating means reducing the Bee's health to exactly 0.
target.reduce_health(target.health)
self.cooldown = self.chew_cooldown
chew_cooldown = 3在类体里(共享的常量),self.cooldown = 0在__init__里(各自的状态)。这两行的位置差异就是这题的全部考点。super().__init__(health)必须在self.cooldown = 0之前。虽然这里换个顺序也不会出错,但父类初始化优先是通用惯例——万一父类的__init__里也碰这个名字,顺序就要命了。if self.cooldown > 0而不是!= 0:语义上cooldown永远不会是负数,但> 0读起来就是「还在嚼」,更贴近含义。self.cooldown = self.chew_cooldown:读的是self.而不是HungryAnt.。这样如果有人在某只蚂蚁上设ant.chew_cooldown = 1做特化,代码会自动跟随。random_bee(self.place.bees):这里用self.place是安全的——action只会被ants_take_actions对场上活着的蚂蚁调用,而场上的蚂蚁一定有place。这跟 FireAnt 的情况不同,那里是自己刚死导致place变None。
验证
场景:hungry = HungryAnt() 在 tunnel_0_2,
同格有 bee1 = Bee(3) 和 bee2 = Bee(5)。
回合 1 hungry.action(gamestate)
self.cooldown = 0,不 > 0 → 走 else
random_bee([bee1, bee2]) → 假设选中 bee1
bee1.reduce_health(bee1.health) 即 reduce_health(3)
bee1.health = 3 - 3 = 0
0 <= 0 → zero_health_callback()
tunnel_0_2.remove_insect(bee1)
→ Bee.remove_from:place.bees.remove(bee1)
→ Insect.remove_from:bee1.place = None
★ place.bees 现在是 [bee2]
self.cooldown = 3
回合 2 self.cooldown = 3 > 0 → 减为 2,bee2 安然无恙
回合 3 cooldown 2 → 1
回合 4 cooldown 1 → 0
回合 5 cooldown = 0 → 吃掉 bee2
bee2.reduce_health(5),place.bees 变成 []
self.cooldown = 3
回合 6 cooldown 3 → 2
...
回合 9 cooldown = 0 → random_bee([]) → None
target is not None 为假,什么也不做,
★ cooldown 保持 0,下回合来了蜜蜂能立刻吃。
注意回合 1 里的一个微妙之处:target.reduce_health(target.health) 这个表达式,实参 target.health 在调用发生之前就被求值了(值是 3),所以方法内部执行 self.health -= 3 时用的是那个已经算好的 3,不会出现「一边减一边读」的诡异情况。Python 的实参求值先于函数体执行——这是前半学期就讲过的规则,在这里悄悄用上了。
写成 self.place.bees[0] 而不是 random_bee。 跟 Problem 3 一样,测试会用统计方法检验随机性。另外,忘了 self.place.bees 可能是空列表:直接写 self.place.bees[0].reduce_health(...) 会在空格子上抛 IndexError: list index out of range,而且这个错误会在真实对局里随机出现——只要 HungryAnt 前面一时没蜜蜂,游戏就崩。
9. Problem 8a:ContainerAnt——让一格里塞下两只蚂蚁
题目要什么
ProtectorAnt 能罩住同格的另一只蚂蚁:蜜蜂蜇过来只伤到容器,被罩的蚂蚁照常行动;容器死了,被罩的蚂蚁留在原地继续挨打。这套机制放在父类 ContainerAnt 里,8a 要写它的三个方法:
store_ant(ant):把self.ant_contained设成传进来的ant;action(gamestate):容器自己不做事,但要让ant_contained执行它的action;can_contain(other):返回True当且仅当——(a) 自己还没装东西,(b)other不是容器。
怎么想到的
store_ant 一行就完了,没什么可想的。action 也只是「有就转发,没有就算了」。真正值得琢磨的是 can_contain 的两个条件为什么是这两个。
条件 (a)「自己还没装东西」:容器只有一个 ant_contained 槽位。如果允许再装,第二只会把第一只覆盖掉,第一只蚂蚁就凭空消失了——既不在 place.ant 里,也不在任何容器里,但它的 place 属性还指着这格。这是最典型的「对象泄漏」。
条件 (b)「other 不是容器」:如果两个容器可以互相嵌套,一格里就能塞进任意多只蚂蚁,游戏平衡直接崩掉。而且 remove_from 的逻辑只处理了一层嵌套,多层会出错。
怎么判断「是不是容器」?Ant 类里有现成的 is_container = False,ContainerAnt 里重写成 True。所以直接读 other.is_container。不要写 isinstance(other, ContainerAnt)——虽然眼下结果一样,但这套代码的风格是用类属性做标记(is_hive、is_waterproof、blocks_path、is_container 全是这个套路),跟着走能少很多麻烦。
再看 action。为什么 ContainerAnt.action 要写成「转发」而不是继承 Insect.action(什么都不做)?因为题面明确说「被罩的蚂蚁仍能执行它原本的行动」。如果不转发,你把一只 ThrowerAnt 塞进 ProtectorAnt,它就再也不扔叶子了——因为 GameState.ants_take_actions 遍历的是 [p.ant for p in places],只拿得到容器,拿不到里面那只。被容器吞掉的蚂蚁与游戏主循环失联了,只能靠容器代为唤醒。
self.ant_contained.action(gamestate) 里的 gamestate 必须原样透传。容器不知道也不该知道里面装的是什么蚂蚁——可能是需要 gamestate.food 的 HarvesterAnt,也可能是压根用不到它的 WallAnt。这就是多态:同一行调用,落到不同的类上执行完全不同的代码。
代码
class ContainerAnt(Ant):
"""
ContainerAnt can share a space with other ants by containing them.
"""
is_container = True
def __init__(self, health: int):
super().__init__(health)
self.ant_contained = None
def can_contain(self, other: Ant) -> bool:
# BEGIN Problem 8a
# Room for exactly one ant, and containers cannot nest.
return self.ant_contained is None and not other.is_container
# END Problem 8a
def store_ant(self, ant: Ant):
# BEGIN Problem 8a
self.ant_contained = ant
# END Problem 8a
def action(self, gamestate: GameState):
# BEGIN Problem 8a
# A container does nothing itself, but the ant it guards still acts.
if self.ant_contained is not None:
self.ant_contained.action(gamestate)
# END Problem 8a
return self.ant_contained is None and not other.is_container:直接返回布尔表达式,不写if ...: return True else: return False。两个条件用and连接,短路特性在这里无关紧要(两边都没副作用),但表达清晰。is None而不是== None:None是单例,判同一性是标准写法。if self.ant_contained is not None这个守卫不能省。刚放下的容器ant_contained是None,直接None.action(gamestate)会抛AttributeError: 'NoneType' object has no attribute 'action',而且是在真实对局的每一回合都抛。- 注意
ContainerAnt.__init__的health没有默认值(是起始代码给的)。这是有意的:ContainerAnt是抽象父类,不该被直接实例化成一个具体蚂蚁,血量必须由子类决定。
验证
>>> container = ContainerAnt(1)
>>> other_ant = ThrowerAnt()
>>> container.can_contain(other_ant)
True
>>> container.store_ant(other_ant)
>>> container.ant_contained is other_ant
True
>>> container.can_contain(ThrowerAnt()) # 已经装了一只
False
>>> ContainerAnt(1).can_contain(ContainerAnt(1)) # 容器不能套容器
False
情形 1:空容器 + 普通蚂蚁 self.ant_contained is None → True other.is_container → ThrowerAnt 继承 Ant.is_container = False not False → True True and True → True ✓ 可以装 情形 2:已装了东西的容器 + 普通蚂蚁 self.ant_contained is None → False(指着 other_ant) and 短路,右边不再求值 → False ✓ 不能再装 情形 3:空容器 + 另一个容器 self.ant_contained is None → True other.is_container → ContainerAnt.is_container = True not True → False True and False → False ✓ 不能嵌套
再验 action 的转发。把一只 HarvesterAnt 装进容器,容器行动一次,食物应该 +1:
container.ant_contained = harvester,gamestate.food = 4
container.action(gamestate)
查找 action:ContainerAnt.__dict__ 里有 → 调用
self.ant_contained is not None → True
harvester.action(gamestate)
查找 action:HarvesterAnt.__dict__ 里有 → 调用
gamestate.food += 1 → 5
返回 None,容器自己没做任何事
→ gamestate.food 现在是 5。
注意 GameState.ants_take_actions 只遍历到 container,
harvester 完全靠这次转发才动起来。
在 can_contain 里漏掉「other 不能是容器」。 单元测试里这一条有专门用例,但更隐蔽的后果在 8b:Ant.add_to 会连着问 place.ant.can_contain(self) 和 self.can_contain(place.ant),两个容器碰面时如果两问都是 True,就会按第一个分支把新容器塞进老容器,而 place.ant 还是老容器——从此那只新容器和它可能装着的蚂蚁全都成了游戏找不到的孤儿。
10. Problem 8b:Ant.add_to——谁装谁,以及 place.ant 该指着谁
题目要什么
现在 Ant.add_to 遇到「这格已经有蚂蚁了」就无脑断言失败。要改成三分支:
- 原本在这格的蚂蚁
can_contain新来的 → 老的装新的,place.ant不变; - 新来的
can_contain原本在这格的 → 新的装老的,而且place.ant要改成新来的这只; - 都不行 → 保持原样,抛出起始代码里那个
AssertionError。
题面用加粗强调了一条不变式(invariant):一格里有两只蚂蚁时,place.ant 必须是那个容器,容器里装着非容器的那只。
怎么想到的
先看起始代码长什么样:
def add_to(self, place: Place):
if place.ant is None:
place.ant = self
else:
# BEGIN Problem 8b
assert place.ant is None, 'Too many ants in {0}'.format(place)
# END Problem 8b
Insect.add_to(self, place)
那句 assert place.ant is None 在 else 分支里,条件必然为假,所以它就是「无条件报错」。我们要做的是在报错之前先试两条路。
为什么必须分两种情况问? 因为玩家放蚂蚁的顺序是自由的:可能先放 ThrowerAnt 再往上盖 ProtectorAnt,也可能先放 ProtectorAnt 再往里塞 ThrowerAnt。两种顺序都得支持,但结果必须一致——place.ant 是 ProtectorAnt,它装着 ThrowerAnt。
第一种顺序(先容器后普通)比较简单:place.ant 已经是容器,place.ant.can_contain(self) 为真,调 place.ant.store_ant(self) 就完事,place.ant 不用动。
第二种顺序(先普通后容器)才是关键:place.ant 是 ThrowerAnt,它的 can_contain 继承自 Ant,永远返回 False;于是问第二句 self.can_contain(place.ant)——新来的容器能装它,为真。这时要做两件事:self.store_ant(place.ant) 把老蚂蚁收进来,还要 place.ant = self 把这格的门面换成容器。漏掉后半句是这题最常见的失败原因。
为什么后半句这么重要?因为 Bee.blocked() 和 Bee.action() 都通过 self.place.ant 找攻击目标。如果 place.ant 还指着被保护的 ThrowerAnt,蜜蜂就会绕过容器直接蜇它,保护完全失效。而且 Ant.remove_from 里 if place.ant is self 的判断也会走错分支。
末尾那句 Insect.add_to(self, place) 是起始代码给的,三个分支之后都要执行它——它做的是 self.place = place,也就是让新来的蚂蚁记住自己在哪。这解释了测试里的一句断言:other_ant.place is container.place → True。被装进容器的蚂蚁,它的 place 属性依然指向那个 Place,而不是指向容器。「在哪个格子」和「被谁罩着」是两个独立的事实,分别记录。
代码
def add_to(self, place: Place):
if place.ant is None:
place.ant = self
else:
# BEGIN Problem 8b
if place.ant.can_contain(self):
# The ant already here protects the newcomer.
place.ant.store_ant(self)
elif self.can_contain(place.ant):
# The newcomer protects the ant already here, so it becomes
# the ant of record for this place.
self.store_ant(place.ant)
place.ant = self
else:
assert place.ant is None, 'Too many ants in {0}'.format(place)
# END Problem 8b
Insect.add_to(self, place)
- 三个分支的顺序不能颠倒。先问「老的能不能装新的」,是因为这一分支不需要改
place.ant,代价更小;而且如果两只都是容器,两个can_contain都会返回False(条件 (b) 挡住了),顺序其实不影响正确性——但保持这个顺序更贴合题面描述。 elif而不是第二个if:第一个分支成立时绝不能再执行第二个,否则会互相装进对方。self.store_ant(place.ant)和place.ant = self的先后顺序至关重要。反过来写:先place.ant = self,那么place.ant已经变成容器自己,再self.store_ant(place.ant)就成了「容器装它自己」,ant_contained指向self,action会无限递归。- 最后那句
assert原样保留。题面说「抛出跟以前一样的 AssertionError」,测试会检查异常类型。 - 没有复制任何一份
can_contain的判断逻辑——题面提示的「利用你写好的can_contain,避免重复代码」就是这个意思。
验证
用测试里的第一个用例,「先放容器再放普通蚂蚁」:
>>> container = ContainerAnt(1)
>>> other_ant = ThrowerAnt()
>>> place = gamestate.places['tunnel_0_0']
>>> place.add_insect(container) # ContainerAnt in place first
>>> place.add_insect(other_ant)
>>> place.ant is container
True
>>> container.ant_contained is other_ant
True
>>> other_ant.place is container.place
True
place.add_insect(container)
→ Place.add_insect 转发给 container.add_to(place)
→ Ant.add_to:place.ant is None → True
place.ant = container
Insect.add_to:container.place = place
状态:place.ant ─→ container
container.ant_contained = None
container.place ─→ place
place.add_insect(other_ant)
→ Ant.add_to(other_ant, place)
place.ant is None ? 否(是 container)→ 走 else
place.ant.can_contain(other_ant)
= container.can_contain(other_ant)
= (None is None) and (not False) = True ✓ 第一分支
container.store_ant(other_ant)
container.ant_contained = other_ant
(place.ant 不变,仍是 container)
Insect.add_to:other_ant.place = place
最终:place.ant ─→ container
container.ant_contained ─→ other_ant
container.place ─→ place
other_ant.place ─→ place ★ 两只蚂蚁的 place 是同一个对象
place.add_insect(other_ant) → place.ant = other_ant
place.add_insect(container)
→ Ant.add_to(container, place)
place.ant is None ? 否 → else
place.ant.can_contain(container)
= other_ant.can_contain(container)
= Ant.can_contain → 恒为 False ✗ 第一分支不成立
self.can_contain(place.ant)
= container.can_contain(other_ant) = True ✓ 第二分支
container.store_ant(other_ant) → ant_contained = other_ant
place.ant = container ★ 换门面
Insect.add_to:container.place = place
最终状态与顺序一完全一致。
再看第三分支:两只 ThrowerAnt 放同一格。can_contain 两边都是 False,走到 assert place.ant is None,而 place.ant 明明是第一只 ThrowerAnt,断言失败,抛出 AssertionError: Too many ants in tunnel_0_0。注意这时候 Insect.add_to(self, place) 那一行根本不会执行——异常已经把函数打断了,所以第二只蚂蚁的 place 保持 None,没有留下半成品状态。
把 place.ant = self 写在 else 分支外面,或者干脆两个分支都写。 第一分支(老容器装新蚂蚁)里如果也执行 place.ant = self,就把普通蚂蚁提到了门面位置,容器反而躲到了后面——蜜蜂会直接蜇到被保护的那只,而且 remove_from 会因为 place.ant is not self 走到 place.ant.remove_ant(self),对一个非容器调用 remove_ant,触发 AssertionError: ... cannot contain an ant。
11. Problem 8c:ProtectorAnt——只剩两行
题目要什么
8a 和 8b 把所有机制都建好了,8c 只需要给 ProtectorAnt(4 食物,2 血)补一个 __init__ 设定初始血量,再把 implemented 置为 True。不需要写 action——它从 ContainerAnt 继承。ProtectorAnt 不造成任何伤害,所以也不需要 damage。
解锁题只有一问:ProtectorAnt 的实例属性直接继承自哪个类? 答案是 ContainerAnt——因为 ant_contained 是在 ContainerAnt.__init__ 里设的。
怎么想到的
这题本身没有难度,但它是检验前两小题做得对不对的试金石。如果你在 8a/8b 里把逻辑写死在 ProtectorAnt 上而不是 ContainerAnt 上,8c 会变得莫名其妙地简单,而 Problem 9 的 TankAnt 会变得莫名其妙地难——你得把整套容器逻辑再抄一遍。题面在 Problem 9 里专门警告了这一点:「如果你发现自己需要修改 TankAnt 之外的代码,回头看看上一题能不能写得更通用」。
值得想一下的是继承链:ProtectorAnt → ContainerAnt → Ant → Insect。ProtectorAnt.__init__(health=2) 调 super().__init__(2) 即 ContainerAnt.__init__,它先调 Ant.__init__(2)(最终设好 health / full_health / place / id / doubled),再设 self.ant_contained = None。三层构造器像洋葱一样一层层剥开,每层只加自己那一点。 这是继承最舒服的用法。
代码
class ProtectorAnt(ContainerAnt):
"""ProtectorAnt provides protection to other Ants."""
name = 'Protector'
food_cost = 4
# OVERRIDE CLASS ATTRIBUTES HERE
# BEGIN Problem 8c
implemented = True # Change to True to view in the GUI
def __init__(self, health: int = 2):
super().__init__(health)
# END Problem 8c
is_container = True 不用重写,从 ContainerAnt 继承。can_contain、store_ant、remove_ant、remove_from、action 全部继承。整个类的净增内容就是「叫 Protector、值 4 块钱、2 滴血、可以在 GUI 里用」。
验证
布局:tunnel_0_0 里有 protector = ProtectorAnt()(2 血)
装着 thrower = ThrowerAnt()(1 血)
同格有 bee = Bee(3)
回合 1 蚂蚁行动:ants_take_actions 遍历 [p.ant for p in places]
→ 只拿到 protector(thrower 不在这个列表里)
protector.action(gamestate) → ContainerAnt.action
ant_contained 不是 None → thrower.action(gamestate)
thrower 打自己这格的 bee:bee.health = 3 - 1 = 2
蜜蜂行动:bee.blocked() → place.ant 是 protector,
protector.blocks_path 为 True → True
bee.sting(protector) → protector.reduce_health(1)
protector.health = 2 - 1 = 1,仍活着
★ thrower 毫发无伤
回合 2 thrower 再打:bee.health = 1
bee 再蜇:protector.health = 0
→ Insect.reduce_health 触发移除
→ place.remove_insect(protector)
→ ContainerAnt.remove_from:place.ant is protector → True
place.ant = self.ant_contained 即 thrower ★
Insect.remove_from:protector.place = None
★ 容器阵亡,被保护的 thrower 顶到前排
回合 3 thrower.action:bee.health = 0 → 蜜蜂死亡,被移出 place.bees
场上没蜜蜂了,thrower 安然无恙。
这一段推演里最漂亮的是 ContainerAnt.remove_from 那句 place.ant = self.ant_contained——它是起始代码给的,不用你写,但它解释了「容器死后被保护者留在原地」这条规则是怎么落实的。注意如果容器里什么都没装,ant_contained 是 None,这一句就退化成 place.ant = None,行为跟普通蚂蚁死掉完全一样。一行代码同时处理了两种情况,因为空容器的「里面那只」恰好就是 None。
12. Problem 9:TankAnt——继承的第二次收获
题目要什么
TankAnt(6 食物,2 血)是一只会打人的容器:既保护同格的蚂蚁,又每回合对同格所有蜜蜂造成 1 点伤害,同时被保护的蚂蚁照常行动。从零写这个类,name = 'Tank'、implemented = True。
解锁题只有一问:除了更贵,TankAnt 和 ProtectorAnt 唯一的区别是什么? 「TankAnt 每回合对同格所有蜜蜂造成伤害」。既然唯一的区别是这个,代码里就应该只有这一处不同。
怎么想到的
题面的提示已经把答案说完了:「你只需要重写父类的 __init__ 和 action」。__init__ 是为了改血量,action 是为了加伤害。
关键在 action 怎么写。它要做两件事:打蜜蜂,以及让被保护的蚂蚁行动。第二件事 ContainerAnt.action 已经写好了,所以:
def action(self, gamestate):
for bee in list(self.place.bees):
bee.reduce_health(self.damage)
super().action(gamestate) # 转发给被保护的蚂蚁
如果你没想到 super().action(gamestate),就会把 if self.ant_contained is not None: self.ant_contained.action(gamestate) 这两行抄一遍。抄一遍不会挂测试,但它意味着以后 ContainerAnt.action 一改,TankAnt 就跟不上了。「先做自己的事,再 super() 做父类的事」是扩展(而非替换)父类行为的标准姿势,FireAnt 是它的另一个例子(那里是先 super() 再做自己的)。
顺序有讲究吗? 先打蜜蜂还是先让里面的蚂蚁动?测试没有强制,但先打蜜蜂更符合「坦克开火掩护队友」的直觉,而且如果被保护的是 HungryAnt,先让坦克打一下可能正好把蜜蜂打死,HungryAnt 就白白进入冷却了……实际上不会:HungryAnt 会先检查 random_bee 返回 None 才决定不吃。两种顺序都能通过全部测试。
另一个必须想到的点:又要遍历副本。 题面的提示原话是「跟 FireAnt 一样,伤害一只蜜蜂可能导致它被移出所在的 place」。1 血的蜜蜂被 1 点伤害打死,从 place.bees 里消失,边遍历边删就会跳过后面的蜜蜂。这个坑在这个项目里出现三次(FireAnt、TankAnt、NinjaAnt),每次都是同一个解法。
class TankAnt(ContainerAnt),不是 class TankAnt(ProtectorAnt)。虽然继承 ProtectorAnt 也能跑,但那在概念上说「坦克是一种护卫」,不对——它们是兄弟,共同的父亲是「容器」。而且继承 ProtectorAnt 会把 name = 'Protector'、food_cost = 4 一起继承过来(虽然你会重写),继承链上多一层没有意义的耦合。选父类时问「它是不是一种 X」,而不是「我能不能省几行」。
代码
class TankAnt(ContainerAnt):
"""TankAnt protects the ant it contains and damages all Bees in its place."""
name = 'Tank'
food_cost = 6
damage = 1
implemented = True
def __init__(self, health: int = 2):
super().__init__(health)
def action(self, gamestate: GameState):
"""Damage every Bee here, and let the contained ant act as usual."""
# Copy the list: a bee that dies is removed from place.bees.
for bee in list(self.place.bees):
bee.reduce_health(self.damage)
super().action(gamestate)
damage = 1是类属性。ContainerAnt继承自Ant继承自Insect,那里damage = 0,不重写的话坦克一点伤害也打不出。for bee in list(self.place.bees):副本。self.place.bees[:]等价。bee.reduce_health(self.damage):读self.damage而不是字面量 1,同 FireAnt 的道理——Problem 12 的 QueenAnt 会把它翻倍,硬编码就没法翻了。super().action(gamestate):super()从TankAnt往上找,下一个是ContainerAnt,它的action正是转发逻辑。- 没有重写
can_contain、store_ant、remove_from——全部继承。整个类 11 行,其中真正的新逻辑 3 行。
验证
布局:tunnel_0_3 里有 tank = TankAnt()(2 血,damage 1),
装着 harvester = HarvesterAnt(),
同格有 b1 = Bee(1)、b2 = Bee(1)、b3 = Bee(3)
place.bees = [b1, b2, b3],gamestate.food = 2
tank.action(gamestate)
snapshot = list(self.place.bees) → 新列表 [b1, b2, b3]
(place.bees 本身仍是原来那个列表对象)
第 1 轮 bee = b1
b1.reduce_health(1) → health 0 → 移除
place.bees.remove(b1) → place.bees 变成 [b2, b3]
★ snapshot 不受影响,仍是 [b1, b2, b3]
第 2 轮 bee = b2
b2.reduce_health(1) → health 0 → 移除
place.bees 变成 [b3]
第 3 轮 bee = b3
b3.reduce_health(1) → health 2,活着
super().action(gamestate) → ContainerAnt.action
ant_contained 是 harvester → harvester.action(gamestate)
gamestate.food = 3
最终:place.bees = [b3],b3.health = 2,food = 3
对照实验:如果写成 for bee in self.place.bees(不复制)
下标 0 → b1,打死后列表变 [b2, b3]
下标 1 → b3(!),打成 2 血
下标 2 → 越界,循环结束
★ b2 被完整跳过,仍是 1 血活着,测试当场失败。
用 self.ant_contained.action(gamestate) 而忘了判空。 一只刚放下、还没装任何东西的 TankAnt,ant_contained 是 None,会抛 AttributeError。用 super().action(gamestate) 就没这个问题,因为判空逻辑已经写在 ContainerAnt.action 里了——这也是复用父类的又一个好处:父类已经处理过的边界情况,你不用再处理一次。
13. Problem 10:Water——一个新的 Place 子类
题目要什么
加一种新地形 Water,只有防水的虫子能待在里面:
- 给
Insect加类属性is_waterproof = False; Bee会飞,重写成is_waterproof = True;- 实现
Water.add_insect:先无条件把虫子加进来,然后如果它不防水,把它的血量减到 0; - 题面强调:不要重复程序里已有的代码,要用已经定义好的方法。
怎么想到的
「先加进来,再淹死」这个顺序看着奇怪——为什么不直接不让它进来?因为 reduce_health 的死亡逻辑里有一句 if self.place is not None: self.place.remove_insect(self)。如果虫子压根没被加进来,place 是 None,那么它血量归零后不会走移除流程:place.ant 不会被清空、GUI 的死亡回调不会触发、玩家的食物白花了却看不到任何反馈。让它进来再死,整个状态机才是一致的。
接下来是「不要重复代码」这条约束怎么落实。有两处可以复用:
第一处,「把虫子加进来」——父类 Place.add_insect 已经写好了(insect.add_to(self))。所以写 super().add_insect(insect)。不要自己写 insect.add_to(self):虽然眼下等价,但那是把父类的实现细节复制了一份。
第二处,「把血量减到 0」——解锁题第三问就在考它:「哪个方法能对虫子造成伤害,并在血量归零时把它移出所在的 place?」 答案是 Insect 里的 reduce_health。所以写 insect.reduce_health(insect.health),跟 HungryAnt 吃蜜蜂用的是同一个手法。不要写 insect.health = 0——那只改数字,不触发移除。
还有一个设计问题值得停下来想:为什么 is_waterproof 加在 Insect 上而不是 Ant 上? 因为 Water.add_insect 接收的参数类型是 Insect,它不该关心进来的是蚂蚁还是蜜蜂。如果这个属性只在 Ant 上有,那么 bee.is_waterproof 会抛 AttributeError,Water 就不得不写 isinstance 判断——又一次违反「用统一接口」的原则。把标记属性放在共同祖先上,是让多态生效的前提。
这题是整个项目里「重写方法」用得最纯粹的一次:Water 重写 add_insect,先调父类做完老事情,再补自己的新事情。三行代码里有两行是「调别人写好的东西」。当你发现自己在一个新类里写超过五行逻辑时,先停下来问:这里面有多少是别处已经有的?
代码
class Insect:
"""An Insect, the base class of Ant and Bee, has health and a Place."""
next_id = 0 # Every insect gets a unique id number
damage = 0
# ADD CLASS ATTRIBUTES HERE
is_waterproof = False # Only waterproof insects survive a Water place
class Bee(Insect):
"""A Bee moves from place to place, following exits and stinging ants."""
name = 'Bee'
damage = 1
is_waterproof = True # Bees can fly over Water
class Water(Place):
"""Water is a place that can only hold waterproof insects."""
def add_insect(self, insect: Insect):
"""Add an Insect to this place. If the insect is not waterproof, reduce
its health to 0."""
# BEGIN Problem 10
super().add_insect(insect)
if not insect.is_waterproof:
insect.reduce_health(insect.health) # Glub glub...
# END Problem 10
super().add_insect(insect)必须在前。反过来先淹死再加入,虫子会以 0 血的状态被塞进格子里,然后永远不被移除。if not insect.is_waterproof:读的是实例上的属性,会沿 MRO 找到具体类的定义。Bee找到True,ThrowerAnt一路找到Insect.is_waterproof = False。insect.reduce_health(insect.health):实参先求值,拿到当前血量;方法内部减掉这么多,正好归零。对一只满血 4 的 WallAnt 就是减 4,对 1 血的 ThrowerAnt 就是减 1。Water没有重写__init__——它需要的name、exit、entrance、bees、ant全部由Place.__init__搞定,包括 Problem 2 里补的那段 entrance 逻辑。wet_layout里Water('water_0_2', exit)的调用方式跟Place(...)一模一样,这才叫「子类可以顶替父类使用」。
验证
water = Water('water_0_2', some_exit)
ant = ThrowerAnt() health=1, place=None, is_waterproof=False
bee = Bee(3) health=3, place=None, is_waterproof=True
water.add_insect(bee)
super().add_insect(bee) → Place.add_insect → bee.add_to(water)
Bee.add_to:water.bees.append(bee)
Insect.add_to:bee.place = water
not bee.is_waterproof → not True → False,跳过
★ 蜜蜂安然停在水里,water.bees = [bee]
water.add_insect(ant)
super().add_insect(ant) → ant.add_to(water)
Ant.add_to:water.ant is None → water.ant = ant
Insect.add_to:ant.place = water
not ant.is_waterproof → not False → True
ant.reduce_health(ant.health) 即 reduce_health(1)
self.health = 1 - 1 = 0
0 <= 0 → zero_health_callback()
self.place(= water)不是 None
→ water.remove_insect(ant)
Ant.remove_from:water.ant is ant → water.ant = None
Insect.remove_from:ant.place = None
★ 蚂蚁被加进来又立刻被移除,water.ant 回到 None,
但整个移除流程完整走过一遍,GUI 能看到它淹死。
再验一个容易被忽略的情形:把一只 ProtectorAnt 放进水里,它装着的蚂蚁怎么办? ProtectorAnt 不防水,被淹死后 ContainerAnt.remove_from 执行 place.ant = self.ant_contained。如果这时候它装着一只普通蚂蚁,那只蚂蚁会顶到 place.ant 的位置——然后不会再被淹一次,因为 Water.add_insect 只对当次加入的那只虫子生效。不过在真实游戏里这种情况不会出现:容器是在水里被放下的,那时它还没装任何东西。
写成 if insect.is_waterproof: super().add_insect(insect)。 这是「不防水就不让进」的直觉写法,测试会挂在两处:一是 place.ant 不会被短暂设置,二是 zero_health_callback 不会被调用(GUI 测试专门检查这个)。题面第一句话就是「先把虫子加进来,不管它防不防水」,这句话不是废话。
14. Problem 11:ScubaThrower——四行代码的一课
题目要什么
ScubaThrower(6 食物,1 血)是防水的 ThrowerAnt,除此之外跟 ThrowerAnt 完全一样。从零写这个类,name = 'Scuba'、implemented = True。
解锁题第二问问得很好:ScubaThrower 应该重写哪些继承来的属性或方法? 选项里的正确答案是「name、is_waterproof、food_cost」——注意没有 action,没有 nearest_bee,没有 damage,也没有 __init__。
怎么想到的
这题几乎没有思考量,但它是整个项目里对「继承到底省了多少事」体会最深的一刻。做完 Problem 4 之后,ThrowerAnt 里躺着一个二十行的 nearest_bee、一个 throw_at、一个 action,还有 lower_bound/upper_bound/damage。ScubaThrower 一个字都不用碰,全部拿来就用。
唯一要判断的是:__init__ 要不要写? 表格里初始血量是 1,而 Ant.__init__ 的默认值正好是 1,所以不用写。这是这个项目里唯一一个「血量恰好等于默认值」的自定义蚂蚁,其他几个(FireAnt 3、WallAnt 4、ProtectorAnt 2、TankAnt 2)都得补 __init__。能不写就不写——多写一个 def __init__(self, health=1): super().__init__(health) 不会错,但它是纯粹的噪音。
另一个值得注意的是继承方向:ScubaThrower(ThrowerAnt),而不是 ScubaThrower(Ant) 再自己实现投掷。这跟 Problem 4 的 ShortThrower/LongThrower 是同一个判断——「它是一种投手」,所以继承投手。
is_waterproof = True 这一行之所以能生效,完全依赖 Problem 10 里把这个属性放在了 Insect 上。如果当初放在了 Bee 上,这里就得先给 Ant 补一个默认值。属性放在继承树的哪一层,决定了后续扩展的成本。 这门课反复讲的「抽象层次」,在这里是一个非常具体的选择。
代码
class ScubaThrower(ThrowerAnt):
"""A ThrowerAnt that is waterproof, so it can be placed in Water."""
name = 'Scuba'
food_cost = 6
is_waterproof = True
implemented = True
四行类属性,没有任何方法。逐行:
name = 'Scuba':覆盖ThrowerAnt.name = 'Thrower'。GUI 与gamestate.ant_types都按这个名字索引。food_cost = 6:覆盖ThrowerAnt.food_cost = 3。防水是要加钱的。is_waterproof = True:覆盖Insect.is_waterproof = False。这是这个类存在的全部理由。implemented = True:覆盖Ant.implemented = False,让它出现在ant_types()里。- 没有
damage:继承ThrowerAnt.damage = 1。没有lower_bound/upper_bound:继承 0 和inf,射程不限。没有__init__:继承Ant.__init__,默认 1 血。
验证
scuba = ScubaThrower()
scuba.__init__ 的查找:
ScubaThrower.__dict__ → 没有
ThrowerAnt.__dict__ → 没有
Ant.__dict__ → 找到!health 默认 1
→ Insect.__init__(self, 1):health=1, full_health=1, place=None
water.add_insect(scuba)
Place.add_insect → scuba.add_to(water) → water.ant = scuba
not scuba.is_waterproof
scuba.__dict__ → 没有 is_waterproof
ScubaThrower.__dict__ → 找到 True ★ 到此为止,不再往上找
not True → False,跳过淹死逻辑
★ 潜水投手安全地站在水里
scuba.action(gamestate)
查找 action:ScubaThrower → 没有;ThrowerAnt → 找到
ThrowerAnt.action:self.throw_at(self.nearest_bee())
nearest_bee 同样在 ThrowerAnt 里找到
self.lower_bound → 一路找到 ThrowerAnt.lower_bound = 0
self.upper_bound → ThrowerAnt.upper_bound = inf
self.damage → ThrowerAnt.damage = 1
→ 行为与普通 ThrowerAnt 完全一致
这段推演里,scuba 这个对象自己的 __dict__ 里只有 health、full_health、place、id、doubled 五个键,其余一切都是靠查找链找到的。「这只蚂蚁是什么」这件事,绝大部分不存在于对象里,而存在于它的类和祖先类里。 这就是 Lecture 13、14 讲的那套查找规则在实战里的样子。
把 is_waterproof = True 写在 __init__ 里成 self.is_waterproof = True。 功能上能跑,但解锁题第二问明确说这该是类属性(「同一子类的蚂蚁要么全防水要么全不防水」)。更实际的问题是:写成实例属性后,ScubaThrower.is_waterproof(直接问类)仍然是 False,而 GUI 和某些工具代码是问类的。
15. Problem 12:QueenAnt——往回走的循环,与「只做一次」的记账
题目要什么
QueenAnt(7 食物,1 血)是一只 ThrowerAnt,另外还有两个本事:
- 鼓舞士气:每次行动时,把她身后(即靠家门口那一侧、沿
exit方向)同一条隧道里所有蚂蚁的damage翻倍。但同一只蚂蚁只能被翻倍一次,翻过就不能再翻。 - 不能死:QueenAnt 血量归零时,整局游戏判蚂蚁输,要调用
ants_lose()。
三个边界必须处理:
- 只翻身后的,不翻自己,也不翻身前的。 从
self.place.exit开始,不是从self.place开始。 - 一格里可能有两只蚂蚁(容器 + 被容器保护的),两只都要翻。
- FireAnt 的反伤不能翻倍,只有它死亡时的额外伤害翻倍。 这一条其实不用特意处理——反伤用的是
damage_taken(外来的伤害值),额外伤害用的是self.damage,翻damage自然只影响后者。
题面还提示:填好 Ant.double 方法,然后在 QueenAnt 里调它。
怎么想到的
先解决「只翻一次」。 直觉上会想「记一个已翻倍蚂蚁的集合」:
class QueenAnt(ThrowerAnt): # 想法 A,可行但更笨重
def __init__(self, health=1):
super().__init__(health)
self.doubled_ants = set()
能跑,但有个坏味道:「我有没有被翻过」是那只蚂蚁自己的属性,不是女王的账本。 而且如果场上有两只女王(游戏没禁止),两本账各记各的,同一只蚂蚁会被翻两次。题面的提示说得明白:「用一个实例属性来记录一只蚂蚁的伤害是否已被翻倍」——属性挂在被翻的那只蚂蚁身上。
于是在 Ant.__init__ 里加一句 self.doubled = False,然后 Ant.double 写成:
def double(self):
if not self.doubled:
self.damage *= 2
self.doubled = True
这里有个非常微妙的点,值得单独说:self.damage *= 2 干了什么? damage 本来是类属性。增强赋值 self.damage *= 2 展开是 self.damage = self.damage * 2:右边读的是类属性(比如 ThrowerAnt.damage = 1),左边写的是实例属性。执行之后,这只蚂蚁的 __dict__ 里多了一个 damage = 2,遮蔽了类属性;而 ThrowerAnt.damage 仍然是 1,其他 ThrowerAnt 一点没受影响。
如果你误写成 type(self).damage *= 2 或者 ThrowerAnt.damage *= 2,就会把全场所有同类蚂蚁一起翻倍,而且以后新造的也是翻倍的——测试里有专门的用例检查「没被鼓舞过的蚂蚁伤害不变」。
再解决「往回走」。 Problem 3 的 nearest_bee 是沿 entrance 往蜂巢方向走,这里是它的镜像:沿 exit 往家门方向走。题面提示:「从 queen.place.exit 开始,反复取上一格的 exit;隧道末端那格的 exit 是 None」。
最后是「一格两只蚂蚁」。 拿到 place.ant 之后,如果它是容器,还要往里再看一层。写成内层循环:
ant = place.ant
while ant is not None:
ant.double()
ant = ant.ant_contained if ant.is_container else None
这个内层循环用 while 而不是 if,写起来是「一层一层往里剥,剥到没有为止」。虽然规则上容器不能嵌套容器(最多两层),但写成循环比写两个 if 更清爽,也更抗未来的规则变化。ant.ant_contained if ant.is_container else None 这个条件表达式是必须的:普通蚂蚁没有 ant_contained 属性,直接访问会抛 AttributeError。
然后是 reduce_health。 题面提示得很直白:「QueenAnt 的 reduce_health 是在父类的基础上额外调用 ants_lose()。怎么既不重复代码又完成父类的全部工作?」——答案就是 FireAnt 用过的那个模式:先 super().reduce_health(damage_taken),再判断 if self.health <= 0: ants_lose()。
QueenAnt 的 action 第一句是 super().action(gamestate)——先当一只普通投手把叶子扔出去,再干鼓舞的活。如果漏了这一句,女王就只会加 buff 不会攻击,测试里 bee.health 会比期望值多 1。「我是一种 X,另外还会 Y」这句话翻译成代码,就是「调 X 的实现,再补 Y」。 这个项目里 FireAnt、TankAnt、QueenAnt 三处用的都是这一招,只是 super() 调用的位置不同(FireAnt 在中间,TankAnt 在末尾,QueenAnt 在开头)。
代码
先是 Ant 里的两处改动:
class Ant(Insect):
"""An Ant occupies a place and does work for the colony."""
def __init__(self, health: int = 1):
super().__init__(health)
# Problem 12: a QueenAnt may only double each ant's damage once.
self.doubled = False
def double(self):
"""Double this ants's damage, if it has not already been doubled."""
# BEGIN Problem 12
if not self.doubled:
self.damage *= 2
self.doubled = True
# END Problem 12
然后是 QueenAnt:
class QueenAnt(ThrowerAnt):
"""QueenAnt boosts the damage of all ants behind her."""
name = 'Queen'
food_cost = 7
# OVERRIDE CLASS ATTRIBUTES HERE
# BEGIN Problem 12
implemented = True # Change to True to view in the GUI
# END Problem 12
def action(self, gamestate: GameState):
"""A queen ant throws a leaf, but also doubles the damage of ants
in her tunnel.
"""
# BEGIN Problem 12
super().action(gamestate) # Throw a leaf like any other ThrowerAnt
# Walk back down the tunnel; place.exit leads away from the Hive.
place = self.place.exit
while place is not None:
ant = place.ant
while ant is not None:
ant.double()
# A container also shelters an ant that deserves the boost.
ant = ant.ant_contained if ant.is_container else None
place = place.exit
# END Problem 12
def reduce_health(self, damage_taken: float):
"""Reduce health by DAMAGE_TAKEN, and if the QueenAnt has no health
remaining, signal the end of the game.
"""
# BEGIN Problem 12
super().reduce_health(damage_taken)
if self.health <= 0:
ants_lose()
# END Problem 12
逐行说明关键处:
self.doubled = False放在Ant.__init__而不是QueenAnt里——因为需要这个标记的是被翻倍的那些蚂蚁,也就是所有蚂蚁,不是女王。place = self.place.exit:从 exit 开始,不是从 self.place 开始。这样自动排除了女王自己,以及同格里可能存在的容器/被保护者。测试里有专门用例检查「女王自己的伤害不翻倍」。while place is not None:exit链的终点是None。注意这个链会经过AntHomeBase(它的ant恒为None,内层循环空转一次),再往下AntHomeBase.exit是None,循环结束。- 内层
while ant is not None:处理容器。第一轮ant是place.ant;若它是容器,第二轮变成ant.ant_contained(可能是None,循环自然结束);若不是容器,条件表达式给出None,循环结束。 super().reduce_health(damage_taken)在前,ants_lose()在后。顺序不能反:ants_lose()直接抛AntsLoseException,写在前面的话父类的扣血逻辑根本执行不到。if self.health <= 0用的是父类扣完血之后的值,跟 FireAnt 一模一样的判断时机。
验证
布局(左边是家,右边是蜂巢):
Base <- _0 <- _1 <- _2 <- _3 <- _4 <- ...
_0: tank = TankAnt() damage 1,装着 t2 = ThrowerAnt() damage 1
_1: (空)
_2: t3 = ThrowerAnt() damage 1
_3: queen = QueenAnt() damage 1(继承自 ThrowerAnt)
_4: t4 = ThrowerAnt() damage 1 ← 在女王前方
queen.action(gamestate)
① super().action(gamestate) → ThrowerAnt.action
nearest_bee 从 _3 往 entrance(_4、_5…)方向找,找到就扔一片叶子。
② place = self.place.exit = _2
外层第 1 轮 place = _2
ant = t3
t3.double():t3.doubled 为 False
t3.damage = t3.damage * 2
右边读 ThrowerAnt.damage = 1 → 2
左边写 t3.__dict__['damage'] = 2
t3.doubled = True
ant = t3.is_container ? … : None → None,内层结束
place = _2.exit = _1
外层第 2 轮 place = _1
ant = None,内层一次都不进
place = _1.exit = _0
外层第 3 轮 place = _0
ant = tank
tank.double():tank.damage 1 → 2,tank.doubled = True
tank.is_container 为 True → ant = tank.ant_contained = t2
t2.double():t2.damage 1 → 2,t2.doubled = True
t2.is_container 为 False → ant = None,内层结束
place = _0.exit = Base(AntHomeBase)
外层第 4 轮 place = Base
Base.ant 恒为 None,内层不进
place = Base.exit = None → 外层结束
结果:t3.damage=2, tank.damage=2, t2.damage=2
t4.damage 仍是 1 ★ 她前方的蚂蚁不受鼓舞
queen.damage 仍是 1(类属性)★ 她自己也不翻倍
ThrowerAnt.damage 仍是 1 ★ 类属性没被动过
第二次 queen.action:
t3.doubled 已是 True → double() 里的 if 为假,什么也不做
damage 保持 2,不会变成 4。
queen = QueenAnt(),health = 1,站在 _3,同格有一只 Bee(2)
蜜蜂行动:blocked() 为真 → sting(queen) → queen.reduce_health(1)
进入 QueenAnt.reduce_health(1)
super().reduce_health(1)
→ ThrowerAnt 没定义 → Ant 没定义 → Insect.reduce_health
self.health = 1 - 1 = 0
0 <= 0 → zero_health_callback()
place.remove_insect(queen) → _3.ant = None
queen.place = None
回到 QueenAnt.reduce_health
self.health(0) <= 0 → ants_lose()
→ raise AntsLoseException()
异常向上抛,穿过 Bee.action、bees_take_actions,
被 GameState.simulate 里的 except AntsLoseException 捕获,
打印 "The bees reached homebase or the queen ant queen has perished..."
yield False,游戏结束。
把 self.doubled 的初始化写在 QueenAnt.__init__ 里。 这样只有女王自己有这个属性,t3.double() 里读 self.doubled 会抛 AttributeError: 'ThrowerAnt' object has no attribute 'doubled'。标记属性要加在会被检查的那一类对象上。
忘了内层循环,只写 place.ant.double()。 被 TankAnt 保护着的 ThrowerAnt 不会被鼓舞。测试里有专门用例,题面也用加粗提示了「一格里可能不止一只蚂蚁」。
把 ants_lose() 写成 return ants_lose() 或 ants_lose(漏括号)。 后者只是求值出那个函数对象然后丢掉,异常永远不抛,女王死了游戏还在继续,测试报 expected AntsLoseException but nothing was raised。
16. 选做题 EC 1–4:把函数当值来改
这四题不计分,但 EC 1 和 EC 2 是这门课前半学期「函数是一等值」那套东西在 OOP 语境下最漂亮的一次回归——你不是在改一个类的方法,而是在运行时把某个具体对象的方法换成另一个函数。本仓库四题全部实现并通过,下面按同样的四问格式讲,但压缩到要点。
EC 1:SlowThrower——给蜜蜂的 action 套一层壳
要什么。 扔糖浆,让一只蜜蜂减速 5 回合。减速期间,蜜蜂只在 gamestate.time 为偶数时正常行动,奇数回合完全不动。如果一只已经被减速的蜜蜂再次中招,计时器重置为 5(不是叠加成 10)。硬性限制:只能改 SlowThrower 类内部的代码,不许动 Bee.action。
怎么想到的。 限制条件已经把答案框死了:不能改 Bee.action 这个方法本身,那就只能改某只具体蜜蜂的 action 属性。Python 里 target.action = some_function 是完全合法的——它在实例的 __dict__ 里塞一个 action,遮蔽掉类里的那个。之后 target.action(gamestate) 找到的就是我们塞进去的函数。
注意一个陷阱:塞进去的函数不会被绑定为方法,调用时不会自动传 self。所以 slowed_action(gamestate) 只有一个参数,它通过闭包捕获外层的 target。这正是「函数是一等值 + 闭包」的用法。
还有一句提示说「你的代码要能在同一只蜜蜂上叠加多个状态,每个新状态都作用在它当前的(可能已被包裹的)action 上」。所以要先 original_action = target.action 把当前的存下来,新函数在末尾调用它——这是标准的装饰器(decorator)结构。
def throw_at(self, target: Bee | None):
# BEGIN Problem EC 1
if target is None:
return
# Getting hit restarts the 5-turn timer, whether or not the bee was
# already slowed. (These attributes live on the Bee instance so that
# no code outside this class has to change.)
target.slow_turns = 5
if getattr(target, 'is_slowed', False):
return # Already wrapped: refreshing the timer is enough
target.is_slowed = True
original_action = target.action # Possibly already wrapped by a status
def slowed_action(gamestate: GameState):
if target.slow_turns > 0:
target.slow_turns -= 1
if gamestate.time % 2 == 1:
return # Stuck in syrup: no action on odd turns
original_action(gamestate)
target.action = slowed_action
# END Problem EC 1
逐行的两个要点。 其一,if getattr(target, 'is_slowed', False): return ——已经被包过一次的蜜蜂,只更新计时器就够了,不能再包一层。否则被打十次就套十层壳,每层都会消耗一次 slow_turns,一回合扣十点,行为完全乱掉。其二,计时器 slow_turns 是在包裹之外更新的(函数最开头那句 target.slow_turns = 5),这样重复命中时新的值会立刻被已存在的闭包读到——因为闭包读的是 target 这个对象的属性,不是一个捕获的数字。
bee 在 time=0 被糖浆命中:slow_turns = 5,action 被换成 slowed_action time=1(奇):slow_turns 5>0 → 减为 4;1%2==1 → return,蜜蜂不动 time=2(偶):slow_turns 4>0 → 减为 3;2%2==0 → 调 original_action,正常行动 time=3(奇):3 → 2;不动 time=4(偶):2 → 1;行动 time=5(奇):1 → 0;不动 time=6:slow_turns 0,不进 if → 直接 original_action,恢复正常 ★ 五个回合的减速,其中三个回合(1、3、5)被冻住。
EC 2:ScaryThrower——让蜜蜂往回退
要什么。 吓唬一只蜜蜂,让它接下来两次行动改成后退而不是前进。规则细节:已经贴着蜂巢的蜜蜂退无可退,就原地不动(但这次尝试仍然计数);被减速冻住的那些奇数回合不算「尝试后退」;一只蜜蜂一辈子只能被吓一次。
怎么想到的。 跟 EC 1 不同,这题允许改 Bee——题面说要实现 Bee.scare,并且「可能需要改 Bee.action 等其他方法」。所以用两个类属性做默认值(has_been_scared = False、scared_turns = 0),在 Bee.action 开头插一段改写目的地的逻辑。
「一辈子只能吓一次」用 has_been_scared 记账,跟 QueenAnt 的 doubled 是同一个套路。「被冻住的回合不算尝试」自动满足——因为 SlowThrower 那层壳在奇数回合直接 return,根本走不到 Bee.action 内部,scared_turns 也就不会减。两个选做题的机制在这里悄悄咬合上了。
def action(self, gamestate: GameState):
"""A Bee's action stings the Ant that blocks its exit if it is blocked,
or moves to the exit of its current place otherwise.
gamestate -- The GameState, used to access game state information.
"""
destination = None
if self.place:
destination = self.place.exit
if self.scared_turns > 0:
# A scared Bee retreats instead of advancing. It stays put when
# the only way back is the Hive, but the attempt still counts.
self.scared_turns -= 1
entrance = self.place.entrance if self.place else None
if entrance is not None and not entrance.is_hive:
destination = entrance
else:
destination = None
if self.blocked() and self.place and self.place.ant:
self.sting(self.place.ant)
elif self.health > 0 and destination is not None:
self.move_to(destination)
def scare(self, length: int):
"""
If this Bee has not been scared before, cause it to attempt to
go backwards LENGTH times.
"""
# BEGIN Problem EC 2
if not self.has_been_scared:
self.has_been_scared = True
self.scared_turns = length
# END Problem EC 2
代码。 有了 Bee.scare,ScaryThrower 本身只剩下三行——它继承 ThrowerAnt 的 nearest_bee 与 action,只把「扔叶子扣血」换成「吓唬」。下面是它在 ants.py 里的完整定义:
class ScaryThrower(ThrowerAnt):
"""ThrowerAnt that intimidates Bees, making them back away instead of advancing."""
name = 'Scary'
food_cost = 6
# BEGIN Problem EC 2
implemented = True # Change to True to view in the GUI
# END Problem EC 2
def throw_at(self, target: Bee | None):
# BEGIN Problem EC 2
if target is not None:
target.scare(2) # Back away on the next two turns it acts
# END Problem EC 2
target is not None 那个判空不能省:nearest_bee() 在射程内没蜜蜂时返回 None,直接 None.scare(2) 会抛 AttributeError。这跟 ThrowerAnt.throw_at 里那句判空是同一个理由。
注意 self.has_been_scared = True 这一句:读的时候读到的是类属性 False,写的时候在实例上新建一个 True。跟 double() 里的 self.damage *= 2 是完全一样的机制——类属性提供默认值,实例属性记录个体差异,这是这个项目反复出现的模式。
EC 3:NinjaAnt——不挡路,但过路必伤
要什么。 忍者蚂蚁(5 食物,1 血)不阻挡蜜蜂前进,但每回合伤害同格所有蜜蜂 1 点。做法:给 Ant 加类属性 blocks_path = True,NinjaAnt 里重写成 False;改 Bee.blocked,让它在「没蚂蚁」或「有蚂蚁但 blocks_path 为 False」时返回 False。
怎么想到的。 这是第四次用「在 Ant 上加类属性做标记,子类重写」的手法(前三次是 is_container、is_waterproof、implemented)。到这一步你应该已经条件反射了:要给某一类蚂蚁开一个特例,先问「能不能用一个布尔类属性表达」。
def blocked(self) -> bool:
"""Return True if this Bee cannot advance to the next Place."""
# Special handling for NinjaAnt
# BEGIN Problem EC 3
return (self.place is not None and self.place.ant is not None
and self.place.ant.blocks_path)
# END Problem EC 3
def action(self, gamestate: GameState):
# BEGIN Problem EC 3
# Copy the list: a bee that dies is removed from place.bees.
for bee in list(self.place.bees):
bee.reduce_health(self.damage)
# END Problem EC 3
三个 and 的顺序是有意的:先确认有 place,再确认有 ant,最后才读 ant.blocks_path。短路求值保证前面任何一个不成立时,后面都不会被求值,不会抛 AttributeError。action 则跟 TankAnt 的前半段一模一样——这是项目里第三次写「遍历副本打所有蜜蜂」。
EC 4:LaserAnt——伤敌一千,自损八百
要什么。 激光蚂蚁(10 食物,1 血,基础伤害 2)打穿自己这格和前方所有格子,不分敌我——蜜蜂、别的蚂蚁、甚至罩着自己的容器统统受伤,只有它自己免疫。伤害有两重衰减:每远一格 −0.25;每实际打中一个虫子,后续伤害 −0.0625(即 1/16),且这个衰减是立即生效的。伤害算成负数就按 0 算。
怎么想到的。 拆成两个方法之后就不难了。insects_in_front 返回一个字典 {虫子: 距离},它的骨架跟 nearest_bee 一模一样——沿 entrance 走、遇 Hive 停、带一个 distance 计数器;只不过不再「找到就返回」,而是把这一格所有虫子都收进字典。收蚂蚁那段又用上了 QueenAnt 的容器内层循环。整个方法是前面两题代码模式的拼装,没有新东西。
「不打自己」用 if ant is not self 排除。注意用 is not 而非 !=:这里要的就是「同一个对象」。
def insects_in_front(self) -> dict[Bee, int]:
# BEGIN Problem EC 4
# Map every Insect from this place forward (but not the Hive, and not
# this LaserAnt) to how many places away it is.
insects = {}
place, distance = self.place, 0
while place is not None and not place.is_hive:
for bee in place.bees:
insects[bee] = distance
ant = place.ant
while ant is not None:
if ant is not self:
insects[ant] = distance
# A container also shields an ant standing in the beam.
ant = ant.ant_contained if ant.is_container else None
place = place.entrance
distance += 1
return insects
# END Problem EC 4
def calculate_damage(self, distance: int) -> float:
# BEGIN Problem EC 4
# The beam fades with distance and with every insect already hit.
weakened = self.damage - 0.25 * distance - 0.0625 * self.insects_shot
return max(0, weakened)
# END Problem EC 4
calculate_damage 里 max(0, weakened) 处理「负数按 0 算」。而「立即生效」由起始代码给的 action 保证——它在循环里每打一个就 self.insects_shot += 1,下一个虫子的 calculate_damage 立刻读到新值。注意 if damage: 这个条件:伤害为 0 时不算「打中」,计数器不增加,这就是题面「只有实际造成伤害才消耗电池」的意思。
laser.damage = 2, insects_shot = 0 自己这格有两只蜜蜂 A、B(距离 0),前方一格有蚂蚁 C(距离 1) 打 A:calculate_damage(0) = 2 - 0 - 0 = 2.0 → insects_shot 1 打 B:calculate_damage(0) = 2 - 0 - 0.0625 = 1.9375 → insects_shot 2 打 C:calculate_damage(1) = 2 - 0.25 - 0.125 = 1.625 → insects_shot 3 ★ 同一格的第二只蜜蜂就已经吃到衰减了,这正是题面 "if there are two Bees in front of LaserAnt and on the same tile, it will do less damage to the second Bee" 的意思。
题面还有一条容易漏的要求:「如果一个虫子的血量没被影响,它的血量应该保持为整数」。 这就是为什么伤害为 0 时不能白白调用 reduce_health(0) 也没关系——health - 0 对 int 来说仍是 int。但如果你写成 reduce_health(0.0),3 - 0.0 会变成 3.0,doctest 里 >>> bee.health 期望 3 却得到 3.0,直接失败。max(0, weakened) 里的 0 是整数字面量,正好避开这个坑——换成 max(0.0, weakened) 就会挂。
17. 整份作业回顾
Ants 的十三道题,如果只记住「哪只蚂蚁怎么写」,那这个项目就白做了。真正带得走的是下面这几样东西。
差异越小,代码越少
ShortThrower 和 LongThrower 加起来只写了两个数字;ScubaThrower 只写了四行类属性;TankAnt 相对 ProtectorAnt 只多了三行。每次你准备复制粘贴一段逻辑时,那就是抽象层次没找对的信号。 正确的问法不是「我怎么实现这个新功能」,而是「新东西和已有的东西,差别到底在哪一个点上——是数据不同,还是行为不同?」数据不同就抽成类属性(upper_bound、damage、food_cost),行为不同才重写方法。
扩展父类的三种姿势
| 写法 | 出现在 | 含义 |
|---|---|---|
先做自己的,再 super() | TankAnt.action | 先开火,再让父类做「唤醒被保护者」这件事 |
先 super(),再做自己的 | QueenAnt.action / QueenAnt.reduce_health / Water.add_insect | 父类的事先办完,我在结果之上追加 |
super() 夹在中间 | FireAnt.reduce_health | 调用前后都要读状态:调用前存 place,调用后判死活 |
第三种最难,也最能说明问题:super() 调用会改变对象的状态,所以「调用前」和「调用后」是两个不同的世界,你必须清楚自己站在哪一边。
类属性做默认值,实例属性记差异
这个模式在项目里出现了至少五次:
| 类属性(默认值) | 被谁改写 | 作用 |
|---|---|---|
Place.is_hive = False | Hive 类里改 True | nearest_bee 判断停在哪 |
Insect.is_waterproof = False | Bee、ScubaThrower 类里改 True | Water 判断淹不淹 |
Ant.is_container = False | ContainerAnt 类里改 True | can_contain 判断能不能嵌套 |
Ant.blocks_path = True | NinjaAnt 类里改 False | Bee.blocked 判断挡不挡路 |
Ant.damage、Bee.has_been_scared | 实例上被 double() / scare() 改写 | 记录这一只的个体经历 |
最后一行是关键的分水岭:前四行是「这一类东西天生如此」,写在类体里;最后一行是「这一只碰巧经历了什么」,通过 self.x = ... 写在实例上。self.damage *= 2 这一行同时用到了两者——读类属性,写实例属性。能把这一行讲清楚,Lecture 13、14 就算学透了。
可变对象的三条铁律
- 边遍历边删会跳元素。 遍历一个可能在循环中被修改的列表,一律
for x in list(lst)。FireAnt、TankAnt、NinjaAnt 三题都栽在这里,GameState.bees_take_actions里的self.active_bees[:]也是同一个原因。 - 调用别人的方法可能改掉你自己的状态。
super().reduce_health()会把self.place变成None。凡是在调用后还要用的东西,调用前先用局部变量存起来。 - 改数字和改状态是两回事。
insect.health = 0只改数字;insect.reduce_health(insect.health)才会走完「回调 + 从 place 移除 + 置空 place」的完整流程。这套代码里,唯一合法的掉血入口是reduce_health。
题目与手法对照
| 题目 | 核心手法 | 迁移到哪里 |
|---|---|---|
| P1 HarvesterAnt | 重写类属性;用参数传入全局上下文 | 任何「配置项写在类里」的设计 |
| P2 Place.entrance | 反转信息流向,破解「先有鸡还是先有蛋」 | 双向链表、图的邻接关系构建 |
| P3 nearest_bee | 沿指针链遍历 + 带守卫的 while | 链表查找、树的路径遍历 |
| P4 Short/LongThrower | 父类写算法,子类填参数 | 模板方法模式;一切「只有阈值不同」的变体 |
| P5 FireAnt | super() 前后的状态差;遍历副本 | 任何有「析构副作用」的对象 |
| P6 WallAnt | 「什么都不做」= 什么都不写 | 识别继承已经给你的默认行为 |
| P7 HungryAnt | 实例状态机(cooldown);事件与状态变更绑定 | 任何带冷却 / 计时 / 节流的逻辑 |
| P8 ContainerAnt | 标记属性 + 双向询问 + 维护不变式 | 装饰器模式;组合优于继承的场景 |
| P9 TankAnt | 「先做自己的,再 super()」 | 扩展而非替换父类行为 |
| P10 Water | 重写 Place 而非 Insect;复用已有方法 | 用多态替代 isinstance 分支 |
| P11 ScubaThrower | 四行类属性完成一个新类型 | 体会「属性放在哪一层」的长期收益 |
| P12 QueenAnt | 反向遍历;实例标记实现幂等;异常控制流 | 幂等操作、一次性初始化 |
| EC1/EC2 | 运行时替换实例的方法;闭包捕获对象 | 装饰器、猴子补丁、动态代理 |
| EC4 | 把已有遍历模式拼装成新功能 | 识别「这我写过」的能力 |
关于验证
本仓库 proj/ants/ 下的代码在 python3 ok --local 下的结果是 166 个测试用例全部通过,无失败项,包含 Problem 0 至 12 以及 EC 1–4 的全部题目。需要说明的是,官方另有一部分隐藏测试只在提交到 Gradescope 时运行,本地无法验证——不过隐藏测试考的通常正是上面那三条「可变对象铁律」,本地测试已经覆盖了同类场景。
最后一句实话:这个项目里最花时间的往往不是想不出算法,而是某个 None 从你没料到的地方冒出来。遇到 AttributeError: 'NoneType' object has no attribute ...,先别急着加 if——回去问一句「这个属性是什么时候、被哪一行代码置成 None 的」。九成的情况下,答案是某只虫子刚刚死掉。