LECTURE 13

对象与属性:把「数据」和「行为」装进同一个东西里

类是图纸,实例是照图纸盖出来的房子。这一讲把「点表达式 x.y 被求值时到底发生了什么」拆到最细。

教材:Composing Programs §2.5 Object-Oriented Programming 对应作业:Lab 06 / Project Ants

0. 本讲导读

期中考试之前,你已经会两种「把一堆值包成一个整体」的办法:一是直接用列表、元组这样的内置容器,二是自己写抽象数据类型(abstract data type, ADT)——写一个 make_xxx 构造器(constructor)和几个 xxx_yyy 选择器(selector),约定好「只准通过这几个函数碰数据」。

ADT 这套办法很优雅,但它有一个根本的脆弱之处:那道抽象屏障(abstraction barrier)只是一句口头约定,Python 根本不知道它的存在。 你写 make_account('Spock') 返回一个二元列表 [0, 'Spock'],别人拿到之后直接写 acc[1] 也照样能跑。Python 不会拦你,因为在 Python 眼里那就是个普通列表。

本讲引入的面向对象编程(object-oriented programming, OOP)要解决的正是这件事。它提供的不是一个新的算法技巧,而是一种新的组织程序的方式:把「一个东西有哪些数据」和「一个东西能做哪些事」绑在一起,形成一个可以被 Python 本身识别、检查、传递的实体。

从「求值过程」的角度看,本讲实际上只加了一条新的求值规则:点表达式(dot expression)<表达式>.<名字> 该怎么求值。 这条规则看起来只有三步,但它牵扯到实例属性、类属性、绑定方法三种情况,而且属性读取和属性赋值走的是完全不同的两条路。绝大多数 OOP 的坑都长在这条规则上,所以本讲会把它拆得非常细。

往前看:这一讲是 Composing Programs §2.5 的开头,衔接的是第 10~11 讲的可变数据与数据抽象。往后看:下一讲讲继承(inheritance)与 __repr__/__str__,那些内容全都建立在本讲的属性查找规则之上;Lab 06 和大项目 Ants 几乎全部是类的编写,本讲的每一个细节到时候都会用到。

核心结论
  • 类(class)是图纸,实例(instance)是照图纸盖出的房子。 类定义了「这一类东西长什么样、能做什么」,实例是按这份定义具体造出来的一个个体。
  • __init__ 是构造器(constructor)。调用 Account('Spock') 时 Python 先造一个空实例,再自动调用 __init__,把这个空实例作为第一个实参传给 self。你从不手动传 self。
  • 点表达式 <表达式>.<名字> 的求值:先求点左边 → 若得到实例,先查实例属性,没有再查类属性 → 都没有则 AttributeError。
  • 读取会沿着「实例 → 类」找上去;赋值只作用在点左边那一个对象上。inst.x = 1 永远是在实例上新建/修改属性,绝不会碰到类属性。
  • inst.method 得到的是绑定方法(bound method)——实例已经被预先塞进第一个参数了。所以 inst.f(a) 和 Class.f(inst, a) 等价。
  • 默认情况下 == 对自定义类的对象等同于 is(比身份)。想让「内容相同就相等」,必须自己定义 __eq__。

1. 为什么需要 OOP:ADT 撑不住了

先老老实实回答一个问题:ADT 到底哪里不够用?幻灯片列了四条,每一条都值得展开,因为它们直接决定了类的设计长什么样。

软肋一:不改变抽象屏障就没法可变

ADT 只给你两种工具:构造器和选择器。构造器造一个新的出来,选择器从里面取一个部件出来。没有「改」这个动作。 假设你用列表实现一个账户:

def make_account(holder):
    return [0, holder]          # 构造器

def account_balance(account):   # 选择器
    return account[0]

def account_holder(account):    # 选择器
    return account[1]

现在要往账户里存钱。你只能写 account[0] = account[0] + 100——这行代码直接摸了下标 0,它绕过了选择器,等于亲手把自己刚立的规矩踩了。你当然可以再加一个 set_account_balance(account, v) 变异器(mutator),但 ADT 的经典形式里并没有这一层,而且每加一个可变的字段就要再写一个函数,很快就散架了。

软肋二:抽象屏障是约定,不是屏障

这是最要命的一条。看这段:

>>> acc = make_account('Spock')
>>> account_holder(acc)     # 规矩内的写法
'Spock'
>>> acc[1]                  # 违规写法——但它跑得一样好
'Spock'

acc[1] 不会报错,因为 acc 在 Python 眼里就是一个普通的 list。屏障存在于你的脑子里和注释里,不存在于解释器里。等到有一天你决定把内部表示从列表改成字典,所有写 acc[1] 的代码全部崩掉,而你根本无从知道有多少处这么写过。

软肋三:没有正式的类型检查手段

给你一个值,怎么判断它「是不是一个账户」?用 ADT 你办不到:

>>> acc = make_account('Spock')
>>> shopping = [0, 'milk']
>>> type(acc) == type(shopping)
True

账户和购物清单在 Python 看来是同一个类型,都是 list。你可以自己约定「第 0 位是数字、第 1 位是字符串就算账户」,但那是猜,不是判断。而用类之后,isinstance(acc, Account) 是一个 Python 层面的、确定的答案。

软肋四:数据和行为被拆散了

一个账户的「余额」在 acc[0] 里,「存钱」这个行为在一个叫 deposit 的全局函数里,「取钱」在另一个叫 withdraw 的全局函数里。它们在文件里可能相隔几百行,名字之间除了前缀之外没有任何正式关联。程序一大,你的全局命名空间里就会堆满 account_deposit、account_withdraw、course_enroll、course_drop……全是靠命名前缀勉强分组的散装函数。

OOP 给出的答案是封装(encapsulation):把属于同一类事物的数据和行为,装进同一个名字底下。Account 这一个名字之下,既有 balance 和 holder,也有 deposit 和 withdraw。

核心结论

OOP 是一种编程范式(programming paradigm)——一种组织程序的方式——它把数据抽象和行为捆绑在一起。它不让你算出以前算不出的东西,它让你的程序在规模变大时不至于散架。

注意

不要以为学了类之后 ADT 就作废了。类是实现 ADT 的一种方式,而且是带强制力的那种。数据抽象的思维方式——「先想清楚这个东西有哪些操作,再想它内部怎么存」——在写类的时候同样、甚至更加重要。

2. 三个基本词:类、实例、方法

这三个词在后面几讲里每一页都会出现,必须先钉死。

术语定义类比Python 里的样子
类(class)定义某一类对象的行为方式图纸class Account: 开头的那一整块
对象 / 实例(object / instance)某个类的一个具体个体按图纸盖出来的一栋房子Account('Spock') 的求值结果
方法(method)与某个类关联的函数房子自带的功能(开门、通风)写在 class 缩进块里的 def
属性(attribute)与某个对象或类关联的局部状态这栋房子的门牌号、颜色self.balance、Account.account_number

图纸和房子的类比要用对地方。一份图纸可以盖出无数栋房子,每栋房子的门牌号、住户各不相同,但「门在哪、窗户开多大」是图纸统一规定的。同理:Account 这个类只有一份,但你可以造出成千上万个 Account 实例,每个实例有自己的 balance 和 holder;而「怎么存钱」这个行为写在类里,所有实例共用同一份代码。

你其实早就在用类了。list 是一个类,[1, 2, 3] 是它的一个实例,append 是它的一个方法:

