LECTURE 14

继承与字符串表示

让一个类「站在另一个类的肩膀上」:属性查找沿着继承链往上走,重复代码因此消失;顺便让对象学会把自己说成一句人话。

0. 本讲导读

上一讲你已经会写类了:class 语句造出一个类对象,调用类会走 __init__ 造出一个实例,self.x = ... 往实例上挂实例属性(instance attribute),写在 class 体里的赋值挂出类属性(class attribute),点表达式 obj.attr 先查实例再查类。

但只要你真的拿这套东西去建模一个稍微像样的系统,马上会撞上一堵墙。这一讲的幻灯片从一个很具体的场景切入:做一个 Spotify。你会需要 User、Artist、Song、Playlist、Album 这几个类。写着写着你会发现两件很别扭的事:

  • Album 和 Playlist 的 play 方法一个字都不差——都是「把里面的歌一首首放出来」。你把同一段代码抄了两遍。
  • Artist 和 User 也有一堆共同的东西:都有 name,都能被关注,都有播放列表。你又抄了一遍。

抄代码的代价不是「打字累」,而是改一处要记得改两处。哪天 play 要加一行「记录播放次数」,你改了 Playlist 忘了改 Album,bug 就埋下了。更深层的问题是:Album 和 Playlist 之间那个「专辑就是一种播放列表」的关系,在代码里完全没有被表达出来。你心里知道,Python 不知道。

本讲补上的第一样东西是继承(inheritance):一种在语言层面表达「A 是一种 B」的手段。写出 class Album(Playlist): 之后,Album 不需要重写 play,它白拿 Playlist 的 play。这不是「复制一份过来」,而是属性查找时多走一步——上一讲那条「先查实例,再查类」的规则,本讲要正式扩写成「先查实例,再查类,再沿着继承链一层层往上查」。所以这一讲的核心仍然是老问题:一个名字是怎么被查到的。

本讲补上的第二样东西是字符串表示(string representation)。你自己写的对象,直接 print 出来是 <__main__.Account object at 0x7f...> 这种鬼东西。一个好的对象应该表现得像它所代表的那种数据——分数应该能打印成 1/2,歌应该能打印成 All Too Well by Taylor Swift (5:27)。Python 给每个对象规定了两种字符串表示(str 给人看、repr 给解释器看),并且提供了 __str__ / __repr__ 两个特殊方法(special method)让你接管它们。

这两块内容合在一起,正好构成本讲最后那个练习:重构(refactor)上一讲的 Spotify 代码——用继承消掉重复,用 __str__ 让每个对象会自我介绍。往后看,Lab 06 全是继承题,Project Ants 里几乎每一种蚂蚁都是 Ant 的子类,Bee、Insect 之间的继承层次是整个项目的骨架。这一讲的属性查找规则不熟,Ants 会寸步难行。

核心结论
  • 继承表达 is-a 关系:class Sub(Base): 声明「一个 Sub 是一种 Base」。子类(subclass)继承基类(base class / superclass)的属性和方法——不是拷贝,而是查不到时往上找。
  • 点表达式的完整求值规则:求点号左边 → 若是实例,先查实例属性 → 查它的类 → 递归地查各级超类 → 都没有则 AttributeError。子类里同名的定义会「挡住」超类的定义,这就是重写(override)。
  • super().method(args) 从超类开始找 method,并自动把 self 传进去;写成 Base.method(self, args) 效果相同,但 self 必须手写。两种写法都常见,混着写就会报 TypeError。
  • 子类重写 __init__ 时,必须自己调 super().__init__(...),否则基类那些 self.x = ... 一行都不会执行,之后取 obj.x 直接 AttributeError。
  • 组合(composition)表达 has-a 关系:Bank 的实例属性里装着 一串 Account。is-a 用继承,has-a 用组合,别搞反。
  • type(obj) 给出恰好是哪个类;isinstance(obj, C) 沿继承链判断,子类的实例也算 C 的实例。
  • 每个对象有两种字符串表示:repr(给解释器看,惯例上是「能求值回一个相等对象的 Python 表达式」,交互式提示符打印的就是它)和 str(给人看,print 和 f-string 用的是它)。分别由 __repr__ 和 __str__ 定制。

1. 动机:Spotify 代码里那些抄了两遍的东西

先把问题摆到台面上。上一讲的课堂练习是给 Spotify 设计一组类,头脑风暴的结果大致是这样:

类它代表什么它跟别的类什么关系
User一个用户可以关注(follow)一个 Artist;有若干 Playlist
Artist一个歌手创作 Song;发行 Album;会被 User 关注
Song一首歌由某个 Artist 创作
Playlist一个歌单由多首 Song 组成,属于某个 User
Album一张专辑由多首 Song 组成,由某个 Artist 发行

如果只用上一讲的工具(class、__init__、实例属性、方法),你写出来的 Playlist 和 Album 会长成这样:

class Playlist:
    def __init__(self, name, songs, owner):
        self.name = name
        self.songs = songs
        self.owner = owner
        self.total_duration_sec = sum([s.duration_sec for s in songs])
        self.owner.playlists.append(self)

    def play(self):
        for song in self.songs:
            song.play()


class Album:                      # 没有继承,什么都得自己写
    def __init__(self, name, songs, artist):
        self.name = name
        self.songs = songs
        self.owner = artist
        self.total_duration_sec = sum([s.duration_sec for s in songs])
        self.owner.playlists.append(self)
        self.owner.albums.append(self)

    def play(self):               # ←←← 和 Playlist.play 一模一样
        for song in self.songs:
            song.play()

两个 play 完全相同,两个 __init__ 有五行里的四行相同。这就是幻灯片里说的「a lot of repeated code」。

直觉

判断该不该用继承,有个屡试不爽的语感测试:把两个类名套进「一个 ___ 是一种 ___」这句话,读起来通顺吗?

  • 「一张专辑是一种歌单」——通顺。专辑无非是所有者恰好是歌手的歌单。✓ 用继承
  • 「一个歌手是一种用户」——通顺。歌手也有名字、也能关注别人、也有 Liked Songs。✓ 用继承
  • 「一个银行是一种账户」——不通顺。银行有很多账户,不是账户。✗ 该用组合
  • 「一个歌单是一种歌」——不通顺。歌单装着歌。✗ 该用组合

「是一种」= is-a = 继承;「有一些」= has-a = 组合。第 8 节会正式讲组合。

注意「Album 是一种 Playlist」这个判断里藏着一层意思:Album 能做的事,Playlist 全都能做;Album 只是多了一点东西、或者把某件事做得不太一样。专辑比歌单多的是「它会被登记到歌手的 albums 列表里」,不一样的是「所有者被限定成 Artist」。除此之外它就是个歌单。这种「基本一样、稍有不同」正是继承要处理的情形。

反过来说,如果两个类只是碰巧有几个同名方法,但概念上毫无从属关系(比如 Song 和 Advertisement 都有 play),硬要给它们造一个共同父类往往是过度设计。继承首先是一个建模决定,其次才是一个省代码手段。

注意

本讲的正式讲解会先离开 Spotify,改用一个更小、更容易画环境图的例子:Account 和 CheckingAccount(银行账户 / 支票账户)。因为继承的所有关键机制——重写、super()、类属性查找——在这个例子里五行代码就能演完。把机制吃透之后,第 11 节再回到 Spotify 做完整重构。

2. 继承:is-a 关系与子类的定义

继承(inheritance)描述子类(subclass,又叫 child class)与基类(base class,又叫 parent class / superclass)之间的一种 is-a 关系。

子类继承基类的属性和方法——「继承」在这里的准确含义是:子类的实例能访问、能使用基类里定义的那些名字。一个 Playlist 可以被 play,所以一个 Album 也可以被 play,哪怕 Album 自己一个字都没写。

继承的定义:subclass 与 base class 之间的 is-a 关系
继承的两句话定义。上半句说清了它建模的是什么关系(is-a,「一张专辑是一个歌单」「一条狗是一种动物」),下半句说清了它带来的能力(子类可以访问超类的属性和方法,所以「歌单能播放 ⇒ 专辑也能播放」)。注意这两句话是因果关系:因为它是一个 Playlist,所以它当然能做 Playlist 能做的事。

语法

