Python 基础体系 · 第 5/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 名称、对象与绑定:变量不是盒子,赋值不是复制
在 Python 中,变量不是装着值的盒子,赋值也不是把右侧内容复制到左侧。更准确的模型是:
对象保存数据,名称指向对象,赋值建立或改变名称到对象的绑定。
Python 语言参考把名称描述为“引用对象的名称”,名称通过绑定操作被引入;对象则具有身份、类型和值三个基本属性。(docs.python.org)
这个模型可以解释一系列看似分散的现象:
- 为什么
b = a后修改a,b也可能受到影响; - 为什么
a = a + 1与a.append(1)的行为不同; - 为什么函数可以修改传入列表,却不能通过重新绑定参数修改调用者的名称;
- 为什么
is和==不能互换; - 为什么浅拷贝后嵌套对象仍然共享;
- 为什么“传值”与“传引用”都不足以准确描述 Python 的参数传递。
一、先建立运行模型:名称、对象和命名空间
1. 名称不是对象
考虑这段代码:
count = 10
可以分解为两个步骤:
- 创建或取得整数对象
10; - 在当前命名空间中,把名称
count绑定到这个整数对象。
可以抽象成:
命名空间
┌─────────┐
│ count ──┼────► 整数对象 10
└─────────┘
这里的 count 是名称,不是对象本身。对象才是运行时真正承载值、类型和身份的实体。
如果随后执行:
count = 20
发生的不是“盒子里的数字从 10 改成 20”,而是:
执行前:
count ─────► 10
执行后:
count ─────► 20
原来的整数对象 10 并没有被修改。整数是不可变对象,因此不存在“把整数对象 10 改成 20”这件事;只是名称 count 改为绑定另一个对象。
名称绑定不仅由普通赋值产生,也可能由函数定义、类定义、函数形参、for 循环目标、with ... as、异常处理中的 as、导入语句以及赋值表达式等操作产生。(docs.python.org)
2. 命名空间是名称到对象的映射
在实现层面,模块、函数和类等作用域都拥有命名空间。可以把命名空间近似理解为:
namespace = {
"count": <对象 10>,
"name": <对象 "Alice">,
}
这不是说所有命名空间都必须由普通 dict 实现,而是一个有用的语义模型:名称作为键,绑定关系作为映射结果。
可以用 globals() 观察模块级命名空间:
value = 42
print(globals()["value"])
print(globals()["value"] is value)
预期输出:
42
True
globals()["value"] 和表达式 value 取得的是同一个对象。这里的 is 返回 True,不是因为两个整数“值相同”,而是因为两个表达式最终取得了同一个对象。
函数内部则有自己的局部命名空间:
def demo():
value = 42
print(locals()["value"] is value)
demo()
输出:
True
不过,locals() 主要适合观察,不应把它当成修改局部变量的可靠接口。局部变量如何存储和访问属于实现细节,不能简单假设修改 locals() 就一定会改变函数中的局部绑定。
3. 绑定关系可以改变,对象身份通常不变
看下面的代码:
items = []
alias = items
print(items is alias) # True
items = ["new"]
print(items is alias) # False
print(alias) # []
执行过程如下。
初始时:
items ─┐
├──► 列表对象 []
alias ─┘
执行:
items = ["new"]
后:
items ─────► 新列表对象 ["new"]
alias ─────► 原列表对象 []
items 被重新绑定了,但 alias 没有变化。重新绑定一个名称不会自动修改其他名称的绑定。
这正是“变量不是盒子”模型与“变量是盒子”模型的关键区别:如果变量是盒子,给 items 重新赋值似乎应当影响共享同一个盒子的 alias;但 Python 中两个名称可以独立地改变绑定。
二、对象的三个属性:identity、type 和 value
Python 数据模型规定,每个对象都有:
- identity:对象身份;
- type:对象类型;
- value:对象的值。
对象创建后,身份不会改变;is 用于比较两个名称是否指向同一个对象,id() 返回表示对象身份的整数。在 CPython 中,id() 当前通常对应对象存储位置,但“身份等同于内存地址”不是所有 Python 实现都应依赖的语言层保证。(docs.python.org)
x = []
y = x
z = []
print(x is y) # True
print(x is z) # False
print(type(x)) # <class 'list'>
print(id(x) == id(y)) # True
这里有三个列表相关事实:
x和y绑定到同一个列表对象;z绑定到另一个新创建的列表对象;- 两个空列表即使内容相同,也不代表身份相同。
Python 数据模型明确区分了:
c = []
d = []
这两个表达式会创建两个不同的新列表;而:
e = f = []
会把同一个列表对象同时绑定给 e 和 f。(docs.python.org)
三、identity 与 equality:is 和 == 比较的不是同一件事
1. identity:是否是同一个对象
a = [1, 2]
b = a
c = [1, 2]
print(a is b) # True
print(a is c) # False
a is b 的含义是:
a 和 b 是否取得同一个对象?
它不关心对象内容是否相等。
2. equality:对象的值是否相等
print(a == b) # True
print(a == c) # True
== 的含义是:
a 和 b 的值是否相等?
对于列表,值相等通常意味着元素按顺序相等;对于自定义类型,== 可能由 __eq__() 定制。
因此可能出现:
a == b # True
a is b # False
最典型的例子是两个独立但内容相同的列表:
a = [1, 2]
b = [1, 2]
assert a == b
assert not (a is b)
比较可以用下表概括:
| 表达式 | 比较对象 | 常见用途 |
|---|---|---|
x is y |
身份:是否同一个对象 | 判断单例对象,如 None |
x == y |
值:是否相等 | 判断数据内容 |
id(x) |
对象身份的整数表示 | 调试和观察身份 |
判断是否没有返回值时,应写:
result = some_function()
if result is None:
...
而不是:
if result == None:
...
原因不是 None 的值“特殊”,而是 None 是一个单例对象,检查“是否就是这个对象”表达得更准确。Python 数据模型规定 None 类型只有一个对象。(docs.python.org)
3. 相等不等于可互换
即使两个对象满足:
x == y
也不能推断它们身份相同,更不能推断修改其中一个会影响另一个。
x = [1, 2]
y = [1, 2]
x.append(3)
print(x) # [1, 2, 3]
print(y) # [1, 2]
它们值曾经相等,但对象是独立的。
反过来,如果两个名称绑定同一可变对象:
x = [1, 2]
y = x
x.append(3)
print(x) # [1, 2, 3]
print(y) # [1, 2, 3]
这里不是 y 被“同步更新”,而是 x 和 y 始终观察同一个列表对象。
四、可变与不可变:修改对象,还是重新绑定名称
1. 不可变对象
不可变对象创建后,其值不能原地改变。常见不可变类型包括:
intfloatcomplexboolstrbytestuplefrozenset
“不可变”并不意味着对象永远存在,也不意味着变量不能改变;它只表示该对象自身的值不能被原地修改。
number = 10
other = number
number += 1
print(number) # 11
print(other) # 10
number += 1 对整数不能执行原地修改。语义上可以近似理解为:
number = number + 1
过程是:
- 读取名称
number当前绑定的整数对象10; - 计算
10 + 1,得到新的整数对象11; - 将名称
number重新绑定到11; other仍然绑定原来的10。
状态变化如下:
执行前:
number ─┐
├──► 整数对象 10
other ──┘
执行 number += 1 后:
number ─────► 整数对象 11
other ─────► 整数对象 10
对于不可变对象,许多看似“修改变量”的操作,本质上都是计算新对象并重新绑定名称。
2. 可变对象
可变对象允许在原对象上改变其内部状态。常见可变类型包括:
listdictset- 大多数用户自定义实例
items = [1, 2]
alias = items
items += [3]
print(items) # [1, 2, 3]
print(alias) # [1, 2, 3]
print(items is alias) # True
对于列表,+= 通常可以执行原地扩展,因此两个名称仍然绑定同一个列表对象。
但不能把 += 永远理解成“原地修改”。它的具体行为取决于类型是否实现了对应的原地运算协议。例如,对于元组:
items = (1, 2)
alias = items
items += (3,)
print(items) # (1, 2, 3)
print(alias) # (1, 2)
print(items is alias) # False
元组不可变,不能在原对象上追加元素。因此这里会创建新元组,再把 items 重新绑定到新对象。
判断某段代码是否会影响别名,关键不是看代码中有没有 =,而是区分两类操作:
原地修改对象:
items.append(3)
mapping["key"] = value
values.add(x)
重新绑定名称:
items = items + [3]
mapping = {}
values = values | {x}
原地修改会影响所有指向该对象的名称;重新绑定只影响被重新赋值的名称。
3. 不可变容器也可能包含可变对象
“元组不可变”经常被误解为“元组中所有内容都不可变”。实际情况是,元组本身不能替换元素位置上的引用,但它引用的对象仍可能是可变的。
record = (["pending"],)
record[0].append("done")
print(record) # (['pending', 'done'],)
这里不能执行:
record[0] = ["finished"]
因为元组的元素绑定不能改变;但可以修改元组中那个列表对象。
结构可以画成:
record ─────► 元组对象
│
└──► 列表对象 ["pending"]
执行:
record[0].append("done")
改变的是列表对象,而不是元组中“第 0 个槽位”的绑定。Python 数据模型也明确指出,包含可变对象引用的不可变容器,其整体可观察值可能随着内部可变对象变化。(docs.python.org)
因此,需要区分两种不变性:
- 容器结构不变:不能增删或替换容器中的引用;
- 递归对象图不变:容器内部所有可达对象都不能变化。
Python 的 tuple 只保证前者,不自动保证后者。
五、赋值不是复制:= 到底做了什么
1. 普通赋值只建立绑定
source = [1, 2]
target = source
结果是:
source ─┐
├──► 列表对象 [1, 2]
target ─┘
没有第二个列表对象产生。Python 官方 copy 文档也直接说明,赋值语句不会复制对象,而是在目标和对象之间建立绑定。(docs.python.org)
验证方式:
source = [1, 2]
target = source
print(source is target) # True
target.append(3)
print(source) # [1, 2, 3]
如果想创建一个新的外层列表,需要显式复制:
source = [1, 2]
target = source.copy()
print(source is target) # False
target.append(3)
print(source) # [1, 2]
print(target) # [1, 2, 3]
2. 多目标赋值也不复制对象
a = b = []
这不是创建两个空列表,而是:
- 创建一个空列表;
- 将
b绑定到它; - 将
a绑定到同一个对象。
a = b = []
a.append("x")
print(a) # ['x']
print(b) # ['x']
print(a is b) # True
如果需要两个独立列表,应写:
a = []
b = []
或者:
a, b = [], []
3. 右侧先求值,左侧再绑定
Python 表达式从左到右求值,但赋值时右侧先于左侧求值。(docs.python.org)
因此:
x = 1
x, y = x + 1, x + 2
print(x) # 2
print(y) # 3
右侧整体先根据旧绑定计算:
x + 1 → 2
x + 2 → 3
然后才进行左侧绑定:
x ← 2
y ← 3
这也解释了交换写法:
left = "L"
right = "R"
left, right = right, left
print(left, right) # R L
可以理解为:
- 先计算右侧,得到两个对象
"R"和"L"; - 再将
left绑定"R"; - 再将
right绑定"L"。
它不是通过一个中间变量复制字符串,而是先保存右侧表达式的结果,再完成目标绑定。
六、参数传递:Python 是“对象共享”语义
1. 调用函数时,形参绑定到实参对象
考虑:
def inspect(value):
print(value)
print(id(value))
data = [1, 2]
print(id(data))
inspect(data)
调用函数时,函数参数表达式先求值;对于用户定义函数,函数体开始执行时,形参被绑定到对应的实参对象。(docs.python.org)
因此,调用:
inspect(data)
可以抽象成:
调用者命名空间:
data ──────► 列表对象 [1, 2]
函数局部命名空间:
value ─────► 同一个列表对象 [1, 2]
函数没有收到一个自动复制的列表,也没有收到“调用者名称 data 本身”。它收到的是对象,并在自己的局部命名空间中创建了名称 value。
这通常称为:
- call by sharing
- pass by object reference
- 对象共享传递
“Python 按值传递”若把“值”理解为对象引用的值,可以勉强描述一部分现象;“Python 按引用传递”则容易误导,因为函数无法直接重新绑定调用者的名称。对象共享是更精确的说法。
2. 函数可以修改共享的可变对象
def append_item(items):
items.append("new")
data = ["old"]
append_item(data)
print(data) # ['old', 'new']
过程如下:
调用前:
data ──────► 列表 ["old"]
进入函数:
data ─────► 列表 ["old"]
items ─────► 同一个列表 ["old"]
执行 items.append("new"):
data ─────► 列表 ["old", "new"]
items ─────► 同一个列表 ["old", "new"]
函数调用者和函数局部参数共享同一个列表对象,因此原地修改对调用者可见。
3. 函数不能通过重新绑定形参修改调用者名称
def replace_items(items):
items = ["replacement"]
data = ["original"]
replace_items(data)
print(data) # ['original']
进入函数时:
data ─────► 原列表 ["original"]
items ─────► 原列表 ["original"]
执行:
items = ["replacement"]
之后:
data ─────► 原列表 ["original"]
items ─────► 新列表 ["replacement"]
函数只改变了局部名称 items 的绑定,调用者的名称 data 仍然指向原列表。
这两种代码的差别必须牢牢记住:
def mutate(items):
items.append(1) # 修改共享对象
def rebind(items):
items = items + [1] # 创建新对象并重新绑定局部名称
调用者是否看到变化,取决于函数执行的是原地修改还是局部重新绑定,而不是取决于参数是否“传引用”。
4. 返回值是把新对象交给调用者
如果函数想让调用者获得新的绑定,通常通过返回值:
def add_item(items):
return items + ["new"]
data = ["old"]
data = add_item(data)
print(data) # ['old', 'new']
过程是:
items + ["new"]创建新列表;- 函数返回这个新列表对象;
- 调用者执行
data = ...; data重新绑定到新列表。
函数内部的 items 和调用者的 data 仍然是不同命名空间中的名称;返回值只是把对象传回调用者,再由调用者决定是否重新绑定。
5. 默认参数中的可变对象会被共享
函数默认值不是每次调用函数时重新计算,而是在函数定义时计算一次。若默认值是列表或字典,它会被后续多次调用共享。(docs.python.org)
def collect(value, bucket=[]):
bucket.append(value)
return bucket
print(collect("a")) # ['a']
print(collect("b")) # ['a', 'b']
等价于函数定义阶段已经存在一个列表:
函数对象
└── 默认参数 bucket ───► 列表 []
第一次调用把 "a" 加入这个列表,第二次调用使用的仍然是同一个默认列表。
通常应使用 None 作为哨兵,再在函数体内创建新列表:
def collect(value, bucket=None):
if bucket is None:
bucket = []
bucket.append(value)
return bucket
print(collect("a")) # ['a']
print(collect("b")) # ['b']
这里每次省略 bucket 时,函数体都会创建一个新的列表对象。
七、浅拷贝:复制外层,保留内部引用
1. 浅拷贝的定义
对于复合对象,例如列表、字典或自定义实例:
- 浅拷贝创建一个新的外层复合对象;
- 外层对象中的元素或属性引用,尽可能直接复用原对象中的引用;
- 因此,嵌套的可变对象仍然共享。
copy.copy()、列表切片、list.copy() 和 dict.copy() 都可能用于创建浅拷贝;具体语义是创建新的复合对象,但将原对象中的内部引用放入新对象。(docs.python.org)
original = [1, 2]
shallow = original.copy()
print(original is shallow) # False
print(original == shallow) # True
这里复制了列表外壳,因此:
original ──► 列表对象 A ──► 1, 2
shallow ──► 列表对象 B ──► 1, 2
两个外层列表不同。
2. 嵌套结构中的共享引用
original = [["a"], ["b"]]
shallow = original.copy()
print(original is shallow) # False
print(original[0] is shallow[0]) # True
对象图是:
original ──► 外层列表 A
│
├──► 内层列表 X ["a"]
└──► 内层列表 Y ["b"]
shallow ──► 外层列表 B
│
├──► 内层列表 X ["a"]
└──► 内层列表 Y ["b"]
执行:
shallow[0].append("changed")
只修改了共享的内层列表 X,因此两个外层列表都会观察到变化:
print(original) # [['a', 'changed'], ['b']]
print(shallow) # [['a', 'changed'], ['b']]
但如果修改的是外层结构:
shallow.append(["new"])
print(original) # [['a', 'changed'], ['b']]
print(shallow) # [['a', 'changed'], ['b'], ['new']]
此时只修改了外层列表 B,所以 original 不受影响。
3. 字典浅拷贝的同样规律
original = {
"name": "Alice",
"roles": ["reader"],
}
copied = original.copy()
copied["name"] = "Bob"
copied["roles"].append("writer")
print(original)
print(copied)
输出:
{'name': 'Alice', 'roles': ['reader', 'writer']}
{'name': 'Bob', 'roles': ['reader', 'writer']}
原因分为两部分:
copied["name"] = "Bob"
替换的是新字典中的键值绑定,因此只影响 copied。
copied["roles"].append("writer")
取得的是原字典和新字典共同引用的列表,再修改这个列表,因此两者都受到影响。
浅拷贝不是“复制一层语法结构”,而是复制对象图的一个节点,并复用它指向的下一层节点。
4. 多维列表的经典错误
下面的写法容易制造共享引用:
matrix = [[0] * 3] * 2
matrix[0][0] = 1
print(matrix) # [[1, 0, 0], [1, 0, 0]]
对象图不是两个独立的行,而是:
matrix ──► 外层列表
│
├──► 同一个内层列表 [0, 0, 0]
└──► 同一个内层列表 [0, 0, 0]
* 2 复制的是对内层列表的引用,不是递归复制内层列表。
如果需要独立的行,应让每次迭代创建新列表:
matrix = [[0] * 3 for _ in range(2)]
matrix[0][0] = 1
print(matrix) # [[1, 0, 0], [0, 0, 0]]
列表推导式每次执行 [0] * 3,因此创建一个新的内层列表对象。
八、深拷贝:递归复制对象图,但不是无条件的“完全隔离”
1. 深拷贝的基本语义
深拷贝会创建外层对象,并递归复制其中的对象。(docs.python.org)
from copy import deepcopy
original = [["a"], ["b"]]
copied = deepcopy(original)
print(original is copied) # False
print(original[0] is copied[0]) # False
copied[0].append("changed")
print(original) # [['a'], ['b']]
print(copied) # [['a', 'changed'], ['b']]
对象图变为:
original ──► 外层列表 A
│
├──► 内层列表 X
└──► 内层列表 Y
copied ───► 外层列表 B
│
├──► 内层列表 X'
└──► 内层列表 Y'
其中 X' 和 Y' 是递归复制得到的新对象。
2. deepcopy() 必须处理循环引用
对象图不一定是树,也可能包含环:
original = []
original.append(original)
此时结构是:
original ──► 列表 A
│
└──────► 列表 A
如果深拷贝算法每遇到一个对象就无条件递归,它会无限循环。deepcopy() 使用 memo 字典记录当前复制过程中已经处理过的对象,从而既避免递归循环,也尽量保持对象图中的共享关系。(docs.python.org)
from copy import deepcopy
original = []
original.append(original)
copied = deepcopy(original)
print(copied is copied[0]) # True
print(copied is original) # False
结果表示:
copied是新的列表;copied[0]仍然指向copied自己;- 循环结构被保留,但节点已经复制。
3. 深拷贝可能复制过多
深拷贝的风险不是只有性能。一个对象图中可能存在本来就应该共享的对象:
class Document:
def __init__(self, content, schema):
self.content = content
self.schema = schema
如果 schema 是全局共享、不可变或只读配置,那么复制文档时通常希望:
文档 A ──► schema S
文档 B ──► schema S
而不是:
文档 A ──► schema S
文档 B ──► schema S'
deepcopy() 不理解业务层面的“哪些对象应该共享”。它只能根据类型和复制协议递归处理对象。官方文档也指出,深拷贝可能复制过多,例如本来就应在副本之间共享的数据。(docs.python.org)
所以“需要独立副本”不能自动等价为“直接调用 deepcopy()”。应先定义对象图中哪些节点需要隔离、哪些节点必须共享。
九、显式控制复制边界:只复制真正需要复制的部分
假设有一个请求对象:
class Request:
def __init__(self, headers, body, client):
self.headers = headers
self.body = body
self.client = client
其中:
headers是可变字典,需要隔离;body是不可变字符串,不需要复制;client代表连接客户端,应该共享,而不是复制连接资源。
与其盲目深拷贝整个对象,不如显式构造:
from copy import copy
class Request:
def __init__(self, headers, body, client):
self.headers = headers
self.body = body
self.client = client
def fork(self):
return Request(
headers=self.headers.copy(),
body=self.body,
client=self.client,
)
使用:
client = object()
request = Request({"trace-id": "abc"}, "payload", client)
forked = request.fork()
forked.headers["user"] = "alice"
print(request.headers) # {'trace-id': 'abc'}
print(forked.headers) # {'trace-id': 'abc', 'user': 'alice'}
print(request.client is forked.client) # True
这个实现把复制策略写成了业务语义:
- 复制请求头;
- 共享客户端连接;
- 复用不可变正文。
相比 deepcopy(request),它的边界更清晰,也不会尝试复制无法或不应复制的外部资源。
十、名称解析:绑定不仅影响数据,也影响代码行为
名称绑定还决定函数体中名称如何解析。
count = 10
def show():
print(count)
count = 42
show()
输出:
42
函数执行时,函数体中的自由变量名称会按照运行时环境解析;函数定义时并不会把当前整数值自动复制进函数体。Python 语言参考明确描述了自由变量的运行时解析行为。(docs.python.org)
但只要函数体中出现对某个名称的绑定操作,该名称在整个函数块中通常会被视为局部名称:
count = 10
def broken():
print(count)
count = 42
broken()
结果通常是:
UnboundLocalError
原因是 Python 在分析函数代码块时发现了:
count = 42
于是把 count 视为局部名称;执行 print(count) 时,局部名称还没有绑定对象。语言参考明确指出,只要代码块中任何位置出现名称绑定,该名称在整个代码块中的使用都会按当前代码块的绑定处理。(docs.python.org)
如果确实要修改模块级绑定,需要显式声明:
count = 10
def update():
global count
count = 42
update()
print(count) # 42
这里修改的是模块命名空间中的绑定,不是修改整数对象 10。
十一、从对象图理解共享、复制和生命周期
名称、对象和引用关系可以统一成一张对象图:
flowchart LR
N1["名称 a"] --> O1["列表对象 L1"]
N2["名称 b"] --> O1
N3["名称 c"] --> O2["浅拷贝列表 L2"]
O1 --> E1["内层列表 X"]
O2 --> E1
O1 --> E2["整数对象 1"]
O2 --> E2
这张图表示:
a和b绑定同一个外层列表L1;c绑定浅拷贝得到的外层列表L2;L1和L2仍共享内层列表X;- 不可变整数对象可以被多个容器共享,而无需担心原地修改。
当执行:
b.append("new")
变化的是 L1。
当执行:
c.append("other")
变化的是 L2。
当执行:
c[0].append("nested")
变化的是共享的 X,因此通过 a、b 和 c 都可能观察到变化。
对象何时被回收,也与对象图有关:当对象不再从任何可达根对象访问到时,它才具备被垃圾回收的条件。对象不会被程序显式“销毁”;具体回收时间由 Python 实现决定。CPython 当前使用引用计数,并辅以用于检测循环引用的循环垃圾回收机制,但程序不应依赖对象失去引用后立即完成资源释放。(docs.python.org)
例如:
import gc
class Node:
pass
a = Node()
b = Node()
a.other = b
b.other = a
del a
del b
print(gc.collect())
a 和 b 之间形成循环,即使外部名称已经删除,两个对象仍互相引用。循环 GC 才可能识别这类不可达循环并回收它们。gc.collect() 可以请求执行收集,但这不是管理文件、 socket 等外部资源的替代方式;外部资源应通过 close() 或 with 显式释放。(docs.python.org)
十二、常见误解与失败表现
误解一:b = a 会复制 a
错误理解:
a = [1, 2]
b = a
认为 b 获得了一个新列表。
实际情况:
assert a is b
如果需要独立的外层列表,应使用:
b = a.copy()
如果嵌套对象也要独立,再考虑:
from copy import deepcopy
b = deepcopy(a)
但深拷贝的范围应由对象图和业务语义决定,而不是机械使用。
误解二:函数参数是调用者变量的别名
错误理解:
def reset(value):
value = 0
number = 10
reset(number)
print(number) # 仍然是 10
函数中的 value 只是局部名称。重新绑定它,不会重绑定调用者的 number。
但如果参数指向可变对象:
def clear(items):
items.clear()
items = [1, 2, 3]
clear(items)
print(items) # []
clear() 修改了共享对象,因此调用者能观察到变化。
误解三:== 为真就可以用 is
a = [1, 2]
b = [1, 2]
print(a == b) # True
print(a is b) # False
== 比较值,is 比较身份。除非明确需要判断单例身份,否则不要用 is 替代 ==。
尤其不要依赖整数、字符串等不可变对象的身份复用:
a = 1000
b = 1000
print(a == b) # True
至于:
print(a is b)
结果可能受实现、编译方式和对象缓存策略影响。Python 数据模型允许不可变对象在值相同时复用,但不应把这种身份关系作为程序逻辑。(docs.python.org)
误解四:浅拷贝意味着完全独立
a = [[1]]
b = a.copy()
b[0].append(2)
print(a) # [[1, 2]]
外层列表已经独立,内层列表仍共享。诊断嵌套结构时,应分别检查每一层:
print(a is b) # False
print(a[0] is b[0]) # True
误解五:元组完全不可变
value = ([],)
value[0].append(1)
print(value) # ([1],)
元组的元素绑定不可变,但元素指向的列表仍可变。判断一个结构是否安全共享,必须检查其内部对象图,而不能只看最外层类型。
误解六:del name 会销毁对象
items = []
alias = items
del items
print(alias) # []
del items 只删除名称 items 的绑定。对象仍被 alias 引用,因此仍然可达。只有当对象不再被任何有效引用访问时,才可能成为垃圾回收候选对象。
十三、诊断共享引用的实用方法
1. 用 is 检查共享身份
def inspect_aliases(left, right):
print("same object:", left is right)
print("same value:", left == right)
print("left id:", id(left))
print("right id:", id(right))
示例:
a = [["x"]]
b = a.copy()
inspect_aliases(a, b)
inspect_aliases(a[0], b[0])
可能输出:
same object: False
same value: True
...
same object: True
same value: True
...
第一组检查外层列表,第二组检查共享的内层列表。
2. 用 copy.copy() 和 copy.deepcopy() 对比对象图
from copy import copy, deepcopy
original = [{"tags": []}]
shallow = copy(original)
deep = deepcopy(original)
print(original is shallow) # False
print(original[0] is shallow[0]) # True
print(original[0] is deep[0]) # False
print(original[0]["tags"] is deep[0]["tags"]) # False
这个例子展示了复制层级:
赋值:
外层共享,内部共享
浅拷贝:
外层独立,内部共享
深拷贝:
外层独立,内部递归独立
3. 使用 gc 辅助诊断循环和引用
在 CPython 中,可以使用 gc 模块观察垃圾回收状态:
import gc
print(gc.isenabled())
print(gc.get_count())
print(gc.get_threshold())
print(gc.is_tracked([]))
print(gc.is_tracked(1))
gc 是循环垃圾回收器的控制和诊断接口;它可以启用或禁用自动收集、触发收集、查看统计信息和调试引用关系。具体对象是否被跟踪、收集策略如何工作,可能受 Python 实现和版本影响,不应把这些实现细节当作跨实现的对象模型保证。(docs.python.org)
对于普通共享引用问题,优先检查:
x is y
对于对象图循环问题,再使用 gc、调试器或专门的内存分析工具。不要因为观察到两个名称指向同一对象,就直接推断存在内存泄漏;共享可能是设计需要,只有无法释放的可达路径才构成真正的生命周期问题。
十四、如何选择赋值、浅拷贝和深拷贝
可以用对象图需求来做判断:
只想创建另一个名称
alias = original
适用于明确希望共享同一个对象的场景。
只需要隔离外层容器
clone = original.copy()
适用于内部元素不可变,或内部可变对象本来就应该共享的场景。
需要递归隔离大部分对象
from copy import deepcopy
clone = deepcopy(original)
适用于对象图中大多数可变节点都应独立的场景,但需要确认:
- 是否含有循环引用;
- 是否包含文件、socket、锁、线程或其他外部资源;
- 是否存在本来应该共享的对象;
- 自定义类是否定义了特殊复制逻辑;
- 复制结果是否保留了期望的共享关系。
需要精确控制边界
显式构造新对象:
clone = Config(
values=original.values.copy(),
schema=original.schema,
cache={},
)
这种方式通常最能表达业务语义:哪些状态复制,哪些状态共享,哪些状态重新初始化。
结语:把 Python 代码看成对象图上的绑定变化
理解 Python 名称与对象,最重要的不是记住“列表可变、整数不可变”这句结论,而是持续追踪三个问题:
- 当前名称绑定到哪个对象?
- 这个对象能否原地修改?
- 其他名称是否也绑定到同一个对象?
赋值改变的是名称绑定:
name = object
原地操作改变的是对象状态:
object.method(...)
浅拷贝复制外层对象、共享内部引用;深拷贝递归复制对象图,但仍需要面对循环、外部资源和共享语义。函数参数则是在新的局部命名空间中,把形参绑定到调用者提供的对象,而不是把调用者的名称交给函数修改。
一旦用“名称—对象—引用关系”而不是“变量盒子”来观察代码,赋值、参数传递、可变性、身份比较和拷贝行为就会统一成同一个模型:名称可以重新绑定,对象可以被共享,只有明确的复制操作才会创建新的对象节点。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 词法与语法基础:缩进、Token、表达式、语句和注释
- 下一篇:Python 数值类型:int、float、complex、bool 与运算边界
- 延伸:Python 复制与序列化:浅拷贝、深拷贝、pickle 和不可信输入
- 延伸:Python 内存与垃圾回收:引用计数、循环 GC、分代与诊断
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论