Chapter 3: Object Reference Semantics
Python 程序里的名字、参数、容器元素和返回值都要先落到对象关系上理解。本章讨论 object reference semantics:一个名字如何指向对象,一个容器如何保存对象引用,一个函数调用如何把实参对象绑定到形参名字,以及对象在什么条件下继续存活。
读完本章后,你应能追踪一段 Python 代码里的对象身份、值比较、共享引用、复制边界和生命周期。这个能力直接影响调试:看到列表被函数修改、默认参数跨调用累积、浅拷贝后内层对象仍然联动、异常 traceback 持有局部对象时,你需要先画出引用图,再判断哪一个对象真的发生了状态变化。
本章采用 Python 语言语义作为主线,并在涉及生命周期时标注 CPython 边界。官方 Data model 规定每个对象都有 identity、type 和 value;官方 copy 模块文档 说明赋值创建绑定,浅拷贝和深拷贝只在 compound object 上产生关键差异。本章把这些规则整理成可阅读代码的判断顺序。
贯穿材料使用下面这个短程序。后续每节都会回到它,观察同一个对象图在赋值、调用、修改、复制和释放中的变化。
def add_event(history=[], tag=None):
if tag is None:
tag = {"name": "auto", "marks": []}
history.append(tag)
return history
first = {"name": "first", "marks": []}
log_a = add_event(tag=first)
log_b = add_event()
shadow = log_a
outer_copy = log_a.copy()
first["marks"].append("checked")
这段代码里至少有四类关系:log_a、log_b 和 shadow 指向同一个默认 history 列表;first 和 log_a[0] 指向同一个 dict;outer_copy 是新的外层列表,但它的元素仍指向原来的 dict;first["marks"] 是内层列表,它的状态变化能通过多个路径被观察到。
3.1 object identity 与 equality
Object identity 是对象身份,表示“这两个引用是否抵达同一个对象”。is 比较 identity,id() 返回 identity 的整数表示。Equality 是值相等判断,== 会进入对象类型定义的比较行为,例如内置容器会比较元素,自定义类可以通过 __eq__ 定义相等规则。
在贯穿材料中,log_a is log_b 的结果为 True,因为两次调用返回的是同一个默认 history 列表。log_a == log_b 也为 True,因为它们抵达同一个列表对象,值自然相等。outer_copy is log_a 的结果为 False,因为 list.copy() 创建了新的外层列表;outer_copy == log_a 通常为 True,因为两个列表按元素比较,而元素引用抵达同一批 dict。
print(log_a is log_b) # True
print(outer_copy is log_a) # False
print(outer_copy == log_a) # True
print(first is log_a[0]) # True
这组输出说明 identity 和 equality 分属两个层级。Identity 回答“对象是否同一个”,equality 回答“按照类型规则看值是否相等”。阅读 Python 代码时,is 适合判断单例、哨兵对象和共享引用;== 适合判断业务值。把这两个判断混用,会让缓存对象、interning、自定义比较和容器比较产生误判。
CPython 对 identity 的实现细节需要单独标注。官方文档说明,在 CPython 中 id(x) 当前等于对象存储地址的整数表示;这个说法属于 CPython 实现细节。代码的语义判断应依赖 is,内存地址形式的 id() 只适合调试观察。小整数、短字符串等对象可能被复用,两个相等的不可变对象可能共享 identity;新建的两个空列表具有不同 identity。
left = []
right = []
same_left = left
print(left is right) # False
print(left == right) # True
print(left is same_left) # True
这里的关键结论是:identity 追踪引用图,equality 追踪类型协议。后续讨论 mutability、aliasing 和 copy 时,先判断 identity,再判断值变化,顺序会更稳定。
3.2 mutability 与 immutability
Mutability 是类型允许对象值在原 identity 上变化的能力。list、dict、set 这类对象支持原地修改;int、str、tuple 这类对象的值一旦创建就保持稳定。名字重新绑定和对象原地修改是两件事:重新绑定让名字指向新对象,原地修改改变已有对象内部状态。
贯穿材料里的 first["marks"].append("checked") 修改的是内层列表对象。first 这个名字仍然指向同一个 dict,first["marks"] 仍然指向同一个 list;变化发生在 list 的内容上。因此,通过 log_a[0]["marks"] 和 outer_copy[0]["marks"] 也能看到同一个修改。
print(first["marks"]) # ['checked']
print(log_a[0]["marks"]) # ['checked']
print(outer_copy[0]["marks"]) # ['checked']
不可变对象的常见表现是“值变化”会产生新的绑定结果。下面的 count += 1 会计算出新的 int 对象,然后把名字 count 绑定到这个新对象。旧对象的值没有被改写。
count = 10
old_identity = id(count)
count += 1
print(old_identity == id(count)) # 结果依赖对象复用和实现细节,不作为语义依据
print(count) # 11
tuple 的边界更容易误判。Tuple 的不可变性约束的是它直接保存的引用集合:某个位置继续指向原对象,位置数量和每个位置上的引用都保持稳定。如果 tuple 内部引用了 list,这个内层 list 仍可原地修改。
box = (["a"],)
box[0].append("b")
print(box) # (['a', 'b'],)
这个例子说明“容器自身可变性”和“容器内部对象可变性”需要分层判断。阅读代码时,先确认操作目标是哪一层对象,再判断该层对象的类型是否支持原地修改。
3.3 aliasing 与 reference sharing
Aliasing 表示多个名字或路径指向同一个对象。Reference sharing 表示这种共享引用关系跨过了局部变量、容器元素、对象属性或函数边界。共享引用本身是 Python 的正常对象模型;风险来自后续原地修改会被所有别名观察到。
在贯穿材料中,shadow = log_a 创建了一个新的名字绑定,shadow 和 log_a 指向同一个列表。随后通过 shadow.append(...) 修改列表,log_a 和 log_b 都会看到新增元素。
shadow.append({"name": "manual", "marks": []})
print(len(log_a)) # 3
print(len(log_b)) # 3
这段代码没有复制列表,也没有创建独立历史记录。赋值语句只把目标名字绑定到右侧表达式求值出来的对象。这个规则也适用于容器元素和对象属性:records[0] = first 会让列表位置保存对 first 所指对象的引用;user.profile = first 会让属性保存对同一对象的引用。
对象图可以把这类关系压缩成更稳定的观察方式。下面的图只描述贯穿材料执行后的关键共享路径。
图里的箭头表达“某个名字、容器槽位或属性持有对对象的引用”。当 marks list 被修改时,first、log_a[0]、outer_copy[0] 都只是通向同一对象的不同路径。调试共享状态问题时,把名字当作对象本身会制造混乱;把名字看成对象图入口,问题会清晰很多。
共享引用的检查顺序是:先找所有入口名字,再找容器内部引用,再找发生原地修改的对象,最后判断调用方是否仍持有到达该对象的路径。这个顺序比直接猜测“函数是否改变了变量”更可迁移。
3.4 parameter passing 的真实行为
Python 函数调用的参数行为可以描述为对象共享绑定:实参表达式先求值为对象,调用时形参名字在新 frame 的 local namespace 中绑定到这些对象。调用过程不会自动复制实参对象。函数体内的形参重新绑定只影响当前 frame 的名字;函数体内通过形参触达对象并原地修改,会影响所有共享该对象的路径。
贯穿材料的第一次调用 add_event(tag=first) 中,实参 first 求值为一个 dict 对象,形参 tag 绑定到这个 dict。history.append(tag) 修改默认 history 列表,把这个 dict 的引用放进列表。函数返回后,tag 这个局部名字消失,但 dict 仍被 first 和 history[0] 持有。
def rename(tag):
tag = {"name": "new", "marks": []}
rename(first)
print(first["name"]) # first
上面这段代码只重新绑定了函数内部的 tag 名字。调用方的 first 仍指向原 dict。对比下面的原地修改,差异就落在“名字重新绑定”与“对象状态变化”上。
def mark(tag):
tag["marks"].append("from function")
mark(first)
print(first["marks"]) # ['checked', 'from function']
这段代码通过 tag 抵达了调用方也能抵达的 dict,再抵达 dict 内部的 list,并对 list 执行原地修改。因此修改结果跨过函数边界可见。参数传递的核心判断可以压缩成一句:形参是当前调用 frame 里的名字,形参绑定的对象可能与调用方共享。
这个模型也解释了很多 API 设计选择。函数如果需要把输入列表当作可修改缓冲区,应在文档或命名中明确表示会原地更新;函数如果需要返回独立结果,应在函数内部创建新对象或执行合适层级的复制。参数传递语义本身只提供对象共享绑定,是否共享状态由函数体操作决定。
3.5 shallow copy 与 deep copy
Shallow copy 复制 compound object 的外层对象,并把原对象里的元素引用放入新外层对象。Deep copy 递归复制对象图,并通过 memo 记录已经复制过的对象,用来处理循环引用和共享引用。选择复制方式时,关键问题是“需要隔离哪一层对象的后续修改”。
贯穿材料里的 outer_copy = log_a.copy() 是浅拷贝。outer_copy 是新的列表,所以外层增删只改变 outer_copy 自己;但 outer_copy[0] 和 log_a[0] 指向同一个 dict,所以修改这个 dict 或其内层 marks list 仍会被两边观察到。下面用同形的局部对象把这个边界独立跑出来。
base = [{"name": "first", "marks": ["checked"]}]
shallow = base.copy()
shallow.append({"name": "only shallow", "marks": []})
print(len(shallow)) # 2
print(len(base)) # 1
shallow[0]["marks"].append("from shallow")
print(base[0]["marks"]) # ['checked', 'from shallow']
深拷贝适合需要隔离嵌套可变对象的场景。下面的代码会创建新的外层列表、新的 dict 和新的内层 list。随后修改 deep copy 的内层列表,原来的 base 不会跟随变化。
import copy
base = [{"name": "first", "marks": ["checked", "from shallow"]}]
isolated = copy.deepcopy(base)
isolated[0]["marks"].append("isolated")
print(base[0]["marks"]) # ['checked', 'from shallow']
print(isolated[0]["marks"]) # ['checked', 'from shallow', 'isolated']
Deep copy 的边界来自对象图本身。对象图可能包含循环引用,可能包含需要共享的连接、缓存、锁、文件句柄或函数对象,也可能包含自定义类想控制复制范围。Python 的 copy.deepcopy() 使用 memo 字典记录当前复制过程中已经处理过的对象,并允许类通过 __copy__ 和 __deepcopy__ 自定义复制行为。
因此,复制判断需要从对象图层级出发。只隔离外层列表,使用浅拷贝;需要隔离嵌套业务数据,考虑深拷贝;对象里有资源、缓存或共享对象,优先设计明确的复制协议。盲目深拷贝会增加对象分配和遍历成本,还可能复制本该共享的状态。
3.6 mutable default argument
Mutable default argument 是默认参数对象在函数定义执行时创建,并保存在 function object 上;后续调用省略该参数时,会复用同一个默认对象。这个规则适用于所有默认参数,可变对象让共享效果更明显。
贯穿材料里的 history=[] 在 def add_event(...) 执行时创建一次。第一次 add_event(tag=first) 省略 history,形参 history 绑定到这个默认列表;第二次 add_event() 继续省略 history,形参再次绑定到同一个列表。因此 log_a is log_b 为 True,两次调用返回同一条历史列表。
print(add_event.__defaults__)
print(log_a is add_event.__defaults__[0]) # True
print(log_b is add_event.__defaults__[0]) # True
__defaults__ 是函数对象保存 positional-or-keyword 默认值的元组。这里的元组本身保存对默认列表的引用;列表作为可变对象会在多次调用之间保留状态。这个事实把“默认参数跨调用累积”从语法现象还原成对象关系:function object 长期持有默认对象,调用 frame 临时绑定到它,函数体修改它。
稳定写法是使用不可变哨兵表达“调用方没有提供值”,再在函数体内创建新的可变对象。常见哨兵是 None,当 None 也是合法业务值时,可以创建私有 sentinel object。
def add_event_safe(history=None, tag=None):
if history is None:
history = []
if tag is None:
tag = {"name": "auto", "marks": []}
history.append(tag)
return history
这个版本每次省略 history 都会创建新列表。None 在这里承担“未提供 history”的标记作用,真正要修改的列表在函数调用期间创建,并由返回值或调用方传入对象决定生命周期。
Mutable default argument 有时也可被有意用作缓存或状态保存,但这种设计需要明确的 API 语义、清晰的清理方式和并发边界。普通业务函数里,默认可变对象通常会让调用之间产生隐藏共享状态,调试成本高于收益。
3.7 object lifetime 与 reference graph
Object lifetime 由引用图决定:只要对象仍可从某个活跃入口抵达,它就处于可达状态。入口可能来自当前 frame 的 local namespace、模块 globals、容器、对象属性、闭包 cell、函数默认参数、traceback、异常对象、调试器或其它 runtime 结构。
在贯穿材料中,默认 history 列表长期存活,因为 function object 的 __defaults__ 持有它。first 指向的 dict 在模块名字 first、默认 history 列表和浅拷贝列表中都有路径。即使删除 first 这个名字,只要 log_a[0] 仍指向 dict,dict 仍然可达。
del first
print(log_a[0]["name"]) # first
这段代码删除的是当前 namespace 里的名字绑定,并没有销毁 dict 对象。对象销毁需要引用图中所有可达路径都断开。CPython 使用引用计数作为主要回收策略,并配合循环垃圾回收处理循环引用;其它 Python 实现可以使用不同策略。因此,依赖“某一行之后对象立即释放”的代码具有实现边界,管理文件、锁、网络连接等外部资源时应使用 with 或显式关闭。
循环引用会让单纯引用计数无法立刻回收。下面的两个 dict 互相持有引用,删除名字后,对象之间仍形成环。CPython 的 cyclic GC 可以在后续 collection 中处理这类不可达环,但回收时机受实现、配置和运行状态影响。
left = {}
right = {"peer": left}
left["peer"] = right
del left
del right
Traceback 和 frame 也会延长对象生命周期。异常发生时,traceback 会连接到执行现场,frame 里可能保存 local namespace;如果代码把异常对象或 traceback 保存到长寿命容器里,相关局部对象也可能继续可达。调试器、profile 工具和交互环境也可能持有 frame 或结果对象,让内存观察与脚本正常运行不同。
本章的最终判断顺序可以写成一条对象图检查链:先确认名字绑定到哪些对象,再确认哪些操作是原地修改,再确认哪些路径共享同一对象,再确认复制隔离了哪一层,最后沿引用图判断对象为什么仍然存活。这个顺序能解释默认参数、函数调用、浅拷贝、深拷贝和生命周期的大部分常见问题。
最小自检任务
阅读下面代码,判断每个 print 的输出,并说明对应的对象关系。
import copy
def collect(bucket=[], item=None):
if item is None:
item = {"labels": []}
bucket.append(item)
return bucket
seed = {"labels": []}
one = collect(item=seed)
two = collect()
shallow = one.copy()
deep = copy.deepcopy(one)
seed["labels"].append("A")
shallow[0]["labels"].append("B")
deep[0]["labels"].append("C")
print(one is two)
print(one is shallow)
print(one[0] is shallow[0])
print(one[0]["labels"])
print(deep[0]["labels"])
print(collect.__defaults__[0] is one)
答案要点
one is two 输出 True。bucket=[] 在函数定义时创建一次,两次省略 bucket 的调用都绑定到同一个默认列表。
one is shallow 输出 False。one.copy() 创建新的外层列表,所以两个名字指向不同列表对象。
one[0] is shallow[0] 输出 True。浅拷贝只复制外层列表,元素位置继续保存原 dict 的引用。
one[0]["labels"] 输出 ['A', 'B']。seed、one[0] 和 shallow[0] 指向同一个 dict,这个 dict 内部的 labels list 被两次原地修改。
deep[0]["labels"] 输出 ['C']。copy.deepcopy(one) 在执行时复制了当时的对象图;此后 seed 和 shallow[0] 的修改不进入 deep copy 的内层 list。deep[0]["labels"].append("C") 只修改 deep copy 内部对象。
collect.__defaults__[0] is one 输出 True。默认列表由 function object 持有,one 是函数返回的同一列表对象。
本章知识点总结
- 对象三元组:每个 Python 对象都可从 identity、type 和 value 三个维度观察。
- 身份判断:
is比较两个引用是否抵达同一对象,适合追踪共享引用和哨兵对象。 - 值相等:
==按类型比较协议判断值,相等对象可以拥有不同 identity。 - 可变性边界:可变对象支持原地修改,不可变对象的值保持稳定,名字可重新绑定到新对象。
- 容器分层:容器自身的可变性和容器内部对象的可变性需要分层判断。
- 别名关系:赋值会创建新的名字绑定,多个名字可以指向同一对象。
- 共享修改:通过任一别名执行原地修改,所有能抵达该对象的路径都能观察到变化。
- 参数绑定:函数调用会把形参名字绑定到实参对象,调用过程不会自动复制对象。
- 重绑定边界:函数内重新绑定形参只改变当前 frame 的名字,原地修改共享对象会跨调用方可见。
- 浅拷贝:浅拷贝创建新的外层 compound object,并复用内部元素引用。
- 深拷贝:深拷贝递归复制对象图,并用 memo 处理循环引用和共享引用。
- 默认参数:默认参数在函数定义时创建并由 function object 持有,多次省略参数会复用同一默认对象。
- 生命周期:对象存活取决于引用图可达性,名字删除只断开一条入口路径。
- 实现边界:CPython 使用引用计数配合循环 GC,其它实现的回收时机可以不同。
- 检查顺序:先看名字绑定,再看原地修改,再看共享路径,再看复制层级,最后看引用图生命周期。