我用乘法快速初始化了一个二维列表,给其中一个格子赋值,结果发现每一行的那个格子都跟着变了:一次 Python 列表乘法复制的是引用而非副本的深度复盘
那个 bug 是计分板"改一个格子、整列都变"才暴露的:我要初始化一个 3×3 的二维列表(比如棋盘/计分矩阵),图省事用了乘法:grid = [[0] * 3] * 3。打印出来 [[0,0,0],[0,0,0],[0,0,0]],看着完全正确。可当我给某个格子赋值 grid[0][0] = 1 时,诡异的事发生了:打印 grid,变成了 [[1,0,0],[1,0,0],[1,0,0]]——我明明只改了第 0 行第 0 列,可每一行的第 0 列都变成了 1!我盯着这个"一改全改"愣住了。我把这几个列表的 id() 打出来一对比,才看明白,后背发凉:问题出在 [[0]*3] * 3 这个乘法上。列表的 * 3 操作,复制的是"引用"而不是"副本"——它把同一个内层列表对象 [0,0,0] 的引用,重复放了 3 次;也就是说,grid 里的三个"行",根本不是三个独立的列表,而是指向同一个列表对象的三个引用;于是我 grid[0][0] = 1 修改的那个列表,同时就是 grid[1]、grid[2]——它们仨是同一个,所以"每一行"的第 0 列都变了(因为它们本就是一行)。根本原因是:列表的乘法(以及很多"复制"操作)对于其中的可变对象,复制的是引用(浅层),而非独立的副本;于是"多份"其实是"同一份的多个引用",改一个就改了全部。问题的根,是 [[0]*3]*3 的乘法复制的是内层列表的引用(浅复制),三行指向同一个列表对象,改一行即改全部。这篇就把这次"列表乘法复制引用"的坑,从头到尾复盘一遍。
故障现场:改一个格子,整列都变
问题在于 [[0]*3]*3 把同一个内层列表的引用重复了 3 次:
# ✗ 出问题的代码: 用乘法初始化二维列表
grid = [[0] * 3] * 3
print(grid) # [[0, 0, 0], [0, 0, 0], [0, 0, 0]] ← 看着完全正确
grid[0][0] = 1 # 只想改第0行第0列
print(grid) # ✗ [[1, 0, 0], [1, 0, 0], [1, 0, 0]] ← 每一行的第0列都变了!
# 验证: 三个"行"其实是同一个对象
print(id(grid[0]) == id(grid[1]) == id(grid[2])) # ✗ True! 三行是同一个列表对象!
# 为什么? 列表的 * 复制的是"引用", 不是"独立副本":
# 1. [0] * 3 → [0,0,0] (这层没问题, int是不可变的);
# 2. [[0,0,0]] * 3 → 把【同一个内层列表[0,0,0]的引用】重复放3次;
# → grid = [行, 行, 行], 而这三个"行"是【指向同一个列表对象】的三个引用!
# 3. grid[0][0] = 1: 修改的是那个唯一的内层列表;
# → 由于grid[0]、grid[1]、grid[2]都指向它, 所以"三行"的第0列全变了。
# 同样的坑也出现在:
# row = [0] * 3
# grid = [row] * 3 # 3个引用都指向同一个row
# # 或用 [x] * n 创建"n个可变对象"时(其实是n个同一对象的引用)
# 对比: 为什么 [0] * 3 没事? 因为里面是int(不可变), 你"改"它其实是替换成新int, 不影响别人;
# 而内层是list(可变), 多个引用指向它, 改它的内容, 所有引用都看得见。
# 关键: 列表乘法 [x] * n 对可变对象x复制的是【引用】(n个引用指向同一个x), 不是n个独立副本;
# 改其中"一个"就改了全部 —— 用乘法创建"多个可变对象"是陷阱。
第一次看到 id(grid[0]) == id(grid[1]) 是 True 时,我又荒谬又恍然:"我以为 *3 是'复制出三个一样的列表',完全没想到它是'把同一个列表摆了三次';它们看起来是三行,其实是一行被引用了三次。"这个坑最隐蔽的地方在于:它打印出来完全正确([[0,0,0],[0,0,0],[0,0,0]] 看着就是个正常的二维列表),给了你"初始化成功了"的错觉;只有当你去"修改其中一个内层列表"时,"它们是同一个"的真相才暴露;而且如果你只是"整行替换"(grid[0] = [...])而非"改行内的元素",又碰巧没事(替换的是引用),更迷惑。下面就来拆解,Python 的复制到底是怎么回事、二维列表该怎么建。
第一件事:搞懂引用、浅复制与"复制的是什么"
我顺着这次事故,把 Python 的引用、复制层次彻底理清了。
Python 的 [x]*n、复制, 到底复制的是什么?
【核心: Python变量是引用; [x]*n / 浅复制 复制的是"引用"而非可变对象的独立副本; 多个引用指向同一个可变对象, 改一个即改全部】
1. Python 变量是"引用": 变量存的是"指向对象的引用", 不是对象本身
- a = [1,2]; b = a → a和b指向【同一个】列表; b.append(3) 后 a也变了(它俩是一个)。
2. [x] * n 复制的是"引用":
- [x] * n → 得到n个【指向同一个x】的引用(不是n个x的独立副本);
- 若x是不可变的(int/str/tuple): "改"它其实是替换成新对象, 不影响别的引用 → 没事;
- 若x是可变的(list/dict/set): 改它的内容, 所有指向它的引用都看得见 → 一改全改(本文)。
3. 复制的几个层次:
- 引用复制(b = a): 完全是同一个对象, 改一个全改;
- 浅复制(list(a) / a[:] / a.copy() / [x]*n): 复制最外层, 但内层的可变对象仍是【共享引用】;
→ 改外层结构不影响原对象, 但改内层可变对象会互相影响;
- 深复制(copy.deepcopy(a)): 递归复制所有层, 完全独立, 改谁都不影响谁。
4. 本文的坑就是"浅"的问题:
- [[0]*3] * 3: 外层乘法复制了"行"的引用, 但这些引用都指向【同一个内层列表】(浅);
- → 改内层列表(grid[0][0]=1), 所有"行"都受影响。
5. 正确创建"n个独立可变对象":
- 用列表推导(每次循环新建一个): [[0]*3 for _ in range(3)] → 3个独立的内层列表;
- 推导式里的表达式每次迭代都【重新求值】, 生成全新的对象 → 不共享。
6. 同源的坑: 可变默认参数(同350篇)、dict.fromkeys(keys, [])(值共享同一list)等, 都是"多处共享同一可变对象"。
一句话: Python变量是引用; [x]*n和浅复制复制的是引用、内层可变对象仍共享; 多个引用指向同一个可变对象时
改一个即改全部; 要n个独立的可变对象用列表推导[... for _ in range(n)], 要完全独立用deepcopy。
这套认知,是整个坑的根。Python 变量是引用——变量存的是指向对象的引用,b = a 后 a、b 指向同一个列表,改一个全改。[x] * n 复制的是引用——得到 n 个指向同一个 x 的引用;x 不可变(int/str)时"改"是替换新对象不影响别人,x 可变(list/dict)时改它的内容所有引用都看得见、一改全改。复制的几个层次:引用复制(同一对象)、浅复制(复制外层、内层可变对象仍共享引用)、深复制(deepcopy 递归完全独立)。本文是浅的问题:[[0]*3]*3 外层复制了行的引用、都指向同一个内层列表。正确创建 n 个独立可变对象:用列表推导 [[0]*3 for _ in range(3)](每次迭代重新求值、生成全新对象、不共享)。同源的坑:可变默认参数、dict.fromkeys(keys, []) 等。一句话:Python 变量是引用;[x]*n 和浅复制复制的是引用、内层可变对象仍共享;多个引用指向同一个可变对象时改一个即改全部;要 n 个独立的可变对象用列表推导,要完全独立用 deepcopy。
第二件事:正解——用列表推导创建独立的内层列表
搞懂了原理,正解就清晰了:要创建"n 个独立的可变对象"(二维列表的各行),用列表推导(每次迭代新建一个),而不是用乘法(复制引用);需要完全独立的副本用 copy.deepcopy。
# ====== 正解一: 用列表推导创建二维列表(每行独立) ======
grid = [[0] * 3 for _ in range(3)] # ★ 推导式每次迭代都新建一个 [0,0,0]
print(grid) # [[0, 0, 0], [0, 0, 0], [0, 0, 0]]
grid[0][0] = 1
print(grid) # ✓ [[1, 0, 0], [0, 0, 0], [0, 0, 0]] 只改了第0行!
print(id(grid[0]) == id(grid[1])) # ✓ False! 三行是独立的不同对象
# → 关键: [... for _ in range(3)] 里的 [0]*3 每次循环都【重新求值】, 生成全新的列表 → 三行互相独立。
# (注意: 内层的 [0]*3 没问题, 因为元素是int不可变; 只有"创建多个可变对象"时才需要推导式。)
# ====== 正解二: 需要复制一个已有的嵌套结构 → 按需选浅/深复制 ======
import copy
original = [[1, 2], [3, 4]]
shallow = original[:] # 浅复制: 外层新列表, 但内层[1,2]/[3,4]仍共享
shallow[0][0] = 99 # ✗ original[0][0] 也变99(内层共享)
deep = copy.deepcopy(original) # ★ 深复制: 完全独立
deep[0][0] = 99 # ✓ 不影响 original
# ====== 创建/复制可变结构的要点 ======
# 1. 创建"n个独立的可变对象": 用列表推导 [新建表达式 for _ in range(n)], 别用 [x]*n;
# - [[0]*c for _ in range(r)] ✓ (r个独立的行)
# - [[0]*c] * r ✗ (r个引用指向同一行)
# - [{} for _ in range(n)] ✓ (n个独立的dict); [{}]*n ✗;
# 2. [x]*n 只适合 x 是【不可变】的(int/str/tuple) 或 你确实想要共享同一对象;
# 3. 复制嵌套结构: 浅复制(切片/copy())只复制外层, 内层可变对象共享; 要完全独立用 copy.deepcopy;
# 4. 心里清楚"我现在是想要'共享同一个'还是'各自独立的'": 想独立就别用复制引用的方式;
# 5. 同类坑: 可变默认参数(def f(x=[]))、dict.fromkeys(ks, [])(所有键共享同一list) → 都用推导/None判断。
# 核心: 创建多个独立的可变对象用列表推导(每次迭代新建), 别用 [x]*n(复制引用、共享同一对象);
# 复制嵌套结构按需选浅/深复制(deepcopy完全独立); 时刻清楚自己要的是"共享"还是"独立"。
修复的核心,是"创建多个独立可变对象用列表推导,别用乘法"。正解一:用列表推导创建二维列表——[[0]*3 for _ in range(3)],推导式每次迭代重新求值、生成全新列表,三行独立,改 grid[0][0] 只改第 0 行。正解二:复制嵌套结构按需选浅/深——浅复制(切片/copy())内层共享,要完全独立用 copy.deepcopy。要点:创建 n 个独立可变对象用列表推导别用 [x]*n、[x]*n 只适合 x 不可变或确实想共享、复制嵌套要完全独立用 deepcopy、心里清楚要"共享"还是"独立"、同类坑(可变默认参数/dict.fromkeys)。归根结底:创建多个独立的可变对象用列表推导(每次迭代新建),别用 [x]*n(复制引用、共享同一对象);复制嵌套结构按需选浅/深复制(deepcopy 完全独立);时刻清楚自己要的是"共享"还是"独立"。
第三件事:Python 引用与可变性相关的其他常见坑
排查后我把 Python 中引用、可变性相关的其他坑也系统梳理了一遍。
Python 引用与可变性的其他常见坑
# 1. [x]*n创建可变对象(本文): n个引用指向同一对象, 一改全改。→ 列表推导。
# 2. 可变默认参数(同350篇): def f(x=[]) 默认值在多次调用间共享。→ 用None, 函数内新建。
# 3. 浅复制误当深复制: list(a)/a[:]/copy() 内层可变对象仍共享。→ 要独立用deepcopy。
# 4. 函数内修改传入的可变参数: 影响调用方的对象(引用传递)。→ 需要不变就先拷贝。
# 5. dict.fromkeys(keys, [])/默认值为可变对象: 所有键共享同一个list。→ 推导/defaultdict注意。
# 6. 在遍历时修改列表/字典: 行为异常或RuntimeError。→ 遍历副本或用推导式生成新的。
# 7. is 与 ==: is比身份(同一对象), ==比值; 值比较用==(is只用于is None)。→ 别用is比较值。
# 8. 把可变对象当字典key/放进set: 可变对象不可哈希(list不行)/或哈希后改了出问题。→ 用不可变(tuple)。
# 共同根源: Python里"变量是引用、对象分可变与不可变"; 很多坑都源于"没分清自己操作的是'引用'还是'对象本身',
# 以及'多个变量/位置是否共享同一个可变对象'"; 当多处共享一个可变对象、又有人改它时, 就会互相影响。
# 核心: 牢记Python变量是引用、对象有可变/不可变之分; 操作时分清"共享同一对象"还是"各自独立";
# 创建多个独立可变对象用推导、复制要独立用deepcopy、值比较用==; 当心"无意中的共享"导致一改全改。
排查让我把引用与可变性的其他坑也梳理清了。一、[x]*n 创建可变对象(本文)。二、可变默认参数。三、浅复制误当深复制。四、函数内修改传入的可变参数。五、dict.fromkeys 值共享。六、遍历时修改。七、is 与 ==。八、可变对象当 key/放进 set。它们的共同根源是:Python 里"变量是引用、对象分可变与不可变";很多坑都源于"没分清自己操作的是引用还是对象本身,以及多个变量/位置是否共享同一个可变对象";当多处共享一个可变对象、又有人改它时,就会互相影响。核心是:牢记 Python 变量是引用、对象有可变/不可变之分;操作时分清"共享同一对象"还是"各自独立";创建多个独立可变对象用推导、复制要独立用 deepcopy、值比较用 ==;当心"无意中的共享"导致一改全改。下面这张图,是这次列表乘法坑的成因与解法:
第四件事:复制层次对比表
这次踩坑后,我把 Python 几种"复制"的层次对比成一张表。
| 方式 | 复制了什么 | 内层可变对象 | 改内层会互相影响吗 |
|---|---|---|---|
| b = a(引用复制) | 啥也没复制(同一对象) | 共享 | 会(完全是同一个) |
| [x] * n / 切片 / copy()(浅) | 最外层结构 | 共享引用 | 会(本文的坑) |
| copy.deepcopy(a)(深) | 递归所有层 | 各自独立 | 不会 |
| 列表推导新建 | 每次新建独立对象 | 独立 | 不会 |
这张表把复制的层次钉清了。核心是:"复制"这个词,在 Python(乃至很多语言)里是分"深浅"的、是个连续谱——从"啥也没复制(只是又一个引用)",到"只复制了表面一层(浅)",到"彻底复制了每一层(深)";而 bug 往往出在"我以为我复制出了一份独立的, 实际只复制了表面、内里还和原来共享着"。它给我的最大启发是:"复制一份"这件事,远不是"有/无"的二元,而是有"程度/深度"的——"独立到什么层次"才是关键;当你说"我要一份副本"时,要想清楚"我要的是'表面独立、内里共享'(浅), 还是'彻底独立'(深)?";"以为复制了就独立了", 而忽略了"复制的深度", 是共享类 bug 的常见来源。这给了我一种处理"副本/隔离"的清醒:每当我需要"一份独立的副本"(为了改它不影响原件、为了隔离)时,要明确地问"我需要的隔离, 要到哪一层?表面够吗, 还是要彻底?",并选用对应深度的复制——需要彻底独立(尤其嵌套结构)就用深复制, 别用浅复制糊弄;"明确所需的隔离深度、选用相应深度的复制",是避免'以为独立实则共享'的关键。认清复制分深浅是连续谱、明确所需隔离深度选对应复制——是这个坑带给我的认知。
第五件事:这次事故暴露的"看起来一样,本质却共享"
这次让我反思更深一层:那三行打印出来一模一样,本质却是同一个。我把"看起来多份"和"实际几份"对比成表。
| 维度 | [[0]*3]*3(本文) | [[0]*3 for _ in range(3)] |
|---|---|---|
| 打印出来 | [[0,0,0],[0,0,0],[0,0,0]] | [[0,0,0],[0,0,0],[0,0,0]] |
| 看起来 | 三行一样 | 三行一样 |
| 实际是几个对象 | 1 个(被引用 3 次) | 3 个独立的 |
| 改一个内层 | 三行全变 | 只变一个 |
| 区别在哪 | 看不出来(要看 id) | 看不出来(要看 id) |
这张表道出了最迷惑的地方。核心是:这两个二维列表,"看起来"(打印出来的样子)一模一样,可它们的"本质"(内部是几个独立对象、是否共享)截然不同;而这个决定性的差异,从表面(值)上完全看不出来,只有去看"身份(id)"、或者"改一个看会不会影响别的"才暴露。它给我的深刻启发是:两个东西"当前的值/表现一样",完全不代表它们"背后的结构/共享关系一样"——"值相同"是表面的、静态的;"是否共享同一底层、改一个会不会动另一个"是深层的、动态的;很多隐蔽的 bug, 正藏在"表面值相同、但底层共享关系不同"的地方——它们平静时一模一样, 一旦有人"修改", 隐藏的共享关系才以"一改全改"的形式暴露。这给了我一种审视数据结构的清醒:判断两个/多个数据"是不是真的独立",不能只看"它们现在的值是否一样",而要看"它们背后是不是同一个对象、改一个会不会牵动另一个"——关注"共享关系/别名(aliasing)",而非只看当前的值;"看穿表面值相同之下的共享关系差异、警惕意外的别名",是理解和调试涉及可变状态、引用的程序的关键能力。认清值相同不等于结构独立、要关注共享关系而非只看值——是这个列表乘法坑带给我的认知。
第六件事:要创建/复制可变结构时,我现在的自检习惯
现在每当我要创建或复制一个含可变对象的结构,我都会先按这张图问自己:
这张图的精髓,是"创建多个可变对象用推导别用乘法,复制要彻底独立用 deepcopy"。创建多个可变对象用列表推导、x 不可变[x]*n 可以、复制要彻底独立deepcopy、浅复制记住内层共享。这套习惯,让我从"初始化二维列表随手 [[0]*n]*m"变成了"创建多个可变对象一律用推导式"——核心始终是:创建多个独立的可变对象用列表推导(每次迭代新建),别用 [x]*n(复制引用、共享同一对象);复制要彻底独立用 deepcopy。
我立下的几条规矩
这场"改一个格子整列都变"的事故,换来了我写 Python 时,刻进骨子里的几条铁律:
- Python 变量是引用,对象分可变与不可变。这是理解一切共享问题的基础。
- [x]*n 复制的是引用——n 个引用指向同一个 x,不是 n 个独立副本。
- x 是可变对象(list/dict)时,[x]*n 会让"多份"共享同一个,改一个全改。
- 创建多个独立的可变对象,用列表推导 [新建表达式 for _ in range(n)]。
- [x]*n 只适合 x 是不可变的(int/str/tuple),或你确实想共享同一对象。
- 复制嵌套结构:浅复制内层共享,要完全独立用 copy.deepcopy。
- 判断数据是否独立,看共享关系(id/会不会一改全改),而非只看当前值。
写在最后
回头看,这场由"一个图省事的列表乘法"引发的、一改全改的事故,真正教给我的,远不止"用列表推导建二维列表"这一个技巧。它让我对"'复制'这个我们以为天经地义、轻而易举的动作, 背后藏着'到底复制了什么、复制得有多深'的微妙; 而'看起来复制出了多份独立的', 可能只是'同一份被引用了多次'",有了一次刻骨的体会。我栽跟头,是因为我对"复制"有一个过于简单、想当然的理解——"* 3 嘛, 就是复制三份呗";我脑子里的"复制",是那种"像复印机一样, 印出三张一模一样但各自独立的纸"的朴素画面;可计算机里的"复制", 默认往往是'浅'的、复制的是'引用'——它给了我"三个指向同一张纸的便签", 而不是"三张独立的纸";我以为我有了三份能各自涂改的拷贝, 实际我只是给同一份东西, 贴了三个标签。这让我领悟到一个关于"复制的深浅与共享"的深刻认知:在与"引用、对象、内存"打交道的世界里,"复制"不是一个简单的、必然产生'独立副本'的动作——它默认常常是"浅"的, 复制出的"多份", 在更深的层次上可能仍然共享着同一个底层对象;"我复制了一份" 不等于 "我得到了一份和原来彻底无关、互不影响的独立体";"共享" 常常悄悄地、默认地存在着, 而你以为你拥有的是"独立"。这给了我一种处理可变状态的根本审慎:每当我"复制"一份可变的数据、或"创建多个"可变对象时,要清醒地问"它们是真的各自独立, 还是在某个层次上共享着同一个底层?我对它们的修改, 会不会意外地互相影响?"——需要独立就用确保独立的方式(推导/深复制), 而不是想当然地以为"复制了就独立了";"清醒地区分'独立副本'与'共享引用'、明确并掌控数据间的共享关系",是写对一切涉及可变状态的程序的根本基础。认清复制默认常是浅的、复制的多份可能仍共享底层、要清醒区分独立与共享——这,是我用一次列表乘法的事故,换来的、关于 Python、也关于如何掌控数据共享关系的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次初始化二维列表时,把 [[0]*c]*r 换成 [[0]*c for _ in range(r)],那我对着那"改一个变一列"的计分板排查的这段时间,就值了。
—— 别看了 · 2026