CS 61A  /  作业解析
LAB 06

Lab 6:面向对象编程ok 4 项通过

类、实例、属性查找、继承——这次要把「一个点号 . 到底做了什么」彻底搞清楚。

对应讲次:Lecture 13 对象与属性、Lecture 14 继承 官方题面:cs61a.org/lab/lab06 代码:labs/lab06/lab06.py 本地验证:python3 ok --local → 4 test cases passed

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 一下就行了」,然后卡在三个问题上。逐个拆。

1 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 的最后专门讲过, 但真写代码时还是很容易踩。
2 id 从哪来? 题面的定义是「此前该账户已经发生的操作笔数」。想到这一步的关键,是意识到这个数已经在手边了: 既然每笔操作都往 self.transactions 里追加一个元素,那么在追加第 k 笔之前, 列表长度恰好就是 k。所以 len(self.transactions) 就是下一笔的 id,一个额外的计数器都不用加。

也可以真的加一个 self.count = 0,每次 self.count += 1。能过,但它是一份冗余的状态: 两个地方记同一件事,一旦有一条路径忘了自增就对不上。用 len 是「单一真相来源」,不会出这种错。
3 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。
4 失败的取款怎么记? 原来的 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 循环的输出对一遍:

Transactionchanged()msgreport()
T(0, 0, 100)Trueafter > before → 'increased 0->100'0: increased 0->100
T(1, 100, 70)True70 > 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。

常见误区
  1. 把 transactions = [] 写成类属性。症状是 len(a.transactions) 返回 8(把 b 的也算进去了),id 也变成 0..7。 更隐蔽的是它不报错,只是结果不对。
  2. 失败的取款没记。症状是 len(a.transactions) 返回 3, 而且后面 b 的 id 会错位。原因几乎总是把 append 写在了 return 'Insufficient funds' 后面——那行代码根本执行不到。
  3. 先改余额再取 before。 写成 self.balance = self.balance + amount 然后 Transaction(id, self.balance, self.balance),得到 0: no change。因为两个参数取的是同一个(已经更新过的)值。
  4. report 里忘了 str()。真实报错: TypeError: can only concatenate str (not "int") to str。
  5. 用 self.id 之类的名字给 id 计数。id 是 Python 内置函数的名字。 在 Transaction.__init__ 里作为参数名用是官方给的、没问题(它只遮蔽了这个函数体内的 id),但不要在别处随手把 id 当全局变量名用。

2. Email:三个类互相指来指去

题目要什么

这题不需要发明任何算法,全部工作量是填五个空,每个空都是一个「从我手上的东西出发, 怎么走到我想要的东西」的问题。难点不在写,在于分清此刻 self 是谁。

先把三个类的关系画清楚。系统里有一台 Server,若干个 Client, 它们之间传的东西是 Email。

类实例属性方法它「认识」谁
Emailmsg(str)、sender(Client 实例)、recipient_name(str)—认识发件人对象,但只知道收件人的名字
Serverclients:{名字(str): Client 实例}send、register_client认识所有注册过的 Client
Clientinbox(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()]) 那行也是在查同一件事。

怎么想到的

填空题的正确做法是:每个空,先写出「我现在站在哪个对象上」, 再写出「目标在哪个对象上」,然后找一条路径。一个一个来。

1 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 那一段特意提醒「注意方法的参数类型」,说的就是这个。
2 Server.register_client(self, client) 里的 ____[____] = ____
目标:往字典里加一条「名字 → 对象」。 字典是 self.clients;键是这个 client 的名字,即 client.name;值就是 client 本身。 写出来是 self.clients[client.name] = client。

为什么键用 client.name 而不是别的?因为 send 那边只有一个字符串名字可用, 两头必须对得上。这是「查表」这个设计里最容易忽略的一致性要求:存的时候用什么当键, 取的时候就得用什么当键。
3 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 更直接一点。
4 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 本身对不对。

常见误区
  1. Email(message, self.name, recipient_name)。 类型错了。报错出现在很后面:AttributeError: 'str' object has no attribute 'name'。
  2. self.clients[client] = client.name(键值写反)。 len(s.clients) 变成 3,且 send 里的字典查询会 KeyError: 'Alice'。
  3. email.recipient.inbox。 AttributeError: 'Email' object has no attribute 'recipient'—— 属性名是 recipient_name。
  4. 在 send 里写 self.clients[...].inbox = email。 这把整个 inbox 换成了一封信(不再是列表), len(b.inbox) 会报 TypeError: object of type 'Email' has no len()。 要 append,不是赋值。
  5. 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() 变成 125worth 里必须写 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)。

