跳转到内容
新建笔记

Python copy:对象身份、共享数据与深浅拷贝

复制对象时,要分别问三个问题:变量是否指向同一个对象,容器内部是否仍引用同一个子对象,数组或张量是否共享底层数据。对于张量,还要再问复制后是否保留自动求导关系。把这些问题都归为“深拷贝或浅拷贝”,很容易判断错。

本文的 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 = original
shallow = copy(original)
deep = deepcopy(original)
assert alias is original
assert shallow is not original
assert 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] == 6
assert shallow[-1] == 4

is 判断对象身份;== 判断值是否相等。两个新列表可以值相等但身份不同。对于 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 source
assert result["left"] is not shared
assert 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 cycle
assert 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 deepcopy
import 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] = -1
assert a[0, 1] == -1
assert copied[0, 1] == 1
assert a.astype(a.dtype, copy=False) is a
assert 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_grad
assert not snapshot.requires_grad
assert 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.0
assert x[0].item() == 9.0
assert snapshot[0].item() == 1.0
array = detached.numpy()
array[1] = 8.0
assert x[1].item() == 8.0
array_snapshot = detached.numpy().copy()
array_snapshot[1] = 0.0
assert x[1].item() == 8.0

默认 numpy() 要求 CPU、无梯度要求、受支持的 dtype 和布局等条件;numpy(force=True) 可能先处理设备与共轭等状态,也不保证一定复制。需要独立 NumPy 数据时,应明确追加数组 .copy()。Tensor.numpy 文档

继续阅读 Tensor:类型、形状与存储 时,也可以沿用“对象身份、底层数据、梯度关系”这三个检查点。