Lab 6:面向对象编程ok 4 项通过
类、实例、属性查找、继承——这次要把「一个点号 . 到底做了什么」彻底搞清楚。
0. 这份作业在练什么
前面五个 lab 里,「数据」和「操作数据的函数」一直是分开的。做数据抽象时我们用
rational(n, d) 造一个二元组,再写一堆 numer、denom、mul_rational
去操作它——数据在一边,函数在另一边,靠程序员自觉把它们配对使用。这套办法能跑,但有两个裂缝:
- 状态改不动。一个银行账户的余额是会变的。用闭包(closure)可以做到(Lecture 12 的
make_withdraw),但每加一个操作就要往那个返回的函数里塞一个if,很快就烂掉。 - 相似的东西没法共享。Nickel 和 Dime 有九成一样,只有面值不同。用函数写法只能复制粘贴。
面向对象编程(Object-Oriented Programming, OOP)就是把这两条裂缝一起补上: 类(class)规定一类东西「有哪些属性、能做哪些事」, 实例(instance)是按这个规定造出来的一个个具体的东西,各自带着自己的状态; 继承(inheritance)让一个类说「我跟那个类几乎一样,只有这几点不同」。
- Q1 Bank Account:给已有类加实例属性、让一个类的方法去创建另一个类的实例。核心是想清楚
「新的
Transaction该在哪一刻造出来、它的id从哪来」。 - Q2 Email:三个类互相持有引用。核心是每写一行前先问「此刻
self是谁、我手上有哪些名字」。 - Q3 Mint:类属性(class attribute)与实例属性(instance attribute)的查找规则,以及 「把类本身当值传来传去」。这题是整个 lab 里最能暴露理解漏洞的一道。
- WWPD Inheritance ABCs:
A.x += 1会不会影响B.x?obj.y = 1之后C.y变了吗?这组题问的全是属性查找的方向。 - Q4 VirFib(选做):在方法内部给对象新增实例属性,用一个「记住前一项」的属性把 斐波那契从递归的指数时间压到每步常数时间。
ferrari.drive() 里,self 是怎么被绑定上的?如果你答不上来,
先回 Lecture 13。一句话版本:ferrari.drive 这个表达式求值出来的
不是 Car 里那个原始函数,而是一个绑定方法(bound method)——一个已经把
ferrari 预先塞进第一个参数位的函数。所以后面的空括号 () 里虽然什么都没写,
self 还是有值。
验证状态
本仓库 labs/lab06/ 下的 lab06.py 已按官方要求完成,运行
cd labs/lab06 && python3 ok --local
输出 4 test cases passed! No cases failed.——对应 lab06.ok 里的四个默认测试
BankAccount、Server、Client、Mint。本页所有代码都逐字取自这份通过的文件。
选做题 VirFib 也一并实现了(它不在 default_tests 里,所以不计入这 4 项)。
lab06.py 的第一行是 from __future__ import annotations。原因是
withdraw 的返回类型标注写成了 int | str,这个语法在 Python 3.10 之前会直接报
TypeError: unsupported operand type(s) for |: 'type' and 'type'。加上这一行以后,
所有类型标注都不会在定义时求值,只当字符串存着,于是老版本 Python 也能跑。
官方题面里专门提到了这条。
1. Bank Account:给账户加一本流水账
题目要什么
起始文件里已经有一个能用的 BankAccount:它有 balance 和 holder,
deposit 加钱、withdraw 减钱,余额不够就返回字符串 'Insufficient funds'。
现在要求给它加一个 transactions 属性,是一个列表,把这个账户上发生过的每一笔操作按顺序记下来。
「每一笔」这三个字里藏着这道题唯一的陷阱:失败的取款也要记。题面原话是
"even if the action is not successful"。doctest 里 a.withdraw(100) 余额只有 80,取不出来,
返回了 'Insufficient funds',但紧接着 len(a.transactions) 是 4 而不是 3。
列表里装的是 Transaction 实例,每个有三个属性:
| 属性 | 含义 | 失败取款时是什么 |
|---|---|---|
before | 这笔操作之前的余额 | 当时的余额 |
after | 这笔操作之后的余额 | 和 before 一样(没变) |
id | 此前该账户已经发生过的操作笔数 | 照常递增 |
关于 id,题面说得很细:「同一个 BankAccount 造出来的两个 Transaction 不能有相同的 id,
但跨账户不需要唯一」。doctest 用两个账户交替操作,专门验证这一点——a 的 id 是 0,1,2,3,
b 的 id 也是 0,1,2,3,两边各数各的,互不干扰。
还有两个方法要补:
>>> Transaction(3, 20, 10).report()
'3: decreased 20->10'
>>> Transaction(4, 20, 50).report()
'4: increased 20->50'
>>> Transaction(5, 50, 50).report()
'5: no change'
changed() 返回余额有没有变;report() 返回一个描述串。注意
report 的格式是三段拼起来的:id、冒号加空格、然后是描述。描述部分只有三种:
'no change'、'increased 前->后'、'decreased 前->后'。
起始代码已经把外层骨架搭好了,msg 默认是 'no change',只在 changed() 为真时才改写它。
怎么想到的
第一反应通常是「在 deposit 里 append 一下就行了」,然后卡在三个问题上。逐个拆。
transactions 这个列表在哪儿创建?
它必须是实例属性,写在 __init__ 里:self.transactions = []。
一个很自然但完全错误的念头是把它写成类属性——像 Car.max_tires = 4 那样,直接在
class BankAccount: 下面写一行 transactions = []。这会造成灾难:
所有账户共用同一个列表。doctest 里 a 做完 4 笔、b 做完 4 笔之后,
len(a.transactions) 会是 8。而且它是可变对象,self.transactions.append(...)
根本不会创建实例属性,它只是找到那个类属性再往里塞东西。这个坑在 Lecture 13 的最后专门讲过,
但真写代码时还是很容易踩。
id 从哪来?
题面的定义是「此前该账户已经发生的操作笔数」。想到这一步的关键,是意识到这个数已经在手边了:
既然每笔操作都往 self.transactions 里追加一个元素,那么在追加第 k 笔之前,
列表长度恰好就是 k。所以 len(self.transactions) 就是下一笔的 id,一个额外的计数器都不用加。
也可以真的加一个
self.count = 0,每次 self.count += 1。能过,但它是一份冗余的状态:
两个地方记同一件事,一旦有一条路径忘了自增就对不上。用 len 是「单一真相来源」,不会出这种错。
before 怎么拿?
这是最容易写反的地方。deposit 原来的代码是
self.balance = self.balance + amount——执行完这一行,旧余额就没了,
self.balance 已经是新值。所以必须先用一个局部变量把旧值扣下来:
before = self.balance,改完余额再拿 before 和 self.balance 去造 Transaction。
我第一版写的是
Transaction(id, self.balance - amount, self.balance),
用反推的方式还原旧余额。deposit 里它碰巧对,但 withdraw 里要写成
self.balance + amount,两边符号相反,非常容易写混;更要命的是失败取款那条路径上
余额压根没变,反推公式直接就是错的。所以老老实实存一个 before。
withdraw 在余额不足时直接 return 'Insufficient funds'。
这个 return 一执行,函数就退出了,后面的 append 永远不会跑到。
所以记录必须写在 return 之前,塞进那个 if 分支里面。
这笔记录的 before 和 after 都是当时的余额(因为没变),于是
changed() 返回 False,report() 输出 '3: no change'——正好对上 doctest。
把「下一笔的 id」单独抽成一个方法 next_id(),返回 len(self.transactions)。
这不是为了少打几个字(其实没少打),而是给这个表达式起了个名字:
读到 Transaction(self.next_id(), before, self.balance) 时,
你不需要停下来推导「为什么列表长度就是 id」,名字已经说了。
一共有三处要造 Transaction,三处都写 len(self.transactions) 的话,
将来 id 规则一变就得改三个地方。
代码
class Transaction:
def __init__(self, id: int, before: int, after: int):
self.id = id
self.before = before
self.after = after
def changed(self) -> bool:
"""Return whether the transaction resulted in a changed balance."""
return self.before != self.after
def report(self) -> str:
"""Return a string describing the transaction.
>>> Transaction(3, 20, 10).report()
'3: decreased 20->10'
>>> Transaction(4, 20, 50).report()
'4: increased 20->50'
>>> Transaction(5, 50, 50).report()
'5: no change'
"""
msg: str = 'no change'
if self.changed():
# Describe the direction of the change, then the two balances.
if self.after > self.before:
verb = 'increased'
else:
verb = 'decreased'
msg = verb + ' ' + str(self.before) + '->' + str(self.after)
return str(self.id) + ': ' + msg
changed 是一行:return self.before != self.after。
这里值得停一秒——很多人会写成
if self.before != self.after:
return True
else:
return False
它没错,但 != 求值出来的本来就是一个布尔值,再拿 if 把它翻译成
True/False 是多余的一层。这门课从第一周起就在强调这件事:
表达式的值可以直接返回,不用先过一遍条件语句。
report 里,起始代码给的 msg: str = 'no change' 已经是「默认值」的写法——
先假设没变化,只有确实变了才改写它。这样 'no change' 这个分支不用单独写。
剩下的活是判断方向:self.after > self.before 为真就是 'increased',
否则(既然已经知道两者不等,那就只能是小于)是 'decreased'。
str(self.before) 和 str(self.after) 外面的 str() 不能省。
余额是 int,直接和字符串用 + 拼会得到
TypeError: can only concatenate str (not "int") to str。最后一行同理,
str(self.id) + ': ' + msg。
class BankAccount:
def __init__(self, account_holder: str):
self.balance: int = 0
self.holder = account_holder
# Every transaction made on this account, in the order it happened.
self.transactions: list = []
def next_id(self) -> int:
"""Return the id for the next transaction on this account, which is
the number of transactions made on this account so far."""
return len(self.transactions)
def deposit(self, amount: int) -> int:
"""Increase the account balance by amount, add the deposit
to the transaction history, and return the new balance.
"""
before = self.balance
self.balance = self.balance + amount
self.transactions.append(Transaction(self.next_id(), before, self.balance))
return self.balance
def withdraw(self, amount: int) -> int | str:
"""Decrease the account balance by amount, add the withdraw
to the transaction history, and return the new balance.
"""
before = self.balance
if amount > self.balance:
# A failed withdrawal is still recorded, with an unchanged balance.
self.transactions.append(Transaction(self.next_id(), before, before))
return 'Insufficient funds'
self.balance = self.balance - amount
self.transactions.append(Transaction(self.next_id(), before, self.balance))
return self.balance
逐行看几个决策点:
self.transactions: list = []写在__init__里。 每次调用BankAccount(...)都会执行一次__init__, 所以每个账户拿到的是一个全新的空列表——这正是我们要的隔离。next_id里的len(self.transactions)必须在 append 之前求值。 看deposit那行:Transaction(self.next_id(), ...)是append(...)的实参,Python 先把实参完整求值出来(此时列表还没变长), 再调用append。求值顺序在这里救了我们一命。withdraw里before = self.balance放在if之前。 两条分支都要用它,提到前面只写一次。- 失败分支写的是
Transaction(self.next_id(), before, before)—— 两个参数都是before。这不是笔误,它就是「余额没变」的字面表达。 写成(before, self.balance)其实也对(因为这条路径上self.balance没被改过), 但写两个before把「没变」这件事写在了脸上,读代码的人不用去推。
验证
拿 doctest 的前半段真的跑一遍。注意两个账户是交错操作的,这正是用来检查 id 有没有串台。
>>> a = BankAccount('Eric')
a.balance = 0, a.holder = 'Eric', a.transactions = [] ← 列表 L_a
>>> a.deposit(100)
before = 0
a.balance = 0 + 100 = 100
next_id() = len(L_a) = len([]) = 0
L_a.append(Transaction(0, 0, 100)) → L_a = [T(0,0,100)]
返回 100 ✓ 100
>>> b = BankAccount('Erica')
b.balance = 0, b.transactions = [] ← 全新的列表 L_b,和 L_a 无关
>>> a.withdraw(30)
before = 100
30 > 100 ? 否 → 正常取款
a.balance = 100 - 30 = 70
next_id() = len(L_a) = 1
L_a.append(Transaction(1, 100, 70)) → L_a = [T(0,0,100), T(1,100,70)]
返回 70 ✓ 70
>>> a.deposit(10)
before = 70; a.balance = 80
next_id() = len(L_a) = 2
L_a.append(Transaction(2, 70, 80))
返回 80 ✓ 80
>>> b.deposit(50)
before = 0; b.balance = 50
next_id() = len(L_b) = len([]) = 0 ← b 从 0 重新开始数
L_b.append(Transaction(0, 0, 50))
返回 50 ✓ 50
>>> b.withdraw(10)
before = 50; b.balance = 40
next_id() = len(L_b) = 1
L_b.append(Transaction(1, 50, 40))
返回 40 ✓ 40
>>> a.withdraw(100)
before = 80
100 > 80 ? 是 → 走失败分支
next_id() = len(L_a) = 3
L_a.append(Transaction(3, 80, 80)) ← 余额没动,但记录照发
返回 'Insufficient funds' ✓
>>> len(a.transactions)
L_a = [T(0,0,100), T(1,100,70), T(2,70,80), T(3,80,80)]
返回 4 ✓ 4
>>> len([t for t in a.transactions if t.changed()])
T(0,0,100).changed() → 0 != 100 → True
T(1,100,70).changed() → 100 != 70 → True
T(2,70,80).changed() → 70 != 80 → True
T(3,80,80).changed() → 80 != 80 → False ← 被筛掉
返回 3 ✓ 3
再把最后那个 for 循环的输出对一遍:
| Transaction | changed() | msg | report() |
|---|---|---|---|
T(0, 0, 100) | True | after > before → 'increased 0->100' | 0: increased 0->100 |
T(1, 100, 70) | True | 70 > 100 假 → 'decreased 100->70' | 1: decreased 100->70 |
T(2, 70, 80) | True | 'increased 70->80' | 2: increased 70->80 |
T(3, 80, 80) | False | 保持默认 'no change' | 3: no change |
与 doctest 里那四行完全一致。b 那边同理,它的第 2 笔(b.withdraw(100),
当时余额 40)失败,输出 2: no change,第 3 笔取 30 成功,输出
3: decreased 40->10。
- 把
transactions = []写成类属性。症状是len(a.transactions)返回 8(把b的也算进去了),id 也变成 0..7。 更隐蔽的是它不报错,只是结果不对。 - 失败的取款没记。症状是
len(a.transactions)返回 3, 而且后面b的 id 会错位。原因几乎总是把 append 写在了return 'Insufficient funds'后面——那行代码根本执行不到。 - 先改余额再取
before。 写成self.balance = self.balance + amount然后Transaction(id, self.balance, self.balance),得到0: no change。因为两个参数取的是同一个(已经更新过的)值。 report里忘了str()。真实报错:TypeError: can only concatenate str (not "int") to str。- 用
self.id之类的名字给 id 计数。id是 Python 内置函数的名字。 在Transaction.__init__里作为参数名用是官方给的、没问题(它只遮蔽了这个函数体内的id),但不要在别处随手把id当全局变量名用。
2. Email:三个类互相指来指去
题目要什么
这题不需要发明任何算法,全部工作量是填五个空,每个空都是一个「从我手上的东西出发,
怎么走到我想要的东西」的问题。难点不在写,在于分清此刻 self 是谁。
先把三个类的关系画清楚。系统里有一台 Server,若干个 Client,
它们之间传的东西是 Email。
| 类 | 实例属性 | 方法 | 它「认识」谁 |
|---|---|---|---|
Email | msg(str)、sender(Client 实例)、recipient_name(str) | — | 认识发件人对象,但只知道收件人的名字 |
Server | clients:{名字(str): Client 实例} | send、register_client | 认识所有注册过的 Client |
Client | inbox(list)、server(Server 实例)、name(str) | compose | 认识自己那台 Server |
注意这张表里最要紧的一处不对称:Email 存的是发件人对象,
却只存收件人的字符串名字。为什么?因为写信的时候,你手上确实有自己(self),
但你只知道对方叫什么,不知道对方那个 Client 对象在哪儿。
把名字翻译成对象,是 Server 的职责——它那本 clients 字典就是干这个的。
这个设计和真实邮件系统完全一致:你写 to: bob@x.com,找到 Bob 的信箱是服务器的事。
Server 的 doctest 里有几行值得单独看:
>>> new_a = Client(s, 'Alice') # 又来一个也叫 Alice 的
>>> s.register_client(new_a)
>>> len(s.clients) # 字典的键必须唯一
2
>>> s.clients['Alice'] is new_a # 'Alice' 现在指向新的那个
True
这不是在考什么特殊逻辑,恰恰相反:它在验证你确实用名字当键。
如果你把 Client 对象当键、名字当值(写反了),len(s.clients) 会是 3,因为两个 Alice 是不同对象。
后面 all([type(c) == str for c in s.clients.keys()]) 那行也是在查同一件事。
怎么想到的
填空题的正确做法是:每个空,先写出「我现在站在哪个对象上」, 再写出「目标在哪个对象上」,然后找一条路径。一个一个来。
Server.send(self, email) 里的 ____.inbox.append(email)目标:把
email 放进收件人的 inbox。
我手上有:self(这台 Server)、email(一封信)。
收件人是谁?email.recipient_name 给出名字——但那是个字符串,字符串没有 inbox。
要把名字换成对象,用 self.clients 这本字典查:
self.clients[email.recipient_name]。这就是那个空。
弯路警告:我第一次写成了
email.recipient.inbox.append(email),
报 AttributeError: 'Email' object has no attribute 'recipient'。
Email 里根本没有 recipient 这个属性,只有 recipient_name。
题面在 Important 那一段特意提醒「注意方法的参数类型」,说的就是这个。
Server.register_client(self, client) 里的 ____[____] = ____目标:往字典里加一条「名字 → 对象」。 字典是
self.clients;键是这个 client 的名字,即 client.name;值就是 client 本身。
写出来是 self.clients[client.name] = client。
为什么键用
client.name 而不是别的?因为 send 那边只有一个字符串名字可用,
两头必须对得上。这是「查表」这个设计里最容易忽略的一致性要求:存的时候用什么当键,
取的时候就得用什么当键。
Client.__init__ 里的 server.register_client(____)目标:把「我自己」注册到服务器上。我自己是谁?
self。
所以是 server.register_client(self)。
这行是整道题里最容易愣住的地方,因为它出现在
__init__ 里——对象还没「造好」就把自己交出去了?
其实完全没问题:执行到这一行时,前面三行已经把 inbox、server、name 都设好了,
这个对象已经具备 register_client 需要的一切(它只用 .name)。
__init__ 的作用就是「在对象交付给调用者之前,把它布置好」,
在这中间它已经是一个可以被引用的、货真价实的对象了。
另外注意这里写的是
server.register_client 而不是 self.server.register_client。
两者此刻等价(上一行刚做过 self.server = server),用哪个都能过。
用参数 server 更直接一点。
Client.compose 里的 Email(message, ____, ____)对着
Email.__init__(self, msg, sender, recipient_name) 的签名一一对应:
第二个参数是 sender,类型是 Client 实例——写信的人就是我,所以填 self;
第三个是 recipient_name,类型是 str——compose 的参数里正好有
recipient_name,直接填它。
最常见的错是这里填
self.name。它是个字符串,看起来「更像发件人」,但类型不对。
后果要到 Client 的 doctest 最后一行才暴露:
b.inbox[1].sender.name 会报
AttributeError: 'str' object has no attribute 'name'。
题面在 Note 里第一条就写着「sender 参数是一个 Client 实例」,就是防这个。
三个类交叉引用,看着绕,其实只有一条主干:Client 知道 Server,Server 知道所有 Client。
Client 之间互相不认识——Alice 想给 Bob 发信,必须经过 Server 中转。
所有代码路径都是沿着这条主干走的:compose 走
self → self.server,send 走 self.clients[名字] → 那个 Client。
只要不试图从一个 Client 直接跳到另一个 Client,就不会写错。
代码
class Server:
def __init__(self):
self.clients = {}
def send(self, email: Email):
"""Append the email to the inbox of the client it is addressed to.
email is an instance of the Email class.
"""
self.clients[email.recipient_name].inbox.append(email)
def register_client(self, client):
"""Add a client to the clients mapping (which is a
dictionary from client names to client instances).
client is an instance of the Client class.
"""
self.clients[client.name] = client
class Client:
def __init__(self, server: Server, name: str):
self.inbox: list = []
self.server = server
self.name = name
server.register_client(self)
def compose(self, message: str, recipient_name: str):
"""Send an email with the given message to the recipient."""
email = Email(message, self, recipient_name)
self.server.send(email)
send 那一行值得拆开读,它是一条四段的链式访问,从左到右求值:
self.clients[email.recipient_name].inbox.append(email)
① self.clients → 这台 Server 的字典对象,比如 {'Alice': <A>, 'Bob': <B>}
② email.recipient_name → 字符串 'Alice'
③ ①[②] → <Client Alice> 对象
④ ③.inbox → Alice 那个列表对象
⑤ ④.append(email) → 把信塞进去(改的是列表本身,没有返回值)
第 ⑤ 步是变更(mutation),不是重新赋值。这一点很关键:
Alice.inbox 这个名字始终绑定在同一个列表对象上,我们只是往那个对象里加东西。
所以在别处持有同一个列表的人(比如 doctest 里的 new_a.inbox)立刻就能看到新邮件,
不需要再赋值回去。
register_client 里的 self.clients[client.name] = client
在键已存在时会覆盖旧值——这正是 doctest 里两个 Alice 那段要的行为,
不需要写任何 if 'Alice' in self.clients 之类的判断。Python 的字典赋值天然就是
「有则改、无则加」。
compose 分两步:先造信 Email(message, self, recipient_name),
再交给服务器 self.server.send(email)。为什么不写成一行?
写成 self.server.send(Email(message, self, recipient_name)) 也完全正确,
起始代码分成两行只是为了让填空位置清楚。
验证
把 Client 那段 doctest 完整走一遍,同时画出谁指着谁。
>>> s = Server()
s.clients = {} ← 记这个字典对象为 D
>>> a = Client(s, 'Alice')
进入 Client.__init__,self = 新对象 A,server = s,name = 'Alice'
A.inbox = [] ← 记为列表 LA
A.server = s
A.name = 'Alice'
s.register_client(A)
→ self = s, client = A
→ D['Alice'] = A D = {'Alice': A}
>>> b = Client(s, 'Bob')
B.inbox = [] ← 列表 LB
B.server = s, B.name = 'Bob'
D['Bob'] = B D = {'Alice': A, 'Bob': B}
>>> a.compose('Hello, World!', 'Bob')
a.compose → 绑定方法,self = A
email = Email('Hello, World!', A, 'Bob') ← 记为 E1
E1.msg = 'Hello, World!'
E1.sender = A (是对象,不是 'Alice')
E1.recipient_name = 'Bob' (是字符串,不是 B)
A.server.send(E1) → s.send(E1)
① self.clients = D
② E1.recipient_name = 'Bob'
③ D['Bob'] = B
④ B.inbox = LB
⑤ LB.append(E1) → LB = [E1]
compose 没有 return,返回 None,交互式下不打印任何东西 ✓
>>> b.inbox[0].msg
b.inbox = LB = [E1]; LB[0] = E1; E1.msg
'Hello, World!' ✓
>>> a.compose('CS 61A Rocks!', 'Bob')
E2 = Email('CS 61A Rocks!', A, 'Bob')
LB.append(E2) → LB = [E1, E2]
>>> len(b.inbox)
2 ✓
>>> b.inbox[1].msg
LB[1] = E2; E2.msg
'CS 61A Rocks!' ✓
>>> b.inbox[1].sender.name
LB[1] = E2
E2.sender = A ← 这里必须是对象,才能继续点下去
A.name = 'Alice'
'Alice' ✓
最后一行是整道题的验收点:E2.sender 必须是对象,
才能再点一个 .name。如果 compose 里填了 self.name,
E2.sender 就是字符串 'Alice',而字符串没有 .name 属性,立刻炸。
再看 Server 那段 doctest 里两个 Alice 的处理:
此刻 D = {'Alice': A, 'Bob': B}
>>> new_a = Client(s, 'Alice') ← 注意:这里用的是 doctest 里那个假 Client 类,
它的 __init__ 不会自动注册
NA.inbox = [] (列表 LNA), NA.name = 'Alice'
>>> s.register_client(new_a)
D['Alice'] = NA D = {'Alice': NA, 'Bob': B}
← 键 'Alice' 已存在,值被换掉;字典没有变长
>>> len(s.clients)
2 ✓
>>> s.clients['Alice'] is new_a
D['Alice'] 是 NA,new_a 也是 NA,同一个对象
True ✓
>>> e = Email("I love 61A", b, 'Alice')
>>> s.send(e)
D['Alice'].inbox.append(e) → NA.inbox = LNA = [e]
← 老的 A 收不到了,它已经被从字典里挤掉
>>> len(new_a.inbox)
1 ✓
顺带一提:Server 的 doctest 里自己重新定义了一个只有 __init__、
不会自动注册的假 Client。这是为了让 Server 的测试不依赖你的
Client 实现——题面明确说了「先跑 ok -q Server,再跑 ok -q Client」,
就是这个原因。如果用真 Client,Client(s, 'Alice') 自己就会注册一次,
后面那句显式的 s.register_client(a) 就成了重复注册,测不出 register_client 本身对不对。
Email(message, self.name, recipient_name)。 类型错了。报错出现在很后面:AttributeError: 'str' object has no attribute 'name'。self.clients[client] = client.name(键值写反)。len(s.clients)变成 3,且send里的字典查询会KeyError: 'Alice'。email.recipient.inbox。AttributeError: 'Email' object has no attribute 'recipient'—— 属性名是recipient_name。- 在
send里写self.clients[...].inbox = email。 这把整个 inbox 换成了一封信(不再是列表),len(b.inbox)会报TypeError: object of type 'Email' has no len()。 要append,不是赋值。 server.register_client(self.name)。 传进去的是字符串,register_client里client.name立刻炸。 它要的是整个对象。
3. Mint:类属性、实例属性,和「把类当值传」
题目要什么
造币厂(Mint)盖年份章,硬币(Coin)按面值和年龄算价值。三个空:
Mint.update():把这台造币厂的年份章设为Mint.present_year。Mint.create(coin):coin是Coin的一个子类本身(不是实例!), 造一枚该子类的硬币,年份用这台造币厂的章(不一定是当前年份)。Coin.worth():返回面值,再加上「超过 50 岁的部分,每多一年加 1 分」。 年龄 =Mint.present_year− 硬币的year。
doctest 是这道题的灵魂,它像一部时间旅行小说,每一行都在考一个不同的点:
>>> mint = Mint()
>>> mint.year
2025
>>> dime = mint.create(Dime)
>>> dime.year
2025
>>> Mint.present_year = 2105 # Time passes
>>> nickel = mint.create(Nickel)
>>> nickel.year # The mint has not updated its stamp yet
2025
>>> nickel.worth() # 5 cents + (80 - 50 years)
35
>>> mint.update() # The mint's year is updated to 2105
>>> Mint.present_year = 2180 # More time passes
>>> mint.create(Dime).worth() # 10 cents + (75 - 50 years)
35
>>> Mint().create(Dime).worth() # A new mint has the current year
10
>>> dime.worth() # 10 cents + (155 - 50 years)
115
>>> Dime.cents = 20 # Upgrade all dimes!
>>> dime.worth() # 20 cents + (155 - 50 years)
125
把它藏的考点列出来:
| doctest 片段 | 它在考什么 |
|---|---|
nickel.year 是 2025 而不是 2105 | 造币厂的章是实例属性,不会跟着 Mint.present_year 自动变 |
Mint().create(Dime).worth() 得 10 | 新造币厂在 __init__ 里调 update(),拿到最新年份;年龄 0,不加钱 |
Dime.cents = 20 之后 dime.worth() 变成 125 | worth 里必须写 self.cents(每次动态查),不能在 __init__ 里把面值拷进实例 |
mint.create(Dime) 传的是 Dime 不是 Dime(...) | 类本身也是一个值,可以传参、可以调用 |
还有一个题面没直说、但 doctest 逼出来的边界情况:
Mint().create(Dime).worth() 那一行,硬币年龄是 0,比 50 小。
如果 worth 写成 self.cents + (age - 50),会得到 10 + (0 - 50) = -40。
所以「超过 50 岁的部分」这句话必须理解为:不足 50 岁时一分不加,加的量是
max(0, age - 50)。
怎么想到的
self.year 和 Mint.present_year 为什么必须分成两个东西。读题时最容易糊涂的就是这里:造币厂不是有个年份吗,为什么还要一个
present_year?
看 doctest:Mint.present_year = 2105 之后,nickel.year 还是 2025。
这说明「现在是哪一年」(全世界共享,属于类)和「这台造币厂的章刻的是哪一年」(各台不同,属于实例)
是两件事。造币厂的章是手动更新的,update() 就是那个「去把章改一下」的动作。
这个区分正是类属性 vs 实例属性存在的意义:所有实例共享的事实放类属性, 每个实例各自的状态放实例属性。
update 里该写 self.year = Mint.present_year 还是
self.year = self.present_year?两个都能过 doctest。但含义不同,而且第二个有个真实的坑:
self.present_year 会先在实例里找,找不到才去类里找。
如果哪天有人做了 mint.present_year = 1999(给这台机器加了个同名实例属性),
self.present_year 就会读到 1999,而 Mint.present_year 仍然读全局的那个。
题面说的是「sets the year stamp of the instance to the present_year class attribute of the Mint class」——
明确指定了是类属性,所以写 Mint.present_year 更忠实于题意。
反过来,第 3 步会看到
worth 里必须用 self.cents,恰恰相反。
两者的区别值得琢磨:present_year 我们要的是「那一个全局的值」;
cents 我们要的是「按我实际是哪种硬币去查」。
worth 里为什么必须是 self.cents?最后两行 doctest 是专门为这条设计的:
Dime.cents = 20 之后,那枚早就造好的 dime 的 worth() 从 115 变成 125。
如果
Coin.__init__ 里写了 self.cents = ... 把面值拷贝到实例上,
或者 worth 里硬写了 10,改 Dime.cents 就影响不到已有的 dime。
而 self.cents 是每次调用时才去查的:先看 dime 这个实例有没有
cents(没有),再沿继承链找到 Dime.cents——读到的永远是当时最新的值。
这就是「属性查找是动态的」这句话的实际后果。它不是语言的边角料, 而是让「升级所有 Dime」这种操作变得可能的机制。
那
Coin.cents = None 是干什么的?它是给子类留的占位。
直接 Coin(2025).worth() 会得到
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'——
这是故意的,Coin 本身不该被实例化,它只是个「抽象」的基类。
create 里的 coin 是个类,怎么用?这是全 lab 最反直觉的一处。参数名叫
coin,看着像一枚硬币,
但题面强调 "takes a subclass of Coin (not an instance!)",调用处也写的是
mint.create(Dime)——Dime 后面没括号。
在 Python 里,
class Dime(Coin): ... 这条语句做的事和 def 很像:
它造出一个对象(一个类对象),然后把名字 Dime 绑定到它。
既然是对象,就能当参数传。而「调用一个类」就是造实例。所以:
coin → Dime 这个类对象 coin(2025) → 等价于 Dime(2025),一枚 2025 年的一角硬币如果第一次见,可以这样类比:这跟高阶函数里把
square 传给 apply_twice 是同一件事,
只不过传的是类而不是函数。
年份用哪个?题面说 "stamped with the Mint's year (which may be different from
Mint.present_year if it has not been updated)"——用 self.year。
所以 return coin(self.year)。
写成 coin(Mint.present_year) 会让 nickel.year 输出 2105 而不是 2025,直接挂。
Mint.__init__ 里已经写好的 self.update() 是怎么回事?起始代码给了
def __init__(self): self.update(),这一行不用改,但要看懂:
新造币厂一出厂就自动把章设成当前年份。
妙的地方在于,self.year 这个实例属性不是在 __init__ 里直接赋值的,
而是被 update 间接创建的。这完全合法——实例属性可以在任何方法里创建,
不限于 __init__。Mint().create(Dime).worth() 得 10,靠的正是这个自动 update。
读 obj.x:先在 obj 的实例属性里找;没有,就去 type(obj) 里找;
还没有,就沿继承链往父类找;都没有 → AttributeError。
写 obj.x = v:永远在 obj 自己身上创建/修改实例属性,
绝不会碰类属性。想改类属性只能写 Cls.x = v。
这两条规则不对称,是本 lab 所有「为什么是这个输出」的总根源。
代码
class Mint:
present_year = 2025
def __init__(self):
self.update()
def create(self, coin):
# coin is a Coin subclass; calling it makes an instance stamped
# with this mint's year (not necessarily the present year).
return coin(self.year)
def update(self) -> None:
self.year = Mint.present_year
class Coin:
cents = None # will be provided by subclasses, but not by Coin itself
def __init__(self, year: int):
self.year = year
def worth(self) -> int:
# self.cents looks up the subclass's class attribute, so upgrading
# Dime.cents later changes the worth of existing dimes too.
age = Mint.present_year - self.year
return self.cents + max(0, age - 50)
class Nickel(Coin):
cents = 5
class Dime(Coin):
cents = 10
三行有效代码,每一行都有讲究:
return coin(self.year)——coin是类,coin(...)造实例,self.year是这台机器的章。别忘了return:create必须返回硬币,不然mint.create(Dime).worth()会报AttributeError: 'NoneType' object has no attribute 'worth'。self.year = Mint.present_year——注意左边是self.year(实例属性,各台机器独立), 右边是Mint.present_year(类属性,全局共享)。 一个字母之差写成Mint.year = Mint.present_year,就变成给Mint类加了个类属性, 所有造币厂共享同一个章,doctest 里mint.update()会连带影响别人。age = Mint.present_year - self.year——年龄要用当前年份减硬币年份。 用Mint.present_year而不是别的,因为题面明确写了 "by subtracting the coin's year from thepresent_yearclass attribute of theMintclass"。 硬币不知道造它的是哪台机器,只能直接问Mint类。return self.cents + max(0, age - 50)——max(0, ...)是那个 「不足 50 岁不加钱」的边界保护。等价写法是self.cents + (age - 50 if age > 50 else 0),或者先if age <= 50: return self.cents。max版本最短,且一眼能看出意图:加的量下限是 0。
验证
整段 doctest 一行行走。这里要同时盯住三份状态:类属性 Mint.present_year、
每台 Mint 的 self.year、每枚硬币的 self.year。
初始:Mint.present_year = 2025,Coin.cents = None,Nickel.cents = 5,Dime.cents = 10
>>> mint = Mint()
__init__ → self.update() → self.year = Mint.present_year = 2025
mint.year = 2025 ← 实例属性
>>> mint.year
2025 ✓
>>> dime = mint.create(Dime)
create(self=mint, coin=Dime)
→ return Dime(mint.year) = Dime(2025)
dime.year = 2025,dime 没有自己的 cents
>>> dime.year
2025 ✓
>>> Mint.present_year = 2105
只改类属性。mint.year 仍是 2025,dime.year 仍是 2025
>>> nickel = mint.create(Nickel)
→ Nickel(mint.year) = Nickel(2025)
nickel.year = 2025
>>> nickel.year
2025 ← 章还没更新,所以造出来的是「旧年份」硬币 ✓
>>> nickel.worth()
age = Mint.present_year - self.year = 2105 - 2025 = 80
self.cents:nickel 实例里没有 → 查 Nickel 类 → 5
return 5 + max(0, 80 - 50) = 5 + 30 = 35 ✓ 35
>>> mint.update()
mint.year = Mint.present_year = 2105
>>> Mint.present_year = 2180
>>> mint.create(Dime).worth()
create → Dime(mint.year) = Dime(2105)
worth: age = 2180 - 2105 = 75
cents = 10
10 + max(0, 75 - 50) = 10 + 25 = 35 ✓ 35
>>> Mint().create(Dime).worth()
Mint() → __init__ → update() → 新机器 .year = 2180
create → Dime(2180)
worth: age = 2180 - 2180 = 0
10 + max(0, 0 - 50) = 10 + max(0, -50) = 10 + 0 = 10 ✓ 10
← 没有 max 的话这里是 10 + (-50) = -40,直接挂
>>> dime.worth()
dime 是最早那枚,dime.year = 2025
age = 2180 - 2025 = 155
cents = 10
10 + max(0, 155 - 50) = 10 + 105 = 115 ✓ 115
>>> Dime.cents = 20
改的是 Dime 这个类对象上的属性。
dime 实例本身从来没有过 cents 属性,所以它下次查还是走到 Dime 类
>>> dime.worth()
age = 2180 - 2025 = 155(没变)
self.cents:dime 实例没有 → 查 Dime 类 → 现在是 20
20 + max(0, 105) = 20 + 105 = 125 ✓ 125
最后两步是这道题的验收核心。画成属性查找图更清楚:
Dime 类对象 dime 实例对象
┌────────────────┐ ┌────────────────┐
│ cents = 10→20 │ ◄───────────┤ year = 2025 │
│ (继承自 Coin: │ 查不到 cents │ (没有 cents) │
│ __init__ │ 就往这边找 └────────────────┘
│ worth) │
└───────┬────────┘
│ 继承
┌───────▼────────┐
│ Coin 类对象 │
│ cents = None │
│ __init__ │
│ worth │
└────────────────┘
dime.worth() 里的 self.cents:
dime 实例 → 没有 → Dime 类 → 有!取 20(改过之后的最新值)
dime.worth 这个方法本身:
dime 实例 → 没有 → Dime 类 → 没有 → Coin 类 → 找到,绑定 self=dime
dime.cents = 20 会怎样
注意 doctest 写的是 Dime.cents = 20(改类),不是 dime.cents = 20(改实例)。
后者会在 dime 这个实例上新建一个 cents 属性,
从此这枚 dime 的查找在第一步就命中,再也看不到 Dime.cents 的后续变化,
而其他 Dime 完全不受影响。这就是上面「写属性永远写在实例上」那条规则的直接后果,
也是下一节 WWPD 题反复考的点。
create里写return coin(Mint.present_year)。 症状:nickel.year输出 2105,期望 2025。造币厂的章和当前年份是两回事。create里写return coin(忘了调用)。 返回的是类不是实例,dime.year会报AttributeError: type object 'Dime' has no attribute 'year'。create里写return Coin(self.year)。 所有硬币都成了基类Coin,worth()里self.cents是None, 报TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'。 参数coin传进来就是让你用的。worth里漏了max(0, ...)。 新硬币算出负数,Mint().create(Dime).worth()得 −40。worth里写Dime.cents或者在__init__里self.cents = self.cents。前者让 Nickel 也按 Dime 算; 后者把面值冻结在实例上,最后一行 doctest 输出 115 而不是 125。update里写Mint.year = Mint.present_year。 给类加了属性,mint.year虽然还能读到(沿查找链走到类), 但所有 Mint 从此共用一个章,Mint().create(Dime).worth()那行会错。
4. WWPD:Inheritance ABCs
这组题在 labs/lab06/tests/inheritance-abc.py 里,类型是 wwpp
(What Would Python Print),'points': 0——不计分,但它是本 lab 最值钱的一段。
上面三道编程题的所有坑,答案都在这里。
题目先定义三个类:A 是基类,B 和 C 都继承 A,
两个子类都重写了 __init__(都是空的 return,什么也不做):
>>> class A:
... x, y = 0, 0
... def __init__(self):
... return
>>> class B(A):
... def __init__(self):
... return
>>> class C(A):
... def __init__(self):
... return
关键点:x 和 y 只在 A 里定义了,B 和 C 自己什么属性都没有。
画成图是这样:
┌──────────────┐
│ 类 A │
│ x = 0 │
│ y = 0 │
│ __init__ │
└───▲──────▲────┘
│ │ 继承
┌───────┴──┐ ┌─┴────────┐
│ 类 B │ │ 类 C │
│ __init__ │ │ __init__ │
│ (无 x,y) │ │ (无 x,y) │
└──────────┘ └──────────┘
第 1 问
>>> print(A.x, B.x, C.x)
0 0 0
A.x 直接在 A 上找到,是 0。
B.x 呢?B 自己没有 x,于是沿继承链往上找,在 A 里找到 0。
C.x 同理。三个名字读到的是同一个 A.x——
B 和 C 并没有各自的副本,它们只是「看得见」A 的那份。
这一点是后面所有问题的基础。
第 2 问
>>> B.x = 2
>>> print(A.x, B.x, C.x)
0 2 0
B.x = 2 是赋值,不是读取。赋值永远作用在点号左边那个对象上,
所以它在 B 这个类对象上新建了一个属性 x = 2,
完全不碰 A。
┌──────────────┐
│ 类 A │
│ x = 0 │ ← 没被动过
│ y = 0 │
└───▲──────▲────┘
│ │
┌───────┴──┐ ┌─┴────────┐
│ 类 B │ │ 类 C │
│ x = 2 │ │ (无 x) │ ← B 新增了自己的 x,遮住了 A.x
└──────────┘ └──────────┘
于是 A.x 还是 0;B.x 在 B 自己身上就找到了,是 2;
C.x 依旧要爬到 A,读到 0。输出 0 2 0。
B.x = 2 之后,B 的那个 x 把 A 的 x 遮住了。
从此 B.x 再也看不到 A.x 的任何变化——第 3 问马上验证这一点。
第 3 问
>>> A.x += 1
>>> print(A.x, B.x, C.x)
1 2 1
A.x += 1 展开是 A.x = A.x + 1:先读 A.x(得 0),
加 1 得 1,再写回 A.x。A 的 x 变成 1。
现在三个输出:
A.x→ 1。B.x→B自己有x = 2,在第一步就命中,看不到A的变化,还是 2。C.x→C没有x,爬到A,读到刚改过的 1。
输出 1 2 1。这一问把「继承不是拷贝,是查找」讲得再清楚不过:
C 跟 A 是活连接,A 一改 C 立刻跟着变;
B 因为自己定义过,连接被切断了。
这正是 Q3 里 Dime.cents = 20 能立刻影响已有 dime 的原因——
dime 实例从没有过自己的 cents,一直是活连接。
第 4 问
>>> obj = C()
>>> obj.y = 1
>>> C.y == obj.y
False
C() 调用 C.__init__,它只是 return,什么属性都没设。
所以 obj 是一个空的 C 实例。
obj.y = 1:又是赋值,作用在点号左边的 obj 上,
给这个实例新建了属性 y = 1。类 C 和类 A 都没被碰。
类 A: x = 1, y = 0
▲
│
类 C: (无 x, 无 y)
▲
│ 实例的类
obj: y = 1 ← 只有实例自己有 y
比较 C.y == obj.y:
C.y→C没有y→ 爬到A→0。obj.y→ 实例自己有 →1。
0 == 1 → False。
很多人在这里答 True,理由是「obj 是 C 的实例,改 obj.y 不就是改 C.y 吗」。
不是。实例属性和类属性是两个不同的存储位置,只是读取时会从前者找到后者。
一旦你在实例上写了同名属性,两者就分家了。
Q1 里如果把 transactions 写成类属性、又用 append 去改它,
踩的就是这条规则的另一面:append 不是赋值,它不会创建实例属性,
于是所有实例真的共享了同一个列表。
第 5 问
>>> A.y = obj.y
>>> print(A.y, B.y, C.y, obj.y)
1 1 1 1
右边 obj.y 先求值,得 1。然后赋给 A.y,
把 A 的类属性 y 从 0 改成 1。
四个输出逐个查:
| 表达式 | 查找路径 | 值 |
|---|---|---|
A.y | A 自己有 | 1 |
B.y | B 没有 → A | 1 |
C.y | C 没有 → A | 1 |
obj.y | 实例自己有(第 4 问设的) | 1 |
输出 1 1 1 1。
这一问有点狡猾:四个 1 看起来「都一样」,容易让人误以为它们是同一个东西。
其实 obj.y 的那个 1 和另外三个来源完全不同——它存在实例上,
只是数值碰巧相等。如果接着写 A.y = 99,
输出会变成 99 99 99 1,分家立刻显形。
读属性向上爬,写属性就地停。
读 o.attr 会沿「实例 → 类 → 父类」一路找,找到第一个就返回;
写 o.attr = v 永远只在 o 自己身上落地,不管上面有没有同名的。
本 lab 三道编程题外加这五问,考的都是这一条。
自测:把上面五问按顺序执行后,再执行 B.y += 1,
print(A.y, B.y, C.y) 输出什么?
1 2 1。B.y += 1 展开为 B.y = B.y + 1:
右边 B.y 读取时 B 自己没有 y,爬到 A 读到 1;
1 + 1 = 2;左边赋值落在 B 上,给 B 新建了 y = 2。
于是 A.y 仍是 1,B.y 是 2,C.y 仍爬到 A 得 1。
和第 2、3 问是同一个套路:+= 在类属性上会「先继承地读、再本地地写」,
一次操作就把连接切断了。
5. VirFib(选做):用一个属性把指数变成常数
题目要什么
VirFib 的实例代表一个 Virahanka–Fibonacci 数(就是我们熟悉的斐波那契数,
这门课用它的印度起源命名)。value 属性存那个数。
要实现 next():返回一个新的 VirFib 实例,
它的 value 是序列里的下一项。
三条硬性约束,每一条都对应 doctest 里的一行:
| 约束 | 出处 | 后果 |
|---|---|---|
next() 必须只花常数时间 | 题面 "should take only constant time" | 不能在里面重算斐波那契 |
| 必须返回新对象,不能改自己 | 最后一行 doctest 注释 "Ensure start isn't changed" | 不能写 self.value = ... |
| 要能连续链式调用 | start.next().next().next() | 返回的对象自己也得能 next() |
序列是 0, 1, 1, 2, 3, 5, 8, ...,起点 VirFib() 用了默认参数 value=0。
显示形式由已经写好的 __repr__ 决定:VirFib object, value 5。
题面特意提醒「doctest 里没有任何 print,每次 .next() 返回的是对象,
显示的样子是 __repr__ 的返回值」——这是 Lecture 14 的内容,
交互式解释器打印一个值时用的是 repr 而不是 str。
怎么想到的
F(n) = F(n-1) + F(n-2)——要算下一项,
光知道当前这一项不够,还得知道前一项。
可是 VirFib 的 __init__ 只存了 value。
所以第一个念头往往是「那就在 next 里从头算一遍」——
但那要么需要知道自己是第几项(也没存),要么就是指数时间的递归,
两条路都被「常数时间」这个要求堵死了。
next.
You can create new instance attributes for objects at any point, even outside the __init__ method."
这句话如果第一次读会有点冲击:实例属性不是只能在
__init__ 里定义的吗?
不是。obj.anything = v 在任何地方都能给对象加属性。
Q3 的 Mint.update 已经演示过一次(self.year 就是在 update 里创建的),
这里更进一步:在 next 里给刚造出来的另一个对象加属性。
previous,含义是「这个对象的前一项斐波那契数」。
那么 next() 就是:
下一项的 value = self.value + self.previous 下一项的 previous = self.value造好新对象之后,把
previous 挂上去,这样它再调 next() 时也有前一项可用。
链条就这么接起来了,每一步都是常数时间:两次加法和两次赋值。
start = VirFib() 是通过 __init__ 造的,
__init__ 里没有设 previous。
如果直接写 self.value + self.previous,会报
AttributeError: 'VirFib' object has no attribute 'previous'。
第一版我想在
__init__ 里加 self.previous = 0。数学上说得通(把 F(-1) 当作 0),
而且 0 + 0 = 0……错了,第二项应该是 1 不是 0。
序列 0, 1, 1, 2 的开头是硬规定的,用 F(-1)=0 推不出来。
而且改
__init__ 还有个副作用:所有 VirFib(k) 都会带上 previous = 0,
而 next 里明明会覆盖它,多此一举。
所以改成在
next 里特判:如果 self.value == 0,
下一项直接写死为 1。这样起点不需要 previous,
而所有由 next 造出来的对象都一定有 previous。
两种情况正好互补,没有缝隙。
self.value == 0 这个特判是安全的
序列里 0 只在第一项出现过一次,后面全是正数。
所以「value 是 0」和「我是那个没有 previous 的起点」是等价的。
如果序列里 0 会重复出现,这个特判就不成立了,那时得换成
if not hasattr(self, 'previous') 之类的写法。这里不需要。
代码
class VirFib():
def __init__(self, value: int = 0):
self.value = value
def next(self):
# The very first VirFib has no predecessor, so treat its previous
# value as 0 and hard-code the next Fibonacci number as 1.
if self.value == 0:
result = VirFib(1)
else:
result = VirFib(self.value + self.previous)
# Record this number as the new object's predecessor, so the next
# call to next() takes only constant time.
result.previous = self.value
return result
def __repr__(self) -> str:
return "VirFib object, value " + str(self.value)
逐行:
if self.value == 0:— 只有起点满足。result = VirFib(1)把第二项写死。else: result = VirFib(self.value + self.previous)— 标准递推。走到这一支说明self是被next造出来的, 一定有previous。result.previous = self.value— 这一行在 if/else 外面,两支都要执行。 它给新对象(不是self)挂上前一项。 注意这里改的是result,self从头到尾没被动过—— 这正是「Ensure start isn't changed」那条 doctest 要的。return result— 返回新对象,于是可以继续.next()。
为什么 result.previous = self.value 不写在 __init__ 里?
因为 __init__ 拿不到「前一项」这个信息——它只收到一个 value。
当然也可以把 __init__ 改成 def __init__(self, value=0, previous=0),
但题面给的签名就是单参数的,doctest 里 VirFib() 也只传零个参数。
在方法里给对象追加属性是这道题想教的手法,顺着题面走就好。
验证
把 start.next().next().next().next() 完整展开。
求值是从左往右一层层来的,每一层都造一个新对象。
>>> start = VirFib()
value 默认 0 → start.value = 0
start 没有 previous 属性
>>> start
交互式打印 → 调 __repr__ → "VirFib object, value 0" ✓
--- start.next() ---
self = start,self.value = 0 → 走 if 分支
result = VirFib(1) 记为 O1,O1.value = 1
O1.previous = start.value = 0
返回 O1 → VirFib object, value 1 ✓
--- start.next().next() ---
先算 start.next() = O1(如上,又新造了一个,和上一行那个不是同一对象)
再对 O1 调 next:
self = O1,self.value = 1 ≠ 0 → 走 else
result = VirFib(1 + O1.previous) = VirFib(1 + 0) = VirFib(1) 记为 O2
O2.previous = O1.value = 1
返回 O2 → VirFib object, value 1 ✓
--- 第三层 ---
对 O2 调 next:
O2.value = 1 ≠ 0 → else
result = VirFib(1 + O2.previous) = VirFib(1 + 1) = VirFib(2) 记为 O3
O3.previous = O2.value = 1
返回 O3 → VirFib object, value 2 ✓
--- 第四层 ---
对 O3 调 next:
result = VirFib(O3.value + O3.previous) = VirFib(2 + 1) = VirFib(3) = O4
O4.previous = 2
返回 O4 → VirFib object, value 3 ✓
--- 第五层 ---
VirFib(3 + 2) = VirFib(5) = O5,O5.previous = 3
→ VirFib object, value 5 ✓
--- 第六层 ---
VirFib(5 + 3) = VirFib(8) = O6,O6.previous = 5
→ VirFib object, value 8 ✓
用表格再看一眼每一步两个属性的配合,就能明白为什么每步都是常数时间:
| 第几次 next | 作用在谁身上 | self.value | self.previous | 新对象 value | 新对象 previous |
|---|---|---|---|---|---|
| 1 | start | 0 | (无,走特判) | 1 | 0 |
| 2 | O1 | 1 | 0 | 1 | 1 |
| 3 | O2 | 1 | 1 | 2 | 1 |
| 4 | O3 | 2 | 1 | 3 | 2 |
| 5 | O4 | 3 | 2 | 5 | 3 |
| 6 | O5 | 5 | 3 | 8 | 5 |
每一行的「新对象 value」都等于本行 self.value + self.previous,
「新对象 previous」都等于本行 self.value。
对象把「继续算下去所需的全部信息」随身携带,所以每一步只做一次加法。
这跟朴素递归 fib(n) = fib(n-1) + fib(n-2) 那种指数级重复计算是天壤之别,
而代价只是每个对象多存一个整数。
最后那行 doctest:
>>> start.next().next().next().next().next().next() # Ensure start isn't changed
VirFib object, value 8
它连着出现两次,第二次的注释是「确认 start 没被改动」。
我们的 next 里只对 result 赋值,start.value 始终是 0,
所以第二遍重新展开,走的路径和第一遍完全相同,还是 8。
如果当初图省事写成 self.value = self.value + self.previous; return self,
第一次调用就把 start 改掉了,第二遍会从一个错误的起点出发,输出对不上。
- 修改
self并返回self。start被污染,最后一行 doctest 挂。题目要的是「返回一个新实例」。 - 把
result.previous = self.value写进if/else的某一支。 漏掉的那一支造出的对象没有previous,下次next()报AttributeError: 'VirFib' object has no attribute 'previous'。 - 写成
result.previous = self.previous。 存错了一项,序列变成0, 1, 1, 1, 1...。新对象的前一项是我,不是我的前一项。 - 在
next里重新递归计算斐波那契。 结果对,但违反常数时间要求。 - 把
previous写成类属性。 所有 VirFib 共享一个previous,链条彻底乱掉——和 Q1 里transactions写成类属性是同一个错误。
6. 整份作业回顾
这个 lab 的四道题看着是四个不相干的场景(银行、邮件、造币厂、斐波那契), 其实反复在问同一个问题:这个值该存在哪里,什么时候去查它。
| 题目 | 核心手法 | 迁移到哪里 |
|---|---|---|
| Q1 Bank Account | 可变的实例状态(列表);用 len(列表) 当计数器避免冗余状态;
在改动前先扣下旧值 |
任何需要「历史记录 / 日志 / 撤销栈」的对象;
项目 Ants 里每个 Place 记住自己的 insects |
| Q2 Email | 对象之间互相持有引用;用字典把「名字」翻译成「对象」;
写每一行前先确认 self 是谁 |
任何多类协作的设计。「谁认识谁」这张图画对了,代码几乎是抄下来的 |
| Q3 Mint | 类属性 vs 实例属性的取舍;self.cents 的动态查找;
把类本身当参数传 |
「所有实例共享的配置」放类属性;
需要按实际类型分派时用 self.xxx 而不是写死类名 |
| WWPD ABCs | 读属性向上爬、写属性就地停;+= 会切断继承连接 |
考试必考。上面三题的每个 bug 都能用这两条规则解释 |
| Q4 VirFib | 在方法里给对象追加实例属性;用「随身携带的状态」把重复计算消掉 | 记忆化、迭代器、任何「对象记住自己走到哪儿了」的设计 |
一张速查表
| 写法 | 发生了什么 | 影响范围 |
|---|---|---|
Cls.attr = v | 在类对象上创建/修改属性 | 所有没有同名实例属性的实例,立刻可见 |
obj.attr = v | 在实例上创建/修改属性 | 只有 obj;从此遮蔽类属性 |
obj.attr(读) | 实例 → 类 → 父类,逐层查找 | 返回第一个找到的 |
obj.lst.append(x) | 变更 lst 指向的列表对象 | 如果 lst 是类属性,所有实例都受影响 |
obj.m() | 查到函数后绑定 self = obj 再调用 | — |
Cls.m(obj) | 直接调用原始函数,手动传 self | 与上一行等价 |
OOP 没有引入任何新的求值规则。obj.attr 只是一次查找,
obj.m() 只是一次把 obj 当第一个参数的函数调用。
本 lab 所有「为什么是这个输出」的困惑,
最后都能归结为「查找从哪里开始、在哪里停」和「赋值落在哪个对象上」。
本地跑一遍
cd labs/lab06
python3 ok --local # 4 test cases passed! No cases failed.
python3 ok -q BankAccount --local
python3 ok -q Server --local
python3 ok -q Client --local
python3 ok -q Mint --local
python3 ok -q VirFib --local # 选做题,不在 default_tests 里
顺序有讲究:Server 的测试用的是 doctest 里自定义的假 Client,
不依赖你的 Client 实现;Client 的测试则同时检验两者。
所以先跑 Server,它过了再跑 Client,
不然 Client 失败时你分不清是哪个类写错了。