>>> type([1, 2, 3])
<class 'list'>
>>> s = [1, 2, 3]
>>> s.append(4)      # 调用 list 类的 append 方法
>>> s
[1, 2, 3, 4]

s.append(4) 这个写法你已经用了十几讲了,本讲要做的就是把这个写法背后的机制彻底讲清楚,并且让你能自己造出这样的东西。

直觉

「属性」和「方法」在 Python 内部其实是一回事——都是挂在对象上的一个名字,只不过方法这个名字恰好绑着一个函数。查找的时候 Python 完全不区分它们。我们平时把它们分开说,只是因为人类习惯把「这个东西是什么」(属性)和「这个东西能做什么」(方法)分开想。做属性查找题时,请把这个区分暂时忘掉,一律当成「名字」。

3. 定义一个类:Account 逐行解剖

下面是本讲的第一份完整代码,也是教材 §2.5 的经典例子。先把它整个看一遍,再逐行拆。

class Account:
    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
Defining Classes 幻灯片:Account 类的完整定义
一个类定义的三个方法。注意每个 def 的第一个形参都叫 self,而且函数体里凡是要碰「这个账户自己的数据」,全都写成 self.某某。__init__ 前后各两个下划线,这是 Python 认得的特殊名字,不是随便起的。

class Account: 这一行做了什么

它是一条语句,被执行时做两件事:先执行缩进块里的内容(于是三个 def 被执行,产生三个函数对象),然后把这些名字打包成一个类对象,最后把名字 Account 绑定到这个类对象上。

这和 def 的机制是完全平行的:def square(x): ... 执行后,全局帧里多了一个名字 square 指向一个函数对象;class Account: ... 执行后,全局帧里多了一个名字 Account 指向一个类对象。类本身就是一个值,可以赋给别的名字,可以当参数传。

执行完 class 语句后的全局帧
Global frame
    Account ──→ class Account
                    __init__  ──→ func __init__(self, account_holder)
                    deposit   ──→ func deposit(self, amount)
                    withdraw  ──→ func withdraw(self, amount)

注意这三个函数此刻只是被定义了,一次都没被调用。它们住在类对象里面,不在全局帧里——所以你在类外面直接写 deposit(...) 会得到 NameError。

self 到底是什么

self 就是一个普通的形参名字。 它没有任何语法魔力,你把它改名叫 me、this、x,代码照样跑(我在下一节会真的演示这一点)。它之所以特殊,只是因为:

  1. Python 在通过实例调用方法时,会自动把那个实例作为第一个实参传进去;
  2. 整个 Python 社区一致约定这个形参叫 self。

换句话说,self 是方法体内部指代「正在被操作的那一个实例」的手段。deposit 这份代码只有一份,被一万个账户共用,它靠 self 知道「这次是给哪个账户存钱」。

常见误区

方法体里写 balance = balance + amount 而不是 self.balance = self.balance + amount。这会得到:

UnboundLocalError: local variable 'balance' referenced before assignment

(Python 3.11 及以后措辞改成了 cannot access local variable 'balance' where it is not associated with a value,是同一个错。)

因为 balance 是方法这一帧里的局部名字,跟实例的属性 self.balance 毫无关系。更隐蔽的一种是在 __init__ 里写:

class A:
    def __init__(self, x):
        x = x          # 什么也没干成

>>> a = A(5)
>>> a.x
AttributeError: 'A' object has no attribute 'x'

x = x 不报错、不做任何事,把局部名字 x 重新绑到它自己的值上,帧一销毁就没了。实例上从头到尾没有多出任何属性。要在实例上留下东西,句子左边必须是 self. 开头。

三个方法各自在做什么

1 __init__(self, account_holder):构造器。它不返回账户,它装修一个已经造好的空账户——给它挂上 balance(初值 0)和 holder(用传进来的名字)两个属性。
2 deposit(self, amount):读出当前 self.balance,加上 amount,写回 self.balance,再把新余额返回。注意「写回」这一步——没有它,余额不会变。
3 withdraw(self, amount):先做守卫检查。钱不够就直接 return 'Insufficient funds',函数当场结束,后面两行根本不执行——余额一分没动。这是 return 提前退出的典型用法。
注意

withdraw 在余额充足时返回数字,不足时返回字符串。这种「返回类型不统一」的写法在真实工程里是要挨骂的(调用方拿到结果没法直接做算术),但它是教材原样,而且很适合考察 return 的短路行为。做题时要留意:acc.withdraw(90) + 5 在余额不足时会炸 TypeError: can only concatenate str (not "int") to str。

4. 创建实例:Account('Spock') 一步步发生了什么

这是本讲最需要慢下来看的地方。表面上你只写了一次调用,实际上 Python 做了四件事。

>>> spock_account = Account('Spock')
逐步推演
1 求值算子 Account。在全局帧里查到那个类对象。调用一个类,意思就是「造一个这个类的实例」——这是类对象被调用时的特殊含义。
2 求值算子数 'Spock',得到字符串 'Spock'。
3 Python 造出一个全新的、空的 Account 实例。此刻它一个属性都没有,只知道自己属于 Account 类。
4 Python 自动调用 Account.__init__,把刚造出的那个空实例作为第一个实参、'Spock' 作为第二个实参传进去。于是新帧里 self 绑到空实例,account_holder 绑到 'Spock'。
5 执行 __init__ 的函数体:self.balance = 0 在实例上挂一个属性 balance,值为 0;self.holder = account_holder 在实例上挂一个属性 holder,值为 'Spock'。
6 __init__ 返回 None(它没有 return 语句)。但 Account('Spock') 这个调用表达式的值不是 None,而是第 3 步造出的那个实例。最后名字 spock_account 绑到它上面。

第 6 步是最容易搞混的一点。__init__ 的返回值被丢掉了;它的作用完全靠副作用——在传进来的那个实例上安装属性。所以 __init__ 里写 return self 是多余的,而写 return 某个别的东西 会直接报错:TypeError: __init__() should return None。

环境图:执行完 spock_account = Account('Spock')
Global frame
    Account       ──→ class Account
                          __init__  ──→ func __init__(self, account_holder)
                          deposit   ──→ func deposit(self, amount)
                          withdraw  ──→ func withdraw(self, amount)
    spock_account ──┐
                    │
                    └─→ Account instance          ← 一个独立的对象
                            balance ──→ 0
                            holder  ──→ 'Spock'

f1: Account.__init__ [parent=Global]        (已返回,图上留作对照)
    self            ──→ 上面那个 Account instance
    account_holder  ──→ 'Spock'
    返回值          ──→ None

请特别注意图里的一件事:实例是一个独立于任何帧的对象,它画在帧的外面(跟函数对象、列表对象一样)。f1 这一帧执行完就消失了,但实例不会消失,因为全局帧里的 spock_account 还指着它。这正是「对象持有状态」的机制——状态活在对象里,不活在帧里。

验证:属性确实挂在实例上

>>> spock_account.balance
0
>>> spock_account.holder
'Spock'

你还可以直接把实例的属性字典打印出来(这不是课程要求的内容,但看一眼很有说服力):

>>> spock_account.__dict__
{'balance': 0, 'holder': 'Spock'}

实例内部就是这么一张「名字 → 值」的表,跟一个帧长得很像。self.balance = 0 做的事,就是往这张表里加一条记录。

造第二个实例:两张表互不相干