定义子类只需要在类名后面加一对括号,里面写基类名:

class 子类名(基类名):
    <类体>

本讲的运行例子是账户。先看基类 Account:

class Account:
    interest = 0.02                      # 类属性:所有账户共享的默认利率

    def __init__(self, account_holder):
        self.balance = 0
        self.holder = account_holder

    def deposit(self, amount):
        self.balance = self.balance + amount
        return self.balance

    def withdraw(self, amount):
        if amount > self.balance:
            return 'Insufficient funds'
        self.balance = self.balance - amount
        return self.balance

这里没有任何新东西,全是上一讲的内容:interest 是类属性(写在类体里、不带 self.),balance 和 holder 是实例属性(在 __init__ 里用 self. 挂上去)。

现在定义支票账户。它跟普通账户几乎一样,只有两点不同:取款要收 1 元手续费,利率低一些(0.01)。

class CheckingAccount(Account):
    withdraw_charge = 1
    interest = 0.01

    def withdraw(self, amount):
        return Account.withdraw(self, amount + self.withdraw_charge)

请数一下这个类没有写什么:没有 __init__、没有 deposit。但下面这些全都能跑:

>>> emma = CheckingAccount('Emma')     # 用的是 Account.__init__
>>> emma.balance
0
>>> emma.holder
'Emma'
>>> emma.deposit(100)                  # 用的是 Account.deposit
100

这就是继承在做的事。CheckingAccount 里查不到 __init__,Python 就去它的基类 Account 里查,查到了就用。

核心结论

继承不是把基类的代码复制进子类。子类的类对象里根本没有 deposit 这个名字。发生的事情是:查找 emma.deposit 时,先查 emma 这个实例(没有),再查 CheckingAccount 类(没有),再顺着「基类」这根指针走到 Account(有了)。属性查找是一个运行时沿链搜索的过程,不是一次性的代码拷贝。

这一点有个可以直接验证的推论:基类改了,子类立刻跟着变。因为子类每次用 deposit 都是现查现用的。

重写(override)

CheckingAccount 自己定义了一个 withdraw,而 Account 里也有一个 withdraw。这时对 emma.withdraw 的查找会在 CheckingAccount 这一层就命中,根本走不到 Account。子类的定义「挡住」了基类的同名定义,这叫重写(override,也译作覆盖)。

类属性同理:Account.interest 是 0.02,但 CheckingAccount.interest 是 0.01,后者重写了前者。

名字CheckingAccount 里有吗查 emma.<名字> 最终用的是
deposit没有Account.deposit(继承)
__init__没有Account.__init__(继承)
withdraw有CheckingAccount.withdraw(重写)
interest有(0.01)CheckingAccount.interest(重写)
withdraw_charge有(1)CheckingAccount.withdraw_charge(新增)
balance—实例属性,由 Account.__init__ 挂上去

3. 一次 withdraw 调用里到底发生了什么

这一节把幻灯片上那段交互记录逐步拆开。这是本讲最该慢下来看的一段。

>>> sriya = Account('Sriya')
>>> sriya.deposit(100)
100
>>> sriya.withdraw(50)
50
>>> emma = CheckingAccount('Emma')
>>> emma.deposit(100)
100
>>> emma.withdraw(50)
49

前四行没什么可说的:sriya 是普通账户,存 100 取 50 剩 50。关键在最后一行——为什么是 49 而不是 50。

逐步推演:emma.withdraw(50)
1 这是一个调用表达式,算子是点表达式 emma.withdraw,算子数是 50。先求算子。
2 求 emma.withdraw:先求点号左边的 emma,在全局帧查到一个 CheckingAccount 实例。
3 在这个实例里找名为 withdraw 的属性。实例上只有 balance 和 holder(__init__ 挂的),没有 withdraw。
4 去实例所属的类 CheckingAccount 里找。找到了——就是子类自己定义的那个。于是把它绑定(bind)成一个绑定方法,self 已经锁定为 emma。(注意:查找到此为止,Account.withdraw 被挡住了。)
5 求算子数 50。新建一帧 f1: CheckingAccount.withdraw,绑定 self = emma、amount = 50。
6 执行函数体 return Account.withdraw(self, amount + self.withdraw_charge)。先算实参:self.withdraw_charge —— 实例上没有,CheckingAccount 上有,得 1;所以第二个实参是 50 + 1 = 51。
7 算算子 Account.withdraw:点号左边求值成类对象 Account,在它里面查 withdraw,得到那个普通函数(不是绑定方法——左边是类不是实例,没有 self 可绑)。
8 新建一帧 f2: Account.withdraw,绑定 self = emma(手写传进去的)、amount = 51。
9 执行:51 > 100 为假,不返回 'Insufficient funds';self.balance = 100 - 51 = 49,改的是 emma 的 balance;返回 49。
10 f2 返回 49 给 f1,f1 原样 return 出去。最终显示 49。

把两帧画出来,注意两帧里的 self 是同一个对象——这是理解继承的关键:整个过程只有一个 emma,没有「父类那一半」和「子类那一半」之分。

环境图 + 对象
Global frame
    Account         ──→ class Account
    CheckingAccount ──→ class CheckingAccount   [base: Account]
    sriya           ──→ Account instance #1
    emma            ──→ CheckingAccount instance #2

class Account                       class CheckingAccount(Account)
    interest        0.02                withdraw_charge  1
    __init__        func                interest         0.01
    deposit         func                withdraw         func
    withdraw        func

Account instance #1                 CheckingAccount instance #2
    balance   50                        balance   49      ← 被 f2 改掉
    holder    'Sriya'                   holder    'Emma'
                                        (实例上没有 withdraw / interest)

f1: CheckingAccount.withdraw   [parent=Global]
    self    ──→ instance #2
    amount  50
    正在算  Account.withdraw(self, 50 + 1)

f2: Account.withdraw           [parent=Global]
    self    ──→ instance #2      ← 和 f1 里的 self 指向同一个对象
    amount  51
    返回值  49
常见误区

误区一:以为 CheckingAccount 的实例里有两份 balance。没有。Account.__init__ 里的 self.balance = 0 中的 self 就是那个 CheckingAccount 实例,属性直接挂在它身上。一个实例就是一个属性字典,继承层次多深都只有这一个字典。

误区二:以为 emma.withdraw(50) 会先跑 Account.withdraw 再跑子类的。不会。属性查找一旦命中就停,只会调到 CheckingAccount.withdraw。基类那个方法之所以也执行了,纯粹是因为子类的代码自己显式调用了它。如果你在子类里写:

class CheckingAccount(Account):
    withdraw_charge = 1
    def withdraw(self, amount):
        return amount            # 完全不碰基类

那基类的 withdraw 永远不会被执行,取钱也不会扣余额。调不调基类,是你写代码时的选择,不是语言自动做的。

误区三:以为参数里的 self.withdraw_charge 可以写成 withdraw_charge。不行。withdraw_charge 是类属性,它不在 withdraw 这一帧的作用域链上。函数帧的 parent 是定义处的环境(这里是全局帧),类体不构成一个函数能往上查的作用域。直接写会报:

NameError: name 'withdraw_charge' is not defined

要拿类属性,只能通过 self.withdraw_charge 或 CheckingAccount.withdraw_charge 这样的点表达式。

为什么第 6 步一定要写 self.withdraw_charge 而不是 CheckingAccount.withdraw_charge?

在目前这段代码里,两种写法结果都是 1。但它们的含义不同,而且差别马上就会显现:

  • CheckingAccount.withdraw_charge:写死去 CheckingAccount 这个类上取。
  • self.withdraw_charge:从当前这个对象出发往上查。如果实例上有自己的 withdraw_charge(比如第 5 节里手续费 0.5 的那个账户),就用实例的;如果哪天又有个 CheckingAccount 的子类把手续费改成 3,用 self. 写的这段代码不用改一个字就能正确收 3 块。

这就是所谓的动态查找(dynamic lookup):方法体里写 self.x,具体取到哪个 x 要到运行时、看 self 究竟是谁才知道。这是继承体系里绝大多数灵活性的来源。默认就该写 self.,除非你确实想要「无论如何都用这个类上的那个值」。

4. 两种调用基类的写法:Account.withdraw(self, …) 与 super()

