复制对象时,要分别问三个问题:变量是否指向同一个对象,容器内部是否仍引用同一个子对象,数组或张量是否共享底层数据。对于张量,还要再问复制后是否保留自动求导关系。把这些问题都归为“深拷贝或浅拷贝”,很容易判断错。
本文的 Python 示例使用 3.11;数组与张量部分对应 NumPy 2.4、PyTorch 2.8 的普通 CPU 数据。
赋值、浅拷贝、深拷贝分别改变什么
跳转到“赋值、浅拷贝、深拷贝分别改变什么”以包含一个列表的外层列表为例:
| 操作 | 外层对象 | 内部可变对象 | 适合的目的 |
|---|---|---|---|
b = a | 与 a 是同一个对象 | 完全相同的引用 | 为已有对象增加一个名称 |
b = a.copy() 或 copy.copy(a) | 新外层列表 | 仍引用原来的子对象 | 独立增删外层元素,同时共享元素对象 |
b = copy.deepcopy(a) | 按深拷贝协议构造结果 | 通常递归复制可复制的可变子对象 | 为一个对象图建立独立的可变状态 |
赋值没有执行复制。浅拷贝也不是“只给原对象起另一个名字”:对于列表和字典,它创建了新的外层容器。Python copy 文档 将浅拷贝与深拷贝的差别放在复合对象内部引用的处理上。
下面的例子区分了修改子列表与替换一个外层元素:
from copy import copy, deepcopy
original = [1, [2, 3], 4]alias = originalshallow = copy(original)deep = deepcopy(original)
assert alias is originalassert shallow is not originalassert shallow[1] is original[1]assert deep[1] is not original[1]
shallow[1].append(5) # 修改共享的子列表assert original == [1, [2, 3, 5], 4]assert deep == [1, [2, 3], 4]
shallow[0] = 99 # 替换新外层列表中的一个引用assert original[0] == 1
alias.append(6) # alias 和 original 是同一个外层列表assert original[-1] == 6assert shallow[-1] == 4is 判断对象身份;== 判断值是否相等。两个新列表可以值相等但身份不同。对于 NumPy 数组,== 通常逐元素返回布尔数组,更不能用它代替共享内存检查。
内置容器不能只按可变与不可变分类
跳转到“内置容器不能只按可变与不可变分类”| 类型或写法 | 需要注意的行为 |
|---|---|
int、float、str | 值本身不可原位修改;重新给变量赋值不会改变其他变量绑定的值。复制操作可以返回原对象 |
tuple | 元组中的引用不能被替换,但被引用的列表等对象仍可能修改;“元组不可变”不等于整棵对象图不可变 |
list.copy()、list(a)、a[:] | 对普通列表进行浅拷贝 |
[item for item in a] | 新建外层列表,元素仍是原来的对象;仅凭列表推导式不能得到深拷贝 |
dict.copy() | 新建字典,键和值仍是原对象的引用 |
set.copy() | 新建集合,元素对象不递归复制;元素必须可哈希 |
整数执行 n += 1 会把变量绑定到计算结果;列表执行 items += [...] 通常修改列表本身。表达式看起来相似,行为取决于对象类型。
深拷贝保留关系,不是把每条引用都拆开
跳转到“深拷贝保留关系,不是把每条引用都拆开”两个位置原本引用同一个列表,深拷贝通常会让它们共同引用同一个新列表。同一次复制中的 memo 记录已经复制的对象,这也使循环引用能够被处理。
from copy import deepcopy
shared = [10]source = {"left": shared, "right": shared}result = deepcopy(source)
assert result is not sourceassert result["left"] is not sharedassert result["left"] is result["right"]result["left"].append(20)assert result["right"] == [10, 20]assert shared == [10]
cycle = []cycle.append(cycle)cycle_copy = deepcopy(cycle)assert cycle_copy is not cycleassert cycle_copy[0] is cycle_copy
inside_tuple = ([1], "label")tuple_copy = deepcopy(inside_tuple)tuple_copy[0].append(2)assert inside_tuple[0] == [1]因此,“深拷贝后所有地址都不同”“所有修改都绝不影响源对象”都过于绝对。函数、类和某些不可变值可以复用原对象;自定义类能通过 __copy__、__deepcopy__ 决定行为。文件、套接字等外部资源也不是递归复制一遍就能获得等价资源。实现自定义深拷贝时,应把收到的 memo 继续传给子对象的 deepcopy,并正确处理自身的循环关系。
NumPy:区分数组数据与 object 元素
跳转到“NumPy:区分数组数据与 object 元素”以下表格针对普通 ndarray。基础切片是否共享数据与数组是一维还是二维无关;整数数组索引、布尔索引等高级索引则产生结果副本。直接执行 a[index] = value 是写回目标数组,不等同于先取出一个副本再修改。NumPy 索引规则
| 操作 | 数组数据是否共享 |
|---|---|
b = a | 同一个数组对象 |
a.view()、基础切片 a[:, 1:] | 通常得到共享数据的视图 |
高级索引 a[[0, 2]] | 结果数组的数据单独分配 |
a.copy()、np.copy(a) | 复制数组的数据缓冲区;若 dtype=object,缓冲区内保存的 Python 对象引用仍然共享 |
a.astype(dtype) | 默认 copy=True,即使 dtype 相同也创建新数组 |
a.astype(dtype, copy=False) | dtype、布局等条件满足时可以直接返回输入;不满足时仍需复制 |
np.shares_memory(a, b) 用于检查数组数据是否重叠。a.base 可以帮助理解来源,但不能独自充当所有视图关系的判定标准。np.copy 的 object 说明、astype 的 copy 参数
from copy import deepcopyimport numpy as np
a = np.arange(12).reshape(3, 4)view = a[:, 1:3]copied = a.copy()selected = a[[0, 2]]assert np.shares_memory(a, view)assert not np.shares_memory(a, copied)assert not np.shares_memory(a, selected)
view[0, 0] = -1assert a[0, 1] == -1assert copied[0, 1] == 1assert a.astype(a.dtype, copy=False) is aassert not np.shares_memory(a, a.astype(a.dtype))
objects = np.empty(1, dtype=object)objects[0] = [1, 2]buffer_copy = objects.copy()recursive_copy = deepcopy(objects)assert not np.shares_memory(objects, buffer_copy)assert buffer_copy[0] is objects[0] # 缓冲区独立,元素对象仍共享buffer_copy[0].append(3)assert objects[0] == [1, 2, 3]assert recursive_copy[0] == [1, 2]PyTorch:存储共享与梯度关系是两个维度
跳转到“PyTorch:存储共享与梯度关系是两个维度”detach() 的作用是脱离自动求导关系,返回的张量仍共享原数据;clone() 复制数据,但通常保留可微关系。需要独立数据且不再关联原计算图时,使用 x.detach().clone()。detach、clone
| 操作 | 存储关系 | 自动求导关系 |
|---|---|---|
y = x | 同一个张量对象 | 原关系不变 |
x.clone() | 独立数据 | 当输入需要梯度时,梯度可回传到输入 |
x.detach() | 共享数据 | 结果不需要梯度,脱离原图 |
x.detach().clone() | 独立数据 | 脱离原图 |
x.to(device, dtype) | 已满足目标且未强制复制时可返回原对象,否则转换并复制 | 浮点等可微转换通常保留关系;转整数不保留梯度 |
x.to(..., copy=True) | 强制创建新张量 | 不等于脱离原图 |
x.numpy() | 符合默认转换条件时与 CPU 张量共享数据 | 数组没有 PyTorch 自动求导机制 |
torch.tensor(x) | 复制数据 | 相当于创建脱离原图的新叶子张量 |
.cuda() 在目标 GPU 已匹配时也可能返回原对象;设备迁移不能简单称为“浅拷贝”。.data 会绕开正常自动求导使用路径,不应用来实现“独立副本”。原笔记中的 torch.Tensor(tensor, copy=True) 也不是这里应使用的接口:明确需要复制就用 clone(),明确需要数据转换则用 to()。to 的复制规则、torch.tensor
import torch
x = torch.tensor([1.0, 2.0], requires_grad=True)cloned = x.clone()detached = x.detach()snapshot = x.detach().clone()
assert detached.data_ptr() == x.data_ptr()assert cloned.data_ptr() != x.data_ptr()assert snapshot.data_ptr() != x.data_ptr()assert not detached.requires_gradassert not snapshot.requires_gradassert x.to(dtype=x.dtype, device=x.device) is x
(cloned * 3).sum().backward()assert torch.equal(x.grad, torch.tensor([3.0, 3.0]))
# 在上述反向计算结束后演示共享;不在待反向计算的图中原位改数据。detached[0] = 9.0assert x[0].item() == 9.0assert snapshot[0].item() == 1.0
array = detached.numpy()array[1] = 8.0assert x[1].item() == 8.0array_snapshot = detached.numpy().copy()array_snapshot[1] = 0.0assert x[1].item() == 8.0默认 numpy() 要求 CPU、无梯度要求、受支持的 dtype 和布局等条件;numpy(force=True) 可能先处理设备与共轭等状态,也不保证一定复制。需要独立 NumPy 数据时,应明确追加数组 .copy()。Tensor.numpy 文档
继续阅读 Tensor:类型、形状与存储 时,也可以沿用“对象身份、底层数据、梯度关系”这三个检查点。