同样是把一个变量赋值给另一个变量,整数和列表的表现却完全不同:前者互不影响,后者会跟着一起变。这不是 Python 的 bug,而是可变类型与不可变类型这套机制在起作用。本文从 id() 验证入手,讲清赋值、函数传参、默认参数、is 与 ==、可哈希性等几个最容易踩坑的地方,并给出可落地的规避写法。
先看两段现象相反的代码:
- a = 100
- b = a
- a = 200
- print(b) # 100
复制代码- lst1 = [1, 2, 3]
- lst2 = lst1
- lst1.append(4)
- print(lst2) # [1, 2, 3, 4]
复制代码
第一段里 b 没有跟着变,第二段里 lst2 跟着变了。原因在于 Python 的变量不是装数据的盒子,而是贴在对象上的标签。赋值 a = obj 只是把标签贴到 obj 上,不拷贝数据。后续操作到底是“换了一个对象”还是“在同一个对象上改”,决定了另一个标签看到的内容会不会变。
一、用 id() 判断对象是否被重建
id() 返回对象的唯一标识(CPython 中就是内存地址),是观察对象有没有被重新创建的最直接工具。
- x = 100
- print(f'x={x}, id={id(x)}')
- x = x + 1
- print(f'x={x}, id={id(x)}')
复制代码
执行后会发现两次 id 不同。x + 1 创建了一个新整数对象,x 只是转而指向了它,原来的 100 对象并没有被“改”成 101。
- lst = [1, 2, 3]
- print(f'lst={lst}, id={id(lst)}')
- lst.append(4)
- print(f'lst={lst}, id={id(lst)}')
复制代码
这次 id 没变。append 直接在原列表上追加,没有创建新列表。字符串的“修改”同样会生成新对象:
- s = 'hello'
- print(f'原始: id={id(s)}, s={s}')
- s = s + ' world'
- print(f'拼接后: id={id(s)}, s={s}')
- s = s.upper()
- print(f'大写后: id={id(s)}, s={s}')
复制代码
三次输出的 id 都不一样。字符串的拼接、upper()、replace()、切片等操作,返回的都是新字符串,原字符串保持不变。
二、Python 中两类对象的具体划分
不可变类型包括:int、float、complex、bool(int 的子类)、str、bytes、tuple、frozenset、NoneType。可变类型包括:list、dict、set、bytearray,以及自定义类的实例(默认可变)。
对不可变类型做下标赋值或原地修改会直接报错,这一点可以用来确认类型归属:
- s = 'hello'
- s[0] = 'H' # TypeError: 'str' object does not support item assignment
- b = b'hello'
- b[0] = 72 # TypeError: 'bytes' object does not support item assignment
- t = (1, 2, 3)
- t[0] = 100 # TypeError: 'tuple' object does not support item assignment
- fs = frozenset([1, 2, 3])
- fs.add(4) # AttributeError: 'frozenset' object has no attribute 'add'
复制代码
可变类型则可以原地改:
- lst = [1, 2, 3]
- lst[0] = 100
- lst.append(4)
- d = {'a': 1}
- d['a'] = 2
- d['b'] = 3
- s = {1, 2, 3}
- s.add(4)
- s.remove(1)
- ba = bytearray(b'hello')
- ba[0] = 72
- print(ba) # bytearray(b'Hello')
- class Person:
- def __init__(self, name):
- self.name = name
- p = Person('小明')
- p.name = '小红'
复制代码
从可否作为字典键的角度看:int、float、complex、bool、str、bytes、frozenset、NoneType 不可变且可哈希,可以直接当键;tuple 本身不可变,但只有元素全部可哈希时整体才可哈希;list、dict、set、bytearray 可变更不可哈希,不能当键;自定义类实例默认可变、默认可哈希,能否当键取决于 __hash__ 的实现。
三、不可变类型的“修改”到底发生了什么
字符串方法都是返回新对象,原对象不受影响:
- original = 'Hello'
- upper_version = original.upper()
- print(original, upper_version) # Hello HELLO
- replaced = original.replace('H', 'J')
- print(original, replaced) # Hello Jello
- sliced = original[1:4]
- print(sliced) # ell
复制代码
原字符串始终没变。这也解释了新手最常见的一个无效操作:
- text = ' hello '
- text.strip()
- print(f'无效strip后: "{text}"') # 还是 ' hello '
- text = text.strip()
- print(f'有效strip后: "{text}"') # 'hello'
复制代码
strip() 返回的是新字符串,不接住返回值等于白做。整数同理,x += 5 等价于 x = x + 5,每次都会产生新对象。需要注意的是小整数缓存:Python 会缓存 -5 到 256 的整数,所以在这个范围内值相同的两个变量用 is 比较可能得到 True,超出范围通常为 False。
- a = 256
- b = 256
- print(a is b) # True,缓存范围内
- a = 257
- b = 257
- print(a is b) # 通常 False,超出缓存范围
复制代码
元组的不可变是浅层的,这一点最容易误解:
- t2 = ([1, 2], [3, 4], 5)
- print(f'修改前: {t2}, id={id(t2)}')
- t2[0].append(999)
- print(f'修改后: {t2}, id={id(t2)}')
复制代码
元组本身的 id 不变,但里面的列表内容变了。元组保证的是 t2[0] 永远指向同一个列表对象,并不保证那个列表的内容不变。也正因为如此,包含可变元素的元组不可哈希,拿它当字典键会抛出 TypeError: unhashable type: 'list'。
四、可变类型的原地修改与 += 的差异
列表的常用操作几乎都是原地完成,id 全程不变:
- lst = [1, 2, 3]
- original_id = id(lst)
- lst.append(4)
- lst.extend([5, 6])
- lst.insert(0, 0)
- lst.remove(2)
- lst.pop()
- lst.sort()
- lst.reverse()
- lst[0] = 100
- lst[1:3] = [20, 30]
- del lst[2]
- print(id(lst) == original_id) # True
复制代码
但 + 和 += 的行为并不一样:
- lst2 = [1, 2, 3]
- original_id = id(lst2)
- lst2 = lst2 + [4]
- print(id(lst2) != original_id) # True,新建了列表
- lst3 = [1, 2, 3]
- original_id = id(lst3)
- lst3 += [4]
- print(id(lst3) != original_id) # False,等价于 extend,原地修改
复制代码
字典和集合也是同样的规则,赋值、update、del、pop、add、remove、discard 都不改变对象 id:
- d = {'a': 1, 'b': 2}
- original_id = id(d)
- d['c'] = 3
- d['a'] = 100
- d.update({'d': 4})
- del d['b']
- d.pop('c')
- print(id(d) == original_id) # True
- s = {1, 2, 3}
- original_id = id(s)
- s.add(4)
- s.remove(1)
- s.update({5, 6})
- s.discard(2)
- print(id(s) == original_id) # True
复制代码
五、赋值与函数传参:贴标签与对象引用传递
理解赋值行为的关键是“贴标签”模型:
- a = 100
- b = a
- a = 200
- print(b) # 100
- lst1 = [1, 2]
- lst2 = lst1
- lst1.append(3)
- print(lst2) # [1, 2, 3]
复制代码
对不可变对象,a = 200 只是让 a 改贴到新对象上,b 仍然指向 100。对可变对象,lst1 和 lst2 是同一张标签贴在同一个列表上,通过任意一个标签做原地修改,另一个都会看到。
函数传参也是同一套机制。Python 既不是值传递也不是引用传递,而是对象引用传递(pass by object reference),也叫 call by sharing。参数拿到的是对象的引用,但参数本身和外层变量是两个独立的标签。因此:
- def modify_int(n):
- n = 999
- print(f'函数内: n={n}, id={id(n)}')
- x = 100
- modify_int(x)
- print(f'调用后: x={x}, id={id(x)}') # 100,不受影响
复制代码- def modify_list(lst):
- lst.append(999)
- my_list = [1, 2, 3]
- modify_list(my_list)
- print(my_list) # [1, 2, 3, 999],被改了
复制代码
真正的分水岭是“重新赋值”还是“原地修改”:
- def reassign(lst):
- lst = [4, 5, 6]
- lst.append(7)
- original = [1, 2, 3]
- reassign(original)
- print(original) # [1, 2, 3],参数重新赋值后切断了联系
- def mutate(lst):
- lst.append(4)
- lst[0] = 100
- original = [1, 2, 3]
- mutate(original)
- print(original) # [100, 2, 3, 4],原地修改对外可见
- def mixed(lst):
- lst.append(99)
- lst = ['新', '列表']
- lst.append('更多')
- original = [1, 2, 3]
- mixed(original)
- print(original) # [1, 2, 3, 99],只看到第一步行内修改的效果
复制代码
默认参数陷阱就建立在这套规则上:
- def add_user_bad(name, roles=[]):
- roles.append('user')
- roles.append(name)
- return roles
- print(add_user_bad('Alice')) # ['user', 'Alice']
- print(add_user_bad('Bob')) # ['user', 'Alice', 'user', 'Bob']
复制代码
默认值 [] 只在函数定义时求值一次,所有不传 roles 的调用共享同一个列表,上一次的数据就串到了下一次。正确做法是用 None 作为哨兵,在函数体内创建新列表:
- def add_user_good(name, roles=None):
- if roles is None:
- roles = []
- roles.append('user')
- roles.append(name)
- return roles
- print(add_user_good('Alice')) # ['user', 'Alice']
- print(add_user_good('Bob')) # ['user', 'Bob']
复制代码
结论很直接:不要把可变对象当作函数默认参数。
六、is 与 ==,以及可变对象为什么不能当字典键
== 比较值是否相等,is 比较是不是同一个对象(id 是否相同)。
- a = 'hello world'
- b = 'hello world'
- print(a == b) # True
- print(a is b) # 可能 True 可能 False,取决于字符串驻留
- lst1 = [1, 2, 3]
- lst2 = [1, 2, 3]
- print(lst1 == lst2) # True
- print(lst1 is lst2) # False
- x = None
- print(x is None) # True,None 是单例
复制代码
不可变类型值相同不一定是同一对象,存在字符串驻留和小整数缓存;可变类型即使值相同也几乎一定不是同一对象。判断 None 一律推荐用 is None。
可变对象不能作为字典键:
- d = {[1, 2]: 'value'} # TypeError: unhashable type: 'list'
复制代码
原因是字典依赖哈希值定位条目。如果列表能当键,先存入 [1, 2],再用同一个对象 append(3) 改成 [1, 2, 3],哈希值随之变化,原来的条目就再也找不到了。不可变类型没有这个问题:
- d = {(1, 2): 'value'}
- d = {frozenset([1, 2]): 'value'}
- d = {'key': 'value'}
- d = {42: 'value'}
复制代码
七、对外暴露可变数据时返回副本
类内部持有的列表如果直接返回引用,外部代码就能绕过类的方法改掉内部状态:
- class Team:
- def __init__(self):
- self._members = []
- def get_members_bad(self):
- return self._members
- def get_members_good(self):
- return self._members.copy()
- def add_member(self, name):
- self._members.append(name)
- team = Team()
- team.add_member('Alice')
- members = team.get_members_bad()
- members.append('Hacker')
- print(team._members) # ['Alice', 'Hacker'],内部状态被污染
- team2 = Team()
- team2.add_member('Alice')
- members = team2.get_members_good()
- members.append('Hacker')
- print(team2._members) # ['Alice'],内部状态安全
复制代码
只读数据则优先用不可变类型传递。用元组或 frozenset 传配置,任何试图修改的操作都会抛 TypeError,这比在函数里到处加防御性拷贝更省事,也更容易在早期暴露越界修改。
八、Python 这样设计的取舍
不可变类型的实际优势主要有三点。第一是可哈希,能作为字典键和集合元素,这是缓存、去重等场景的基础。第二是线程安全,多个线程同时读取同一个不可变对象无需加锁。第三是行为可预测,函数拿到一份元组配置可以放心使用,不必担心被“远程修改”,字符串驻留等解释器优化也依赖不可变性。
可变类型的优势则是内存效率:需要大批量增删改时,原地操作不必反复创建新对象,列表、字典、集合的常用方法都是为此设计的。
落到日常编码,几条规则值得固定下来:判断值相等用 ==,判断是否同一对象用 is,判断 None 用 is None;函数默认参数不要写 []、{} 或 set(),统一用 None 哨兵;返回类内部可变属性时给副本;传递且不应被修改的数据用元组或 frozenset;对共享的列表优先使用 append、extend 等原地方法,避免无意间用 + 或重新赋值切断引用关系。 |