幻灯片把同一个 CheckingAccount.withdraw 写了两遍,这不是重复,是在对比两种等价写法。

CheckingAccount 用 Account.withdraw(self, amount + self.withdraw_charge) 调用基类方法,self 被高亮
写法一:直接拿类名去调基类方法。self 被高亮是有原因的——通过类取出来的 withdraw 是一个普通函数,它的第一个形参 self 没有被任何东西自动填上,必须你亲手把当前对象传进去。这里传的 self 就是那个 CheckingAccount 实例,所以基类方法里的 self.balance 改的正是它的余额。
class CheckingAccount(Account):
    withdraw_charge = 1
    interest = 0.01

    def withdraw(self, amount):
        return Account.withdraw(self, amount + self.withdraw_charge)

写法二用内建函数 super():

class CheckingAccount(Account):
    withdraw_charge = 1
    interest = 0.01

    def withdraw(self, amount):
        return super().withdraw(amount + self.withdraw_charge)

super() 的含义是:「从我的超类开始查这个名字,并且把当前的 self 自动补上」。所以 super().withdraw(51) 拿到的是一个已经绑定好 self 的方法,你不能再手写一遍 self。

Account.withdraw(self, x)super().withdraw(x)
从哪里开始查名字写死的 Account 类当前类的超类(由类定义决定)
取出来的东西普通函数(unbound)绑定方法(self 已绑好)
self 谁来给你手写自动
基类改名后要改这一行不用改
能否在类外使用能,Account.withdraw(obj, 5) 随处可写不能,super() 只在类体内的方法里有意义

为什么 super() 更受推荐?因为它把「我的父亲是谁」这个信息只写一遍——写在 class CheckingAccount(Account): 那一行。哪天你把继承关系改成 class CheckingAccount(SavingsAccount):,用 super() 的代码自动跟着变;用类名硬写的代码则会悄悄地继续调用 Account,而且不报错——这是最难查的那类 bug。

CS 61A 的作业和考试两种写法都会出现,Ants 项目的框架代码里 super() 用得很多,所以两种都必须认得。

常见误区

把两种写法混起来,是初学继承时最高频的报错,而且报错信息看起来很莫名其妙。

错法 A:用了 super() 还手写 self——

def withdraw(self, amount):
    return super().withdraw(self, amount + self.withdraw_charge)   # ✗
TypeError: Account.withdraw() takes 2 positional arguments but 3 were given

「takes 2 but 3 were given」里的 2 指的是 self 和 amount。super().withdraw 已经悄悄把 self 算作第 1 个了,你又塞了 self 和 amount,一共 3 个。看到「多给了一个参数」,第一反应就该是「self 传重了」。

错法 B:用了类名却忘了写 self——

def withdraw(self, amount):
    return Account.withdraw(amount + self.withdraw_charge)         # ✗
TypeError: Account.withdraw() missing 1 required positional argument: 'amount'

这条报错特别容易读岔:明明传了一个数,怎么说缺 amount?因为你传的那个 51 被当成了 self,amount 就空着了。看到「缺最后一个参数」,第一反应就该是「self 忘传了」。顺带说,就算侥幸没报错(比如方法只有 self 一个形参),你也会把一个数字当成账户对象来用,接着在 self.balance 处炸成 AttributeError: 'int' object has no attribute 'balance'。

直觉

记住这条规则就不会错:点号左边是实例,self 自动补;点号左边是类,self 自己写。

emma.withdraw(50)            # 左边是实例 → self=emma 自动
CheckingAccount.withdraw(emma, 50)   # 左边是类 → self 手写
super().withdraw(51)         # super() 特殊:查找从超类开始,但 self 仍自动

super() 之所以能「自动」,是因为它知道当前方法帧里的 self 是谁——它是在方法内部调用的,Python 替你把上下文接了过去。

5. 在子类里重写 __init__

到目前为止 CheckingAccount 借用了基类的 __init__。但如果子类需要多存一点东西,或者想让调用者多传一个参数,就得自己写 __init__。

需求:允许开户时指定手续费,不指定就用默认的 1。

CheckingAccount 定义自己的 __init__,第一行 super().__init__(holder) 被高亮
子类自己的 __init__。被高亮的第一行是全部要点:子类一旦定义了 __init__,基类的 __init__ 就被彻底挡住了,必须自己显式把它叫回来,否则 self.balance 和 self.holder 根本不会被创建。super().__init__(holder) 只传 holder——self 由 super() 自动补上。
class CheckingAccount(Account):
    withdraw_charge = 1
    interest = 0.01

    def __init__(self, holder, withdraw_charge=None):
        super().__init__(holder)
        if withdraw_charge:
            self.withdraw_charge = withdraw_charge
        else:
            self.withdraw_charge = CheckingAccount.withdraw_charge

    def withdraw(self, amount):
        return super().withdraw(amount + self.withdraw_charge)
>>> emma = CheckingAccount('Emma')
>>> emma.withdraw_charge
1
>>> amy = CheckingAccount('Amy', 0.5)
>>> amy.withdraw_charge
0.5
>>> amy.deposit(100)
100
>>> amy.withdraw(50)
49.5
逐步推演:CheckingAccount('Amy', 0.5) 之后 amy.withdraw(50)
1 CheckingAccount('Amy', 0.5):调用一个类 → 造一个空实例,然后调 __init__。查 __init__:CheckingAccount 里有,用它。
2 帧 f1: CheckingAccount.__init__,绑定 self=新实例、holder='Amy'、withdraw_charge=0.5。
3 super().__init__(holder) → 帧 f2: Account.__init__,self=同一个实例、account_holder='Amy'。执行 self.balance = 0、self.holder = 'Amy'。此刻实例上才有了 balance 和 holder。
4 回到 f1:if withdraw_charge: 中 0.5 为真 → self.withdraw_charge = 0.5。这在实例上新建了一个属性,它会挡住类属性 1。
5 amy.withdraw(50):查 withdraw → 实例没有 → CheckingAccount 有 → 帧 f3,amount=50。
6 算 self.withdraw_charge:实例上有 0.5,直接返回,不再往类上查。实参 = 50 + 0.5 = 50.5。
7 super().withdraw(50.5) → 帧 f4: Account.withdraw。50.5 > 100 假 → self.balance = 100 - 50.5 = 49.5 → 返回 49.5。
两个 CheckingAccount 实例的属性对比
class CheckingAccount(Account)
    withdraw_charge  1          ← 类属性,一份
    interest         0.01
    __init__ / withdraw  func

emma (没传手续费)              amy (传了 0.5)
    balance          0              balance          49.5
    holder           'Emma'         holder           'Amy'
    withdraw_charge  1              withdraw_charge  0.5
                     ↑                               ↑
        else 分支写上去的实例属性,        if 分支写上去的实例属性,
        值恰好等于类属性,但是            值来自调用者
        实例上确实存了一份

注意 else 分支:self.withdraw_charge = CheckingAccount.withdraw_charge 把类属性的值抄了一份到实例上。这一句其实可以不写——不写的话 emma.withdraw_charge 照样能通过类属性查到 1。幻灯片这样写是为了让「每个账户都有自己的手续费」这件事在实例层面统一、显式。代价是:以后改 CheckingAccount.withdraw_charge = 2,已经开出去的账户不会跟着变,因为它们实例上已经存了自己的一份。这是一个有意的设计取舍,不是 bug。

常见误区

误区一:子类写了 __init__ 但忘了调 super().__init__(...)。这是继承里最经典的错误。

class Artist(User):
    def __init__(self, name):
        self.albums = []          # ✗ 忘了 super().__init__(name)
>>> a = Artist('Taylor Swift')
>>> a.name
AttributeError: 'Artist' object has no attribute 'name'

关键在于:创建实例时不报错,一切看起来都好;直到很久以后某个地方取 name,才炸出一个看上去毫无关系的 AttributeError。诊断口诀:报「实例没有某属性」,先回去看这个属性该在哪个 __init__ 里被赋值,再看那个 __init__ 到底跑没跑过。

误区二:if withdraw_charge: 判的是真假性,不是「有没有传」。传 0 会走到 else:

>>> free = CheckingAccount('Free', 0)
>>> free.withdraw_charge
1

