我用 [[0]*m]*n 飞快地建了个二维数组,往里填一个格子的值,结果整整一列都跟着变了,我对着 Python 列表乘法复制的是引用而不是值这个坑排查大半天的复盘

一个看着人畜无害、却让我对着输出怀疑大半天人生的 Python 坑。需求很普通:做一个棋盘/网格小算法,要一个 n 行 m 列、初始全 0 的二维数组。凭着对 Python 简洁的信赖,我顺手写下自以为最 Pythonic 的一行 grid =

我用 [[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 时,刻进骨子里的几条铁律:

  1. 列表/容器装的是引用,不是对象本身。这是理解一切共享坑的根基。
  2. * 复制可变对象 = 复制同一引用 n 份。[[0]*m]*n 的 n 行是同一个。
  3. 建二维数组用列表推导。[[0]*m for _ in range(n)],每行独立新建。
  4. 可变默认参数用 None 占位。def f(x, acc=None): if acc is None: acc=[]。
  5. 浅拷贝只拷一层。里层要独立用 copy.deepcopy。
  6. 数值计算优先 numpy。np.zeros 没有这个坑,还更快。
  7. 测"改 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我做的 AI Agent 跑短任务都好好的,可一上真实长会话就越来越慢越来越贵,最后直接报 context length exceeded 整个挂掉,我对着每轮把全部历史和工具结果无限塞进上下文排查大半天的复盘

2026-6-2 9:36:00

技术教程

我对一个数字数组调了 sort() 排序,结果 10 竟然排在了 2 的前面,整个榜单顺序全乱,我对着 JavaScript 的 sort 默认按字符串字典序排序这个坑排查大半天的复盘

2026-6-2 9:46:23

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索