对象与属性:把「数据」和「行为」装进同一个东西里
类是图纸,实例是照图纸盖出来的房子。这一讲把「点表达式 x.y 被求值时到底发生了什么」拆到最细。
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
def 的第一个形参都叫 self,而且函数体里凡是要碰「这个账户自己的数据」,全都写成 self.某某。__init__ 前后各两个下划线,这是 Python 认得的特殊名字,不是随便起的。class Account: 这一行做了什么
它是一条语句,被执行时做两件事:先执行缩进块里的内容(于是三个 def 被执行,产生三个函数对象),然后把这些名字打包成一个类对象,最后把名字 Account 绑定到这个类对象上。
这和 def 的机制是完全平行的:def square(x): ... 执行后,全局帧里多了一个名字 square 指向一个函数对象;class Account: ... 执行后,全局帧里多了一个名字 Account 指向一个类对象。类本身就是一个值,可以赋给别的名字,可以当参数传。
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,代码照样跑(我在下一节会真的演示这一点)。它之所以特殊,只是因为:
- Python 在通过实例调用方法时,会自动把那个实例作为第一个实参传进去;
- 整个 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. 开头。
三个方法各自在做什么
__init__(self, account_holder):构造器。它不返回账户,它装修一个已经造好的空账户——给它挂上 balance(初值 0)和 holder(用传进来的名字)两个属性。deposit(self, amount):读出当前 self.balance,加上 amount,写回 self.balance,再把新余额返回。注意「写回」这一步——没有它,余额不会变。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')
Account。在全局帧里查到那个类对象。调用一个类,意思就是「造一个这个类的实例」——这是类对象被调用时的特殊含义。'Spock',得到字符串 'Spock'。Account 类。Account.__init__,把刚造出的那个空实例作为第一个实参、'Spock' 作为第二个实参传进去。于是新帧里 self 绑到空实例,account_holder 绑到 'Spock'。__init__ 的函数体:self.balance = 0 在实例上挂一个属性 balance,值为 0;self.holder = account_holder 在实例上挂一个属性 holder,值为 'Spock'。__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)
spock_account.deposit,算子数是 100。先求算子。spock_account,得到那个 Account 实例。再找名字 deposit——实例的属性表里只有 balance 和 holder,没有 deposit,于是向上找类,在 Account 类里找到了。100。绑定方法把它记着的实例自动放到第一个位置,于是真正的调用是 deposit(spock_account, 100)。新建一帧:self → 实例,amount → 100。self.balance = self.balance + amount:右边先求值,self.balance 查到 0,加 100 得 100;再把实例的 balance 改成 100。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,给下一个人留号
...
请先自己把下面这段的输出预测一遍,再往下看。 这段代码是本讲区分度最高的地方。
+= 作用在谁身上、以及给实例属性赋值为什么完全影响不到类属性。第三行输出 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
account_number = 1。此时还没有任何实例。Account('Kaz'):造空实例 → 调 __init__。self.balance = 0、self.holder = 'Kaz' 各挂一个实例属性。self.account_number = Account.account_number:右边先求值。Account.account_number 点左边是类,直接读类属性,得 1。然后左边 self.account_number = 1 在实例上新建一个同名属性,值 1。现在实例和类各有一个 account_number,都等于 1。Account.account_number += 1:展开成 Account.account_number = Account.account_number + 1。读类属性得 1,加 1 得 2,写回类属性。现在:类的是 2,Kaz 实例的还是 1。kaz_account.account_number → 点左边是实例,先查实例属性表,找到了(值 1),直接返回 1,压根不去看类。kaz_account.account_number += 10 → 展开成 kaz_account.account_number = kaz_account.account_number + 10。读到实例属性 1,加 10 得 11,写回实例属性。类属性纹丝不动,仍是 2。kaz_account.account_number → 11。Account.account_number → 2。Account('Inej'):又走一遍 __init__。这次读到的类属性是 2,所以 Inej 的实例属性是 2;然后类属性变成 3。inej_account.account_number → 2。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. 点表达式的求值规则:查找与赋值
前面所有现象,其实都由同一条规则推出来。现在把它正式写下来。
规则本身
点表达式的语法是:
<实例或类的表达式>.<名字>
- 求值点左边的表达式(它可以是任意表达式,不一定是一个简单名字)。
- 确定
<名字>求值成什么:- 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。
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 里逐行跑过,输出与上面完全一致。下面一条条讲为什么。
a.drive = lambda distance: ...:这是属性赋值,不是查找。赋值只作用在点左边那一个对象上——在实例 a 上新建一个属性 drive,指向那个只收一个参数的 lambda。类上的 drive 没有任何变化,b 也毫不知情。a.drive(1):先求 a.drive。a 是实例 → 查实例属性 → 找到了(第 2a 步命中)。因为是在实例上直接找到的,不做绑定,拿到的就是那个 lambda 本身。调用 lambda(1),distance 绑到 1,返回 'Car overheating!'。b.drive(5):b 的实例属性只有 fuel,没有 drive → 走 2b 到类上找,找到原来那个 drive 函数 → 点左边是实例,所以包成绑定方法 → 调用等价于 Car.drive(b, 5)。5 > 10 为假,b.fuel 从 10 减到 5,返回 5。注意 b 的油真的少了。Car.drive = lambda self: "Tire popped!":点左边是类,所以这次赋值改的是类属性。原来那个正经的 drive 被彻底覆盖掉了,再也找不回来。新的 lambda 只收一个参数。a.drive(100):a 实例上仍然有自己的 drive(第 1 步装的),2a 步就命中,根本不会去看类。所以类被改了也影响不到 a。返回 'Car overheating!'。b.drive():b 实例上没有 drive → 上类里找 → 找到新的 lambda → 点左边是实例,包成绑定方法 → b 被自动塞进第一个参数 self。所以调用时一个实参都不用写,返回 'Tire popped!'。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,而且永远改不了——身份就是身份,两个对象就是两个对象,没有任何办法让它们变成同一个。变的只有 ==。
Person 唯一的差别是右边多了一个 __eq__。两边的 is 都是 False 且无法改变;只有 == 的结果从 False 变成了 True。这张图想让你记住的是:相等的含义是类的作者定义的,不是语言天生的。rebecca1 == rebecca2 怎么走到 __eq__
== 运算符,把它翻译成一次方法调用:rebecca1.__eq__(rebecca2)。__ 开头结尾的名字都是这类「运算符背后的方法」,Python 叫它们特殊方法(special method)。rebecca1.__eq__:实例上没有 → 类上有 → 包成绑定方法。这一步跟第 7 节的规则一字不差。self 绑到 rebecca1,other 绑到 rebecca2。type(other) is not Person:type(rebecca2) 就是 Person 这个类对象,is not 为假,跳过。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,该有哪些类?它们之间是什么关系? 这不是语法练习,而是本讲的真正目的——学会用对象的眼光看一个真实系统。
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)
rebecca.follow → 实例上没有 → 类 User 上有 → 绑定方法。调用等价于 User.follow(rebecca, taylor)。self.following.append(artist):读出 rebecca 的 following 列表,就地把 taylor 这个对象追加进去。列表里存的是对象本身,所以之后 rebecca.following[0] is taylor 为 True。artist.followers += 1:展开成 artist.followers = artist.followers + 1。读 taylor 的 followers 得 0,加 1 得 1,写回 taylor 实例的属性。于是 taylor.followers 变成 1。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。// 取整除、% 取余,两个一起用就是「拆成两级单位」的标准手法。
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()
四处值得说:
sum([song.duration_sec for song in self.songs]):列表推导式先把每首歌的秒数取出来成一个列表,再 sum 求和。[327, 217, 162] → 706,正是 doctest 期望的值。注意这个总时长是在构造时算好存下来的,之后往 songs 里加歌不会自动更新——这是一个真实的设计权衡,题目没要求处理。self.owner.playlists.append(self):self 可以被传给别人。 这行把「我自己」这个还在构造中的对象追加进拥有者的列表。读法是三段点:先 self.owner 拿到 User 对象,再 .playlists 拿到它的列表,最后调 append。Playlist.play 里的 song.play():把工作转包出去。 歌单不需要知道怎么格式化一首歌的信息,它只要挨个叫每首歌自己去打印。这是 OOP 里最常用的组合方式,也是「封装」带来的直接好处——改了 Song.play 的格式,歌单和专辑的输出自动跟着变,一行都不用动。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 已经在全局帧里了。所以建议按顺序把五个类全写完再统一跑测试。
拿 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 / getattr | getattr 第二个参数忘加引号 → 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——虽然这里两种写法结果一样,但写类名能表达「这是全局规则」的意图,也避免了将来某个实例意外遮蔽它。