本想开一个免手续费账户,结果被收了 1 块。0 是 falsy,和 None 一样进 else。要严格区分「没传」和「传了 0」,条件得写成 if withdraw_charge is not None:。幻灯片上的写法在这个场景下是可以接受的(没人开 0 手续费的支票账户),但你要知道它为什么可以接受,而不是照抄。

误区三:把 super().__init__(holder) 写成 super().__init__(self, holder)。同第 4 节,TypeError: Account.__init__() takes 2 positional arguments but 3 were given。

6. 点表达式求值的完整规则

上一讲给过一个简化版规则(先查实例、再查类)。有了继承之后,规则要补上「再往上」这一步。这是本讲唯一需要背下来的东西,而且考试年年考。

点表达式求值规则:求左边、查实例、查类、递归查超类、否则 AttributeError
点表达式求值的完整算法。被高亮的 2c 是本讲新增的一步:递归地到超类里重复 2a、2b,直到没有超类为止。「递归」这个词用得很准——超类自己也可能有超类,查找过程就是沿着这条链一节一节往上走,任何一节命中就立刻停下并返回。

把它拆成人话,一条一条对着念:

求值 <表达式>.<name>
1 先求点号左边那个表达式。注意它可以是任意表达式,不一定是个名字——bank.accounts[0].balance 就是从左往右一层层来的。求完之后,结果要么是一个实例,要么是一个类(也可能是别的对象,规则一样)。
2a 如果第 1 步得到的是实例,先在这个实例自己的属性里找 <name>。找到就返回它的值,结束。
2b 实例上没有(或者第 1 步得到的本来就是个类),就到类上找 <name>。找到就返回,结束。(若这一步取到的是函数、且第 1 步是实例,取出来的是绑定方法。)
2c 类上也没有,就到它的超类上重复 2b;超类还没有,就到超类的超类……一层层往上,直到没有超类可查。
2d 一路查到头都没有 → 抛出 AttributeError。

用这条规则把前面所有结果重新验算一遍:

点表达式2a 实例2b 类 CheckingAccount2c 超类 Account结果
emma.balance命中 0——0
emma.deposit无无命中绑定方法 Account.deposit
emma.withdraw无命中(不查)绑定方法 CheckingAccount.withdraw
emma.interest无命中 0.01(不查)0.01
sriya.interest无(不适用)命中 0.020.02
CheckingAccount.deposit(跳过 2a)无命中普通函数
emma.rate无无无AttributeError

最后一行的真实报错分两种,取决于点号左边是什么:

>>> emma.rate
AttributeError: 'CheckingAccount' object has no attribute 'rate'

>>> CheckingAccount.rate
AttributeError: type object 'CheckingAccount' has no attribute 'rate'

两句话的差别是 'CheckingAccount' object 和 type object 'CheckingAccount'。读到 type object,说明你点的是类不是实例——这条信息在调试时很有用,比如你本想写 emma.balance 却手滑写成了 CheckingAccount.balance。

为什么 2b 里说「或者第 1 步得到的本来就是个类」

因为类没有「实例属性」这一说,直接从 2b 起步。这也解释了一个初学者常问的问题:为什么 Account.balance 会报错,而 sriya.balance 好好的?因为 balance 是在 __init__ 里用 self.balance = 0 挂到某个具体实例上的,类身上从来就没有过这个名字。

核心结论

这套规则和第 1 讲的作用域链形式上很像(查不到就往 parent 走),但是两条完全不同的链,千万别混:

名字查找(作用域链)属性查找(继承链)
触发它的写法光秃秃一个名字 x点表达式 obj.x
沿什么走帧的 parent(由函数定义的位置决定)实例 → 类 → 超类(由 class 语句决定)
尽头是全局帧没有超类的那个类
找不到时NameErrorAttributeError

在方法体里写 withdraw_charge(没有 self.)走的是左边这条链,所以查不到,报 NameError;写 self.withdraw_charge 走的是右边这条链,才能查到类属性。这是第 3 节那个误区的根本原因。

常见误区

误以为「子类查不到就查父类」对赋值也成立。不成立。查找会往上走,赋值绝不往上走。

>>> emma.interest            # 查找:一路走到 CheckingAccount,得 0.01
0.01
>>> emma.interest = 0.08     # 赋值:直接在 emma 这个实例上创建一个新属性
>>> emma.interest
0.08
>>> CheckingAccount.interest # 类属性纹丝不动
0.01

obj.x = v 永远只做一件事:在 obj 自己身上绑定 x。它不会去修改类属性,也不会去修改超类的属性。第 7 节整节都在讲这件事的后果。

7. 类属性、实例属性与继承的相互作用

上一讲你已经知道「类属性只有一份、被所有实例共享」。加上继承之后,「一份」这个说法要说得更精确:一份属于定义它的那个类。子类重写了就是子类自己新的一份,子类没重写就共用基类那一份。

下面这段交互把四种情况全试了一遍,输出是真实跑出来的:

>>> a = Account('A')
>>> b = Account('B')
>>> emma = CheckingAccount('Emma')
>>> a.interest, b.interest, emma.interest
(0.02, 0.02, 0.01)

>>> Account.interest = 0.05          # ① 改基类的类属性
>>> a.interest, b.interest
(0.05, 0.05)
>>> emma.interest                    # CheckingAccount 有自己的,不受影响
0.01

>>> a.interest = 0.08                # ② 给某个实例赋值
>>> a.interest, b.interest, Account.interest
(0.08, 0.05, 0.05)
逐步推演:为什么是这三个数
1 Account.interest = 0.05 修改的是 Account 这个类对象上的绑定。a 和 b 实例上都没有 interest,每次取值都要走到 Account,所以两个都变成 0.05。共享不是「值被复制了三份」,而是「三条查找路径终点是同一个格子」。
2 emma.interest 的查找在 CheckingAccount 就命中了 0.01,压根走不到 Account,所以基类怎么改都跟它无关。
3 a.interest = 0.08 在实例 a 上创建了一个新的实例属性。从此 a.interest 在 2a 就命中,再也不往上走。
4 b 身上什么都没变,还是查到类属性 0.05。Account.interest 本身也没被动过,仍是 0.05。

可以直接看实例的属性字典来确认第 3 步真的往 a 身上加了东西:

>>> a.__dict__
{'balance': 0, 'holder': 'A', 'interest': 0.08}
>>> b.__dict__
{'balance': 0, 'holder': 'B'}
你写的实际改了谁谁会看到变化
Account.interest = 0.05Account 类对象所有没有自己 interest 的 Account 实例;以及没重写 interest 的子类及其实例
CheckingAccount.interest = 0.03CheckingAccount 类对象只有 CheckingAccount(及其子类)的实例
a.interest = 0.08实例 a只有 a
self.interest = 0.08(在方法里)当前那个实例只有当前那个实例
注意

如果类属性是可变对象(列表、字典),共享就会变得非常危险:

class Playlist:
    songs = []          # ✗ 类属性列表,所有实例共用同一个列表!

    def add(self, song):
        self.songs.append(song)     # 没有赋值,只是修改那个共享列表

self.songs.append(...) 里没有赋值,走的是「查找 → 拿到那个共享列表 → 改它」。于是 A 用户加的歌,B 用户也看得见。这不是继承特有的坑,但在继承体系里更难发现,因为那个列表可能定义在三层之上的某个基类里。规则很简单:可变的默认值要在 __init__ 里用 self.songs = [] 创建,每个实例一份。本讲的 Bank.__init__ 里写 self.accounts = [] 而不是把 accounts = [] 写在类体里,正是这个原因。

直觉

什么该当类属性?「这个类的所有实例都一样、而且不随实例变化的东西」——利率的默认值、手续费的默认值、蚂蚁的造价和血量上限(Ants 项目里到处都是这种类属性)。它同时充当「默认值 + 文档」:读代码的人一眼就知道支票账户默认收 1 块。

什么该当实例属性?「每个对象各不相同的东西」——余额、户主、名字、这只蚂蚁当前还剩多少血。

8. 组合:has-a 关系,以及 type() 与 isinstance()

组合(composition)描述两个类之间的 has-a 关系:一个银行有很多账户;一门课有很多学生;一个歌单有很多首歌。

