我用 [[0]*m]*n 飞快地建了个二维数组,往里填一个格子的值,结果整整一列都跟着变了,我对着 Python 列表乘法复制的是引用而不是值这个坑排查了大半天的复盘
这是一个看起来人畜无害、却让我对着输出怀疑了大半天人生的 Python 坑。它的隐蔽之处在于:出问题的那行代码,简洁、优雅、"看起来完全正确",连 IDE 都挑不出一点毛病。
事情起于一个再普通不过的需求:我要做一个棋盘/网格类的小算法,需要一个 n 行 m 列、初始全为 0 的二维数组。凭着对 Python "简洁"的信赖,我顺手写下了我自以为最 Pythonic 的一行:
# 我要建一个 3 行 4 列、全是 0 的二维数组
n, m = 3, 4
grid = [[0] * m] * n # 看起来多优雅! "m个0组成一行, 这样的行来n个"
print(grid)
# [[0, 0, 0, 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, 0, 0]]
# 实际打印出来的:
# [[1, 0, 0, 0], [1, 0, 0, 0], [1, 0, 0, 0]] # 💥 三行的第一个都变成1了?!
我当时整个人都是懵的。我明明只改了 grid[0][0] 这一个格子,怎么 grid[1][0] 和 grid[2][0] 也跟着变成 1 了?我反复看那行赋值,grid[0][0] = 1,清清楚楚就是改第 0 行第 0 列,没有任何循环、没有任何其他赋值。我甚至怀疑是不是 print 出错了、是不是 Python 解释器坏了。但事实就摆在那:改一个格子,整整一列(其实是每行的同一位置)都跟着变。
第一件事:看清真相——[x]*n 复制的是"引用",不是"值"
我把这个诡异现象在交互式环境里一步步拆开,用 id() 打印每一行的内存地址,真相才赤裸裸地暴露出来——那三行,根本不是三个独立的列表,而是同一个列表的三个引用。
# 用 id() 看清每一行到底是不是同一个对象
n, m = 3, 4
grid = [[0] * m] * n
print(id(grid[0])) # 比如 140234...800
print(id(grid[1])) # 140234...800 ← 和上面一模一样!
print(id(grid[2])) # 140234...800 ← 还是一模一样!
# 三行的 id 完全相同 → 它们是【同一个列表对象】, 只是被引用了三次!
print(grid[0] is grid[1]) # True ← 铁证: 它们就是同一个对象
[x] * n 的真相
# 关键认知: Python 列表里装的从来不是"对象本身", 而是"对象的引用"。
# 1. [0] * 4 对【不可变】的 int 0:
# → 得到 [0,0,0,0]。虽然也是4个对同一个0的引用,
# 但 int 不可变, 你只能整体替换(lst[i]=5), 不能"改0本身",
# 所以这一层【感觉不到】问题。
# 2. [行] * n 对【可变】的 list:
# inner = [0,0,0,0] # 先算出里层这一个列表对象
# grid = [inner] * n # ★ 把【这同一个 inner 的引用】复制了 n 份!
# → grid = [inner, inner, inner] 三个元素指向【同一个】列表
# → grid[0]、grid[1]、grid[2] 是同一个对象的三个别名
# 3. 所以 grid[0][0] = 1 的真实含义:
# - grid[0] 拿到 inner 这个列表
# - 改它的第0个元素为1
# - 但 grid[1]、grid[2] 也是 inner! 所以它们"看到"的也是改后的
# → 表现为"改一个, 一整列全变"
# 核心: * 复制序列时, 复制的是【元素的引用】而非【元素本身】;
# 对可变元素(list/dict/对象), [可变对象]*n 会得到 n 个指向【同一对象】的引用,
# 改其中任意一个, 全部"跟着变"——因为它们本就是同一个。
真相大白,我又好气又好笑。原来 Python 的列表里,装的从来不是"对象本身",而是"对象的引用";而 * 运算符在复制序列时,复制的也只是元素的引用,不是元素本身。所以 [[0]*m]*n 这一行,其实是:先算出里层那一个 [0,0,0,0] 列表(记作 inner),然后 [inner] * n 把这同一个 inner 的引用复制了 n 份——于是 grid 的 n 行,全都指向同一个 inner 列表,它们是同一个对象的 n 个别名。这就解释了一切:grid[0][0] = 1 改的是 inner 的第 0 个元素,而 grid[1]、grid[2] 也是 inner,自然"看到"的也是改后的值——表现出来,就是"改一个格子,一整列全变"。而为什么里层 [0]*4 没出问题?因为 int 0 是不可变的,你只能整体替换它(lst[i]=5)、不能"改 0 本身",所以那一层的引用共享感觉不到;坑只在可变对象(list)被 * 复制时才暴露。
第二件事:正解——用列表推导为每一行创建独立的列表
搞懂了原理,正解就清晰了:不要用 * 复制可变对象,而是用列表推导(或循环),为每一行真正地创建一个全新的、独立的列表。
# ====== 正解一(标准做法): 列表推导 ======
n, m = 3, 4
grid = [[0] * m for _ in range(n)] # ★ for 每循环一次, 都【新建】一个 [0]*m
# 关键: [0]*m 在这里对【不可变的0】用*没问题; 外层用推导, 每行是独立新建的
grid[0][0] = 1
print(grid)
# [[1, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]] # ✓ 只有第0行变了, 正确!
print(grid[0] is grid[1]) # False ← 现在它们是不同的对象了
# ====== 为什么列表推导是对的 ======
# [[0]*m for _ in range(n)]:
# - for 循环执行 n 次
# - 【每一次】都重新求值 [0]*m, 创建一个【全新的】列表
# - 所以 n 行是 n 个互相独立的列表对象 ✓
# ====== 正解二: 普通循环(等价, 更直白) ======
grid = []
for _ in range(n):
grid.append([0] * m) # 每次 append 一个新建的行
# 同样: 每行都是独立新建的
# ====== 三维及以上同理: 每层都要"新建" ======
# ✗ cube = [[[0]*x]*y]*z # 错: 各种共享引用
# ✓ cube = [[[0]*x for _ in range(y)] for _ in range(z)] # 对: 层层推导新建
# ====== 用 numpy 时没这个坑(它是专门的数组) ======
# import numpy as np
# grid = np.zeros((n, m), dtype=int) # numpy的二维数组, 元素独立, 推荐做数值计算
# 核心: 要n个独立的可变对象, 用"列表推导/循环"让每个都【新建】(for _ in range(n));
# 绝不用 [可变对象]*n (那是复制同一引用n份)。数值计算优先用 numpy。
修复的核心,是"用列表推导让每一行真正独立新建,而不是用 * 复制同一个引用"。正解一(标准做法):列表推导 [[0]*m for _ in range(n)]——关键在于 for 每循环一次都重新求值 [0]*m、创建一个全新的列表,所以 n 行是 n 个互相独立的对象;此时 grid[0] is grid[1] 是 False。(注意:里层 [0]*m 对不可变的 0 用 * 是没问题的,问题只在用 * 复制可变的行。)正解二:普通循环——每次 append([0]*m) 一个新建的行,等价但更直白。三维及以上同理——每一层都要用推导"新建",[[[0]*x for _ in range(y)] for _ in range(z)]。做数值计算优先用 numpy——np.zeros((n,m)) 的二维数组元素天然独立,没有这个坑。归根结底:要 n 个独立的可变对象,用列表推导/循环让每个都新建(for _ in range(n));绝不用 [可变对象]*n。
第三件事:Python 引用共享 / 浅拷贝的其他常见坑
排查后我把 Python 里这类"引用共享"的同源坑也系统梳理了一遍,它们全都源自"变量/容器存的是引用"这一点。
引用共享 / 浅拷贝的其他常见坑
# 1. [可变对象]*n 共享(本文): [[0]*m]*n。→ 列表推导新建。
# 2. 可变默认参数: def f(x, acc=[]): acc.append(x); return acc
# → 默认值 [] 在函数【定义时只创建一次】, 多次调用【共享同一个】!
# f(1)→[1], f(2)→[1,2] (而非[2])。→ 用 acc=None, 函数内 if acc is None: acc=[]
# 3. 浅拷贝只拷一层: b = a.copy() 或 list(a) 或 a[:]
# → 只复制最外层; 里面的可变元素还是【共享引用】。
# b[0].append(x) 会影响 a[0]。→ 需要彻底独立用 copy.deepcopy(a)
# 4. dict.fromkeys 配可变默认: dict.fromkeys(['a','b'], [])
# → 所有key共享【同一个】list! d['a'].append(1) 后 d['b'] 也有1。
# 5. 赋值不是拷贝: b = a (a是list/dict)
# → b 和 a 是同一对象的两个名字, 改b就是改a。→ 要副本得显式拷贝。
# 6. 函数内修改传入的可变参数: def f(lst): lst.append(1)
# → 会改到外面传进来的那个列表(引用传递)。→ 别意外修改入参。
# 共同根源: Python 中变量/容器元素存的都是【对象的引用】, 赋值/*/浅拷贝
# 复制的都是引用而非对象; 对【可变对象】, 共享引用就意味着"一改全改"。
# 核心: 凡涉及"复制/默认值/拷贝"且元素是可变对象时, 都要警惕"是不是共享了同一引用";
# 需要真正独立, 就显式地为每个新建对象, 或用 deepcopy。
排查让我把这类引用共享的坑都梳理清了。一、[可变对象]*n 共享(本文)。二、可变默认参数——def f(x, acc=[]) 的默认值在函数定义时只创建一次、多次调用共享同一个,要用 acc=None 再在函数内 if acc is None: acc=[]。三、浅拷贝只拷一层——a.copy()/list(a)/a[:] 只复制最外层,里面的可变元素仍共享,要彻底独立用 copy.deepcopy。四、dict.fromkeys 配可变默认(所有 key 共享同一 list)。五、赋值 b=a 不是拷贝(同一对象两个名字)。六、函数内修改传入的可变参数(会改到外面)。它们的共同根源是:Python 中变量/容器元素存的都是对象的引用,赋值/*/浅拷贝复制的都是引用而非对象;对可变对象,共享引用就意味着"一改全改"。核心是:凡涉及"复制/默认值/拷贝"且元素是可变对象时,都要警惕是不是共享了同一引用;需要真正独立就显式为每个新建对象,或用 deepcopy。下面这张图,是这次二维数组一改全变的成因与解法:
第四件事:值 vs 引用、可变 vs 不可变 速查表
这次踩坑后,我把 Python 里"什么时候会共享、什么时候安全"整理成一张表,写代码时对照着想。
| 写法 | 元素是不可变(int/str/tuple) | 元素是可变(list/dict/对象) |
|---|---|---|
| [x] * n | ✓ 安全(只能整体替换) | ✗ n个共享同一引用(本文) |
| 列表推导 for _ in range(n) | ✓ 安全 | ✓ 安全(每个新建) |
| b = a | ✓ 安全(改b是重新绑定) | ✗ 同一对象, 改b即改a |
| b = a.copy()/a[:] | ✓ 安全 | △ 浅拷贝, 里层仍共享 |
| b = copy.deepcopy(a) | ✓ 安全 | ✓ 安全(递归全拷) |
| 默认参数 def f(x=[]) | ✓ 安全 | ✗ 多次调用共享同一个 |
这张表把"何时共享、何时安全"钉死了。核心规律就一条:问题(意外共享)只在"对可变对象"用"* 复制 / 直接赋值 / 浅拷贝 / 可变默认参数"时才发生;对不可变对象(int/str/tuple)做这些都安全(因为你根本"改不动"它,只能整体替换)。它给我的最大启发是:在 Python 里,理解一个操作"安不安全",关键看两个维度的交叉——"这个操作是复制了引用还是复制了对象"× "对象本身是可变还是不可变";只有当"复制了引用"遇上"可变对象"时,才会出现"意外的一改全改"。这其实揭示了一个更本质的认知:"可变性(mutability)"和"共享(aliasing,别名/多个引用指向同一对象)",这两件事单独都不可怕,但它们一旦结合,就是绝大多数"诡异的、一处改动牵连他处"类 bug 的根源;因为"共享"意味着多个地方指向同一对象,"可变"意味着这个对象能被原地修改——于是任何一处的修改,都会被所有共享它的地方"看到"。看清"可变 + 共享"这对危险组合,在它们相遇的地方多留个心眼——这是写对 Python(乃至一切有引用语义的语言)的一项核心功底。
第五件事:为什么这个坑这么"隐蔽"
这次最让我后怕的,是这个坑"能骗过初步测试"的隐蔽性。我复盘了它为何如此能藏。
| 原因 | 说明 |
|---|---|
| 语法完全合法 | [[0]*m]*n 没有任何语法/类型错误, IDE不报 |
| print 初值看着对 | 刚建好打印是 [[0,0,0,0],...] 完全符合预期 |
| 只读不出错 | 只读取 grid[i][j] 永远正常, 不写就不暴露 |
| 整体替换也不出错 | grid[0] = [9]*m (替换整行)正常, 掩盖了问题 |
| 只在"原地改里层"暴露 | grid[i][j]=x 这种原地改才触发"全变" |
| 现象反直觉 | "我没动别的行啊", 让人往别处找原因 |
这张表道出了这个坑"能藏这么深"的原因。核心是:它在"创建时""读取时""整体替换时"全都表现正常,只在"原地修改里层元素(grid[i][j]=x)"这一种特定操作下才暴露;而暴露出来的现象("我没碰别的行,别的行却变了")又极其反直觉,让人本能地往别处找原因。它给我的深刻启发是:最危险的 bug,往往不是那种"一上来就报错、让你立刻知道哪错了"的,而是这种"在大多数情况下表现正常、只在特定操作下才暴露、且暴露时现象误导你往错误方向排查"的"潜伏型" bug;它们能轻松骗过"跑一下看着没问题"式的初步验证,潜伏到生产环境、潜伏到数据已经被悄悄污染之后,才以诡异的方式爆发。这让我更加敬畏"测试"这件事:"跑一下、打印出来看着对"远远不是"测试";真正的测试,要专门去覆盖那些"容易出问题的操作路径"(对这个二维数组,就是"原地修改某个格子后,验证其他格子没受影响")、要构造能让潜在 bug "现形"的用例;尤其对那些涉及"共享、状态、可变"的代码,更要主动设计"修改 A 是否意外影响了 B"这类验证。带着"它会不会在某个我没测的路径上藏着雷"的警觉去写测试——这是这个隐蔽的小坑,给我上的一堂关于"测试要测什么"的课。
第六件事:创建集合/复制对象时,我现在的判断习惯
现在每当我要"批量创建"或"复制"一个容器,我都会按这张图先想清楚:
这张图的精髓,是"先问元素可不可变,可变就别用 * 复制,要独立就新建或 deepcopy"。元素不可变(int/str/tuple)随便用;元素可变就警惕——要 n 个独立新对象就用列表推导/循环每个新建,要复制已有结构就看需不需要里层也独立(要就 deepcopy,不要就清楚浅拷贝的里层共享)。这套习惯,让我创建集合/复制对象时,从"图省事用 * 一把梭"变成了"先想元素可不可变、要不要真正独立"——核心始终是:可变对象 + 共享引用 = 一改全改的坑;要独立就显式新建,绝不用 [可变对象]*n。
我立下的几条规矩
这场"二维数组一改全变"的事故,换来了我写 Python 时,刻进骨子里的几条铁律:
- 列表/容器装的是引用,不是对象本身。这是理解一切共享坑的根基。
- * 复制可变对象 = 复制同一引用 n 份。[[0]*m]*n 的 n 行是同一个。
- 建二维数组用列表推导。[[0]*m for _ in range(n)],每行独立新建。
- 可变默认参数用 None 占位。def f(x, acc=None): if acc is None: acc=[]。
- 浅拷贝只拷一层。里层要独立用 copy.deepcopy。
- 数值计算优先 numpy。np.zeros 没有这个坑,还更快。
- 测"改 A 是否影响 B"。涉及共享/可变的代码,专门验证隔离性。
附:一个能让你彻底看懂"标签 vs 盒子"的实验
口说无凭。下面这段代码,把"变量是指向对象的标签、* 复制的是引用"这件事,变成了可以亲眼对比的输出:
import copy
print("=== 实验1: [可变]*n vs 列表推导 ===")
bad = [[0] * 3] * 3 # 用 * 复制行
good = [[0] * 3 for _ in range(3)] # 列表推导, 每行新建
bad[0][0] = 1
good[0][0] = 1
print("bad :", bad) # [[1,0,0],[1,0,0],[1,0,0]] ← 全变了!
print("good:", good) # [[1,0,0],[0,0,0],[0,0,0]] ← 只第0行变 ✓
print("bad 三行是同一个吗:", bad[0] is bad[1]) # True ← 同一对象
print("good三行是同一个吗:", good[0] is good[1]) # False ← 独立对象
print("\n=== 实验2: 赋值 vs 浅拷贝 vs 深拷贝 ===")
a = [[1, 2], [3, 4]]
b = a # 赋值: 同一对象
c = a.copy() # 浅拷贝: 外层新, 里层共享
d = copy.deepcopy(a) # 深拷贝: 彻底独立
a[0][0] = 99 # 改 a 的里层元素
print("a:", a) # [[99,2],[3,4]]
print("b:", b) # [[99,2],[3,4]] ← b就是a, 跟着变
print("c:", c) # [[99,2],[3,4]] ← 浅拷贝, 里层共享, 也变了!
print("d:", d) # [[1,2],[3,4]] ← 深拷贝, 完全独立, 没变 ✓
print("\n=== 实验3: 可变默认参数 ===")
def bad_append(x, acc=[]): # ✗ 危险
acc.append(x); return acc
def good_append(x, acc=None): # ✓ 正确
if acc is None: acc = []
acc.append(x); return acc
print("bad :", bad_append(1), bad_append(2)) # [1] [1, 2] ← 共享! 第二次带着上次的
print("good:", good_append(1), good_append(2)) # [1] [2] ← 每次独立 ✓
# 核心: 跑一遍这三个实验, "变量是标签、*复制引用、浅拷贝只拷一层、可变默认参数共享"
# 这几件事, 就从"抽象的规则"变成了"亲眼可见的输出差异", 一次刻进认知。
这段实验代码,是我这次踩坑后特意写下、并保存在手边的"认知校准器"。它用三组对比,把 Python 里几个最容易因"盒子直觉"而搞错的行为,全都变成了肉眼可辨的输出差异:实验一让你看到 [行]*n 的三行 is 比较是 True(同一对象、一改全变)、而列表推导的是 False(独立、改一个不影响别的);实验二让你看到对同一个嵌套结构,赋值/浅拷贝/深拷贝在"改里层元素"时的三种不同结局(b、c 跟着变,只有 deepcopy 的 d 纹丝不动);实验三让你看到可变默认参数如何在两次调用间"偷偷共享"。这正是我想用这段代码,留给每个 Python 学习者的核心方法:当你对某个"反直觉"的语言行为将信将疑时,最好的办法不是反复读文档里抽象的文字描述,而是写一段最小的对比实验,用 id()、is、和"改一个看另一个变不变"的方式,让语言自己把它的真实行为"跑"给你看。因为"变量-对象模型"这种最底层的东西,光靠"被告知规则"很难真正内化;只有当你亲眼看到那个 True/False 的对比、那个"我改 a 你也变"的现象,这个模型才会以一种具体而牢固的方式,刻进你的直觉。把抽象的语言规则,变成具体的、可对比的运行现象——这份"动手让代码自证"的习惯,是我整个踩坑系列里贯穿始终、理解一切反直觉行为最可靠的法门。对任何拿不准的行为,别空想,写个三五行的对比实验,让答案自己显现。
写在最后
回头看,这场由"[[0]*m]*n"引发的、改一个格子整列全变的事故,真正教给我的,远不止"用列表推导建二维数组"这一个技巧。它让我对 Python(以及一切现代语言)的"变量与对象的关系"这个最底层的模型,有了一次刻骨铭心的认识。我栽跟头,是因为我心里默认了一个"朴素而错误"的模型:我以为"变量/列表元素,就像一个个盒子,直接装着值;[行]*n 就是把这个'盒子里的东西'复制 n 份"。可 Python 的真实模型是:变量和列表元素,都只是"贴在对象上的标签/指向对象的引用";[行]*n 复制的,是"指向同一个对象的标签" n 份,而不是把对象本身复制 n 份。这让我领悟到一个深刻的认知:要真正理解一门语言里"赋值、复制、传参"的行为,就必须先搞清楚它最底层的"变量-对象模型"——变量到底是"装着值的盒子"(值语义),还是"指向对象的标签"(引用语义)?;这个模型,决定了"把 a 给 b、复制 a、把 a 传进函数"时,到底发生了什么——是产生了独立的副本,还是制造了指向同一对象的别名。这其实是一个跨语言的、最值得花时间彻底搞懂的基础:几乎每种语言都要回答"变量是值还是引用、复制是深还是浅、可变对象如何共享"这组问题;把这组最底层的模型搞透,你就能预判无数"看起来诡异"的行为(一改全改、传参被改、拷贝不彻底),而不必每次都靠踩坑去发现。看清"变量是标签、对象在别处、共享的是引用"这个底层真相——这,是我用一次"二维数组一改全变"的事故,换来的、关于 Python、也关于一切编程语言的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写下 [[ ]*m]*n 之前,先愣一下、改成列表推导,那我对着那个"我没动它它却变了"的网格抓耳挠腮的这大半天,就值了。
—— 别看了 · 2026