>>> picard_account = Account('Picard')
>>> picard_account.balance
0
>>> spock_account.balance = 500
>>> picard_account.balance
0

每次调用 Account(...) 都会走一遍上面六步,造出一个全新的实例,配一张全新的属性表。改一个实例的 balance 完全不影响另一个。这就是「一份图纸盖出很多栋房子,每栋房子的门牌号各自独立」。

常见误区

忘了写 self 形参:

class C:
    def greet():
        return 'hi'

>>> c = C()
>>> c.greet()
TypeError: C.greet() takes 0 positional arguments but 1 was given

报错信息里的「1 was given」就是那个被自动塞进去的实例。你明明一个参数都没写,Python 却说你给了 1 个——看到这句报错,第一反应就应该是「我漏了 self」。反过来,多写一个实参会得到 takes 2 positional arguments but 3 were given,同样是因为 self 占掉了一个位置。

5. 调用方法:绑定方法与 self 的来历

接着上面的账户往下做,这是幻灯片上的完整会话:

>>> spock_account = Account('Spock')
>>> spock_account.balance
0
>>> spock_account.holder
'Spock'
>>> spock_account.deposit(100)
100
>>> spock_account.withdraw(90)
10
>>> spock_account.withdraw(90)
'Insufficient funds'
>>> picard_account = Account('Picard')
>>> picard_account.balance += 100
>>> Account.withdraw(picard_account, 75)
25

逐条追一遍。

逐步推演:spock_account.deposit(100)
1 这是一个调用表达式,算子是点表达式 spock_account.deposit,算子数是 100。先求算子。
2 求点表达式:先求点左边的 spock_account,得到那个 Account 实例。再找名字 deposit——实例的属性表里只有 balance 和 holder,没有 deposit,于是向上找类,在 Account 类里找到了。
3 关键一步:因为点左边是一个实例,而找到的又是一个函数,Python 不会把光秃秃的函数还给你,而是包成一个绑定方法(bound method)——里面同时装着「那个函数」和「那个实例」。
4 调用这个绑定方法,实参是 100。绑定方法把它记着的实例自动放到第一个位置,于是真正的调用是 deposit(spock_account, 100)。新建一帧:self → 实例,amount → 100。
5 执行 self.balance = self.balance + amount:右边先求值,self.balance 查到 0,加 100 得 100;再把实例的 balance 改成 100。
6 return self.balance 返回 100。REPL 显示 100。

亲眼看看绑定方法长什么样:

>>> spock_account.deposit
<bound method Account.deposit of <__main__.Account object at 0x7f3a...>>
>>> Account.deposit
<function Account.deposit at 0x7f3a...>
>>> type(spock_account.deposit)
<class 'method'>
>>> type(Account.deposit)
<class 'function'>

同一个 deposit,从实例身上取得到 method,从类身上取得到 function。区别只有一个:method 记住了一个实例,调用时自动补上第一个实参;function 什么都不记,你得自己传。

核心结论

实例.方法(实参...) 与 类.方法(实例, 实参...) 完全等价。前者是语法糖,Python 帮你把实例塞进第一个参数;后者是把这一步手写出来。这条等式是理解 self 的全部秘密。

这正是最后两行在演示的东西:

>>> picard_account.balance += 100     # 直接改属性,等价于 picard_account.balance = picard_account.balance + 100
>>> Account.withdraw(picard_account, 75)
25

Account.withdraw 是一个普通函数(不是绑定方法),所以必须手动把实例作为第一个实参传进去。函数体里 self 就绑到 picard_account,amount 绑到 75。检查 75 > 100 为假,跳过 return;self.balance = 100 - 75 = 25;返回 25。

常见误区

用类调方法却忘了传实例:

>>> Account.withdraw(75)
TypeError: Account.withdraw() missing 1 required positional argument: 'amount'

这条报错第一次看会让人极其困惑:「我不是给了 75 吗,怎么说少参数?」原因是 75 被当成了 self,缺的是 amount。只要看到「missing 1 required positional argument」而你觉得自己给够了,就去数一数 self 那一格是不是空着。

再确认一次:self 只是个名字

不信的话,把它改名:

class Dog:
    def __init__(banana, name):
        banana.name = name
    def bark(banana):
        return banana.name + ' says woof'

>>> d = Dog('Rex')
>>> d.bark()
'Rex says woof'

照跑不误。Python 只关心「第一个形参」这个位置,不关心它叫什么。但你在作业和考试里必须写 self——这是全世界 Python 程序员的共同约定,写别的会让人读不懂,Ants 项目的模板代码也一律用 self。

另一条常被忽略的等式:self 可以指向别的实例

方法的参数里出现的别的实例,处理方式和 self 一模一样——它们都只是普通的名字,指向普通的对象:

class Account:
    # ... 前面的方法照旧 ...
    def transfer(self, other, amount):
        """把 amount 从 self 转到 other。"""
        if amount > self.balance:
            return 'Insufficient funds'
        self.balance -= amount
        other.balance += amount
        return self.balance

a.transfer(b, 50) 里,self 是 a,other 是 b。两个实例各有各的属性表,各改各的。这个方法在两张表上分别做了一次修改,没有任何魔法。

6. 实例属性 vs 类属性

到目前为止,所有属性都是在 __init__ 里用 self.xxx = ... 挂上去的,每个实例一份。但有些数据天然属于「这一类东西整体」,而不属于某一个个体——比如「已经开过多少个账户」。这种数据应该放在类上。

实例属性(instance attribute)类属性(class attribute)
存在哪每个实例自己的属性表里类对象的属性表里,只有一份
怎么创建方法体里 self.x = ...类定义体里直接 x = ...(不在任何 def 内)
有几份造 100 个实例就有 100 份,互不影响永远只有 1 份,所有实例共享
典型用途余额、持有人、姓名——个体的状态计数器、利率、常量——整类共有的信息

幻灯片的例子给 Account 加了一个自动编号:

class Account:
    account_number = 1                      # ← 类属性,写在类定义体里

    def __init__(self, account_holder):
        self.balance = 0
        self.holder = account_holder
        self.account_number = Account.account_number   # ← 实例属性,抄一份当前编号
        Account.account_number += 1                    # ← 把类属性加 1,给下一个人留号
    ...

请先自己把下面这段的输出预测一遍,再往下看。 这段代码是本讲区分度最高的地方。

Instance vs. Class Attributes (2 of 2) 幻灯片:kaz_account 的一串交互
这一串交互里藏着三个独立的考点:类属性和实例属性同名时读哪个、+= 作用在谁身上、以及给实例属性赋值为什么完全影响不到类属性。第三行输出 11 而第五行仍是 2,就是全部答案。
>>> kaz_account = Account('Kaz')
>>> kaz_account.account_number
1
>>> kaz_account.account_number += 10
>>> kaz_account.account_number
11
>>> Account.account_number
2
>>> inej_account = Account('Inej')
>>> inej_account.account_number
2
逐步推演
1 class 语句执行完:类对象上有一个属性 account_number = 1。此时还没有任何实例。
2 Account('Kaz'):造空实例 → 调 __init__。self.balance = 0、self.holder = 'Kaz' 各挂一个实例属性。
3 self.account_number = Account.account_number:右边先求值。Account.account_number 点左边是类,直接读类属性,得 1。然后左边 self.account_number = 1 在实例上新建一个同名属性,值 1。现在实例和类各有一个 account_number,都等于 1。
4 Account.account_number += 1:展开成 Account.account_number = Account.account_number + 1。读类属性得 1,加 1 得 2,写回类属性。现在:类的是 2,Kaz 实例的还是 1。
5 kaz_account.account_number → 点左边是实例,先查实例属性表,找到了(值 1),直接返回 1,压根不去看类。
6 kaz_account.account_number += 10 → 展开成 kaz_account.account_number = kaz_account.account_number + 10。读到实例属性 1,加 10 得 11,写回实例属性。类属性纹丝不动,仍是 2。
7 kaz_account.account_number → 11。Account.account_number → 2。
8 Account('Inej'):又走一遍 __init__。这次读到的类属性是 2,所以 Inej 的实例属性是 2;然后类属性变成 3。inej_account.account_number → 2。
环境图:第 7 步之后的状态
Global frame
    Account      ──→ class Account
                         account_number ──→ 2          ← 类属性,一份
                         __init__ ──→ func __init__(self, account_holder)
    kaz_account  ──→ Account instance
                         balance        ──→ 0
                         holder         ──→ 'Kaz'
                         account_number ──→ 11         ← 实例属性,遮住了类属性
    inej_account (尚未创建)

用一个词概括第 5 步和第 6 步:遮蔽(shadowing)。实例上一旦有了同名属性,类上那个就永远读不到了(除非你写 Account.account_number 明确从类走)。这和函数里局部名字遮住全局名字是同一个道理。

常见误区

「我用 self.x += 1 改类属性」——改不到。 看这个真实会犯的错:

class E:
    n = 0                 # 想做一个所有实例共享的计数器
    def inc(self):
        self.n += 1       # 错!

>>> e1, e2 = E(), E()
>>> e1.inc()
>>> e1.n, e2.n, E.n
(1, 0, 0)
>>> e1.__dict__
{'n': 1}

self.n += 1 展开是 self.n = self.n + 1:读的时候实例上没有 n,就沿着链找到类属性 0;写的时候却在 e1 实例上新建了一个 n。类属性和 e2 都毫发无损。要改类属性,必须写 E.n += 1(或 type(self).n += 1)。

常见误区

可变的类属性会被所有实例共享,而且这次是真的共享。

class D:
    items = []            # 危险:一个列表,所有实例共用
    def add(self, x):
        self.items.append(x)

>>> d1, d2 = D(), D()
>>> d1.add(1)
>>> d2.items
[1]
>>> d1.items is D.items
True

为什么这次跑不掉?因为 self.items.append(x) 不是赋值。它先读 self.items(找到类属性那个列表对象),然后就地改动那个对象。没有赋值发生,就不会在实例上新建属性,改的就是那唯一一份列表。这和第 10 讲讲的可变列表被多个名字共享是同一个坑,只是换了个地方长出来。

要每个实例一份列表,必须写在 __init__ 里:self.items = []。 这就是为什么本讲实战里的 self.albums = []、self.following = [] 全都写在 __init__ 里。

7. 点表达式的求值规则:查找与赋值

前面所有现象,其实都由同一条规则推出来。现在把它正式写下来。

规则本身

点表达式的语法是:

<实例或类的表达式>.<名字>
核心结论:点表达式的求值
  1. 求值点左边的表达式(它可以是任意表达式,不一定是一个简单名字)。
  2. 确定 <名字> 求值成什么:
    • a. 如果第 1 步得到一个实例,先看这个实例有没有叫这个名字的实例属性,有就返回它的值;
    • b. 否则(实例上没有,或者第 1 步得到的本来就是一个类),去看类属性里有没有匹配的;
    • c. 还是没有,抛 AttributeError。

三点补充,每一点都对应一类考题:

  • Python 在查找时完全不区分「属性」和「方法」。 方法本身既是属性,也是函数。查找只认名字。
  • 如果第 2b 步在类上找到的是一个函数,而第 1 步得到的是实例,那么返回的是绑定方法(第 5 节讲过)。若第 1 步得到的是类,返回的就是普通函数,你得自己传 self。
  • 若第 2a 步就在实例上找到一个函数,返回的是那个函数本身,不做任何绑定。这是下面 Car 例子的关键。

Car:把规则用到底

>>> class Car:
...     def __init__(self):
...         self.fuel = 10
...     def drive(self, distance):
...         if distance > self.fuel:
...             return "Can't drive that far"
...         self.fuel -= distance
...         return self.fuel
...
>>> a = Car()
>>> b = Car()

此刻的状态:类 Car 上有 __init__ 和 drive;实例 a 上只有 fuel = 10;实例 b 上只有 fuel = 10。

Attribute/Method Lookup (4 of 5) 幻灯片:Car 实例与类上互相覆盖 drive 的交互
这一页是本讲最难的一页。同一个名字 drive 同时存在于实例 a 和类 Car 上,两个 lambda 的参数个数还不一样。全部七行输出都可以只用「先查实例、再查类;实例上直接找到的函数不绑定」这一条规则推出来。
>>> a.drive = lambda distance: "Car overheating!"
>>> a.drive(1)
'Car overheating!'
>>> b.drive(5)
5
>>> Car.drive = lambda self: "Tire popped!"
>>> a.drive(100)
'Car overheating!'
>>> b.drive()
'Tire popped!'
>>> f = a.drive
>>> f(1)
'Car overheating!'

这一串我在 Python 3 里逐行跑过,输出与上面完全一致。下面一条条讲为什么。

逐步推演
1 a.drive = lambda distance: ...:这是属性赋值,不是查找。赋值只作用在点左边那一个对象上——在实例 a 上新建一个属性 drive,指向那个只收一个参数的 lambda。类上的 drive 没有任何变化,b 也毫不知情。
2 a.drive(1):先求 a.drive。a 是实例 → 查实例属性 → 找到了(第 2a 步命中)。因为是在实例上直接找到的,不做绑定,拿到的就是那个 lambda 本身。调用 lambda(1),distance 绑到 1,返回 'Car overheating!'。
3 b.drive(5):b 的实例属性只有 fuel,没有 drive → 走 2b 到类上找,找到原来那个 drive 函数 → 点左边是实例,所以包成绑定方法 → 调用等价于 Car.drive(b, 5)。5 > 10 为假,b.fuel 从 10 减到 5,返回 5。注意 b 的油真的少了。
4 Car.drive = lambda self: "Tire popped!":点左边是类,所以这次赋值改的是类属性。原来那个正经的 drive 被彻底覆盖掉了,再也找不回来。新的 lambda 只收一个参数。
5 a.drive(100):a 实例上仍然有自己的 drive(第 1 步装的),2a 步就命中,根本不会去看类。所以类被改了也影响不到 a。返回 'Car overheating!'。
6 b.drive():b 实例上没有 drive → 上类里找 → 找到新的 lambda → 点左边是实例,包成绑定方法 → b 被自动塞进第一个参数 self。所以调用时一个实参都不用写,返回 'Tire popped!'。
7 f = a.drive 再 f(1):求 a.drive 的过程和第 5 步一样,得到那个 lambda,绑到名字 f 上。f 就是个普通函数名,f(1) 返回 'Car overheating!'。属性一旦被取出来绑到名字上,它就是个普通的值,跟原来的对象再无瓜葛。

