本节目标:读完这一节,你能彻底理解「变量是名字而不是盒子」,能用
id/is/==分辨同一性与相等性,能解释小整数缓存与字符串驻留为何会让is结果出乎意料,能避开可变默认参数这个经典陷阱,并在需要复制时正确选择copy.copy与copy.deepcopy。
适用版本:Python 3.12+(实测 3.14.6)
3.3 可变与不可变、引用语义与拷贝
上一节我们把四种容器都过了一遍。但有一个问题贯穿始终:为什么把列表传给函数,函数里一改,外面的列表也跟着变了?为什么 a = b 之后改 b 会影响 a?这些困惑都指向同一个底层机制——Python 的变量不是「盒子」,而是「贴在对象上的名字」。理解这一点,你能一次性消灭一整类「见鬼了」的 bug。
变量是名字,不是盒子
很多语言里 int a = 5 意味着开一个叫 a 的盒子、把 5 放进去。Python 不是这样:a = [1, 2, 3] 先创建一个列表对象,再让名字 a 指向它。赋值是「建立绑定」,不是「拷贝内容」。所以下面这段代码里,a 和 b 指向的是同一个列表:
a = [1, 2, 3]
b = a # 绑定同一个对象,不复制
b.append(4)
print("a =", a, "b =", b, "a is b:", a is b)
a = [1, 2, 3, 4] b = [1, 2, 3, 4] a is b: True
改 b 等于改 a,因为它们是同一个对象的两个名字。想真正复制,必须显式调用拷贝(见后文)。
id()、is 与 ==
id() 返回对象在内存中的身份标识(CPython 里就是地址),在对象存活期间唯一且不变。is 比较的是 id(同一性),== 比较的是值(相等性):
print([1, 2] == [1, 2]) # 值相等
print([1, 2] is [1, 2]) # 却是两个不同对象
print(None is None) # 单例,永远是 True
True
False
True
规则很简单:判断值用 ==,判断「是不是同一个对象」用 is。 唯一该用 is 比较值的场景是 None、True、False 这类单例——x is None 比 x == None 更快也更安全(后者可能被自定义的 __eq__ 干扰)。
小整数缓存与字符串驻留
既然 is 比的是身份,那为什么有时候两个「独立」的整数或字符串 is 起来却是 True?因为 CPython 做了缓存优化。
小整数缓存:-5 到 256 之间的整数在解释器启动时就创建好了,所有用到这些值的变量都指向同一批对象:
print(int("256") is int("256")) # 缓存在范围内
print(int("257") is int("257")) # 超出范围,各自新建
True
False
256 是同一个对象,257 不是。这纯属实现细节,绝不能依赖它写逻辑——换一个 Python 实现(如 PyPy)结果可能不同。
字符串驻留(interning):看起来像标识符的短字符串,以及编译期就能确定的字面量,会被共享;运行期拼出来的则不会:
a = "hello world!"
b = "".join(["hello", " world!"])
print(a is b) # 运行期拼接,是新对象
print(a == b) # 但值相等
False
True
想要强制驻留,可以用 sys.intern(s)。同样地,这些都是优化,比较字符串永远用 ==,不要用 is。
可变默认参数的经典陷阱
Python 的函数默认值只在定义时求值一次,之后所有调用共享同一个对象。如果默认值是可变对象(列表、字典、集合),就会出问题:
def add_item(item, target=[]):
target.append(item)
return target
print(add_item("a"))
print(add_item("b"))
print(add_item("c"))
['a']
['a', 'b']
['a', 'b', 'c']
每次调用都在往同一个列表里加东西,因为它就是函数定义时创建的那个默认值对象(可通过 add_item.__defaults__ 看到)。正确写法是用 None 作占位,在函数体内新建:
def add_item_ok(item, target=None):
if target is None:
target = []
target.append(item)
return target
print(add_item_ok("a"))
print(add_item_ok("b"))
['a']
['b']
只要默认值是 []、{}、set() 或自定义可变对象,就应该立刻警觉。 这是一个几乎每个 Python 程序员都踩过的坑。
浅拷贝与深拷贝
要复制容器,用 copy 模块。copy.copy 是浅拷贝:只复制最外层容器,内部的元素仍然是共享的:
import copy
a = [1, 2, [3, 4]]
b = copy.copy(a) # 等价于 a.copy() / a[:] / list(a)
print(a is b, a[2] is b[2])
b[0] = 100 # 改外层:互不影响
b[2].append(99) # 改内层:原列表也变
print("a =", a)
print("b =", b)
False True
a = [1, 2, [3, 4, 99]]
b = [100, 2, [3, 4, 99]]
copy.deepcopy 是深拷贝:递归复制所有层级,两棵对象树完全独立:
a = [1, 2, [3, 4]]
c = copy.deepcopy(a)
print(a[2] is c[2])
c[2].append(99)
print("a =", a)
print("c =", c)
False
a = [1, 2, [3, 4]]
c = [1, 2, [3, 4, 99]]
| 方式 | 外层独立 | 内层独立 | 适用场景 |
|---|---|---|---|
= 赋值 | 否 | 否 | 只想给同一对象起别名 |
copy.copy | 是 | 否 | 扁平结构,或有意共享内层 |
copy.deepcopy | 是 | 是 | 嵌套结构、需要完全隔离 |
深拷贝更彻底,但也更慢,且遇到不可拷贝的对象(如文件句柄、锁)会报错。按需选择,不要无脑 deepcopy。
循环引用与 deepcopy
如果一个对象引用了自己(或形成环),深拷贝会怎么处理?deepcopy 用一张「已拷贝对象」表记录每个对象的副本,因此能正确处理循环,不会无限递归:
import copy
a = [1, 2]
a.append(a) # a[2] 就是 a 自己,形成循环
print("a[2] is a:", a[2] is a)
b = copy.copy(a)
print("浅拷贝 b[2] is a:", b[2] is a) # 仍指向原对象
c = copy.deepcopy(a)
print("深拷贝 c[2] is c:", c[2] is c) # 指向副本自身,环被保留
a[2] is a: True
浅拷贝 b[2] is a: True
深拷贝 c[2] is c: True
注意深拷贝后的 c[2] 指向的是 c 自己,而不是 a——环的结构被忠实复制了,这正是 deepcopy 的 memo 机制在起作用。
函数传参:传对象引用
现在可以回答开头的问题了。Python 的传参既不是「传值」也不是「传引用」,准确说法是传对象引用(call by object reference):函数拿到的是实参对象的同一个引用。
- 如果函数修改对象内容(
lst.append(...)),调用方会看到变化; - 如果函数重新绑定名字(
lst = [...]),只是让函数内的局部名字指向新对象,调用方的名字纹丝不动。
def mutate(lst):
lst.append("added") # 改内容,影响外部
def rebind(lst):
lst = ["new"] # 只改局部绑定,不影响外部
orig = [1, 2]
mutate(orig)
print("mutate 后:", orig)
orig2 = [1, 2]
rebind(orig2)
print("rebind 后:", orig2)
mutate 后: [1, 2, 'added']
rebind 后: [1, 2]
这解释了为什么「传列表进去被改了」和「传列表进去没被改」两种情况都可能出现——区别在于函数内部是改内容还是改绑定。
tuple 里放 list 仍可变
最后澄清一个常见误解。说 tuple 不可变,指的是它的元素绑定不能改,而不是「里面的一切都冻住了」。如果元组里装着一个列表,那个列表本身依然可以改:
t = (1, [2, 3])
t[1].append(4) # 合法:改的是列表的内容
print(t)
t[1] = [9] # 非法:改元组的元素绑定
(1, [2, 3, 4])
TypeError: 'tuple' object does not support item assignment
「不可变」约束的是元组这一层的绑定关系;内部可变对象该怎么变还怎么变。这也是为什么元组里放列表后,它就不再适合当字典的键——只要内容会变,哈希值就可能失效。
小结
这一节我们看穿了 Python 对象模型的底层:
- 变量是「绑定到对象的名字」,
b = a让两者指向同一对象,改一个即改另一个。 is比身份(id),==比值;值比较一律用==,只有单例(None/True/False)用is。- 小整数缓存(
-5~256)与字符串驻留会让is结果出人意料,但都是实现细节,不可依赖。 - 可变默认参数只在定义时求值一次,会导致状态跨调用累积;用
None占位规避。 copy.copy只复制外层,copy.deepcopy递归复制并正确处理循环引用;按需选择。- 函数传参是「传对象引用」:改内容影响外部,改绑定不影响;
tuple内嵌list时列表仍可变。
到这里,「一个值」和「一组值」以及它们的引用语义都讲完了。下一节 4.1 条件、循环与推导式 将开始讲控制流——如何让程序根据条件做判断、重复做一件事,并用推导式把「生成一组值」写得既短又快,你会发现它大量依赖本节的可变/不可变直觉。
阅读导航:上一节:3.2 列表、元组、字典与集合 · 下一节:4.1 条件、循环与推导式 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。