Python 基础体系 · 第 33/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 复制与序列化:浅拷贝、深拷贝、pickle 和不可信输入
Python 中“复制”与“序列化”经常被放在一起讨论,因为它们都涉及对象结构的重新构造。但二者解决的是不同问题:
- 复制:在当前进程内,构造一个新的对象结构;
- 序列化:把对象结构转换成字节流或文本,以便保存、传输或稍后恢复;
- 浅拷贝与深拷贝:描述复制对象内部引用关系的不同策略;
- pickle:Python 原生的对象序列化机制;
- 不可信输入:决定某种反序列化方式是否可能演变成代码执行。
如果没有先区分“对象身份”“对象内容”“引用关系”和“数据格式”,很多看似合理的代码都会产生错误结果。
一、复制的前提:变量保存的是绑定关系
Python 变量不是装对象的盒子。变量名只是绑定到对象的名称,赋值语句建立的是新的绑定,而不是复制对象。
a = [1, 2, 3]
b = a
b.append(4)
print(a) # [1, 2, 3, 4]
print(b) # [1, 2, 3, 4]
print(a is b) # True
执行过程可以形式化为:
a ─┐
├──> 同一个 list 对象 [1, 2, 3]
b ─┘
b = a 只把名称 b 绑定到了 a 当前绑定的对象。它没有创建第二个列表。Python 官方文档也明确区分了赋值和复制:赋值语句不会复制对象,而是创建目标名称与对象之间的绑定。(docs.python.org)
如果重新绑定 b,则不会影响 a:
a = [1, 2, 3]
b = a
b = [10, 20]
print(a) # [1, 2, 3]
print(b) # [10, 20]
print(a is b) # False
这里没有修改原列表,而是让 b 改为指向另一个列表。
因此必须区分两类操作:
b = a # 绑定,不复制
b = a.copy() # 构造一个新的列表对象
二、对象身份、相等性与别名
复制问题通常同时涉及三个概念。
1. is:是否为同一个对象
a = [1, 2]
b = a
c = [1, 2]
print(a is b) # True
print(a is c) # False
is 判断的是对象身份,也就是两个名称是否指向同一个对象。
2. ==:内容是否相等
print(a == b) # True
print(a == c) # True
不同对象可以具有相同内容。
3. 别名:多个路径指向同一个对象
shared = {"count": 0}
state = {
"left": shared,
"right": shared,
}
print(state["left"] is state["right"]) # True
此时 shared 不是被复制了两次,而是被两个键共同引用。
修改任意一条路径,另一条路径也能观察到变化:
state["left"]["count"] += 1
print(state["right"]) # {'count': 1}
这就是别名效应。浅拷贝和深拷贝的核心区别,本质上就是如何处理这种内部引用。
三、浅拷贝:只复制最外层容器
浅拷贝是指:
- 创建一个新的外层复合对象;
- 将原对象内部的元素引用放入新对象;
- 不递归复制内部元素。
对于列表,可以使用 copy.copy()、list.copy() 或完整切片:
import copy
original = [
"Python",
{"version": 3.14},
]
shallow = copy.copy(original)
print(original is shallow) # False
print(original[1] is shallow[1]) # True
对象关系如下:
original ──> list ──> "Python"
└> dict {"version": 3.14}
shallow ──> list ──┘
两个列表本身不同,但第二个元素仍然是同一个字典。
因此修改外层结构不会互相影响:
shallow.append("pickle")
print(original)
# ['Python', {'version': 3.14}]
print(shallow)
# ['Python', {'version': 3.14}, 'pickle']
但是修改内部可变对象会互相影响:
shallow[1]["version"] = 3.15
print(original)
# ['Python', {'version': 3.15}]
这里的因果关系是:
修改 shallow[1]
│
▼
访问同一个 dict 对象
│
▼
original[1] 也观察到变化
官方文档对浅拷贝的定义就是:构造新的复合对象,然后尽可能把原对象中的对象引用插入进去。(docs.python.org)
浅拷贝适合什么情况
如果内部对象本身是不可变对象,浅拷贝通常已经足够:
items = [(1, "a"), (2, "b")]
copy_items = items.copy()
print(items is copy_items) # False
print(items == copy_items) # True
元组和字符串不可通过普通操作原地修改,因此内部引用共享通常没有可观察的副作用。
但“元组不可变”不等于“元组内部永远不可变”:
original = ([1, 2], "x")
shallow = original[:]
print(original is shallow) # False
print(original[0] is shallow[0]) # True
shallow[0].append(3)
print(original) # ([1, 2, 3], 'x')
元组自身不能替换元素,但它引用的列表仍然可以被修改。
四、深拷贝:递归复制对象图
深拷贝尝试构造一份递归复制后的对象结构:
import copy
original = [
"Python",
{"version": 3.14},
]
deep = copy.deepcopy(original)
print(original is deep) # False
print(original[1] is deep[1]) # False
deep[1]["version"] = 3.15
print(original)
# ['Python', {'version': 3.14}]
print(deep)
# ['Python', {'version': 3.15}]
对象关系变为:
original ──> list ──> "Python"
└> dict A {"version": 3.14}
deep ──> list ──> "Python"
└> dict B {"version": 3.15}
外层列表和内部字典都被分别复制。
深拷贝不是简单地“递归调用自己”
实际对象结构可能包含共享引用:
shared = {"value": 0}
original = {
"first": shared,
"second": shared,
}
deep = copy.deepcopy(original)
print(deep["first"] is deep["second"]) # True
print(original["first"] is deep["first"]) # False
正确的深拷贝结果不是:
deep["first"] ──> 新字典 A
deep["second"] ──> 新字典 B
而是:
deep["first"] ─┐
├──> 新字典
deep["second"] ─┘
也就是说,深拷贝既要复制对象,又要保留原对象图中的共享关系。
为此,copy.deepcopy() 在一次复制过程中维护一个 memo 字典,用对象身份记录“已经复制过的对象”。遇到相同对象时,直接复用已创建的副本;遇到循环引用时,也可以避免无限递归。(docs.python.org)
循环引用示例
import copy
original = []
original.append(original)
deep = copy.deepcopy(original)
print(deep is deep[0]) # True
print(deep is original) # False
原对象的结构是:
original ─┐
└──> 自己
深拷贝后的结构是:
deep ────┐
└──> deep
深拷贝并没有把循环“展开”为无限列表,而是构造了一个新的循环对象图。
深拷贝可能复制过多
深拷贝的语义不是“复制业务上应该独立的所有东西”,而是尽可能递归复制对象结构。因此它可能复制一些本来应该共享的状态,例如:
- 缓存;
- 全局配置;
- 连接池;
- 大型只读数据;
- 外部资源的包装对象。
官方文档也指出,深拷贝可能复制过多,尤其是原本有意共享的数据。(docs.python.org)
所以深拷贝不等于通用的对象克隆方案。对于复杂类,通常更准确的方式是显式定义“哪些状态应该复制,哪些状态应该共享”。
五、复制不可复制对象与自定义复制协议
copy 模块无法复制所有对象。模块、文件、套接字、栈帧等对象通常不支持普通复制;函数和类在复制时则会返回原对象本身。(docs.python.org)
import copy
def f():
pass
g = copy.deepcopy(f)
print(f is g) # True
函数的代码对象并不会因为 deepcopy() 而产生一个功能等价但身份不同的新函数。
自定义类可以定义:
__copy__(self)
__deepcopy__(self, memo)
其中 __deepcopy__() 必须接收 memo 参数。如果需要深拷贝成员,应把这个 memo 继续传给 copy.deepcopy()。
import copy
class Session:
def __init__(self, user_id, cache):
self.user_id = user_id
self.cache = cache
self.connection = object() # 假设代表外部连接
def __deepcopy__(self, memo):
if id(self) in memo:
return memo[id(self)]
result = type(self).__new__(type(self))
memo[id(self)] = result
result.user_id = self.user_id
result.cache = copy.deepcopy(self.cache, memo)
# 外部连接不复制,而是按设计共享或重新建立
result.connection = self.connection
return result
这里的设计意图是:
user_id是不可变值,可以直接复用;cache是内部可变状态,需要递归复制;connection代表外部资源,不能机械地深拷贝;- 先把新对象放入
memo,才能正确处理循环引用。
copy.replace() 不是深拷贝
Python 3.13 增加了 copy.replace()。它创建同类型新对象,并用指定字段替换值,但它支持的对象范围有限,主要包括具名元组、数据类以及定义了 __replace__() 的类。(docs.python.org)
from dataclasses import dataclass
from copy import replace
@dataclass(frozen=True)
class User:
name: str
age: int
u1 = User("Ada", 36)
u2 = replace(u1, age=37)
print(u1) # User(name='Ada', age=36)
print(u2) # User(name='Ada', age=37)
它表达的是“基于字段创建一个新值”,不是递归复制整个对象图。不要用 copy.replace() 替代 copy.deepcopy()。
六、序列化:把对象结构转换为数据流
复制发生在内存中,序列化则需要产生一个可保存或传输的表示。
可以把序列化抽象为:
对象结构 ──serialize──> 字节流或文本
对象结构 <─deserialize── 字节流或文本
例如:
import pickle
data = {
"name": "Ada",
"scores": [95, 98],
}
payload = pickle.dumps(data)
restored = pickle.loads(payload)
print(type(payload)) # <class 'bytes'>
print(restored) # {'name': 'Ada', 'scores': [95, 98]}
pickle.dumps() 返回字节对象,pickle.loads() 根据字节流恢复对象层次结构。dump() 和 load() 则分别面向二进制文件对象。(docs.python.org)
序列化不是持久化系统本身。pickle 可以读写文件,但它不负责给持久对象命名,也不负责解决多个进程并发读写同一持久对象的问题。(docs.python.org)
因此:
pickle = 对象结构 <-> 字节流
数据库 = 数据组织、索引、事务、并发和恢复
把一个 pickle 文件保存到磁盘,并不意味着已经获得了数据库语义。
七、pickle 保存的不是源代码,而是 Python 对象重建信息
pickle 可以处理大量 Python 类型,包括内置值、列表、字典、集合、符合条件的函数和类实例。对于函数和类,pickle 通常保存其模块限定名称,而不是函数代码或类代码;反序列化环境必须能够导入相应模块并找到对应名称。(docs.python.org)
# module_a.py
def greet(name):
return f"Hello, {name}"
如果序列化:
from module_a import greet
import pickle
payload = pickle.dumps(greet)
pickle 记录的是类似“module_a.greet”这样的限定名称,而不是把 greet 的函数体完整写入字节流。
这导致几个边界:
# 可能无法恢复的情况
# 1. 模块路径发生变化
# 2. 函数被重命名
# 3. 函数不再存在
# 4. 类从一个模块移动到另一个模块
类实例也有类似特征:通常保存实例状态,而不是把类的代码一起保存。恢复实例时,__init__() 通常不会被调用;默认流程会先创建未初始化实例,再恢复其属性。(docs.python.org)
这意味着反序列化并不等价于重新执行构造函数:
class Account:
def __init__(self, balance):
if balance < 0:
raise ValueError("negative balance")
self.balance = balance
如果某个 pickle 中保存了不符合当前约束的实例状态,恢复过程不一定通过 __init__() 再次验证它。长期保存的对象还可能面对类结构演化问题,因此可以在状态中加入版本号,并在 __setstate__() 中执行迁移。(docs.python.org)
八、pickle 如何保留共享引用
pickle 与深拷贝有一个重要相似点:它们都关注对象图,而不是只关注嵌套值。
import pickle
shared = []
original = {
"a": shared,
"b": shared,
}
payload = pickle.dumps(original)
restored = pickle.loads(payload)
print(restored["a"] is restored["b"]) # True
print(restored["a"] is shared) # False
恢复后:
restored["a"]和restored["b"]仍然指向同一个新列表;- 它们不再指向原进程中的
shared。
pickle 的内部 memo 会记录已经处理过的对象,使共享引用和递归结构能够以引用关系表达,而不是重复展开。官方文档说明,pickle 的 memo 会记住已经看到的对象,因此共享对象或递归对象会按引用而非简单按值重复写入。(docs.python.org)
这也是 JSON 与 pickle 的重要差异之一:JSON 是跨语言、可读的文本格式,但默认只能表示有限的 Python 内置类型;pickle 是 Python 特有的二进制格式,能够表达更多 Python 对象以及对象间的共享关系。(docs.python.org)
九、Python 3.14 中的 pickle 协议
pickle 有多个协议版本。协议越新,通常越能表达新能力或获得更好的效率,但读取方也需要支持对应协议。
在 Python 3.14 中,协议 5 是默认协议。协议 5 引入了带外缓冲区能力,适合把大块 buffer 数据从主 pickle 流中分离出来。(docs.python.org)
普通使用不需要手动指定协议:
import pickle
payload = pickle.dumps({"values": [1, 2, 3]})
如果明确指定协议:
payload = pickle.dumps(
{"values": [1, 2, 3]},
protocol=pickle.HIGHEST_PROTOCOL,
)
但选择最高协议会降低旧 Python 版本的兼容性。需要跨版本读取时,应根据部署矩阵选择协议,而不是机械地使用最高值。
协议 5 的带外缓冲区示意如下:
import pickle
buffers = []
def collect_buffer(view):
buffers.append(view)
return None # 返回假值,表示该缓冲区放到主流之外
payload = pickle.dumps(
bytearray(b"large binary payload"),
protocol=5,
buffer_callback=collect_buffer,
)
print(type(payload)) # <class 'bytes'>
print(len(buffers)) # 具体数量依对象类型和实现而定
buffer_callback 只有在协议 5 或更高版本下才有效。恢复带外缓冲区时,还必须按顺序向 loads() 或 Unpickler 提供对应的 buffers。(docs.python.org)
这项能力改善的是二进制缓冲区传输路径,并不改变 pickle 的安全属性。
十、最重要的安全边界:不要反序列化不可信 pickle
pickle 不是安全的数据格式。精确地说,攻击者可以构造恶意 pickle,使反序列化过程执行任意代码。Python 官方文档明确要求:只对可信数据执行 unpickle,绝不能直接加载来自不可信来源或可能被篡改的数据。(docs.python.org)
危险代码通常长这样:
import pickle
data = request.body # 来自网络请求、上传文件或消息队列
obj = pickle.loads(data)
问题不在于 obj 后续是否被调用,而在于:
pickle.loads(data)
│
├── 解析对象重建指令
├── 导入模块或查找全限定名称
├── 调用重建所需的对象
└── 返回对象
危险行为可能在 loads() 返回之前就发生。
下面是一个仅打印文本的演示。它展示机制,但不会执行系统命令:
import pickle
class Demonstration:
def __reduce__(self):
return (print, ("unpickling executed this callable",))
payload = pickle.dumps(Demonstration())
print("before loads")
pickle.loads(payload)
print("after loads")
预期输出类似:
before loads
unpickling executed this callable
after loads
__reduce__() 向 pickle 提供了“如何重建这个对象”的信息。恶意对象可以把这个信息指向具有副作用的可调用对象。生产代码不应把这个示例改造成命令执行实验,因为这里真正需要理解的是:反序列化本身就是一个可能调用代码的过程。
这和“输入校验”不是一回事
以下代码不能把 pickle 变安全:
obj = pickle.loads(data)
if isinstance(obj, dict):
...
因为安全风险发生在 loads() 内部,类型检查发生得太晚。
同样,先检查文件扩展名、HTTP Content-Type 或字节长度,也不能证明 pickle 是安全的。这些检查最多限制输入形态,不能证明对象重建过程中不会调用危险行为。
十一、Unpickler.find_class() 能否提供安全沙箱
Unpickler.find_class() 可以被子类覆盖,以限制允许导入和查找的模块、类或函数:
import io
import pickle
class RestrictedUnpickler(pickle.Unpickler):
ALLOWED = {
("builtins", "dict"),
("builtins", "list"),
("builtins", "set"),
("builtins", "tuple"),
}
def find_class(self, module, name):
if (module, name) not in self.ALLOWED:
raise pickle.UnpicklingError(
f"global '{module}.{name}' is forbidden"
)
return super().find_class(module, name)
def restricted_loads(data):
return RestrictedUnpickler(io.BytesIO(data)).load()
这个示例可以减少一部分可调用全局对象,但不能轻率地称为“安全反序列化”。原因包括:
- pickle 协议表达能力复杂;
- 允许的对象组合可能产生非预期行为;
- 新版本、第三方类型和自定义 reducer 会扩大边界;
- 代码审查和允许列表本身可能出错。
官方文档只说明重写 find_class() 可以对加载对象类型取得控制,并可能降低安全风险,并没有把它定义为通用安全沙箱。(docs.python.org)
因此,面对不可信输入,首选方案不是设计一个更复杂的 pickle 过滤器,而是不要使用 pickle 作为输入格式。
十二、可信与不可信数据应使用不同格式
如果数据来自用户、第三方服务、网络请求、上传文件或不可完全控制的消息生产者,应优先使用 JSON、CSV、TOML 或其他具有明确数据模型的格式,再进行结构校验。
例如 JSON:
import json
payload = '{"name": "Ada", "scores": [95, 98]}'
data = json.loads(payload)
print(data)
# {'name': 'Ada', 'scores': [95, 98]}
JSON 默认反序列化的是数据值,例如对象、数组、字符串、数字、布尔值和 null,不会因为输入本身就执行任意 Python 可调用对象。Python 文档也明确指出,反序列化不可信 JSON 本身不会造成 pickle 那种任意代码执行漏洞。(docs.python.org)
但这不表示 JSON 自动完成了全部安全工作。仍然需要处理:
- 输入大小限制;
- 嵌套深度;
- 字段类型;
- 必填字段;
- 数值范围;
- 字符串长度;
- 业务状态约束;
- 浮点数精度和大整数边界。
例如:
import json
raw = '{"age": "36"}'
data = json.loads(raw)
if not isinstance(data.get("age"), int):
raise ValueError("age must be an integer")
json.loads() 只负责把文本转换为 Python 数据结构,不负责证明这个数据符合业务 Schema。
JSON 与 pickle 的选择逻辑
可以用以下问题判断:
是否需要跨语言?
是 -> JSON 或其他跨语言格式
是否处理不可信输入?
是 -> 不使用 pickle
是否必须保留 Python 类实例、共享引用或复杂对象图?
是 -> 在可信边界内考虑 pickle
是否需要长期跨版本保存?
是 -> 优先设计显式、版本化的数据格式
pickle 的优势是能够方便地保存复杂 Python 对象结构;代价是 Python 绑定、代码版本耦合和严重的反序列化安全风险。JSON 的优势是可读、跨语言和数据边界清晰;代价是需要显式处理类型映射、Schema 和精度问题。
十三、完整示例:配置模板的浅拷贝错误
假设程序有一份默认配置:
DEFAULT_CONFIG = {
"server": {
"host": "127.0.0.1",
"port": 8000,
},
"features": [],
}
错误的复制方式:
config = DEFAULT_CONFIG.copy()
config["server"]["port"] = 9000
config["features"].append("debug")
print(DEFAULT_CONFIG)
输出:
{
"server": {
"host": "127.0.0.1",
"port": 9000,
},
"features": ["debug"],
}
dict.copy() 只复制最外层字典,server 和 features 仍然共享。
如果配置规模和类型允许,可以使用深拷贝:
import copy
config = copy.deepcopy(DEFAULT_CONFIG)
config["server"]["port"] = 9000
config["features"].append("debug")
print(DEFAULT_CONFIG)
# {
# 'server': {'host': '127.0.0.1', 'port': 8000},
# 'features': []
# }
但如果配置中包含连接对象、锁、缓存或自定义资源,深拷贝可能失败或产生错误语义。更明确的方案是显式构造配置:
config = {
"server": {
"host": DEFAULT_CONFIG["server"]["host"],
"port": 9000,
},
"features": ["debug"],
}
显式构造的代价是代码更长,但复制边界完全可见,不会意外复制外部资源。
十四、诊断复制问题的最小工具集
遇到“修改副本却影响原对象”的问题时,应先观察身份关系,而不是猜测。
def inspect_config(original, copied):
print("outer:", original is copied)
print("server:", original["server"] is copied["server"])
print("features:", original["features"] is copied["features"])
测试:
import copy
original = {
"server": {"port": 8000},
"features": [],
}
shallow = copy.copy(original)
deep = copy.deepcopy(original)
inspect_config(original, shallow)
# outer: False
# server: True
# features: True
inspect_config(original, deep)
# outer: False
# server: False
# features: False
对于更复杂的对象图,还应检查:
id(obj)
type(obj)
obj is other
obj == other
其中 id() 适合辅助诊断,不应作为业务身份永久标识。关键问题通常是:哪几个路径是否指向同一个可变对象。
十五、复制、pickle 与不可信输入的最终边界
可以把四个概念放在同一张图中:
flowchart LR
A[内存中的对象图] -->|赋值| B[新增名称绑定]
A -->|浅拷贝| C[新外层对象<br/>内部引用共享]
A -->|深拷贝| D[递归复制对象图<br/>保留共享关系]
A -->|pickle.dumps| E[Python 字节流]
E -->|pickle.loads<br/>仅限可信输入| F[恢复后的对象图]
U[不可信字节流] -->|禁止直接 loads| X[使用 JSON/CSV/TOML<br/>再做 Schema 校验]
核心判断可以压缩为以下几条:
b = a是绑定,不是复制;- 浅拷贝复制外层容器,内部对象仍可能共享;
- 深拷贝递归复制对象图,并通过
memo保留共享关系、处理循环引用; pickle是 Python 对象结构的二进制序列化机制,不是跨语言数据格式;- pickle 反序列化可能执行任意代码,因此不可信输入不能调用
pickle.loads(); find_class()限制可以降低风险,但不应被当作通用安全沙箱;- 面向不可信数据时,应使用 JSON 等数据格式,并额外进行 Schema、大小、类型和精度校验;
- 面向长期保存的数据,应显式考虑版本迁移,而不能假设类定义永远不变。
理解这些边界后,“复制”和“序列化”就不再是几个孤立 API 的记忆题,而是对对象图、引用关系、代码执行边界和数据兼容性的统一分析。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 泛型类型:TypeVar、ParamSpec、TypeVarTuple 与 Protocol
- 下一篇:Python 内存与垃圾回收:引用计数、循环 GC、分代与诊断
- 延伸:Python 名称、对象与绑定:变量不是盒子,赋值不是复制
- 延伸:Python 结构化数据:JSON、CSV、TOML、Schema 与精度边界
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论