前言可变与不可变是理解 Python 数据模型的第一道坎也是最容易产生诡异 bug 的地方明明只是把列表传进函数改一下函数外面的列表却也跟着变了明明写了a b然后改ab却纹丝不动。这些现象背后是同一个机制——Python 的变量存的是对象的引用而对象分成能改和不能改两类。先澄清一个常见误解有人以为不可变对象就是不能变的变量。不是。不可变immutable说的是对象本身的状态不可改变但指向它的名字随时可以改指向别的对象。另外判断有没有产生新对象的标准只有一个id()变没变。本文围绕这两条讲清可变与不可变的机制、的两种命运以及传参时的真实行为。示例基于 Python 3.8。一、哪些类型可变哪些不可变类型可变性说明list可变可增删改元素dict可变可增删改键值set可变可增删元素bytearray可变可原地改字节int/float/bool不可变运算产生新对象str不可变任何修改都是新建字符串tuple不可变元素不能换但元素本身可以可变frozenset不可变可哈希能当字典键bytes不可变与bytearray相对要注意tuple的不可变只到顶层元组里的引用不能换但引用指向的对象照样能改。# 适用于 Python 3.8t (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.8a [1, 2, 3]b a # 只是多了一个名字指向同一个对象print(id(a) id(b)) # Trueb.append(4)print(a) # [1, 2, 3, 4]因为 a 和 b 是同一个对象c a[:] # 切片产生新列表print(id(a) id(c)) # Falseprint(a is c) # Falseis比较的就是id即是不是同一个对象比较内容是否相等。不要用is比较数值或字符串是否相等——小整数缓存和字符串驻留interning只是 CPython 的实现细节不保证成立。# 适用于 Python 3.8a 256b 256print(a is b) # 通常是 True-5~256 的小整数被缓存实现细节不要依赖c 1000d 1000print(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.8lst [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 abprint(id(s))s c # 字符串不可变新对象print(id(s)) # id 变了这解释了一个经典现象函数里lst [x]会影响外部因为改的是同一个列表而tup (x,)只是把函数内的名字重新绑定外部看不到。四、传参传的是什么Python 的传参既不是值传递也不是引用传递准确说法是按对象引用传递call by object reference也叫按赋值传递形参是实参对象的一个新名字。函数内修改对象的内部→ 外部可见前提是对象可变。函数内给形参重新赋值→ 只是把局部名字指向别处外部不受影响。# 适用于 Python 3.8def mutate(items):items.append(新元素) # 改内部外部可见def rebind(items):items [完全新列表] # 重新绑定外部不受影响data [原始]mutate(data)print(data) # [原始, 新元素]rebind(data)print(data) # [原始, 新元素]没变想复制一份再改必须显式复制list(x)、x.copy()、x[:]做浅拷贝shallow copycopy.deepcopy(x)做深拷贝deep copy。五、不可变的代价与收益不可变对象需要新建才能修改所以频繁拼接字符串、反复对大整数做运算都会产生大量临时对象。经典反例是循环里用s piece拼字符串更好的做法是先把片段收进列表最后.join(parts)一次拼好——因为字符串拼接在频繁扩容时开销明显而join只需一次分配。# 适用于 Python 3.8parts [a, b, c]result .join(parts)print(result) # abc# 需要可变字符序列时可先用 bytearray 或列表buf bytearray()buf.extend(babc)buf.append(ord(d))print(bytes(buf)) # babcd不可变的收益同样明显值不会在背后被改掉可以被多个名字安全共享还能进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, boxNone):函数体里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()变没变isvsis比身份id比内容有__iadd__就原地否则新建并重绑定传参按对象引用传递改内部可见重新绑定不可见复制浅拷贝copy()/[:]深拷贝copy.deepcopy()一句话总结可变与不可变的全部现象都能用变量是引用、id判身份、能改则原地、不能改则新建这四句解释。写代码时默认选不可变类型、给可变默认参数换成None哨兵、需要独立副本时显式拷贝就能躲开这一大类 bug。补充小整数缓存与字符串驻留都是 CPython 的实现细节官方不保证Python 3 与 Python 2 在这一点的行为一致但 Python 2.7 已于 2020 年 1 月 1 日停止维护无需再作兼容考虑。