组合在语法上没有任何新东西——就是把别的对象存进实例属性里。它值得单独讲,是因为初学者太容易在该用组合的地方用继承。

Bank 类:accounts 列表、open_account 接受 kind 参数、pay_interest 遍历账户
Bank 只用组合:它有一个 accounts 列表。被高亮的两处是这段代码真正精彩的地方——kind=Account 把一个类当成普通的默认参数值,kind(holder) 则是调用这个参数来造对象。类在 Python 里是一等值,可以传参、可以存变量、可以当默认值。
class Bank:
    def __init__(self):
        self.accounts = []

    def open_account(self, holder, kind=Account):
        account = kind(holder)
        self.accounts.append(account)
        return account

    def pay_interest(self):
        for account in self.accounts:
            account.deposit(account.balance * account.interest)
>>> bank = Bank()
>>> sriya = bank.open_account('Sriya')
>>> emma = bank.open_account('Emma', CheckingAccount)
>>> sriya.deposit(100)
100
>>> emma.deposit(100)
100
>>> bank.pay_interest()
>>> sriya.balance
102.0
>>> emma.balance
101.0
逐步推演:为什么一个是 102.0,另一个是 101.0
1 bank.open_account('Sriya'):kind 用默认值 Account。kind(holder) = Account('Sriya'),造出一个普通账户,append 进 bank.accounts,并返回它。所以 sriya 和 bank.accounts[0] 是同一个对象(别名)。
2 bank.open_account('Emma', CheckingAccount):kind 被绑到 CheckingAccount 这个类对象。kind(holder) 就成了 CheckingAccount('Emma')。同一行代码,造出了不同类的对象。
3 两次 deposit(100):emma 用的是继承来的 Account.deposit,两人余额都变成 100。
4 pay_interest() 遍历 self.accounts。第一个是 sriya:sriya.interest 查到 Account.interest = 0.02,存入 100 * 0.02 = 2.0,余额 102.0。
5 第二个是 emma:emma.interest 查到的是 CheckingAccount.interest = 0.01,存入 100 * 0.01 = 1.0,余额 101.0。
6 因为 bank.accounts 里装的就是 sriya / emma 本人(别名,不是拷贝),所以在循环里改余额,外面的 sriya.balance 立刻看得见。

pay_interest 只有两行,却能对两种不同的账户做正确的事——因为它从头到尾没有问过「你是哪一类账户」,只是写 account.interest,让属性查找规则去决定取到哪个值。这种「同一段代码,对不同类型的对象各自做对的事」叫多态(polymorphism),是继承最主要的收益。

核心结论

继承和组合解决的是完全不同的问题,别拿一个去顶另一个:

继承 inheritance组合 composition
表达的关系is-a(是一种)has-a(有一个 / 有一些)
写法class Sub(Base):self.parts = [...]
例子Album 是 Playlist;Artist 是 UserBank 有 Accounts;Playlist 有 Songs
带来什么共享实现、多态把复杂对象拼装出来
误用后果让 Bank 继承 Account,于是「银行」也有了 balance,语义完全错乱让 Album 组合一个 Playlist,于是每次都要写 album.playlist.play(),别扭且丢失多态

一个类同时用两者是常态:Album 继承 Playlist(is-a),同时组合了一串 Song(has-a)。

type() vs isinstance()

有了继承,「这个对象是什么类型」就有了两种答案,Python 用两个不同的函数分别回答:

>>> type(sriya)
<class 'Account'>
>>> type(emma)
<class 'CheckingAccount'>
>>> isinstance(sriya, Account)
True
>>> isinstance(emma, Account)
True
>>> isinstance(sriya, CheckingAccount)
False
>>> isinstance(emma, CheckingAccount)
True

注意其中最关键的一行:isinstance(emma, Account) 是 True。这正是 is-a 关系在语言层面的体现——一个支票账户就是一个账户。而 type(emma) 只回答「它恰好是哪个类造出来的」,是 CheckingAccount,不是 Account。

type(obj)isinstance(obj, C)
返回什么一个类对象True / False
回答的问题「它恰好属于哪个类?」「它算不算一个 C?」
看继承链吗不看,只看直接所属的类看,沿继承链一路往上
emma 与 Accounttype(emma) == Account → Falseisinstance(emma, Account) → True
什么时候用需要精确区分层次时(少见)绝大多数情况
常见误区

用 type(x) == SomeClass 做类型判断,会把子类全部漏掉。比如给 Bank 加一个「统计普通账户数量」的方法:

def count_accounts(self):
    return len([a for a in self.accounts if type(a) == Account])     # 漏掉子类

如果你的本意是「所有账户」,这样写会把 CheckingAccount 全部排除掉,而且不报任何错,只是数字偏小。要「所有账户」就该用 isinstance(a, Account)。

反过来,如果你的本意就是「只要恰好是 Account、不要子类」,那 type(a) == Account 才是对的。先想清楚要问哪个问题,再选函数。

9. 字符串表示:str 与 repr

换一个话题,但动机是同一个:一个对象应该表现得像它所代表的那种数据。你造了一个 Rational 类来表示分数,那它至少应该能被打印成 1/2,而不是:

>>> print(sriya)
<__main__.Account object at 0x7f2c1a3d5f70>

这行默认输出里唯一有用的信息是那串十六进制的内存地址——它能告诉你「两次打印出来的是不是同一个对象」,除此以外毫无价值。

Python 的做法是:每个对象都有两种字符串表示,用途不同。

strrepr
给谁看人(human-readable)Python 解释器(legible to the interpreter)
惯例上应该是好读的描述一个能求值回相等对象的 Python 表达式
什么时候被用print(x)、f-string 里的 {x}、str(x)交互式提示符直接回显、repr(x)、容器里的元素
由谁定制__str____repr__

repr:给解释器看的那一份

repr 函数返回一个字符串,按惯例这个字符串应当是一个 Python 表达式,把它求值回来能得到一个相等的对象。对大多数类型来说:

eval(repr(obj)) == obj

eval 是一个内建函数,接受一个字符串,把它当成 Python 表达式来求值。所以 repr 相当于「把对象序列化成源代码」。

更常用的一个事实是:你在交互式解释器里输入一个表达式,回显出来的那行就是它的 repr。

>>> 3e5
300000.0
>>> print(repr(3e5))
300000.0

这两行输出一样,说明提示符回显走的就是 repr。字符串的例子对比更明显:

>>> s = 'hello'
>>> s
'hello'
>>> print(s)
hello

直接输入 s,回显的是带引号的 'hello'——因为 'hello' 才是一个能求值回这个字符串的 Python 表达式。而 print(s) 用的是 str,给人看的当然不带引号。这就是你写 doctest 时「什么时候要打引号」的全部依据:doctest 比对的是解释器回显,也就是 repr。

不是所有对象都有「能求值回来」的表示。这时 repr 退而求其次,给一个带尖括号的描述:

>>> repr(min)
'<built-in function min>'

注意 repr 的返回值是一个字符串,所以在提示符下它自己又被 repr 了一次,于是外面多了一层单引号。<built-in function min> 显然不是合法的 Python 表达式——惯例就是惯例,做不到时按惯例用尖括号包一个描述。你在开头看到的 <__main__.Account object at 0x...> 正是这种「做不到」的默认形式。

str:给人看的那一份

str 的结果就是 print 打出来的东西,也是 f-string 插值时用的东西。标准库里的 Fraction 把两者区分得很典型:

>>> from fractions import Fraction
>>> frac = Fraction(1, 2)
>>> repr(frac)
'Fraction(1, 2)'
>>> str(frac)
'1/2'
>>> print(frac)
1/2
>>> f'My fraction is {frac}'
'My fraction is 1/2'

逐行对照一遍:

1 repr(frac) 得到 'Fraction(1, 2)'——把这串字符原样粘回解释器,确实能造出一个相等的分数。它面向的是「重建这个对象」。
2 str(frac) 得到 '1/2'——数学课本上分数就长这样。它面向的是「让人读懂」。
3 print(frac) 打出 1/2,没有引号:print 用 str,而且打印字符串时不加引号。
4 f-string 里的 {frac} 同样走 str,所以拼出 'My fraction is 1/2'。整个 f-string 的结果本身是个字符串,提示符回显时带上了外层引号。

