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}

这就是别名效应。浅拷贝和深拷贝的核心区别,本质上就是如何处理这种内部引用。


三、浅拷贝:只复制最外层容器

浅拷贝是指:

  1. 创建一个新的外层复合对象;
  2. 将原对象内部的元素引用放入新对象;
  3. 不递归复制内部元素。

对于列表,可以使用 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()

这个示例可以减少一部分可调用全局对象,但不能轻率地称为“安全反序列化”。原因包括:

  1. pickle 协议表达能力复杂;
  2. 允许的对象组合可能产生非预期行为;
  3. 新版本、第三方类型和自定义 reducer 会扩大边界;
  4. 代码审查和允许列表本身可能出错。

官方文档只说明重写 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() 只复制最外层字典,serverfeatures 仍然共享。

如果配置规模和类型允许,可以使用深拷贝:

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 校验]

核心判断可以压缩为以下几条:

  1. b = a 是绑定,不是复制;
  2. 浅拷贝复制外层容器,内部对象仍可能共享;
  3. 深拷贝递归复制对象图,并通过 memo 保留共享关系、处理循环引用;
  4. pickle 是 Python 对象结构的二进制序列化机制,不是跨语言数据格式;
  5. pickle 反序列化可能执行任意代码,因此不可信输入不能调用 pickle.loads()
  6. find_class() 限制可以降低风险,但不应被当作通用安全沙箱;
  7. 面向不可信数据时,应使用 JSON 等数据格式,并额外进行 Schema、大小、类型和精度校验;
  8. 面向长期保存的数据,应显式考虑版本迁移,而不能假设类定义永远不变。

理解这些边界后,“复制”和“序列化”就不再是几个孤立 API 的记忆题,而是对对象图、引用关系、代码执行边界和数据兼容性的统一分析。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。