第 3 步和第 6 步的对照最值得反复看:同样写 b.drive,一次要传实参一次不用。因为绑定方法自动补的是第一个参数,剩下几个由你补——原 drive 有 self, distance 两个形参,补掉 self 还剩 1 个要你给;新 lambda 只有 self 一个形参,补掉就一个不剩。

核心结论:读和写走的是两条路
读 obj.x写 obj.x = v
实例上有 x返回实例的那个修改实例的那个
实例上没有 x,类上有沿链上去用类的在实例上新建一个(类的不动)
两边都没有AttributeError在实例上新建

一句话:读会往上找,写永远不往上找。 这一条能解释本讲九成的「为什么输出是这个」。

两种 AttributeError 长得不一样

>>> class Person:
...     def __init__(self, name):
...         self.name = name
...
>>> p = Person('Rebecca')
>>> p.age
AttributeError: 'Person' object has no attribute 'age'
>>> Person.name
AttributeError: type object 'Person' has no attribute 'name'

第一条里的 'Person' object 说明点左边是实例;第二条里的 type object 'Person' 说明点左边是类。第二条特别值得注意:name 是在 __init__ 里挂到实例上的,类身上从来就没有过这个属性。看报错时读清楚是哪一种,能直接告诉你该往哪儿找问题。

注意:能这么写不代表该这么写

幻灯片专门留了一页强调这一点。在类定义外面往实例或类上装方法、覆盖方法,是反惯例的写法,真实项目里不该出现。讲这些的理由和讲环境图的理由完全一样:

  • 让你对「计算机到底怎么存放和处理数据」建立准确的心智模型;
  • 让你读代码时读出它真正做的事,而不是你以为它做的事。

考试会考这种题,因为它能精准检验你是不是真的掌握了查找规则。

8. 身份 vs 相等:is、== 与 __eq__

第 10 讲讲可变数据时区分过 is(是不是同一个对象)和 ==(值是不是相等)。到了自定义类这里,这个区分会给出一个让很多人意外的结果。

>>> class Person:
...     def __init__(self, name):
...         self.name = name
...
>>> rebecca1 = Person('Rebecca')
>>> rebecca2 = Person('Rebecca')
>>> rebecca1 is rebecca2
False
>>> rebecca1 == rebecca2
False

is 是 False,这个不奇怪:调用了两次 Person(...) 就造了两个独立的实例,各占各的位置。

让人意外的是 == 也是 False。两个人的 name 明明都是 'Rebecca'。原因是:Python 不知道你所谓的「相等」是什么意思。 对于一个自定义类,比较两个实例是该看 name?看全部属性?还是看某个 ID?Python 无从判断,于是采用最保守的默认:== 退化成 is——只有同一个对象才算相等。

直觉

为什么 [1,2] == [1,2] 是 True,而两个内容一样的 Person 却是 False?因为 list 这个类的作者(也就是 Python 的开发者)亲手写了「逐个元素比较」的相等规则。Person 的作者是你,你没写,所以就用默认的。相等规则不是天生的,是被定义出来的。

用 __eq__ 自己定义相等

把幻灯片右半边那份代码补上:

class Person:
    def __init__(self, name):
        self.name = name

    def __eq__(self, other):
        if type(other) is not Person:
            return NotImplemented
        return self.name == other.name
>>> rebecca1 = Person('Rebecca')
>>> rebecca2 = Person('Rebecca')
>>> rebecca1 is rebecca2
False
>>> rebecca1 == rebecca2
True

is 仍然是 False,而且永远改不了——身份就是身份,两个对象就是两个对象,没有任何办法让它们变成同一个。变的只有 ==。

Identity vs. Equality Revisited 幻灯片:左右并排的两个 Person 类
左右两份 Person 唯一的差别是右边多了一个 __eq__。两边的 is 都是 False 且无法改变;只有 == 的结果从 False 变成了 True。这张图想让你记住的是:相等的含义是类的作者定义的,不是语言天生的。
逐步推演:rebecca1 == rebecca2 怎么走到 __eq__
1 Python 看到 == 运算符,把它翻译成一次方法调用:rebecca1.__eq__(rebecca2)。__ 开头结尾的名字都是这类「运算符背后的方法」,Python 叫它们特殊方法(special method)。
2 求 rebecca1.__eq__:实例上没有 → 类上有 → 包成绑定方法。这一步跟第 7 节的规则一字不差。
3 调用它,self 绑到 rebecca1,other 绑到 rebecca2。
4 type(other) is not Person:type(rebecca2) 就是 Person 这个类对象,is not 为假,跳过。
5 return self.name == other.name:两个都是字符串 'Rebecca',字符串的 == 比内容,得 True。

NotImplemented 是干什么的

这是整段代码里最容易被囫囵读过去的一行。为什么类型不对时不直接 return False?

NotImplemented 是一个特殊的内置值,含义是「我不知道怎么比,你去问对方」。返回它之后,Python 会转而去问右边那个对象的 __eq__;如果对方也说不知道,Python 才最终退回到比身份,给出 False。

>>> rebecca1 == 5
False
>>> rebecca1 != rebecca2
False

rebecca1 == 5 最终仍然得到 False,看起来跟直接 return False 没区别。区别在于:直接返回 False 会堵死对方表态的机会。假如有人写了一个 PersonRecord 类,它知道怎么和 Person 比较,你返回 NotImplemented 就能让它接手,返回 False 就把路封死了。这是「不知道」和「否」的区别,别混为一谈。

另外,!= 会自动由 __eq__ 的结果取反得来,不必单独写 __ne__——上面 rebecca1 != rebecca2 得 False 就是这么来的。

常见误区

定义了 __eq__ 之后,实例不能再放进集合或当字典的键了。

>>> {rebecca1}
TypeError: unhashable type: 'Person'

因为 Python 规定「相等的对象必须有相同的哈希值」,你一改相等规则,默认的哈希就失效了,Python 干脆把它取消。要恢复得再定义一个 __hash__。这超出本讲范围,但知道有这么回事能省下将来一小时的调试。

注意

type(other) is not Person 用的是 is 而不是 ==,也不是 isinstance。用 is 比类型是标准写法,因为类对象在内存里只有一份,比身份最直接。用 isinstance(other, Person) 的区别在于它对子类也返回 True——那是下一讲继承的话题,两种写法各有各的适用场景。

9. 四个必须会用的内置函数

有了类之后,Python 提供了几个函数让你在运行时询问一个对象的情况。这四个在 Lab 06 和 Ants 里都会用到。

>>> class Person:
...     def __init__(self, name):
...         self.name = name
...
>>> rebecca = Person('Rebecca')
>>> type(rebecca)
<class 'Person'>
>>> isinstance(rebecca, Person)
True
>>> isinstance(rebecca, str)
False
>>> hasattr(rebecca, 'name')
True
>>> getattr(rebecca, 'name')
'Rebecca'
函数返回什么什么时候用
type(obj)obj 所属的类对象要精确判断「正好是这个类」时
isinstance(obj, C)obj 是不是 C(或 C 的子类)的实例,布尔值类型检查的常规选择
hasattr(obj, 'x')obj 上能不能查到名字 x,布尔值不确定属性存不存在,想避开 AttributeError
getattr(obj, 'x')等价于 obj.x,但名字用字符串给属性名要在运行时才算出来时

这解决了第 1 节说的「ADT 没法做类型检查」——isinstance 就是那个正式的答案。

细节一:type 的输出到底长什么样

幻灯片上写的是 <class 'Person'>。在真实的 Python 交互环境里,你看到的其实是:

