《Python编程入门》3.3 可变与不可变、引用语义与拷贝

本节揭开 Python 对象模型的核心:变量只是绑定到对象的「名字」,而不是存放值的盒子。我们用 id、is 与 == 的真实实验讲清同一性与相等性的区别,实测小整数缓存与字符串驻留的边界,演示可变默认参数这个经典陷阱,对比 copy.copy 与 copy.deepcopy 在处理嵌套结构与循环引用时的差异,并说明函数传参为什么叫「传对象引用」。

本节目标:读完这一节,你能彻底理解「变量是名字而不是盒子」,能用 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 条件、循环与推导式 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时