对绝大多数类型(整数、浮点数、列表……)str 和 repr 结果相同,所以平时感觉不到区别。字符串本身和 Fraction 是两个最容易看出差别的例子。

常见误区

误以为 print 一个列表会对元素用 str。不会——容器打印元素时用的是 repr:

>>> names = ['ann', 'bob']
>>> print(names)
['ann', 'bob']

外层的方括号是列表的 str,但里面两个元素带着引号,说明它们走的是 repr。这解释了一个很常见的困惑:明明给自己的类写了漂亮的 __str__,一 print 列表还是一堆 <... object at 0x...>。要让对象在列表里也好看,得写 __repr__。

10. __repr__ 与 __str__

repr(x) 和 str(x) 这两个内建函数,实际上是去找对象的特殊方法(special method) __repr__ 和 __str__ 并调用它们。你在自己的类里定义这两个方法,就接管了对象的字符串表示。

Rational 类定义 __repr__ 返回 Rational(1, 2),__str__ 返回 1/2
同一个 Rational 对象的两副面孔。__repr__ 拼出的字符串长得跟构造它的那行代码一模一样——这正是「能求值回一个相等对象」这条惯例的实现方式:把类名和各个属性值原样拼出来。__str__ 则完全不管 Python 语法,只管人怎么读分数。两个方法都返回字符串,不是 print。
class Rational:
    def __init__(self, numer, denom):
        self.numer = numer
        self.denom = denom

    def __repr__(self):
        return f'Rational({self.numer}, {self.denom})'

    def __str__(self):
        return f'{self.numer}/{self.denom}'
>>> rat = Rational(1, 2)
>>> repr(rat)
'Rational(1, 2)'
>>> str(rat)
'1/2'
>>> rat                      # 提示符回显 → __repr__
Rational(1, 2)
>>> print(rat)               # print → __str__
1/2
>>> [rat, rat]               # 容器里的元素 → __repr__
[Rational(1, 2), Rational(1, 2)]
逐步推演:print(rat) 到底经过了几手
1 求值 print(rat):算子 print,算子数 rat(一个 Rational 实例)。
2 print 拿到这个对象,需要一个字符串才能往屏幕上写,于是对它调用 str(rat)。
3 str 去找 rat 的 __str__——按第 6 节那套点表达式规则:实例上没有 → Rational 类上有。取到绑定方法。
4 调用它,新建一帧,self = rat。执行 return f'{self.numer}/{self.denom}':查 self.numer 得 1,查 self.denom 得 2,拼成 '1/2'。
5 str 把 '1/2' 交还给 print,print 把这 3 个字符写到屏幕并换行。print 本身返回 None。

关键:__str__ 里写的是 return,不是 print。打印这件事是 print 干的,__str__ 的职责只是「交出一个字符串」。

只写一个的话,写哪个?

两者之间有一条单向的兜底规则:

类里定义了repr(x) 得到str(x) 得到
两个都有__repr__ 的结果__str__ 的结果
只有 __repr____repr__ 的结果也是 __repr__ 的结果(兜底)
只有 __str__<__main__.X object at 0x…>(不兜底)__str__ 的结果
都没有<__main__.X object at 0x…>

兜底是单向的:str 找不到 __str__ 会退回去用 __repr__;反过来 repr 绝不会用 __str__。所以如果只写一个,写 __repr__——它一份顶两份,而且能让对象在列表里、在调试输出里都好看。

这条兜底规则不是特例,用第 6 节的属性查找就能解释:str(x) 去查 x 的 __str__,实例没有、类没有、超类……一路查到所有类的共同祖先 object,而 object.__str__ 的实现恰好就是「调用 self.__repr__()」。兜底本身就是一次继承。同理,你没写 __repr__ 时用到的 object.__repr__ 就是那个打印内存地址的实现。

常见误区

误区一:在 __str__ 里用 print 而不是 return。

def __str__(self):
    print(f'{self.numer}/{self.denom}')     # ✗
>>> print(rat)
1/2
TypeError: __str__ returned non-string (type NoneType)

屏幕上先冒出一个 1/2(那是你自己 print 的),紧接着报错——因为函数没有 return,隐式返回了 None,而 print 拿到 None 没法当字符串用。这个报错很好认:看到 returned non-string (type NoneType),就是把 return 写成 print 了。

误区二:__repr__ 返回的字符串里漏了引号。如果属性是字符串,拼 repr 时要保留引号,否则 eval 回不来:

def __repr__(self):
    return f'Account({self.holder})'        # 得到 'Account(Sriya)',eval 会 NameError
def __repr__(self):
    return f'Account({self.holder!r})'      # 得到 "Account('Sriya')"  ✓

{x!r} 是 f-string 的语法,表示「对 x 用 repr 而不是 str」。CS 61A 不会考 !r,但知道有这个东西能帮你理解 repr 的「可求值」惯例到底有多讲究。

误区三:以为 __str__ 能自动继承出漂亮结果。它确实会被继承——但继承的是基类的实现。第 11 节里 Artist 没写 __str__,用的就是 User.__str__,正好返回名字,符合预期;但如果基类的 __str__ 写死了 'User: ' + self.name,子类打印出来就会顶着一个「User:」前缀。基类的字符串表示要写得对子类也成立,通常靠 self.__class__.__name__ 或者干脆让子类重写。

11. 实战:用继承重构 Spotify

重构(refactoring)是「在不改变外部行为的前提下重新组织已有代码」。关键词是「不改变外部行为」——重构之后所有 doctest 必须照样通过,改的只是代码的组织方式。

这次要做两件事:Artist 继承 User、Album 继承 Playlist,并且给各个类加上 __str__。起始代码是课程网站 Lecture 14 下的 14.py,用 python3 -m doctest 14.py 跑测试(没有输出就是全过)。

User:先把基类写对

class User:
    def __init__(self, name: str):
        self.name = name
        self.following = []
        self.followers = 0

        # NOTE: be careful, order matters!
        self.playlists = []
        self.liked_songs = Playlist('Liked Songs', [], self)

    def follow(self, user):
        self.following.append(user)
        user.followers += 1

    def __str__(self):
        return self.name

follow 这个方法值得看一眼:它同时改了两个对象。self.following.append(user) 改的是关注者的列表,user.followers += 1 改的是被关注者的计数。方法的 self 不代表「只能碰自己」——只要拿得到别的对象的引用,就能改它。

逐步推演:rebecca.follow(taylor)
1 查 rebecca.follow:实例上没有 → User 类上有 → 绑定方法,self = rebecca。
2 新建帧,self = rebecca、user = taylor(一个 Artist 实例)。
3 self.following.append(user):先查 self.following 拿到 rebecca 那个列表对象,然后 append 就地修改它。列表里放进去的是 taylor 本人,不是拷贝——所以 rebecca.following[0] is taylor 为 True。
4 user.followers += 1 等价于 user.followers = user.followers + 1。右边是一次属性查找(taylor 实例上有 followers,值 0),左边是一次属性赋值,把 taylor 实例上的 followers 重新绑到 1。
5 函数没有 return,返回 None。所以 doctest 里 >>> rebecca.follow(taylor) 下面一行输出都没有——这是对的,不要给它加返回值。
注意:__init__ 里的语句顺序不是随便排的

起始代码里那句 NOTE: be careful, order matters! 说的就是这两行:

self.playlists = []
self.liked_songs = Playlist('Liked Songs', [], self)

为什么必须是这个顺序?因为 Playlist.__init__ 的最后一行是 self.owner.playlists.append(self)——新建歌单时它会反过来把自己登记到 owner 的 playlists 列表里。而这里传进去的 owner 就是那个还在建造中的 User。如果 self.playlists = [] 还没执行,这个列表就不存在:

>>> rebecca = User('Rebecca')
AttributeError: 'User' object has no attribute 'playlists'

这是一个循环依赖:User 造 Playlist,Playlist 又要访问 User 的属性。解决办法就是「被别人依赖的东西先初始化」。这类 bug 在 Ants 项目里也会遇到,报错信息永远是 'X' object has no attribute 'y',而真正的原因是赋值语句排错了顺序。

Artist:这一节的主角

class Artist(User):
    def __init__(self, name: str):
        super().__init__(name)
        self.albums = []