怎么想到的

1 先弄明白 self.year 和 Mint.present_year 为什么必须分成两个东西。
读题时最容易糊涂的就是这里:造币厂不是有个年份吗,为什么还要一个 present_year? 看 doctest:Mint.present_year = 2105 之后,nickel.year 还是 2025。 这说明「现在是哪一年」(全世界共享,属于类)和「这台造币厂的章刻的是哪一年」(各台不同,属于实例) 是两件事。造币厂的章是手动更新的,update() 就是那个「去把章改一下」的动作。

这个区分正是类属性 vs 实例属性存在的意义:所有实例共享的事实放类属性, 每个实例各自的状态放实例属性。
2 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 我们要的是「按我实际是哪种硬币去查」。
3 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 本身不该被实例化,它只是个「抽象」的基类。
4 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,直接挂。
5 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 the present_year class attribute of the Mint class"。 硬币不知道造它的是哪台机器,只能直接问 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 题反复考的点。

常见误区
  1. create 里写 return coin(Mint.present_year)。 症状:nickel.year 输出 2105,期望 2025。造币厂的章和当前年份是两回事。
  2. create 里写 return coin(忘了调用)。 返回的是类不是实例,dime.year 会报 AttributeError: type object 'Dime' has no attribute 'year'。
  3. create 里写 return Coin(self.year)。 所有硬币都成了基类 Coin,worth() 里 self.cents 是 None, 报 TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'。 参数 coin 传进来就是让你用的。
  4. worth 里漏了 max(0, ...)。 新硬币算出负数,Mint().create(Dime).worth() 得 −40。
  5. worth 里写 Dime.cents 或者在 __init__ 里 self.cents = self.cents。前者让 Nickel 也按 Dime 算; 后者把面值冻结在实例上,最后一行 doctest 输出 115 而不是 125。
  6. 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。

遮蔽(shadowing)

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.yA 自己有1
B.yB 没有 → A1
C.yC 没有 → A1
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。

怎么想到的

1 先看清难点在哪。 斐波那契的递推是 F(n) = F(n-1) + F(n-2)——要算下一项, 光知道当前这一项不够,还得知道前一项。 可是 VirFib 的 __init__ 只存了 value。 所以第一个念头往往是「那就在 next 里从头算一遍」—— 但那要么需要知道自己是第几项(也没存),要么就是指数时间的递归, 两条路都被「常数时间」这个要求堵死了。
2 意识到缺的信息可以自己存。 既然缺「前一项」,那就把它存起来。存在哪?题面的 Hint 直接给了答案: "Keep track of the previous number by setting a new instance attribute inside 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 里给刚造出来的另一个对象加属性。
3 设计这个属性。 叫它 previous,含义是「这个对象的前一项斐波那契数」。 那么 next() 就是:
下一项的 value    = self.value + self.previous
下一项的 previous = self.value
造好新对象之后,把 previous 挂上去,这样它再调 next() 时也有前一项可用。 链条就这么接起来了,每一步都是常数时间:两次加法和两次赋值。
4 处理起点这个特例。 麻烦在于 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.valueself.previous新对象 value新对象 previous
1start0(无,走特判)10
2O11011
3O21121
4O32132
5O43253
6O55385

每一行的「新对象 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 改掉了,第二遍会从一个错误的起点出发,输出对不上。

常见误区
  1. 修改 self 并返回 self。 start 被污染,最后一行 doctest 挂。题目要的是「返回一个新实例」。
  2. 把 result.previous = self.value 写进 if/else 的某一支。 漏掉的那一支造出的对象没有 previous,下次 next() 报 AttributeError: 'VirFib' object has no attribute 'previous'。
  3. 写成 result.previous = self.previous。 存错了一项,序列变成 0, 1, 1, 1, 1...。新对象的前一项是我,不是我的前一项。
  4. 在 next 里重新递归计算斐波那契。 结果对,但违反常数时间要求。
  5. 把 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 失败时你分不清是哪个类写错了。