失物报告单
LOST-002
报告人
免税店库存负责人 吴主管
丢了什么
一个贴了封条的箱子(元组)。秤报错说“封口的箱子不能打开”,可再打开一看,里面多了一件东西。
结案条件
查明为什么报了错东西却还是放了进去,并做出一个真正打不开的箱子。
天空岛国际机场 行李中心
早会上,都美兰班长举起一张封条。“贴了这个的箱子,谁也打不开。可今天的报告说,一个封口的箱子变胖了。”
免税店库存负责人发来的代码很短。往元组里放东西时报了 TypeError,可再把元组打印出来,东西已经在里面了。班长说:“封条贴的是外面那个箱子。里面装的箱子可没封上。”
对象分成两类。创建之后内容还能改的,是可变(mutable)对象;改不了的,是不可变(immutable)对象。
| 类别 | 代表类型 | 放到机场里就是 |
|---|---|---|
| 可变 | list、dict、set | 能拉开拉链的箱子。往里放、往外拿,都还是同一个箱子。 |
| 不可变 | int、float、str、tuple、frozenset、bool、None | 封口的箱子。想改内容,就得重新打包一个新箱子。 |
不可变对象根本没有修改内容的方法。
word = "gate"
print(word.upper(), word)
# GATE gate
word[0] = "G"
# TypeError: 'str' object does not support item assignmentfs = frozenset({1, 2})
fs.add(3)
# AttributeError: 'frozenset' object has no attribute 'add'word.upper() 并没有改动 word。它生成一个大写的新字符串并返回。字符串的方法全都是这样工作的。
那 n = n + 1 又是什么?它并没有改动整数 10 这个箱子,而是得到一个新的整数对象 11,再把标签 n 移过去。不可变对象的“修改”,永远是新对象 + 重新绑定。
下面的标签板会把整数也单独画成箱子。看看第 3 行之后,n 的箭头指向了哪里。
标签板
步骤 1 / 0
下一行将执行: 0
正在准备实验台…
刚刚变化的
封口的箱子(不可变)
+= 的两副面孔(10 分钟)a += b 看上去只做了一件事。但 Python 是这样处理的:
a 的类型有 __iadd__(就地加法)方法,就调用它。它就地改动箱子,并返回同一个箱子。a = a + b 那样,用 __add__ 创建一个新对象,再移动标签。print(hasattr(list, "__iadd__"), hasattr(int, "__iadd__"))
# True False
print(hasattr(str, "__iadd__"), hasattr(tuple, "__iadd__"))
# False False列表是可变的,所以有 __iadd__。不可变类型没有,毕竟封口的箱子没法就地改动。于是同样一个 +=,结果就分成了两路:
a = [1, 2]
b = a
a += [3]
print(a, b, a is b)
# [1, 2, 3] [1, 2, 3] True
a = (1, 2)
b = a
a += (3,)
print(a, b, a is b)
# (1, 2, 3) (1, 2) False
a = "T1"
b = a
a += "-A"
print(a, b, a is b)
# T1-A T1 False| 类型 | a += x 做了什么 | 另一个标签 b 能看到吗 |
|---|---|---|
list | __iadd__:对同一个箱子 extend | 能 |
tuple、str、int | __add__:创建新对象,只移动 a | 不能 |
这就是第 1 课的 a = a + [3] 和列表的 a += [3] 结果不同的原因:前者是新列表,后者是同一个列表。set 的 |=、dict 的 |= 也和列表一样,是就地修改。
把三种类型轮流运行一下。
标签板
步骤 1 / 0
下一行将执行: 0
正在准备实验台…
刚刚变化的
封口的箱子(不可变)
小钉提示
→ 函数接收一个列表后执行 x += [...],调用方的列表也会跟着变;换成整数或字符串,外面就纹丝不动。记住:同一行代码,会因类型不同而表现不同(第 4 课)。
说元组不可变,准确的意思是“元组装着的引用不能换”。每个格子指向哪个对象是固定的。可如果格子指向的是列表,列表本身照样可以随便改。
t = (1, [2])
t[1].append(3)
print(t)
# (1, [2, 3])
t[1] = [9]
# TypeError: 'tuple' object does not support item assignment报告人的代码没用 append,而是用了 +=。这时会发生一件怪事:
t = (1, [2])
try:
t[1] += [3]
except TypeError as e:
print("TypeError:", e)
print(t)
# TypeError: 'tuple' object does not support item assignment
# (1, [2, 3])报错了,列表却变了。因为把 t[1] += [3] 拆开来,其实是两步:
t = (1, [2])
tmp = t[1]
tmp = tmp.__iadd__([3]) # 第 1 步:就地修改列表(成功)
print(t)
# (1, [2, 3])
t[1] = tmp # 第 2 步:放回元组的格子里(失败)
# TypeError: 'tuple' object does not support item assignment第 1 步时列表就已经改了。第 2 步的格子赋值失败而报错,但已经没法撤回了。这就是秤一边喊着“封口的箱子不能打开”,一边把东西放了进去的原因。
标签板
步骤 1 / 0
下一行将执行: 0
正在准备实验台…
刚刚变化的
封口的箱子(不可变)
有个简单的办法可以检查是不是连里面都封住了:看能不能 hash()。能求哈希的元组,里面装的也全都是能求哈希的(通常是不可变的)对象(第 12 课)。
print(hash(("passport", ("snack",))) == hash(("passport", ("snack",))))
# True
hash(("passport", ["snack"]))
# TypeError: unhashable type: 'list'+= 拼接字符串的代价(6 分钟)字符串是不可变的,所以 s += "x" 原则上每次都会创建新字符串。把长度为 n 的字符串一个字一个字地拼起来,复制的字符数会膨胀到 1 + 2 + … + n。
def plus(n):
s = ""
for i in range(n):
s += "x"
return s
def plus_shared(n):
s = ""
for i in range(n):
last = s # 同一个字符串上多挂了一个标签
s += "x"
return s
def join(n):
return "".join(["x" for i in range(n)])
print(plus(5) == plus_shared(5) == join(5))
# True在 CPython 中,如果字符串身上只有一个标签,+= 会悄悄地就地扩展,做了这样的优化。所以 plus 比想象中快。但只要多挂一个标签,优化就没了。下面是在这台电脑(CPython 3.14.2)上测得的例子:
| 循环次数 | plus | plus_shared | join |
|---|---|---|---|
| 100,000 | 约 6ms | 约 120ms | 约 3ms |
| 200,000 | 约 12ms | 约 460ms | 约 5ms |
次数翻倍时,plus_shared 的耗时增长了将近 4 倍。这正是复制量按平方增长的样子。这个优化并不是语言层面的承诺,所以在循环里收集字符串时,先放进列表,最后用 "".join() 一次拼好。
is 比较值(7 分钟)不可变对象不会被改动,所以值相同的话,多个地方共用一个对象也很安全。CPython 利用这一点,预先创建好常用的对象。
小整数缓存。CPython 在启动时预先创建好 -5 到 256 的整数,之后总是返回同一个对象。
print(int("256") is int("256"), int("257") is int("257"))
# True False
print(int("-5") is int("-5"), int("-6") is int("-6"))
# True False这里特意用 int("…") 在运行时创建整数。如果在代码里直接写 257 is 257 这样的字面量,编译器可能会把相同的常量合并成一个,结果得到 True。Python 也会对这种比较发出警告:
x = 256
print(x is 256)
# SyntaxWarning: "is" with 'int' literal. Did you mean "=="?
# True字符串驻留。CPython 会把长得像标识符的字符串常量合并成一个对象来用(驻留,interning)。运行时生成的字符串,即使值相同,也可能是不同的对象。用 sys.intern() 可以让值相同的字符串统一成一个对象。
import sys
a = "gate"
b = "".join(["ga", "te"])
print(a == b, a is b)
# True False
print(sys.intern(b) is a)
# True结果取决于有没有缓存、有没有驻留,所以用 is 比较值,有的值对,有的值错。比较值一律用 ==,is 只用于 None 这种独一无二的对象。
小钉提示
→ 测试时只用小数字检查,x is 100 永远是 True,bug 就藏起来了。等到线上来了个 1000,立刻变成 False。数字和字符串请用 == 比较。
下面是报告人的代码,以及做出真正打不开的箱子的方法。
sealed = ("passport", ["snack"])
try:
sealed[1] += ["perfume"]
except TypeError as e:
print("scale:", e)
print(sealed)
# scale: 'tuple' object does not support item assignment
# ('passport', ['snack', 'perfume'])
sealed = ("passport", ("snack",)) # 里面也换成元组
try:
sealed[1] += ("perfume",)
except TypeError as e:
print("scale:", e)
print(sealed)
# scale: 'tuple' object does not support item assignment
# ('passport', ('snack',))__iadd__ 先成功了,失败的只是格子赋值。__iadd__,所以只是创建了一个新元组,然后在格子赋值时失败。原来的箱子毫发无损。班长给里面的箱子也贴上新封条,说:“封口不能只封外面,里面装的箱子也得看。”
结案报告 — LOST-002。元组只固定它装着的引用,里面的列表是可变的。t[1] += [...] 先就地扩展,再在格子赋值时失败。把里面也换成不可变的(元组)后解决。
x = 1000、y = x、x += 1 之后,y 是什么?先画出标签图,再确认。s = {1}、t = s、s |= {2} 之后,t 会怎样?换成 frozenset({1}) 再试一次。t = ([],),t[0] += [1] 和 t[0].extend([1]) 分别会怎样收场?运行之后也把 t 打印出来看看。班长的工作手册
可变:list、dict、set。不可变:int、float、str、tuple、frozenset。
不可变对象的“修改”,永远是创建新对象再移动标签。
+= 有 __iadd__ 就就地修改(list),没有就创建新对象(int、str、tuple)。
元组只固定它装着的引用,里面的列表照样会变。t[1] += [...] 一边报错一边修改。
在循环里收集字符串时,先放进列表,再 "".join()。
小整数缓存和驻留是 CPython 的内部情况。比较值一律用 ==。
订阅新课时和新课程通知
本课程新增课时或推出后续课程时,我们会发邮件通知你。
问答
关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。