整个类只有三行。Artist 比 User 多的只有一个 albums 列表,其余全部继承:name、following、followers、playlists、liked_songs、follow、__str__,一个都不用重写。doctest 里这几行因此自动成立:

>>> taylor = Artist('Taylor Swift')
>>> taylor.followers
0
>>> len(taylor.playlists)
1
>>> taylor.liked_songs is taylor.playlists[0]
True
>>> str(taylor)
'Taylor Swift'

str(taylor) 走的是继承来的 User.__str__,返回 self.name。这就是第 10 节说的「基类的字符串表示要写得对子类也成立」——return self.name 没有写死任何类名,所以对 Artist 一样正确。

还有一个细节:super().__init__(name) 必须在 self.albums = [] 之前吗?在这个例子里两种顺序都能跑。但把 super().__init__() 放第一行是强烈推荐的习惯:基类初始化过程中可能会调用别的方法(比如这里会去造 Playlist),而那些方法可能依赖对象已经处于「基类那部分已就绪」的状态。

Song:__str__ 的实战

class Song:
    def __init__(self, name: str, artist: Artist, duration_sec: int):
        self.name = name
        self.artist = artist
        self.duration_sec = duration_sec

    def __str__(self):
        minutes = self.duration_sec // 60
        seconds = self.duration_sec % 60
        return f'{self.name} by {self.artist} ({minutes}:{seconds})'

    def play(self):
        print(self)

两处需要停下来看:

第一处:f'... by {self.artist} ...'。self.artist 是一个 Artist 对象,不是字符串。f-string 插值时会对它调用 str,于是走到 User.__str__,得到 'Taylor Swift'。这就是给 User 写 __str__ 的回报:这里不用写 self.artist.name,写 self.artist 就够了。(写 .name 也对,但那样 Song 就必须知道 Artist 内部有个叫 name 的属性,抽象屏障被捅破了一点。)

第二处:def play(self): print(self)。print(self) 会对 self 调用 str,也就是这个类自己的 __str__。所以 play 就一行,因为 __str__ 已经把「一首歌该怎么显示」这件事说清楚了。方法体里出现 print(self) 是「我已经写好 __str__」的典型信号。

逐步推演:all_too_well.play()
1 查 all_too_well.play → 实例无 → Song 类有 → 绑定方法。新建帧,self = 那首歌。
2 执行 print(self)。print 需要字符串 → 调 str(self) → 找到 Song.__str__ → 新建一帧。
3 在 __str__ 帧里:minutes = 327 // 60 = 5,seconds = 327 % 60 = 27。
4 拼 f-string:{self.name} → 'All Too Well';{self.artist} → 对 Artist 对象调 str → 又进一层,执行 User.__str__ 返回 'Taylor Swift';{minutes} → '5';{seconds} → '27'。
5 得到 'All Too Well by Taylor Swift (5:27)',返回给 str,str 交给 print。
6 屏幕输出 All Too Well by Taylor Swift (5:27)(无引号),play 返回 None。
常见误区

{minutes}:{seconds} 不补零。本讲的 doctest 恰好都躲过了这个问题(327→5:27、217→3:37、162→2:42,秒数都是两位),但你自己试一个:

>>> str(Song('X', taylor, 305))
'X by Taylor Swift (5:5)'          # 本该是 5:05

要补零得写 {seconds:02d}。这道题不要求补零,doctest 怎么写就怎么实现——但你得知道这是被 doctest 的取值范围掩盖掉的,而不是代码本来就对。这也顺带说明一件事:doctest 通过不等于实现正确,它只保证「这几个输入下对」。

Playlist 与 Album

class Playlist:
    def __init__(self, name: str, songs: list[Song], owner: User):
        self.name = name
        self.songs = songs
        self.owner = owner
        self.total_duration_sec = sum([song.duration_sec for song in self.songs])
        self.owner.playlists.append(self)

    def play(self):
        for song in self.songs:
            song.play()


class Album(Playlist):
    def __init__(self, name: str, songs: list[Song], artist: Artist):
        super().__init__(name, songs, artist)
        self.owner.albums.append(self)

Album 也只有两行。play 一个字都不用写——重构前抄了两遍的那段代码,现在只剩一份。这就是本讲开头那个动机的兑现。

三个细节:

  • super().__init__(name, songs, artist) 把 artist 传给了基类的 owner 形参。形参名不需要一致,位置对上就行。Album 把它叫 artist 是为了在签名上表达「专辑的所有者必须是歌手」。
  • 第二行写的是 self.owner.albums.append(self) 而不是 artist.albums.append(self)。两者在这里等价,但用 self.owner 更稳——它读的是对象当前的真实状态,万一基类的 __init__ 对 owner 做了什么处理,也不会读到过时的值。
  • 一张专辑会被登记两次:基类那行把它 append 进 artist.playlists,子类这行又 append 进 artist.albums。这是有意的——专辑是歌单,理应出现在歌单列表里;同时它又是专辑,所以另有一份索引。这正是 is-a 关系在数据上的体现。
逐步推演:red = Album('Red', [all_too_well, trouble], taylor)
1 调用类 Album → 造一个空实例 → 查 __init__:Album 上有,用它。帧 f1:self=新实例、name='Red'、songs=[两首歌]、artist=taylor。
2 super().__init__(name, songs, artist) → 从超类 Playlist 开始查 __init__,找到。帧 f2:self=同一个实例、name='Red'、songs=[两首歌]、owner=taylor。
3 f2 里依次挂上 self.name、self.songs、self.owner。
4 sum([song.duration_sec for song in self.songs]):列表推导得 [327, 217],求和 544,挂成 self.total_duration_sec。对上了 doctest 里的 544。
5 self.owner.playlists.append(self):taylor.playlists 本来是 [liked_songs],现在变成 [liked_songs, red]。f2 返回 None。
6 回到 f1:self.owner.albums.append(self) → taylor.albums 从 [] 变成 [red]。所以 len(taylor.albums) 是 1,red is taylor.albums[0] 是 True(列表里存的就是那个对象本身)。
7 red.play():查 play → 实例无 → Album 类无 → 超类 Playlist 有。遍历 self.songs,对每首歌调 song.play(),各打印一行。
重构后的对象关系(红字部分是继承带来的)
class User                        class Playlist
    __init__  follow  __str__         __init__  play
        ↑                                 ↑
        │ base                            │ base
        │                                 │
class Artist(User)                class Album(Playlist)
    __init__(只加 albums)           __init__(只加 albums 登记)
    follow    ← 继承                  play      ← 继承
    __str__   ← 继承

taylor : Artist                   red : Album
    name       'Taylor Swift'         name              'Red'
    following  []                     songs             [all_too_well, trouble]
    followers  0                      owner        ─────→ taylor
    playlists  [liked_songs, red] ←──── 被 Playlist.__init__ 登记
    albums     [red]              ←──── 被 Album.__init__ 登记
    liked_songs ──→ Playlist('Liked Songs', [], taylor)

把 14.py 补完后跑 python3 -m doctest 14.py:没有任何输出就是 50 条 doctest 全部通过。加 -v 可以看到详细统计。

本讲小结

概念要点典型陷阱
继承 class Sub(Base):表达 is-a;子类可访问基类的属性与方法把 has-a 关系(Bank 有 Account)写成继承
属性查找实例 → 类 → 超类 → 超类的超类 → AttributeError;命中即停以为基类和子类的同名方法会都执行
重写 override子类同名定义挡住基类的;要用基类版本必须显式调重写后忘了调基类,功能悄悄丢失
super().m(args)从超类查 m,self 自动补多传一个 self → takes 2 positional arguments but 3 were given
Base.m(self, args)等价写法,self 必须手写漏写 self → missing 1 required positional argument
子类 __init__写了就必须自己调 super().__init__(...)忘了调 → 很久之后才炸 'X' object has no attribute 'name'
类属性属于定义它的那个类,一份;子类可重写出自己的一份可变类属性(songs = [])被所有实例共享
属性赋值obj.x = v 永远只在 obj 自己身上绑定以为 emma.interest = 0.08 改了类属性
组合 compositionhas-a,把别的对象存进实例属性该组合的地方硬套继承,语义错乱
type(x)恰好属于哪个类,不看继承链type(a) == Account 把子类实例全漏掉
isinstance(x, C)沿继承链判断,子类实例算 C 的实例—
repr / __repr__给解释器看;提示符回显、容器里的元素用它只写 __str__,结果 print(列表) 还是内存地址
str / __str__给人看;print 和 f-string 用它写成 print(...) 而非 return → __str__ returned non-string (type NoneType)
兜底方向str 缺失 → 用 __repr__;反向不成立只写一个时写错了那一个

