前言
"可变"与"不可变"是理解 Python 数据模型的第一道坎,也是最容易产生诡异 bug 的地方:明明只是把列表传进函数"改一下",函数外面的列表却也跟着变了;明明写了a = b然后改a,b却纹丝不动。这些现象背后是同一个机制——Python 的变量存的是对象的引用,而对象分成"能改"和"不能改"两类。
先澄清一个常见误解:有人以为"不可变对象就是不能变的变量"。不是。不可变(immutable)说的是对象本身的状态不可改变,但指向它的名字随时可以改指向别的对象。另外,判断"有没有产生新对象"的标准只有一个:id()变没变。本文围绕这两条讲清可变与不可变的机制、+=的两种命运,以及传参时的真实行为。示例基于 Python 3.8+。
一、哪些类型可变,哪些不可变
| 类型 | 可变性 | 说明 |
|---|
list | 可变 | 可增删改元素 |
dict | 可变 | 可增删改键值 |
set | 可变 | 可增删元素 |
bytearray | 可变 | 可原地改字节 |
int/float/bool | 不可变 | 运算产生新对象 |
str | 不可变 | 任何"修改"都是新建字符串 |
tuple | 不可变 | 元素不能换,但元素本身可以可变 |
frozenset | 不可变 | 可哈希,能当字典键 |
bytes | 不可变 | 与bytearray相对 |
要注意tuple的"不可变"只到顶层:元组里的引用不能换,但引用指向的对象照样能改。
# 适用于 Python 3.8+
t = (1, [2, 3])
t[1].append(4) # 合法:改的是列表内部
print(t) # (1, [2, 3, 4])
try:
t[1] = [9] # 非法:换掉引用不行
except TypeError as e:
print("改了引用:", type(e).__name__) # TypeError不可变类型可以直接当字典键,可变类型不行,因为字典要求键可哈希(hashable),而可变对象的哈希值会随内容变化,破坏查找。
二、id() 是判断"是否新对象"的唯一标准
id(obj)返回对象在其生命周期内的唯一标识(在 CPython 里就是内存地址)。只要id变了,就说明产生了一个新对象。
# 适用于 Python 3.8+
a = [1, 2, 3]
b = a # 只是多了一个名字,指向同一个对象
print(id(a) == id(b)) # True
b.append(4)
print(a) # [1, 2, 3, 4],因为 a 和 b 是同一个对象
c = a[:] # 切片产生新列表
print(id(a) == id(c)) # False
print(a is c) # Falseis比较的就是id,即"是不是同一个对象";==比较内容是否相等。不要用is比较数值或字符串是否相等——小整数缓存和字符串驻留(interning)只是 CPython 的实现细节,不保证成立。
# 适用于 Python 3.8+
a = 256
b = 256
print(a is b) # 通常是 True:-5~256 的小整数被缓存(实现细节,不要依赖)
c = 1000
d = 1000
print(c is d) # 不要依赖这里的 True/False——是否复用同一对象是实现细节
print(c == d) # True:比较内容永远可靠注意:直接写256 is 256这类字面量之间的is比较,从 Python 3.8 起会触发SyntaxWarning,因为它几乎总是写错了。要比较就统一用==;is只留给None、True、False以及自定义的哨兵对象。
三、+= 有两种命运:原地,还是新建
x += y的行为取决于类型:
- 如果类型实现了
__iadd__(如list),走原地修改,id不变; - 否则退化为
x = x + y,新建对象并把名字重新绑定。
# 适用于 Python 3.8+
lst = [1, 2]
print(id(lst))
lst += [3] # 列表:原地扩展
print(id(lst)) # 和上面一样,id 没变
tup = (1, 2)
print(id(tup))
tup += (3,) # 元组:等价于 tup = tup + (3,),新对象
print(id(tup)) # id 变了
s = "ab"
print(id(s))
s += "c" # 字符串不可变:新对象
print(id(s)) # id 变了这解释了一个经典现象:函数里lst += [x]会影响外部,因为改的是同一个列表;而tup += (x,)只是把函数内的名字重新绑定,外部看不到。
四、传参传的是什么
Python 的传参既不是"值传递"也不是"引用传递",准确说法是按对象引用传递(call by object reference),也叫"按赋值传递":形参是实参对象的一个新名字。
- 函数内修改对象的内部→ 外部可见(前提是对象可变)。
- 函数内给形参重新赋值→ 只是把局部名字指向别处,外部不受影响。
# 适用于 Python 3.8+
def mutate(items):
items.append("新元素") # 改内部,外部可见
def rebind(items):
items = ["完全新列表"] # 重新绑定,外部不受影响
data = ["原始"]
mutate(data)
print(data) # ['原始', '新元素']
rebind(data)
print(data) # ['原始', '新元素'],没变想"复制一份再改",必须显式复制:list(x)、x.copy()、x[:]做浅拷贝(shallow copy),copy.deepcopy(x)做深拷贝(deep copy)。
五、不可变的代价与收益
不可变对象需要新建才能"修改",所以频繁拼接字符串、反复对大整数做运算,都会产生大量临时对象。经典反例是循环里用s += piece拼字符串;更好的做法是先把片段收进列表,最后"".join(parts)一次拼好——因为字符串拼接在频繁扩容时开销明显,而join只需一次分配。
# 适用于 Python 3.8+
parts = ["a", "b", "c"]
result = "".join(parts)
print(result) # abc
# 需要可变字符序列时,可先用 bytearray 或列表
buf = bytearray()
buf.extend(b"abc")
buf.append(ord("d"))
print(bytes(buf)) # b'abcd'不可变的收益同样明显:值不会在背后被改掉,可以被多个名字安全共享,还能进set、当字典键。默认优先用不可变类型,需要可变时再换成列表或字典,是减少 bug 的有效习惯。
常见坑点
坑 1:可变默认参数被所有调用共享。
❌
# 适用于 Python 3.8+(反例)
def add(item, box=[]):
box.append(item)
return box第一次add(1)得到[1],第二次add(2)得到[1, 2]——默认值在定义时创建,只有一份。
✅def add(item, box=None):,函数体里box = [] if box is None else box。
坑 2:以为赋值会复制对象。
❌b = a后改b,以为a不受影响(当a是列表时)。
✅ 记住b = a只是多一个名字;要独立用a.copy()或a[:]。
坑 3:用is判断内容相等。
❌if name is "admin":—— 字符串驻留是实现细节,随时可能不成立。
✅ 用if name == "admin":;is只留给None、True、False和哨兵对象。
坑 4:对元组元素做"修改"。
❌ 以为t = (1, 2); t += (3,)是原地改 —— 实际新生成了元组。
✅ 需要频繁增删就用列表;元组保持不可变语义。
坑 5:浅拷贝当深拷贝用。
❌ 嵌套列表用copy.copy或切片,改内层子列表时原对象也变了。
✅ 嵌套结构用copy.deepcopy;并注意深拷贝会递归复制、可能有循环引用开销。
坑 6:把可变对象当字典键或集合元素。
❌d = {[1, 2]: "x"}—— 抛TypeError: unhashable type: 'list'。
✅ 换成元组(1, 2)做键。
坑 7:在循环里反复s += piece拼长字符串。
❌ 每次拼接都新建字符串,累积大量临时对象。
✅ 收集片段后用"".join(parts)一次拼好。
总结
| 概念 | 结论 |
|---|
| 可变类型 | list/dict/set/bytearray |
| 不可变类型 | int/float/str/tuple/frozenset/bytes |
| 判断是否新对象 | 只看id()变没变 |
isvs== | is比身份(id),==比内容 |
+= | 有__iadd__就原地,否则新建并重绑定 |
| 传参 | 按对象引用传递;改内部可见,重新绑定不可见 |
| 复制 | 浅拷贝copy()/[:];深拷贝copy.deepcopy() |
一句话总结:可变与不可变的全部现象,都能用"变量是引用、id判身份、能改则原地、不能改则新建"这四句解释。写代码时默认选不可变类型、给可变默认参数换成None哨兵、需要独立副本时显式拷贝,就能躲开这一大类 bug。
(补充:小整数缓存与字符串驻留都是 CPython 的实现细节,官方不保证;Python 3 与 Python 2 在这一点的行为一致,但 Python 2.7 已于 2020 年 1 月 1 日停止维护,无需再作兼容考虑。)