>>> type(rebecca)
<class '__main__.Person'>

__main__ 是当前正在运行的模块的名字。幻灯片省略了这个前缀以免分散注意力。不要因为这个差别以为自己写错了。 重点是:type 返回的是类对象本身,不是它的名字字符串,所以 type(rebecca) is Person 得 True。

细节二:hasattr 和 getattr 走的是同一条查找链

它们不是什么另辟蹊径的后门,就是第 7 节那条规则的函数版:

>>> hasattr(rebecca, '__init__')     # 实例上没有,但类上有
True
>>> hasattr(rebecca, 'age')
False
>>> getattr(rebecca, 'age')
AttributeError: 'Person' object has no attribute 'age'
>>> getattr(rebecca, 'age', 0)       # 给个默认值就不报错了
0

getattr 的第三个参数是「找不到时返回什么」,很实用。

直觉:getattr 什么时候真正有用

只要属性名是你写代码时就知道的常量,就直接写 obj.name,别绕弯子。getattr 的价值在于属性名存在一个变量里:

for field in ['name', 'balance', 'holder']:
    print(field, '=', getattr(acc, field, '(无)'))

这种「遍历一批属性名」的写法用点表达式是写不出来的,因为点后面必须是字面的名字,不能是变量。

常见误区

getattr(rebecca, name) —— 传的是变量 name 而不是字符串 'name'。如果全局里恰好有个 name = 'balance',你就查了另一个属性;如果没有,得到 NameError: name 'name' is not defined。getattr 和 hasattr 的第二个参数是字符串,引号不能少。

10. 实战:把 Spotify 拆成五个类

课堂后半段是一道设计题:要做一个音乐流媒体 app,该有哪些类?它们之间是什么关系? 这不是语法练习,而是本讲的真正目的——学会用对象的眼光看一个真实系统。

Classes Brainstorm 幻灯片:Spotify 的类设计与相互关系
先定名词(User、Artist、Song、Album、Playlist),再定它们之间的动词与从属关系。「一个 Album 由一个 Artist 创作、由多首 Song 组成」这样的句子,直接决定了 Album 类里要存哪些属性。

怎么想到该有哪些类

方法很朴素:把需求用大白话写出来,句子里的名词是候选的类,动词是候选的方法,「A 的 B」和「A 由 B 组成」是候选的属性。

大白话需求读出来的设计
用户可以关注艺术家User.follow(artist) 方法;User.following 列表;Artist.followers 计数
用户可以把歌加进「喜欢的歌」歌单User.liked_songs 是一个 Playlist;用户还有 playlists 列表
专辑由某位艺术家创作,包含多首歌Album.artist 指向一个 Artist;Album.songs 是 Song 列表
歌单包含多首歌Playlist.songs 是 Song 列表
歌由某位艺术家创作Song.artist 指向一个 Artist

注意最后三行体现的一个核心观念:一个对象的属性可以是另一个对象。 Song.artist 存的不是艺术家的名字字符串,而是那个 Artist 对象本身。所以你能写 song.artist.name——连续两次点表达式,先从歌拿到艺术家对象,再从艺术家对象拿到名字。这比存一个名字字符串强得多:艺术家改名时,所有歌自动跟着改,因为它们指的是同一个对象。

Artist:最简单的一个,先做它

class Artist:
    def __init__(self, name: str):
        """
        >>> taylor = Artist('Taylor Swift')
        >>> taylor.name
        'Taylor Swift'
        >>> taylor.albums
        []
        >>> taylor.followers
        0
        """
        self.name = name
        self.albums = []
        self.followers = 0

doctest 直接告诉你要装哪三个属性和它们的初值。self.albums = [] 必须写在 __init__ 里——写成类属性 albums = [] 会让所有艺术家共用一个专辑列表(第 6 节那个坑)。

User:一个顺序陷阱

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

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

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

「order matters」这句注释是启动代码里就给好的。为什么?看 Playlist.__init__ 的最后一行(下面就写):self.owner.playlists.append(self)——歌单被创建时,会反过来把自己塞进拥有者的 playlists 列表。所以在调用 Playlist(...) 之前,self.playlists 必须已经是一个列表了。

常见误区:两种顺序写反的后果

写法一:self.playlists = [] 放在 liked_songs 后面。

self.liked_songs = Playlist('Liked Songs', [], self)
self.playlists = []
>>> User('Rebecca')
AttributeError: 'User' object has no attribute 'playlists'

报错发生在 Playlist.__init__ 里那句 self.owner.playlists.append(self):此刻 owner(也就是这个还没装修完的 User)身上根本还没有 playlists。这说明构造一个对象的过程中,它可能被别的对象「半成品」地用到。

写法二:self.playlists = [] 写了两次,一次在前一次在后。 这次不报错,但结果是错的:

>>> r = User('Rebecca')
>>> len(r.playlists)
0                       # 期望是 1
>>> r.liked_songs is r.playlists[0]
IndexError: list index out of range

因为第二次 self.playlists = [] 把名字重新绑到了一个崭新的空列表上,刚才 append 进去的那个歌单还在旧列表里,但已经没人指着旧列表了。不报错的 bug 比报错的 bug 危险得多。

follow 方法则展示了「一个动作同时改两个对象」:

逐步推演:rebecca.follow(taylor)
1 rebecca.follow → 实例上没有 → 类 User 上有 → 绑定方法。调用等价于 User.follow(rebecca, taylor)。
2 self.following.append(artist):读出 rebecca 的 following 列表,就地把 taylor 这个对象追加进去。列表里存的是对象本身,所以之后 rebecca.following[0] is taylor 为 True。
3 artist.followers += 1:展开成 artist.followers = artist.followers + 1。读 taylor 的 followers 得 0,加 1 得 1,写回 taylor 实例的属性。于是 taylor.followers 变成 1。
4 函数没有 return,返回 None。所以 doctest 里 >>> rebecca.follow(taylor) 下面没有任何期望输出——这是对的,返回 None 时 REPL 什么都不显示。

Song:格式化输出

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

    def play(self):
        """Prints the song title, artist name, and duration (M:SS)"""
        minutes = self.duration_sec // 60
        seconds = self.duration_sec % 60
        print(f'{self.name} by {self.artist.name} ({minutes}:{seconds})')

play 是 print 而不是 return——doctest 里 all_too_well.play() 下面那行 All Too Well by Taylor Swift (5:27) 是打印出来的,函数本身返回 None。把 print 改成 return,doctest 会因为多出一对引号而失败。

秒转分秒:327 秒 → 327 // 60 = 5 分,327 % 60 = 27 秒 → 5:27。// 取整除、% 取余,两个一起用就是「拆成两级单位」的标准手法。

注意:这份参考解答有一个真实的格式 bug

docstring 说格式是 M:SS——秒数两位。但上面的写法在秒数小于 10 时会掉一位。我实际跑了一遍:

>>> Song('Short', taylor, 305).play()
Short by Taylor Swift (5:5)

应该是 5:05。所有给出的 doctest(327、217、162)秒数恰好都 ≥ 10,所以测试全过,bug 被藏住了。修法是用格式说明符补零:

print(f'{self.name} by {self.artist.name} ({minutes}:{seconds:02d})')