一页纸的求值规则

求 <expr>.<name>
1. 求 <expr>
2. 若结果是实例 → 查实例自己的属性 ── 命中 → 返回(函数则绑定成方法)
                          │未命中
                          ↓
   查该实例所属的类 ─────────── 命中 → 返回
   (若第 1 步结果本就是类,从这里开始)
                          │未命中
                          ↓
   查超类,重复上一步 ─────────── 命中 → 返回
                          │一直没有超类了
                          ↓
                  AttributeError

赋值 <expr>.<name> = v :不查找、不上溯,直接绑在 <expr> 求出的那个对象上。

动手练习

五道题,都能在本机用 python3 验证。先自己推,再展开答案。

练习 1:查找路径

class A:
    x = 1
    def f(self):
        return self.x

class B(A):
    x = 2

class C(B):
    def f(self):
        return super().f() + 10

c = C()

c.f() 的值是多少?A().f() 呢?如果再执行 c.x = 100,c.f() 又是多少?

看答案

c.f() 是 12。

1 查 c.f:实例无 → C 上有 → 用 C.f,self = c。
2 super().f():从 C 的超类 B 开始查 f,B 上没有 → A 上有 → 调 A.f,self 仍然是 c。
3 A.f 里 return self.x:从 c 出发查 x。实例无 → C 无 → B 有,得 2。注意:不是 A.x 的 1!方法虽然定义在 A 里,但 self.x 的查找是从 self 的真实类型 C 开始的。
4 回到 C.f:2 + 10 = 12。

A().f() 是 1:self 是一个 A 实例,查 x 在 A 上命中 1。

c.x = 100 之后 c.f() 是 110:c 实例上有了自己的 x = 100,第 3 步在 2a 就命中,不再往上走。

这道题的要害是动态查找:self.x 的结果由 self 是谁决定,跟这段代码写在哪个类里没有关系。

练习 2:忘了 super

class Animal:
    def __init__(self, name):
        self.name = name
        self.legs = 4

class Bird(Animal):
    def __init__(self, name):
        self.legs = 2

b = Bird('Tweety')

创建 b 会报错吗?b.legs 是多少?b.name 呢?怎么改?

看答案

创建 b 不报错。Bird.__init__ 顺利执行完,只是它没干 Animal.__init__ 该干的事。

b.legs 是 2:Bird.__init__ 确实挂上了。

b.name 报错:

AttributeError: 'Bird' object has no attribute 'name'

因为 self.name = name 那一行在 Animal.__init__ 里,而它从未被执行——子类定义了 __init__,属性查找在 Bird 就命中并停止了。

改法:

class Bird(Animal):
    def __init__(self, name):
        super().__init__(name)     # 先把基类那套跑完(name=..., legs=4)
        self.legs = 2              # 再覆盖成 2

顺序不能反:super().__init__(name) 会把 legs 设成 4,所以覆盖必须在它之后。

这道题的教训:这类 bug 的报错地点和真正的错误地点相隔很远。看到 'X' object has no attribute 'y',第一件事是去找 self.y = ... 写在哪个 __init__ 里,第二件事是确认那个 __init__ 有没有被调到。

练习 3:共享的列表

class Team:
    members = []
    def add(self, name):
        self.members.append(name)

a, b = Team(), Team()
a.add('ann')
b.add('bob')

a.members 是什么?如果把 add 改成 self.members = self.members + [name],结果又是什么?

看答案

第一种写法:a.members 是 ['ann', 'bob'],b.members 也是 ['ann', 'bob'],Team.members 同样是 ['ann', 'bob']——三个名字指向同一个列表对象。

原因:self.members.append(name) 里没有赋值。它做的是「查找 self.members(实例无 → 类上命中那个共享列表)→ 对这个列表调 append 就地修改」。两个实例查到的是同一个列表,自然互相看得见。

第二种写法:a.members 是 ['ann'],b.members 是 ['bob'],Team.members 仍是 []。

因为 self.members = ... 是一次赋值:右边先求值(查到共享的空列表,拼出一个新列表 ['ann']),左边把这个新列表绑在 a 这个实例上。从此 a.members 在 2a 就命中,跟类属性再无瓜葛。

两种写法都不是好设计。正确做法是给每个实例自己的列表:

class Team:
    def __init__(self):
        self.members = []
    def add(self, name):
        self.members.append(name)

这道题浓缩了本讲最容易被忽略的一条:obj.x.append(v) 是「查找 + 变异」,obj.x = v 是「赋值」,两者在继承体系里的行为天差地别。

练习 4:str 与 repr

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __repr__(self):
        return f'Point({self.x}, {self.y})'

p = Point(1, 2)

下面五行分别输出什么?

>>> p
>>> print(p)
>>> str(p)
>>> [p]
>>> f'{p}'

再问:如果把 __repr__ 改名成 __str__,这五行又分别输出什么?

看答案

只有 __repr__ 时:

>>> p
Point(1, 2)
>>> print(p)
Point(1, 2)
>>> str(p)
'Point(1, 2)'
>>> [p]
[Point(1, 2)]
>>> f'{p}'
'Point(1, 2)'

提示符回显和容器元素本来就用 repr;print、str、f-string 想用 __str__,类里没有,一路查到 object.__str__,它的实现就是转去调 __repr__。所以五处全都好看。注意 str(p) 那行外面有引号(str 返回的是个字符串,提示符回显它时又 repr 了一次),print(p) 那行没有。

只有 __str__ 时:

>>> p
<__main__.Point object at 0x7f...>
>>> print(p)
Point(1, 2)
>>> str(p)
'Point(1, 2)'
>>> [p]
[<__main__.Point object at 0x7f...>]
>>> f'{p}'
'Point(1, 2)'

第 1 行和第 4 行退化了,因为 repr 不会回头去用 __str__。这就是「只写一个就写 __repr__」的理由。

练习 5:把 Album 的 __str__ 写出来

给第 11 节的 Playlist 加一个 __str__,让它满足:

>>> str(love_songs)
'Love Songs by Rebecca (3 songs)'
>>> str(red)
'Red by Taylor Swift (2 songs)'

要求:Album 一个字都不用写。想清楚为什么可以做到,以及 {self.owner} 这一处插值背后连锁调用了几个 __str__。

看答案
class Playlist:
    # ... __init__ 和 play 不变 ...

    def __str__(self):
        return f'{self.name} by {self.owner} ({len(self.songs)} songs)'

为什么 Album 不用写:查 str(red) 的 __str__ → red 实例上没有 → Album 类上没有 → 超类 Playlist 上有,用它,self = red。因为方法体里全部用 self. 取值、没有写死任何类名或属性来源,所以对 Album 一样成立。

{self.owner} 背后:对 red 来说 self.owner 是 taylor,一个 Artist。f-string 对它调 str → Artist 类上没有 __str__ → 超类 User 上有 → 返回 self.name 即 'Taylor Swift'。所以这一行代码里发生了两次沿继承链的方法查找,各自都在超类才命中。

验算 str(love_songs):self.name = 'Love Songs';self.owner = rebecca,是个 User,__str__ 在自己类上就命中,返回 'Rebecca';len(self.songs) = 3。拼成 'Love Songs by Rebecca (3 songs)'。✓

如果要求 Album 打印成 'Red by Taylor Swift (2 songs) [ALBUM]' 呢?那就在 Album 里重写,并复用基类的成果:

class Album(Playlist):
    def __str__(self):
        return super().__str__() + ' [ALBUM]'

注意这里 super().__str__() 后面的括号——__str__ 是个方法,要调用它才拿到字符串。漏掉括号会得到一个方法对象,然后报 TypeError: unsupported operand type(s) for +: 'method' and 'str'。报错里出现 'method' 这个词,基本都是忘了写括号。