Python 对象复制完全指南:深入理解 copy 模块的浅拷贝与深拷贝

原创2026-10-14 21:41:051,436 阅读
文章标签:示例工程

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(记忆字典),保证:

  1. 同一对象被多次引用时只复制一次;
  2. 循环引用(对象引用自身)不会导致无限递归。

这两个特性正是 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. 常见陷阱与排查清单

  1. 用 = 当拷贝用:alias = original 只是绑定新名字,改 alias 会改 original。需要副本时显式调用 copy.copy()。
  2. 浅拷贝后原地修改内层可变对象:如 shallow_copy["items"].append(...) 会穿透到原对象。判断依据:内层元素是否可变 + 修改方式是替换引用还是原地修改(回顾 rectangle.py 与 MutableInt 的 __iadd__ 语义)。
  3. 对含循环引用的对象用序列化代替深拷贝:json 对 cf 这类自引用结构会直接抛错;deepcopy 的 memo 是处理循环引用的正确工具(回顾 golden_ratio.py)。
  4. 复制了不该复制的资源:文件句柄被 copy.copy 默认复制会产生两个指向同一文件的句柄,务必用 __copy__/__deepcopy__ 收敛为"重新打开同一路径"(参考 datafile.py)。
  5. 忘了 type(self) 构造:在 __copy__/__replace__ 中用硬编码类名构造会破坏子类语义,应始终使用 type(self)(...)。
  6. copy.replace 报 AttributeError: module 'copy' has no attribute 'replace':说明 Python 版本低于 3.14,replace() 与 __replace__ 协议是 3.14 新增能力。
  7. 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() 的思路一脉相承。
登录后查看全文
materials