R

ReflexKR

Reflex 官方文档

名牌机场:Python 对象模型

·

课程概览

1

标签不是箱子:名字与对象

2

封口的箱子:可变与不可变

3

箱中箱:容器与复制

4

交出箱子的时候:函数与参数

5

区域与通行证:作用域

6

随身行李:闭包

7

箱子规格书:类与实例

8

机场标准流程:双下划线方法

9

规格书的规格书:继承与 MRO

10

安检口:属性访问的秘密

11

无主的箱子:内存与 GC

12

储物柜号码:哈希与相等,综合

名牌机场:Python 对象模型
第2课

时间 0:50–1:40

封口的箱子:可变与不可变

失物报告单

LOST-002

封口的箱子变胖了

报告人

免税店库存负责人 吴主管

丢了什么

一个贴了封条的箱子(元组)。秤报错说“封口的箱子不能打开”,可再打开一看,里面多了一件东西。

结案条件

查明为什么报了错东西却还是放了进去,并做出一个真正打不开的箱子。

天空岛国际机场 行李中心

早会上,都美兰班长举起一张封条。“贴了这个的箱子,谁也打不开。可今天的报告说,一个封口的箱子变胖了。”

免税店库存负责人发来的代码很短。往元组里放东西时报了 TypeError,可再把元组打印出来,东西已经在里面了。班长说:“封条贴的是外面那个箱子。里面装的箱子可没封上。”

2-1. 封口的箱子与敞开的箱子(9 分钟)

对象分成两类。创建之后内容还能改的,是可变(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 assignment
fs = 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

正在准备实验台…

刚刚变化的

封口的箱子(不可变)

第 3 行之后,整数 10 原样留着,新出现了一个 11 的箱子,只有标签 n 移了过去。m 仍然指向 10。

2-2. += 的两副面孔(10 分钟)

a += b 看上去只做了一件事。但 Python 是这样处理的:

  1. 如果 a 的类型有 __iadd__(就地加法)方法,就调用它。它就地改动箱子,并返回同一个箱子。
  2. 如果没有,就像 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

正在准备实验台…

刚刚变化的

封口的箱子(不可变)

在 list 中,第 3 行之后两支箭头仍指向同一个箱子。在 tuple 和 str 中,会出现一个新箱子,只有 a 移了过去。新元组里的 1、2 和原元组里的是同一批整数对象。

小钉提示

→ 函数接收一个列表后执行 x += [...],调用方的列表也会跟着变;换成整数或字符串,外面就纹丝不动。记住:同一行代码,会因类型不同而表现不同(第 4 课)。

2-3. 封条只贴在外箱上:元组里的列表(8 分钟)

说元组不可变,准确的意思是“元组装着的引用不能换”。每个格子指向哪个对象是固定的。可如果格子指向的是列表,列表本身照样可以随便改。

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

正在准备实验台…

刚刚变化的

封口的箱子(不可变)

最后一步会显示错误,但图里的列表已经装进了 3。元组的格子仍然指向同一个列表。

有个简单的办法可以检查是不是连里面都封住了:看能不能 hash()。能求哈希的元组,里面装的也全都是能求哈希的(通常是不可变的)对象(第 12 课)。

print(hash(("passport", ("snack",))) == hash(("passport", ("snack",))))
# True
hash(("passport", ["snack"]))
# TypeError: unhashable type: 'list'

2-4. 用 += 拼接字符串的代价(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)上测得的例子:

循环次数plusplus_sharedjoin
100,000约 6ms约 120ms约 3ms
200,000约 12ms约 460ms约 5ms

次数翻倍时,plus_shared 的耗时增长了将近 4 倍。这正是复制量按平方增长的样子。这个优化并不是语言层面的承诺,所以在循环里收集字符串时,先放进列表,最后用 "".join() 一次拼好。

2-5. 小整数与驻留:为什么不用 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。数字和字符串请用 == 比较。

结案(10 分钟)

下面是报告人的代码,以及做出真正打不开的箱子的方法。

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] += [...] 先就地扩展,再在格子赋值时失败。把里面也换成不可变的(元组)后解决。

课后练习

  1. 执行 x = 1000、y = x、x += 1 之后,y 是什么?先画出标签图,再确认。
  2. 执行 s = {1}、t = s、s |= {2} 之后,t 会怎样?换成 frozenset({1}) 再试一次。
  3. 设 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 的内部情况。比较值一律用 ==。

← 上一课

第1课 · 标签不是箱子:名字与对象

下一课 →

第3课 · 箱中箱:容器与复制

订阅新课时和新课程通知

本课程新增课时或推出后续课程时,我们会发邮件通知你。

收集项目:电子邮箱 · 用途:本课程的新课时和上线通知 · 保存期限:直到你要求删除。不会用于其他用途,也不会提供给他人。回复任意通知邮件,我们会立即删除。

问答

关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。

以纯文本显示,不支持链接和格式。

R

ReflexKR

动手学 Python 和 Reflex。所有示例均已在 Reflex 0.10.0 上实际运行验证。

ReflexKR 免费,没有横幅广告。如果对你有帮助,欢迎请我们喝杯咖啡。 ☕ 请我们喝杯咖啡

联盟链接与赞助说明
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex