用 Python 列表模拟表格,明明只写了“第零行第二格等于七”,打印出来却是每一行都变成七。先别怀疑循环跑重了:如果初始化用了嵌套列表乘法,几行可能指向同一个列表。本文用三个完整示例定位共享发生在哪一层,再分别处理独立行、可变单元格和复制误区。
一、先复现:改的是一个对象,不是几份数据
下面三个代码块可分别保存为 UTF-8 编码的 Python 文件,再用 Python 3 执行;不依赖第三方包,不访问网络,也不修改业务文件。代码中的 assert 是自检,不要带会关闭断言的 -O 参数运行。
矩阵 = [[0] * 3] * 2
assert 矩阵[0] is 矩阵[1]
矩阵[0][1] = 7
assert 矩阵 == [[0, 7, 0], [0, 7, 0]]
矩阵[0] = [1, 2, 3]
assert 矩阵 == [[1, 2, 3], [0, 7, 0]]
print("共享行反例与整行替换对照通过")最后的整行替换是重要对照。矩阵[0][1] = 7 会找到旧行,再修改旧行的一个槽位;矩阵[0] = [1, 2, 3] 则把外层列表第零项改指向新行。后者没有修改第一项仍然引用的旧行。看着都叫“赋值”,动手的位置并不一样。
二、列表乘法重复引用,不递归复制元素
拆开表达式看:先用 [0] * 3 得到一个行列表,再把“装着这一个行列表的外层列表”乘二。外层确实有两个槽位,但槽位里存放的是同一个行对象的引用。它不是二维数值数组的专用初始化语法,也不承诺给每一行分配独立对象。
用 is 判断这两行是不是同一对象;用 == 只能判断内容是否相等。两份独立的 [0, 0, 0] 内容相等,却完全可以不是同一行。调试时把这两个问题分开,就不用盯着输出猜了。id 适合在对象同时存活时辅助查身份,不应写成跨进程或持久化编号。

三、只有整数单元格:每轮新建一行
常见修正是列表推导式,把新建行的表达式放到循环里。对这里的整数单元格,[0] * 3 没问题:整数不可变,给某个格子赋七只是替换那个槽位的引用,不会把“零这个对象”改成七。关键是不能把同一个可变行再重复到外层。
# 整数单元格:每轮创建一个新行
矩阵 = [[0] * 3 for _ in range(2)]
assert 矩阵[0] is not 矩阵[1]
矩阵[0][1] = 7
assert 矩阵 == [[0, 7, 0], [0, 0, 0]]
# 可变单元格:每个单元格也要独立创建
分组 = [[[] for _ in range(3)] for _ in range(2)]
assert len({id(格) for 行 in 分组 for 格 in 行}) == 6
分组[0][1].append("甲")
assert 分组 == [[[], ["甲"], []], [[], [], []]]
print("独立行与独立单元格检查通过")不要机械地把所有乘号都删掉。先问对象是否可变、后续要修改哪一层,再决定在哪里构造新容器。二行三列只是演示尺寸,换成其他非负行列数也要保留这个构造层次。若尺寸来自用户输入,允许范围与总内存预算另设门禁;没有异常不代表规模合理。
四、单元格也是列表:只修行还不够
把整数换成“每格一个待处理项目列表”时,[[[]] * 3 for _ in range(2)] 虽然创建了两条独立行,每行内部的三个格子却仍共享一个列表。行与行不串,并不代表列与列不串。此时要像上面第二段示例那样,在每个格子的位置分别执行 []。
还有一个更隐蔽的写法:先建一个公共空列表,再在推导式里反复引用它。推导式不是复制开关;循环执行的表达式只返回公共变量时,拿到的还是公共对象。容器嵌套越深,越应逐层说明哪些对象允许共享,哪些对象必须独立,不要拿“最外层是新的”代替检查。
五、为什么整体 deepcopy 也没有拆开共享行
浅复制会新建外层列表,里面仍引用原来的行;切片和 list(原件) 对这种普通列表也是浅层复制。深复制能把本例的行对象与原件隔开,但一次 deepcopy 会使用 memo 记录已复制对象,保留本例内部原有的共享关系,不会把每次出现都强行变成独立副本。
from copy import deepcopy
原件 = [[0] * 3] * 2
浅副本 = 原件.copy()
深副本 = deepcopy(原件)
assert 浅副本 is not 原件
assert 浅副本[0] is 原件[0]
assert 深副本[0] is not 原件[0]
assert 深副本[0] is 深副本[1]
深副本[0][0] = 9
assert 深副本 == [[9, 0, 0], [9, 0, 0]]
assert 原件 == [[0, 0, 0], [0, 0, 0]]
# 按行分别复制:本例只有不可变整数单元格
独立行 = [行.copy() for 行 in 原件]
assert 独立行[0] is not 独立行[1]
独立行[0][0] = 5
assert 独立行 == [[5, 0, 0], [0, 0, 0]]
print("浅复制、深复制与按行重建对照通过")所以“独立于原件”和“副本内部互不共享”是两种要求。对整数行逐行 copy 可以满足本例,但若行里还有可变子对象,浅复制仍会保留它们的引用;按行分别深复制也只划分行之间的复制边界,不保证拆散一行内部已有的别名。需要每格独立时,优先按业务结构逐格新建。
自定义类还可能定义自己的复制行为,文件、连接等资源也不能当普通数据容器照搬。本例只断言内置列表与整数,不把 deepcopy 宣传成通用对象隔离器,更不保证外部资源或事务的独立性。
六、按故障现象找共享层级
| 现象 | 先查什么 | 处理方向 |
|---|---|---|
| 改一格,多行同列变化 | 两行是否为同一列表 | 循环里逐行新建 |
| 行不同,但一行多格联动 | 各格是否共享可变对象 | 逐格新建容器 |
| 复制后仍改动原件 | 是否只复制外层 | 按需要确定复制深度 |
| 不影响原件,副本内部仍联动 | 深复制保留了内部别名 | 重新明确独立单元的边界 |
| 值看起来正确,却担心共享 | 相等比较掩盖了身份 | 补身份与定点修改断言 |
如果数据已经因共享写入而丢失原值,改初始化代码不能把历史数据自动恢复。应从可信输入重新生成或按可核对的来源纠正;不要对运行中的对象随手复制一下,就把问题标记为修好。
七、验收别只打印一张正常表格
本稿随文代码已在本机 Python 3.14.3 实际执行,覆盖共享反例、整行替换、独立行、独立可变格,以及浅复制和深复制的差别。扩展内存夹具再检查空行列、重复公共变量和嵌套可变对象;这些是标准库内存实验,不是线上请求隔离、数据库迁移或第三方矩阵库验收。
- 先写清独立的是外层、每行,还是每个可变单元格。
- 对象同时存活时,用 is 或身份集合验证预期,不对整数身份作业务假设。
- 只改一个格子,核对所有不该改变的位置,再验证整行替换的区别。
- 检查零尺寸与允许的最大尺寸;创建成功不等于内存开销可接受。
- 上线业务前再核对真实输入、输出和共享策略,不用示例通过替代验收。
八、参考资料:把“复制”问得更具体
排查这种问题,与其问“有没有复制”,不如问“复制到了哪一层,哪些引用应该继续共享”。共享本身不是错误,未经说明的共享才容易让下一次修改变成惊喜。这些是长期存在的 Python 语义,不是某个新版本突然改变的行为。
🔕 评论已关闭