Python 对象复制完全指南:深入理解 copy 模块的浅拷贝与深拷贝
Python 对象复制完全指南:深入理解 copy 模块的浅拷贝与深拷贝
导读
Python 的变量绑定的是对象引用而非对象本身,因此"复制一个对象"远没有表面上那么简单:直接赋值只是新增一个指向同一对象的名称,而真正复制对象时必须区分浅拷贝(shallow copy)与深拷贝(deep copy)——前者共享内部子对象,后者递归复制全部引用结构。本文以 python-copy/ 目录下与 Real Python 官方教程配套的源码为骨架,系统讲解 copy 模块的 copy()、deepcopy() 以及 Python 3.14 新增的 replace() 的底层机制、自定义复制协议 __copy__/__deepcopy__/__replace__ 的编写方法,并给出可复现的性能基准与常见陷阱排查方案。
1. 为什么 Python 里"复制对象"是个问题
在 Python 中,= 赋值不会复制对象,它只是让新名称指向同一个对象。这一点在可变对象(list、dict、set、自定义类实例)上尤其容易踩坑:
>>> original = ["a", "b", "c"]
>>> alias = original # 只是起了个别名
>>> alias.append("d")
>>> original
['a', 'b', 'c', 'd'] # 原列表也被改了!
要真正得到一个独立的对象,必须显式复制。copy 模块提供了两个入口:
| 函数 | 行为 |
|---|---|
copy.copy(x) |
浅拷贝:创建一个新对象,但新对象内部的子对象仍与原对象共享 |
copy.deepcopy(x) |
深拷贝:递归复制所有子对象,得到完全独立的对象树 |
选择哪种取决于你的数据是"纯值结构"还是"带有引用关系的对象图",这正是本文后续章节要解决的问题。
2. 浅拷贝:共享内部引用的新外壳
浅拷贝会创建新的顶层容器,但不会递归复制容器内部的元素。对于内层元素是可变对象(如列表中的列表、字典中的列表)的场景,两个对象的"内层"仍然指向同一片内存。
2.1 内置容器的四种浅拷贝写法
对 list、dict、set 等内置容器,至少有四种等价写法:
import copy
numbers = [1, 2, 3]
a = copy.copy(numbers) # 通用方案
b = numbers.copy() # 容器自带方法
c = list(numbers) # 通过构造函数
d = numbers[:] # 切片(仅序列类可用)
它们效果相同——都生成了新的顶层列表。验证方式:a is numbers 为 False(是不同对象),但 a[0] is numbers[0] 为 True(内部元素仍是同一批对象,不可变元素在此无碍)。
2.2 浅拷贝的共享陷阱:来自 rectangle.py 的实证
仓库中的 rectangle.py 用 Rectangle(包含两个 Point)直观演示了浅拷贝的共享行为。先构造对象、做浅拷贝与深拷贝,再修改原对象:
import copy
class Rectangle:
def __init__(self, top_left, bottom_right):
self.top_left = top_left
self.bottom_right = bottom_right
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
bounding_box = Rectangle(
top_left := Point(10, 20), bottom_right := Point(30, 40)
)
shallow_copy = copy.copy(bounding_box)
deep_copy = copy.deepcopy(bounding_box)
bounding_box.bottom_right = Point(500, 700) # 替换引用
bottom_right.x += 100 # 原地修改共享的 Point
在该仓库环境中运行 rectangle.py 得到的输出为:
bounding_box = Rectangle(Point(x=10, y=20), Point(x=500, y=700))
shallow_copy = Rectangle(Point(x=10, y=20), Point(x=130, y=40))
deep_copy = Rectangle(Point(x=10, y=20), Point(x=30, y=40))
三个对象的 top_left 都还是 (10, 20)(该 Point 始终共享);关键差异在右下角:
bounding_box.bottom_right = Point(500, 700)是替换引用,只影响原对象本身;bottom_right.x += 100是原地修改,浅拷贝与原对象共享同一个Point,因此浅拷贝的x也从 30 变为 130;deep_copy拥有独立的Point,完全不受影响,仍是(30, 40)。
由此可以得到两条铁律:浅拷贝共享可变子对象;深拷贝彻底隔离。至于对 Point 这类对象的"原地修改"会不会波及别处,取决于对象是否实现了就地运算(见下文 MutableInt 示例)。
3. 深拷贝:递归复制完整对象图
copy.deepcopy(x) 递归遍历对象的整个引用结构,为每个可达对象创建独立副本,并维护一张 memo(记忆字典),保证:
- 同一对象被多次引用时只复制一次;
- 循环引用(对象引用自身)不会导致无限递归。
这两个特性正是 deepcopy 与"先 json 再 pickle"等序列化方案的本质区别——序列化路线无法保留对象内部的循环引用和共享关系。
3.1 循环引用不会死循环:来自 golden_ratio.py 的示例
仓库中的 golden_ratio.py 构造了一个自我引用的连分数列表 cf,用 21 层递推逼近黄金比例 (1 + √5) / 2:
from fractions import Fraction
cf = [1]
cf.append(cf) # cf[1] 指向 cf 自身,形成循环引用
def evaluate(depth):
if depth > 0:
return cf[0] + Fraction(1, evaluate(depth - 1))
return cf[0]
golden_ratio = (1 + 5**0.5) / 2
for n in range(21):
fraction = evaluate(n)
approximation = float(fraction)
error = abs(golden_ratio - approximation)
print(f"n={n:<3}", f"{fraction:>11}",
"\N{ALMOST EQUAL TO}", f"{approximation:.15f}",
f"(\N{GREEK CAPITAL LETTER DELTA} = {error:<11.10f})")
注意:
Fraction的宽度格式化语法需要 Python 3.14 及以上版本支持(3.12 下会抛出ValueError: Invalid format specifier '>11')。这说明该示例文件同时承担了展示 Python 3.14 新格式化能力的职责。
把这种自引用结构交给 deepcopy 时,memo 会在复制第一层时就登记"副本 ↔ 原对象"的映射,随后遇到 cf[1] 直接返回同一份副本,从而在保留循环关系的同时避免栈溢出。
3.2 就地运算与共享的叠加:MutableInt 示例
python-copy/mutable_int.py 展示了一个实现 __iadd__ 的"可变整数",用于强调运算符的语义决定修改是否会传播:
class MutableInt:
def __init__(self, value):
self.value = value
def __iadd__(self, other):
self.value += other
return self
def __str__(self):
return str(self.value)
x = MutableInt(40)
x += 2
print(x) # 42
关键在于 x += 2 走的是 __iadd__ 就地修改(返回自身)而非新建对象。若把 MutableInt 放进浅拷贝共享的容器里,对其中元素的 += 同样会"穿透"拷贝边界;换成 x = x + 2(走 __add__ 返回新对象)则不会。这再次印证:拷贝只决定对象结构的复制粒度,运算符是否就地修改决定共享引用的可见性。
4. 自定义复制协议:copy 与 deepcopy
当默认的浅拷贝/深拷贝行为不符合业务语义时,可以在类中定义 __copy__() 与 __deepcopy__(memo) 方法,copy.copy() 和 copy.deepcopy() 会优先调用它们。仓库给出两个极具代表性的场景。
4.1 共享资源不应被复制:DataFile 示例
文件句柄、数据库连接、网络套接字等"外部资源"一旦被复制就会产生重复句柄、双倍关闭等问题。datafile.py 的 DataFile 让浅拷贝与深拷贝都返回指向同一路径的新实例,而不是复制底层文件对象:
import copy
import json
class DataFile:
def __init__(self, path):
self.file = open(path, mode="r", encoding="utf-8")
def __copy__(self):
return type(self)(self.file.name)
def __deepcopy__(self, memo):
return self.__copy__()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.file.close()
def read_json(self):
self.file.seek(0)
return json.load(self.file)
with DataFile("person.json") as data_file:
shallow_copy = copy.copy(data_file)
deep_copy = copy.deepcopy(data_file)
运行该脚本(需在 python-copy/ 目录内执行,数据来自同目录 person.json)时,三份对象都基于同一 person.json 输出相同的 JSON 内容。其设计要点是:
__copy__用type(self)构造,保证子类实例也能正确复制;__deepcopy__直接委托__copy__,避免对真实文件句柄做无意义的递归复制;- 结合上下文管理器
__enter__/__exit__,实现"打开—读取—关闭"的规范资源生命周期。
4.2 跨实例共享状态:ConsoleWindow 示例
console.py 模拟了一个带命令历史的多标签终端窗口,它要求所有"窗口副本"共享同一个 tabs 注册表(新窗口创建时自动登记),而 history 是否共享则由拷贝类型决定:
import copy
from datetime import UTC, datetime
class ConsoleWindow:
def __init__(self, tabs):
self.tabs = tabs
self.history = []
self.created_at = datetime.now(UTC)
def __copy__(self):
instance = type(self)(self.tabs)
instance.history = self.history # 浅拷贝共享历史
self.tabs.add(instance)
return instance
def __deepcopy__(self, memo):
instance = type(self)(self.tabs)
instance.history = copy.deepcopy(self.history, memo) # 深拷贝独立历史
self.tabs.add(instance)
return instance
def run_command(self, command):
self.history.append(command)
主流程中:tab1 = copy.deepcopy(window)(历史独立),tab2 = copy.copy(tab1)(历史与 tab1 共享)。在该仓库环境中运行 console.py 得到的输出显示:
- 原
window的历史只有['cd ~/Projects', 'ls -l'],而tab1、tab2拥有git clone ...、cd python/、git checkout 3.13等后续命令; - 三个对象的
tabs集合包含相同的两个窗口实例,证明tabs注册表是跨副本共享的。
这个案例清晰表明:__copy__ 与 __deepcopy__ 让你能够对"哪些字段共享、哪些字段隔离"做逐字段的精细控制。
5. 深度定制浅拷贝:自定义 copyreg 复制函数
除了在类内定义协议方法,copy 模块还提供了基于 copyreg 的全局复制函数注册机制。当 copy.copy() 遇到一个对象,会依次尝试:对象的 __copy__ 方法 → 该类型在 copyreg 中注册的复制函数 → 默认浅拷贝逻辑;copy.deepcopy() 的优先级依次是:__deepcopy__ 方法 → copyreg 注册函数 → 默认深拷贝逻辑。
import copyreg
def pickled_rectangle(rect):
return Rectangle, (rect.top_left, rect.bottom_right)
copyreg.pickle(Rectangle, pickled_rectangle)
适用场景:无法修改第三方类的源码(例如扩展库里的类)时,仍可为其定制复制行为。注册后,copy.copy(rect) 会调用 pickled_rectangle 返回的 (构造器, 参数元组),用构造器重建新实例。copyreg 也是 pickle 序列化的底层机制,因此同一个复制函数往往能同时服务于 copy 与 pickle,实现"一份注册、两处生效"。
从源码结构看,仓库没有提供
copyreg的独立示例文件,但copy与copyreg的配合是官方文档明确支持的复制定制途径,可作为第三类扩展手段自行验证。
6. Python 3.14 新特性:copy.replace() 只读替换
Python 3.14 为 copy 模块新增了 replace() 函数,用于在不改变对象身份的前提下创建"变更了部分字段"的新实例,并配套引入协议方法 __replace__:
copy.replace(obj, /, **changes)
6.1 内置数据类型支持
replace() 开箱即用地支持 dataclasses.dataclass、typing.NamedTuple、collections.namedtuple、functools.partial、decimal.Decimal 等常见类型;对不支持的类型调用会抛出 TypeError: object does not support the replace protocol。
6.2 三种实现模式:来自 person.py 的源码
仓库的 person.py 针对 Person 类展示了 __replace__ 的三种典型实现(该文件依赖 Python 3.14 才能运行,当前仓库环境为 3.12 时运行会报 AttributeError: module 'copy' has no attribute 'replace',这是预期的版本门槛)。
模式一:原地修改并返回同一实例(in-place)
def __replace__(self, **changes):
if unknown := changes.keys() - self.__dict__.keys():
raise AttributeError(", ".join(unknown))
self.__dict__.update(**changes)
先校验传入的字段名是否都在 __dict__ 中,防止注入未知属性;然后原地更新并返回自身。copy.replace(person, age=24, name="Alice Smith") 后打印 vars(person) 显示 name 与 age 已被更新;而传入不存在的字段(如 email)会触发 AttributeError。
模式二:浅拷贝并返回新实例(shallow)
def __replace__(self, **changes):
return type(self)(**self.__dict__ | changes)
用 type(self) 构造新对象,用字典合并操作符 | 把既有字段与变更字段合并传入。原对象保持不变,新对象拿到变更后的字段。
模式三:兼容 slots 与派生属性的写法
对定义了 __slots__(无 __dict__)的类,不能再用 __dict__ 合并的方式构造,需要逐个 setattr:
def __replace__(self, **changes):
instance = type(self)(self.name, self.age)
for name, value in changes.items():
if hasattr(self, name):
setattr(instance, name, value)
return instance
person.py 还给出了"派生属性"场景的完整方案:Person 的 age 是一个从 date_of_birth 实时计算的 @property。__replace__ 需要把 age 这类只读派生字段转换回底层字段(date_of_birth),并拒绝同时设置 age 与 date_of_birth(同时给出会抛出 AttributeError: can't set both 'age' and 'date_of_birth'):
def __replace__(self, **changes):
age = changes.pop("age", None)
dob = changes.pop("date_of_birth", None)
instance = copy.copy(self)
for name, value in changes.items():
if hasattr(self, name):
setattr(instance, name, value)
if age and dob:
raise AttributeError("can't set both 'age' and 'date_of_birth'")
elif age:
dob = copy.replace(date.today(), year=date.today().year - age)
instance.date_of_birth = dob
elif dob:
instance.date_of_birth = dob
return instance
6.3 与 immutable 数据模型的联系
replace() 与 Python 3.15 引入的 frozendict(详见仓库 python315-frozendict 目录)同属"以新对象代替原地修改"的不可变风格:在函数式数据流中,replace() 让"返回变更后的副本"变成一等公民,并且比手写 dataclasses.replace 更通用——因为它以协议方法 __replace__ 为契约,任何实现该协议的类型都能接入。
7. 性能基准:不同拷贝方式的真实开销
"哪种拷贝方式最快"要分场景。仓库的 benchmark.py 用 timeit 对 1,000 元素的 dict / set / list 各执行 10,000 次,对比四种写法。当前仓库环境(Python 3.12.10)下运行得到的结果示例:
============== Size=1,000 (10,000x) ==============
0.05431 dict.copy()
0.04784 dict()
0.05320 copy(dict)
0.09200 set.copy()
0.07723 copy(set)
0.02321 list.copy()
0.02044 list()
0.02177 copy(list)
0.02460 list[:]
从源码与实测可以得出的规律:
- 构造函数(
list(container)、set(container)、dict(container))通常最快或接近最快,因为它直接复用容器的内部批量复制路径; - 容器自带的
.copy()方法与copy.copy()开销接近,copy.copy()内部对内置容器会分派到原生复制路径,几乎没有额外包装成本; - 切片
list[:]略慢于.copy(),但仍是浅拷贝语义,二者等价; - 必须说明:该基准仅测浅拷贝,不适用于深拷贝场景——
deepcopy需要递归并维护memo,其开销取决于对象图的规模与嵌套深度。
8. 何时该用哪种拷贝:决策清单
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 复制纯数据容器(list/dict/set,且内层全是不可变值) | copy.copy() 或构造函数 |
内层不可变,共享引用不会产生可见影响 |
| 内层含可变对象且需要完全隔离 | copy.deepcopy() |
递归复制全部子对象 |
| 数据含循环引用或共享子图 | copy.deepcopy() |
memo 保证只复制一次并保留引用关系 |
| 只读对象上替换部分字段 | copy.replace()(Python 3.14+) |
不破坏不可变语义,返回新实例 |
| 对象持有文件句柄/连接等外部资源 | 自定义 __copy__/__deepcopy__ |
避免资源被复制或双重关闭 |
| 第三方类需要定制复制 | copyreg.pickle() 注册复制函数 |
无需修改类源码 |
| 嵌套深、元素多的超大对象图 | 慎用 deepcopy,评估序列化或手动复制 |
深拷贝开销与对象图规模成正比 |
9. 常见陷阱与排查清单
- 用
=当拷贝用:alias = original只是绑定新名字,改alias会改original。需要副本时显式调用copy.copy()。 - 浅拷贝后原地修改内层可变对象:如
shallow_copy["items"].append(...)会穿透到原对象。判断依据:内层元素是否可变 + 修改方式是替换引用还是原地修改(回顾 rectangle.py 与MutableInt的__iadd__语义)。 - 对含循环引用的对象用序列化代替深拷贝:
json对cf这类自引用结构会直接抛错;deepcopy的memo是处理循环引用的正确工具(回顾 golden_ratio.py)。 - 复制了不该复制的资源:文件句柄被
copy.copy默认复制会产生两个指向同一文件的句柄,务必用__copy__/__deepcopy__收敛为"重新打开同一路径"(参考 datafile.py)。 - 忘了
type(self)构造:在__copy__/__replace__中用硬编码类名构造会破坏子类语义,应始终使用type(self)(...)。 copy.replace报AttributeError: module 'copy' has no attribute 'replace':说明 Python 版本低于 3.14,replace()与__replace__协议是 3.14 新增能力。Fraction格式化报错:f"{fraction:>11}"需要 Python 3.14 的Fraction.__format__增强,旧版本下改用str(fraction)拼接。
10. 进一步探索
- 阅读本仓库配套源码:python-copy 目录下的全部 9 个示例文件,逐一对照本文各小节;
- 查看 console.py 与 datafile.py,研究
__copy__/__deepcopy__的逐字段控制写法; - 查看 person.py,掌握
__replace__协议的三种实现模式与派生属性处理; - 运行 benchmark.py 在你自己的 Python 环境中复现拷贝性能对比;
- 关联学习不可变数据模型:本仓库 python315-frozendict 目录演示了"以新对象代替修改"的不可变风格,与
copy.replace()的思路一脉相承。