理解了内存池和引用计数之后,自然会引出 Python 中一个影响深远的设计:可变对象和不可变对象。它们的区别不仅是“能不能改”,更关键的是在内存中的存储方式不同,这直接决定了变量赋值、函数传参、拷贝等行为的底层表现。
什么是可变与不可变
- 不可变对象:创建后,其值就不能被改变。Python 中的不可变类型包括:
int、float、str、tuple、frozenset、bytes。 - 可变对象:创建后,其内部状态(值)可以被修改,而对象的内存地址不变。可变类型包括:
list、dict、set、bytearray以及绝大多数自定义类的实例。
理解这一点很简单:
- 你无法让一个整数
5本身变成6,只能让变量指向一个值为6的新整数对象。 - 但你可以往一个列表里追加元素,列表对象还是原来那个,只是内容变了。
内存存储的底层差异
这个差异的根源在于 Python 对象在内存中的结构。每个对象都有固定的“头部”信息(引用计数、类型指针等),不同之处在于值数据是如何存放的。
- 不可变对象:值直接“嵌”在对象内部,且对象创建后不允许修改这部分内存。以
int为例,它有固定的内存大小,存储一个整数值;以str为例,字符序列被紧凑存储,Python 底层对其做了严格的不可变保护,任何修改操作实际上都会创建一个新对象。 - 可变对象:对象内部通常有一个指向可变内存区域的指针。例如
list对象内部存储的是一个指向动态数组的指针,数组中的元素是指向其他对象的引用。当你append时,如果动态数组空间足够,就直接修改这块内存区域的内容;对象自身的地址并没有变。
用一个形象的比喻:
- 不可变对象像一块刻了字的石碑,每个石碑上的字永远不变;想要新内容,就得另立一块碑。
- 可变对象像一块白板,白板还是那块白板(地址不变),但你可以擦掉内容重写。
对开发者的实际影响
- 赋值与共享
- 对于不可变对象,看起来像“复制”的行为,因为修改时必须产生新对象,不会影响到其他变量。
a = 5
b = a # b 也指向 5
b = b + 1 # 现在 b 指向新对象 6,a 还是 5
- 对于可变对象,多个变量可能指向同一个对象,修改会互相影响。
a = [1, 2]
b = a # b 和 a 指向同一个列表
b.append(3)
print(a) # [1, 2, 3] —— a 也变了
- 函数传参的本质
Python 的参数传递是传对象引用(有时叫“传引用的值”或“共享传参”)。无论是可变还是不可变,传到函数内部的都是对象的引用。
- 传入不可变对象时,在函数里无法改变调用者那里的原始对象,因为任何“修改”都是在创建新对象。
- 传入可变对象时,函数内部对其的修改(如
list.append)会影响到外部原始对象。这是很多 bug 的来源,也是需要小心的地方。
- 拷贝与深拷贝
对列表等可变对象做切片 [:] 或调用 copy.copy(),得到的是浅拷贝——新容器对象产生了,但容器内的元素仍然是共享引用。如果元素也是可变对象,对元素的修改会反映到原对象中。要完全隔离,必须使用 copy.deepcopy。
- 性能与内存占用
- 不可变对象允许 Python 做优化:小整数缓存、字符串驻留(intern)可以复用对象,节省内存。同时因为不可变,它是可哈希的,可以作为字典的键或集合的元素。
- 可变对象因为可以原地修改,在频繁增删的场景效率更高,避免了大量临时对象的创建和销毁。但也因为不可哈希,不能用作字典键。
- 在容器中的行为
t = ([1], [2]) # 元组是不可变的,但元素是可变列表
t[0].append(10)
print(t) # ([1, 10], [2]) —— 元组“变了”?
严格来说元组本身没变,改变的是它引用的可变对象。这在逻辑上容易造成混乱,用的时候要明确区分。
理解可变与不可变的内存差异,不是为了背概念,而是为了在编码时预判哪些操作会修改原数据、哪些会产生新数据。这个底层认知,会在面向对象、并发编程、性能优化等各个环节反复发挥作用。