:02d 的意思是「按十进制整数输出,不足 2 位就在前面补 0」。这件事本身比这个 bug 更值得记: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:
    def __init__(self, name: str, songs: list[Song], artist: Artist):
        self.name = name
        self.songs = songs
        self.artist = artist
        self.total_duration_sec = sum([song.duration_sec for song in self.songs])
        self.artist.albums.append(self)

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

四处值得说:

1 sum([song.duration_sec for song in self.songs]):列表推导式先把每首歌的秒数取出来成一个列表,再 sum 求和。[327, 217, 162] → 706,正是 doctest 期望的值。注意这个总时长是在构造时算好存下来的,之后往 songs 里加歌不会自动更新——这是一个真实的设计权衡,题目没要求处理。
2 self.owner.playlists.append(self):self 可以被传给别人。 这行把「我自己」这个还在构造中的对象追加进拥有者的列表。读法是三段点:先 self.owner 拿到 User 对象,再 .playlists 拿到它的列表,最后调 append。
3 Playlist.play 里的 song.play():把工作转包出去。 歌单不需要知道怎么格式化一首歌的信息,它只要挨个叫每首歌自己去打印。这是 OOP 里最常用的组合方式,也是「封装」带来的直接好处——改了 Song.play 的格式,歌单和专辑的输出自动跟着变,一行都不用动。
4 Album 和 Playlist 几乎一模一样,只是「注册到谁身上」不同(专辑注册到艺术家的 albums,歌单注册到用户的 playlists)。这种重复正是下一讲继承要消除的东西——把公共部分提到一个父类里。现在先照着写,等你亲手感受过这份重复,继承的动机才立得住。

怎么验证

启动代码是 13.py,用课上说的方式跑测试:

$ python3 -m doctest 13.py

没有任何输出就是全过了(这是 doctest 的惯例,跟 ok 不一样)。想看细节就加 -v。我在本机对参考解答 13-sol.py 跑过:43 tests in 15 items. 43 passed and 0 failed.

课上提示「有些类要等别的类实现完才能跑通 doctest」,原因很具体:User.__init__ 里用到了 Playlist,而 Playlist 定义在文件后面。这不影响运行——class User 语句执行时并不会去查 Playlist 这个名字,只有真正调用 User('Rebecca') 时才会查,那时整个文件早已执行完,Playlist 已经在全局帧里了。所以建议按顺序把五个类全写完再统一跑测试。

直觉:手动追踪一遍 Playlist 的 doctest

拿 len(rebecca.playlists) 得 2 这一条来说:

  • User('Rebecca') 时,__init__ 内部造了 Liked Songs 歌单,那个歌单的 __init__ 把自己 append 进 rebecca.playlists → 长度 1。
  • Playlist('Love Songs', [...], rebecca) 时,又 append 一次 → 长度 2,而且 rebecca.playlists[1] is love_songs 为 True(它就是刚 append 进去的那个对象本身)。

整个链条上没有任何地方复制过对象,全是同一个对象被多处引用。is 为 True 的 doctest 都是在考这一点。

11. 常见误区清单

把本讲散落各处的坑集中起来。每一条都配了真实的报错或真实的错误输出,建议对照着自己敲一遍。

忘了 self.

class Account:
    def __init__(self, holder):
        balance = 0          # 错:这只是个局部名字
        self.holder = holder

>>> a = Account('Spock')
>>> a.balance
AttributeError: 'Account' object has no attribute 'balance'

诊断口诀:看到 'X' object has no attribute 'y',先去 __init__ 里找有没有 self.y = ... 这一行。 十次有八次是漏了 self.。

忘了 self 形参

class Counter:
    def __init__(self):
        self.n = 0
    def increment():          # 错:少了 self
        pass

>>> Counter().increment()
TypeError: Counter.increment() takes 0 positional arguments but 1 was given

「我一个参数都没传,它却说给了 1 个」——那 1 个就是实例。

手动传 self

>>> a = Account('Spock')
>>> a.deposit(a, 100)
TypeError: Account.deposit() takes 2 positional arguments but 3 were given

通过实例调方法时,实例是自动补上的,你再写一次就多了一个。只有用 类.方法(实例, ...) 的形式才需要手写。

把类当实例用

>>> Account.deposit(100)
TypeError: Account.deposit() missing 1 required positional argument: 'amount'
>>> acc = Account            # 错:只是给类起了个别名,没造实例
>>> acc.balance
AttributeError: type object 'Account' has no attribute 'balance'

造实例必须带括号:Account('Spock')。 不带括号只是在传递类本身。type object 'X' has no attribute 这种报错措辞,几乎总意味着「你把类当成实例了」。

用 self.x += 1 去改类属性

见第 6 节。改的是实例,类属性不动,而且从此这个实例被遮蔽,再也读不到类属性的更新。改类属性一律写 类名.属性 += 1。

可变的类属性

见第 6 节。items = [] 写在类里 → 所有实例共用一个列表 → 一个实例 append,全体都变。凡是列表、字典这类可变对象,一律在 __init__ 里创建。

期待 __init__ 的返回值

>>> class Z:
...     def __init__(self):
...         return 5
...
>>> Z()
TypeError: __init__() should return None, not 'int'

__init__ 只负责装修,实例由 Python 自己造好并返回。

print 与 return 混淆

class Song:
    def play(self):
        return f'{self.name} by {self.artist.name}'    # 题目要的是 print

doctest 会这样失败(这是 doctest 的标准报错格式):

Expected:
    All Too Well by Taylor Swift (5:27)
Got:
    'All Too Well by Taylor Swift (5:27)'

看到「Got 里比 Expected 多一对引号」,就是把 print 写成了 return。 因为 REPL 显示返回值时会带引号(用 repr),而 print 是直接输出内容。

以为 == 默认会比内容

见第 8 节。自定义类的 == 默认等同于 is。想比内容必须写 __eq__。

属性存了名字而不是对象

self.artist = artist.name       # 只存了字符串

这样 song.artist.name 会变成 'Taylor Swift'.name,报 AttributeError: 'str' object has no attribute 'name'。需要「顺着关系继续往下走」时,属性要存对象本身。

本讲小结

概念要点典型陷阱
class 语句执行后在当前帧绑定一个类对象;缩进块里的 def 变成类的属性在类外直接调用方法名 → NameError
__init__构造器。C(args) = 造空实例 + 调 __init__(实例, args) + 返回实例写 return 非 None → TypeError;忘写 self. → 属性没建成
self普通形参名,指向「正在被操作的实例」,由 Python 自动传入方法定义忘写 self → takes 0 positional arguments but 1 was given
实例属性每个实例一份,用 self.x = ... 创建误以为在 __init__ 外也能自动有
类属性只有一份,写在类体里,所有实例共享可变类属性被全体共用;self.x += 1 改不到它
点表达式求值求点左边 → 实例先查实例属性 → 再查类属性 → 都无则 AttributeError忘了实例属性会遮蔽同名类属性
属性赋值只作用在点左边那一个对象上,绝不沿链向上以为 inst.x = v 能改类属性
绑定方法从实例取到的类中函数会自动补第一个实参;从类取到的不会C.f(x) 时忘传实例 → missing 1 required positional argument
实例上的函数属性在实例上直接找到的函数不做绑定,按普通函数调用以为它也会自动收到 self
is vs ==is 比身份;== 默认也比身份,除非定义了 __eq__指望内容相同的两个实例默认 == 为 True
NotImplemented__eq__ 里表示「我不知道怎么比」,把机会让给对方写成 return False,堵死对方的比较逻辑
四个内置函数type / isinstance / hasattr / getattrgetattr 第二个参数忘加引号 → NameError
一句话记住整讲

「读会往上找,写永远不往上找;从实例取函数会绑定,从类取函数不绑定。」 本讲九成的题目都只是这两句话的不同排列组合。

动手练习

练习 1:Account 追踪

用第 3 节那个 Account(无类属性版本),下面每一行输出什么?有报错就写出报错类型。

>>> a = Account('Ann')
>>> a.deposit(50)
>>> a.withdraw(60)
>>> a.balance
>>> b = Account('Bob')
>>> b.balance
>>> Account.deposit(b, 30)
>>> a.balance
>>> Account.balance
看答案
>>> a.deposit(50)
50
>>> a.withdraw(60)
'Insufficient funds'
>>> a.balance
50
>>> b.balance
0
>>> Account.deposit(b, 30)
30
>>> a.balance
50
>>> Account.balance
AttributeError: type object 'Account' has no attribute 'balance'

a.withdraw(60):60 > 50 为真,立刻 return 字符串,后面两行不执行,所以 a.balance 还是 50——很多人会以为余额被扣成了 -10。

Account.deposit(b, 30) 是手动传实例的写法,改的是 b,所以 a.balance 不受影响,仍是 50。

最后一条是关键:balance 是在 __init__ 里挂到实例上的,类身上从来没有过它。报错措辞里的 type object 'Account' 明确告诉你点左边是个类。

练习 2:实例属性遮蔽类属性

class Dorm:
    capacity = 100

    def __init__(self, name):
        self.name = name
        self.residents = 0

    def move_in(self, n):
        self.residents += n
        Dorm.capacity -= n
        return self.capacity

下面每一行输出什么?

>>> unit1 = Dorm('Unit 1')
>>> unit2 = Dorm('Unit 2')
>>> unit1.move_in(30)
>>> unit2.capacity
>>> unit1.capacity = 5
>>> unit1.move_in(10)
>>> Dorm.capacity
>>> unit2.capacity
看答案
>>> unit1.move_in(30)
70
>>> unit2.capacity
70
>>> unit1.capacity = 5
>>> unit1.move_in(10)
5
>>> Dorm.capacity
60
>>> unit2.capacity
60

第一次 move_in(30):self.residents 0→30(实例属性);Dorm.capacity 100→70(明确写了类名,所以真的改到了类属性);return self.capacity 时 unit1 实例上没有 capacity,沿链找到类属性 70。

unit2.capacity 同样沿链找到类属性,得 70。此时两个 Dorm 共享同一个数字。

unit1.capacity = 5:赋值只作用在 unit1 上,在 unit1 实例上新建了一个 capacity = 5。类属性还是 70。

第二次 move_in(10):Dorm.capacity 70→60;但 return self.capacity 这次在 unit1 实例上直接命中 5,返回 5。unit1 从此对类属性的变化「失联」了。

最后 Dorm.capacity 是 60,unit2.capacity 沿链也是 60。同一个方法里的 self.capacity 和 Dorm.capacity 指的是两个不同的东西——这正是考试爱考的点。

练习 3:绑定与不绑定

class Robot:
    def __init__(self, name):
        self.name = name
    def speak(self, word):
        return self.name + ': ' + word

r1 = Robot('R1')
r2 = Robot('R2')
r1.speak = lambda word: 'R1 is broken'
Robot.speak = lambda self: self.name + ' hums'

下面四行分别输出什么?

>>> r1.speak('hi')
>>> r2.speak()
>>> r2.speak('hi')
>>> Robot.speak(r1)
看答案
>>> r1.speak('hi')
'R1 is broken'
>>> r2.speak()
'R2 hums'
>>> r2.speak('hi')
TypeError: <lambda>() takes 1 positional argument but 2 were given
>>> Robot.speak(r1)
'R1 hums'

r1.speak('hi'):r1 实例上有自己的 speak(第 2a 步命中),不绑定,就是那个收一个参数的 lambda,直接返回 'R1 is broken',连 self.name 都没碰。

r2.speak():r2 实例上没有 → 上类里找到新的 lambda(原来那个正经 speak 已被覆盖,永久消失)→ 点左边是实例,包成绑定方法,r2 自动填进 self。一个参数都不用给,返回 'R2 hums'。

r2.speak('hi'):绑定方法已经用掉了唯一的形参,你再给一个就超了。报错里的「2 were given」= 自动补的 r2 + 你写的 'hi'。

Robot.speak(r1):点左边是类,拿到的是普通函数不绑定,必须自己传实例。self = r1,返回 'R1 hums'。注意这条绕过了 r1 自己的 speak 属性——从类走就永远不会看实例。

练习 4:给 Playlist 加一个方法

给第 10 节的 Playlist 加一个 add_song(self, song):把歌加到 songs 末尾,并让 total_duration_sec 保持正确。再加一个 longest_song(self),返回时长最长的那首 Song 对象(歌单为空时返回 None)。

看答案
def add_song(self, song):
    self.songs.append(song)
    self.total_duration_sec += song.duration_sec

def longest_song(self):
    if not self.songs:
        return None
    longest = self.songs[0]
    for song in self.songs:
        if song.duration_sec > longest.duration_sec:
            longest = song
    return longest

add_song 必须同时做两件事,因为 total_duration_sec 是构造时算好存下来的快照,不会自己跟着 songs 变。只 append 不更新,总时长就悄悄错了——这是典型的「两份数据必须一起改」的场景。

longest_song 的两个要点:返回的是 Song 对象,不是它的名字或时长(题目问「哪首歌」);空列表必须单独处理,否则 self.songs[0] 会抛 IndexError: list index out of range。if not self.songs 用的是空列表 falsy 的性质。

并列最长时这个写法返回最先出现的那首,因为条件是严格大于 >。改成 >= 就会返回最后一首。这种边界行为要能说清楚。

练习 5:设计题——图书馆

不写代码,只回答:要给一个图书馆管理系统建模,你会定义哪些类?每个类有哪些属性和方法?哪些数据该做成类属性?

看答案

参考设计(不唯一,重点是理由):

  • Book:属性 title、author(指向 Author 对象而不是字符串)、isbn、is_borrowed;方法 describe()。
  • Member:属性 name、borrowed_books(列表,必须在 __init__ 里建);方法 borrow(book)、return_book(book)。
  • Library:属性 books、members;方法 add_book(book)、register(member)。

类属性的判断标准是「这个数据属于整类东西,还是属于某一个个体」:

  • Member.max_borrow_limit = 5(每人最多借几本)——规则对所有会员一样,适合做类属性。
  • Member.next_id = 1 配合 self.member_id = Member.next_id; Member.next_id += 1——自动编号,和第 6 节的 account_number 完全同构。
  • borrowed_books 绝不能做类属性,否则全体会员共用一个书单。

borrow 是一个「同时改两个对象」的方法,和 User.follow 一样:

def borrow(self, book):
    if book.is_borrowed:
        return 'Already borrowed'
    if len(self.borrowed_books) >= Member.max_borrow_limit:
        return 'Limit reached'
    self.borrowed_books.append(book)     # 改会员
    book.is_borrowed = True              # 改书

注意 Member.max_borrow_limit 明确从类读,而不是写 self.max_borrow_limit——虽然这里两种写法结果一样,但写类名能表达「这是全局规则」的意图,也避免了将来某个实例意外遮蔽它。