☰
Python变量赋值详解:引用、可变对象与拷贝避坑指南
2026/10/8 15:56:48 网站建设 项目流程

刚学Python那阵子,我干过一件特别蠢的事:想建一个3×3的表格,写了matrix = [[0] * 3] * 3,然后往matrix[0][0]里塞了一个数,一打印发现整列都变成了这个数。当时我以为Python的乘法写错了,后来才明白,真正的问题出在“Python中变量之间赋值”这件事上。这大概是Python新手最容易绕进去、也是工作里埋bug最多的一个点:a = b到底是复制了一份数据,还是只是让a变成了b的另一个名字?

这篇文章想把Python变量赋值的完整逻辑彻底讲透。我会从对象引用这个最底层概念开始,对比C语言里的变量模型,然后讲可变对象和不可变对象在赋值时的差别、浅拷贝和深拷贝怎么选、函数传参背后那套赋值规则,以及变量作用域里那些让人翻车的细节。不管你是刚接触Python的新人,还是被列表、字典“自己悄悄变掉”折磨过的老手,读完应该都能建立一套稳定不晃的心智模型。

1. 变量到底是什么:先扔掉C语言里的“盒子”概念

1.1 等号不是在“存储”,而是在“贴标签”

Python官方文档里有个很经典的说法:变量名是对象的“名字”。更准确一点说,变量名像是贴在对象上的标签,而不是存放对象的盒子。

执行a = [1, 2, 3]的时候,Python先创建一个列表对象[1, 2, 3],然后把标签a贴到这个对象上。再执行b = a,并没有创建新列表,而是把标签b也贴到了同一个对象上。看段代码感受一下:

a = [1, 2, 3] b = a print(id(a)) # 例如 140560117431360 print(id(b)) # 和上面完全相同 print(a is b) # True

id()是Python内置函数,返回对象在内存中的唯一身份标识。上面两个id完全一样,说明a和b指向的根本就是同一个列表对象。你可以把对象想象成门牌号唯一的房子,a和b只是挂在这套房子门口的两块门牌。

这个心智模型非常重要。很多人学Python时脑子里还是C语言那套“变量就是一个装数据的盒子”,于是看到b = a就默认“把a盒子里的内容复制到b盒子”,后面所有的困惑都从这里开始了。

1.2 和C语言对比:Python变量更像指针,而不是盒子

在C语言里写这两行:

int a = 5; int b = a;

内存里出现两个独立的int变量,各自占4个字节,值都是5。你改b,a纹丝不动。所以C程序员天然地会把“赋值”理解成“数据的拷贝”。

但Python不是这样。Python的变量更像是C语言里的“指针变量”:它存的不是数据本身,而是对象的地址。执行b = a,是把a里保存的那个对象地址复制一份给b,两个变量指向同一个对象。

不过也不能把Python变量等同于C指针,区别还是挺大的:

对比项C指针变量Python变量
类型有类型,如int*、char*无类型,同一个变量可以先后指向任意对象
操作支持指针运算、解引用不能做任何指针运算,也不需要解引用
内存管理手动管理由解释器自动管理,基于引用计数等GC机制

我的建议是:把Python的变量理解成“自动托管的指针变量”,既保留“指向对象”的直觉,又不会被C的指针规则带偏。

1.3 用id()和is看本质:判断两个变量是不是同一个对象

既然变量存的是对象的引用,那最直接的验证办法就是看它们是不是引用了同一个对象。Python提供了两个利器:id()和is。

  • id(obj):返回对象唯一身份标识,CPython里就是对象在内存中的地址。
  • a is b:等价于id(a) == id(b),判断两个变量是否指向同一个对象。
  • a == b:判断两个变量指向的对象“值”是否相等。
a = [1, 2, 3] b = a c = [1, 2, 3] print(a is b) # True,同一个对象 print(a is c) # False,不同对象 print(a == c) # True,内容一样

这三者的区别,是Python面试里出现频率极高的问题。因为[1, 2, 3]和[1, 2, 3]内容一样,所以==是True;但它们是两个独立创建出来的对象,所以is是False。

还有一个很容易迷惑人的现象:小整数缓存。CPython会把-5到256之间的整数提前建好,放在一个缓存池里。于是:

x = 256 y = 256 print(x is y) # True,因为命中了缓存池 x = 257 y = 257 print(x is y) # 大概率 False,各创建了新对象

这是CPython在实现层面的优化,不是语言规范,所以平时千万别用is去比较整数,老老实实用==。我调试代码时怀疑两个变量“莫名其妙一起变”,第一件事就是打印它们的id,基本一打一个准。

2. 可变对象和不可变对象:赋值行为分水岭

2.1 一张表分清可变与不可变类型

Python里的对象分两大类:可变(mutable)和不可变(immutable)。这个区分直接决定了赋值后“改一个变量会不会影响到另一个变量”。

类别常见类型典型场景
不可变int,float,bool,complex,str,tuple,frozenset,bytes数值计算、字符串处理、固定配置
可变list,dict,set,bytearray, 自定义类的实例数据集合、映射关系、状态容器

“不可变”的意思是:对象创建后,它内部的内容就不能被修改了。你只能通过“重新绑定”让变量指向另一个新对象。“可变”则允许你直接在原对象上增删改。

这里有个容易忽略的细节:tuple本身不可变,但如果它的元素里有list,那个list仍然可以被修改。所以“tuple是不可变的”和“tuple的里面永远不变”是两码事。

2.2 不可变对象的赋值:改一个不影响另一个

先看最经典的数值例子:

x = 10 y = x y = 20 print(x) # 10 print(y) # 20

按“标签”模型理解就非常顺:x = 10让x指向整数对象10;y = x让y也指向10;y = 20只是做了一次重新绑定,让y指向新建的整数对象20。x始终还指向10,所以打印x依然是10。

字符串也一样:

s1 = "hello" s2 = s1 s2 += " world" print(s1) # hello print(s2) # hello world

+=在这里创建了一个新的字符串对象,然后重新绑定给s2。s1还指向原来的"hello"。

关键是理解这一点:对于不可变对象,你永远无法“修改”对象本身。你能做的只是换一个标签。所以“赋值之后一个变量被改了,另一个也跟着变”这种场景,在不可变对象身上根本不会发生。

2.3 可变对象的赋值:共享引用的别名陷阱

这才是真正容易出事故的地方。看这段代码:

a = [1, 2, 3] b = a b.append(4) print(a) # [1, 2, 3, 4],你根本没碰过a,但它变了

问题就出在b = a。它让b和a指向同一个列表对象。b.append(4)是对这个列表对象做原地修改,所以通过a这个标签去看同一个对象,内容自然是[1, 2, 3, 4]。

字典也一样:

user = {"name": "张三"} admin = user admin["role"] = "admin" print(user) # {'name': '张三', 'role': 'admin'}

admin["role"] = "admin"是在原字典对象上新增键值对,不是在替换admin这个标签。共享同一个对象的标签全都会看到这个变更。

打个比方:一份云文档,你和同事拿到的是同一个在线链接,不是两张独立拷贝。同事在网页里改了内容,你刷新后看到的自然是改完的版本。这个场景里,“链接”就是对象引用,“刷新后看到变化”就是变量共享引用后的表现。

很多人在Web开发里遇到的“怎么配置字典传出去之后被改得乱七八糟”,十有八九都是这个原因。

2.4 再给变量赋值 vs 修改对象内容:别搞混

可变对象赋值之后,有一个特别容易混淆的操作要单独拎出来说:给变量重新绑定一个新对象,和修改原对象的内容,是完全不同的两件事。

lst = [1, 2] print(id(lst)) # 1405... lst = lst + [3] # 加法创建了新列表 print(id(lst)) # 地址变了,lst 指向新对象 lst2 = [1, 2] print(id(lst2)) # 1405... lst2 += [3] # 列表的 += 相当于就地 extend print(id(lst2)) # 地址不变,还是原来那个对象

这里有个让无数人栽过跟头的隐藏规则:+=对不可变对象(如整数、字符串)是“创建新对象再重新绑定”,对可变对象(如列表)却是“原地修改”。同样是+=,背后的行为完全不同。

在共享引用的场景下,这个区别会成倍放大:

a = [1, 2] b = a a += [3] # 原地扩展,b 也受影响 print(b) # [1, 2, 3] a = [1, 2] b = a a = a + [3] # 重新绑定新对象,b 不受影响 print(b) # [1, 2]

所以写代码时心里要有个标准动作:想清楚你到底要“换一个新对象给变量”,还是“往旧对象里塞东西”。前者是重新绑定,后者是原地修改。这两个动作对共享的变量来说,后果天差地别。

3. 从赋值到拷贝:三个实操技巧隔离数据

3.1 浅拷贝:一层隔离,里层仍在共享

如果赋值会共享引用,那怎么才能拿到一份“独立的副本”?答案是拷贝。Python里最常用的是copy.copy(),也就是浅拷贝。

浅拷贝会创建一个新对象,然后把这个新对象里的元素,设置为原对象里元素的引用。用人话说就是:最外面这层壳是新造的,但壳里面的东西还是原来那些。

import copy a = [[1, 2], [3, 4]] b = copy.copy(a) b[0].append(99) print(a) # [[1, 2, 99], [3, 4]]

这里最外层a和b确实是两个不同的列表了,但a[0]和b[0]指向的还是同一个内层列表[1, 2]。所以b[0].append(99)修改了共享的内层对象,a也就跟着变了。

常用的浅拷贝写法其实很多:

lst1 = [1, 2, 3] lst2 = lst1[:] # 切片 lst3 = lst1.copy() # list.copy lst4 = list(lst1) # 构造器 d1 = {"a": 1, "b": [2, 3]} d2 = d1.copy() # dict.copy 也是浅拷贝 d3 = dict(d1)

一维列表用上面任何写法都没问题:修改lst2[0],lst1不受影响。因为lst2是新列表,虽然元素也是引用,但整数是不可变对象,你改lst2[0]实际上是重新绑定,不会动原来的整数对象。

但二维列表用切片就要小心了:外层独立,内层还是同一个。

3.2 深拷贝:彻底断开所有关系

要彻底隔离,就用copy.deepcopy()。它会递归地复制整个对象树,不仅外层是新的,内层的列表、字典、集合等可变对象全都会创建一个新副本。

import copy a = [[1, 2], [3, 4]] b = copy.deepcopy(a) b[0].append(99) print(a) # [[1, 2], [3, 4]] print(b) # [[1, 2, 99], [3, 4]]

这次a一点没动。深拷贝之后,两边的对象树彻底分家,怎么折腾都不影响对方。

深拷贝虽好,但不能滥用。递归复制整个结构非常耗时,尤其数据量大的时候,几分钟的任务会硬生生拖成几十分钟。如果对象里有文件句柄、网络连接、锁这类不能复制的资源,深拷贝还可能会直接报错或者产生一堆奇怪的副本。所以我的原则是:只有浅拷贝不够用的时候,才上深拷贝。

对于自己写的类,还可以实现__copy__和__deepcopy__方法来自定义拷贝行为,不过这是进阶用法,遇到特殊需求再研究就行。

3.3 邻接矩阵、列表推导式和切片:最常踩坑的拷贝场景

这里要讲一个几乎人人踩过的坑。先看这段代码:

n = 3 matrix = [[0] * n] * n matrix[0][1] = 1 print(matrix) # [[0, 1, 0], [0, 1, 0], [0, 1, 0]]

你只想改matrix[0][1],结果整列全变成了1。原因就是[[0] * n] * n先用[0] * n创建了一个行对象,然后把这个行对象重复引用了3次。也就是说,三行其实是同一个列表的三个标签。

正确写法是用列表推导式,让每一行都是新创建的对象:

matrix = [[0] * n for _ in range(n)] matrix[0][1] = 1 print(matrix) # [[0, 1, 0], [0, 0, 0], [0, 0, 0]]

学“Python构建邻接矩阵”的时候,这几乎是必考题:图论算法里如果邻接矩阵的行被共享了,那整张图都会乱套。Python题解里常见的错误写法就是这个[[0] * n] * n,一提交就WA(Wrong Answer)。

再看切片。很多人以为lst2 = lst1[:]之后,两个列表就“完全没关系”了。一维列表这样说问题不大,但二维列表立刻露馅:

a = [[1, 2], [3, 4]] b = a[:] b[0][0] = 99 print(a) # [[99, 2], [3, 4]]

切片本身是浅拷贝,它只复制了外层引用。遇到嵌套结构,必须用deepcopy或自己重新构造每一层。判断不了的时候有个笨办法:打开这类操作之前,先问自己“里面还有没有可变对象”。有,那浅拷贝大概率不够。

3.4 先想清楚:这个场景真的需要拷贝吗

不是所有赋值都需要拷贝,有些场景共享引用反而是正确的。

举个例子,一个全局配置字典,多个模块都需要读取;你希望某处修改后所有地方都看到最新值,那直接赋值共享就是合理设计。再比如一个大列表需要传给多个函数做只读遍历,你也没必要每次deepcopy一遍,白白消耗内存和时间。

我的选择建议是:

  • 只是读取,不修改:直接赋值,别拷贝。
  • 需要独立快照,且数据结构简单:浅拷贝足够。
  • 需要独立快照,且内部还有嵌套可变对象:用深拷贝。
  • 每次都改完还要把原数据留着:优先考虑先拷贝再操作,而不是提心吊胆地原地修改。

最怕的是那种“看着像副本,实际是引用”的代码。宁可多写一行copy.deepcopy,也别让两个列表在暗地里纠缠不清。

4. 函数参数传递:赋值规则的另一张考卷

4.1 传参的本质是“把对象引用赋值给形参”

理解了变量赋值之后,Python的函数参数传递就会豁然开朗。因为函数调用实质上就在执行一条隐藏的赋值语句:形参 = 实参。

def func(arg): # 进入函数体时,解释器相当于执行了 arg = 实参 pass

所以前面学到的所有赋值规则,在函数传参里全部成立。传进来的是不可变对象,形参被重新绑定时,外部实参不受影响;传进来的是可变对象,形参在函数体里做原地修改,外部实参自然跟着变。

Python官方其实很少称“值传递”或“引用传递”,更准确的叫法是“传对象引用”,也有人叫“传共享对象”。不管哪种叫法,本质都一样:传递的是对象的引用,不是对象的拷贝。

4.2 传不可变参数:函数里改了不影响外面

def increment(x): x += 1 return x num = 10 increment(num) print(num) # 10

函数内部x += 1生成了一个新的整数对象11,然后让形参x指向它。外部的num从头到尾都指向10,所以不受影响。

这也是初学者最容易疑惑的地方:明明函数内部已经做了加法,外面为什么没变?原因就是不可变对象身上不存在“原地修改”这条路。想让函数修改后的值传到外部,最直接的办法是把新值返回出来,再重新赋给外部变量:

num = increment(num) print(num) # 11

这里的num = increment(num),本质上又是一次变量赋值。函数返回的是新对象,赋值让num指向了它,旧的对象10在没人引用之后会被垃圾回收。

4.3 传可变参数:函数里改了外面也变

def add_item(lst, item): lst.append(item) data = [1, 2] add_item(data, 3) print(data) # [1, 2, 3]

这次就完全不同了。lst.append(item)是对列表对象做原地修改,lst和data指向同一个对象,所以外部data也被改了。

这个特性用对了可以很省事,比如写一个批量处理函数,直接往传入的列表里汇总结果。但也很容易成为隐形的数据污染源:一个函数本来只想内部用一下列表,结果把调用方的数据给改了。

如果希望函数内不要影响外部数据,有两个思路:

  1. 函数内先拷贝再操作:
def safe_add_item(lst, item): copied = lst[:] # 或 copy.deepcopy(lst) copied.append(item) return copied
  1. 明确约定函数只返回新对象,绝不动参数:
def add_item(lst, item): return lst + [item]

在实际团队协作里,约定比技巧更重要。函数签名旁边最好写清楚“这个函数会修改传入的列表吗”,避免调用者猝不及防。

4.4 默认参数陷阱:可变对象作为默认值是经典Bug

Python有一个极其经典的坑,几乎所有面试题都会考:

def add_to_list(item, container=[]): container.append(item) return container print(add_to_list(1)) # [1] print(add_to_list(2)) # [1, 2],第二次调用竟然变成了两个元素 print(add_to_list(3)) # [1, 2, 3]

三次调用都没有传container,按理说应该每次都用默认的空列表,但结果却在累积。原因是:默认参数在函数定义时只会被求值一次。那个空列表[]在定义函数时就已经创建好了,之后每次调用如果不传container,用的都是同一个列表对象。

这和我们前面讲的可变对象共享引用是一回事,只是这次共享发生在“默认参数”这个隐藏变量上。

正确写法是用None作为哨兵值,在函数内部再创建新列表:

def add_to_list(item, container=None): if container is None: container = [] container.append(item) return container print(add_to_list(1)) # [1] print(add_to_list(2)) # [2]

所以有一条铁律:函数的默认参数不要写可变对象,[]、{}、set()统统不要写。谁写谁踩坑。

5. 赋值与变量作用域:名字在哪里生效

5.1 LEGB规则:按什么顺序找变量

赋值之后,变量在哪里能被看到,由作用域决定。Python查找变量名字的顺序是:Local(局部)→ Enclosing(外层嵌套函数)→ Global(模块全局)→ Built-in(内置)。

x = "global" def outer(): x = "enclosing" def inner(): x = "local" print(x) # local inner() outer()

这段代码里三层作用域各有一个x,inner内部打印时会先找自己的局部变量,找到了就用局部那个。

但这里有一个极其容易踩的隐藏规则:在Python里,如果函数体某处对某个变量名做了赋值,那么整个函数体内这个变量都会被当成局部变量。这句话怎么理解?看下一个例子。

5.2 函数内赋值却报UnboundLocalError:因为global没写

value = 100 def show(): print(value) # 想读全局变量 value = 200 # 但是后面又对 value 赋值了 show() # UnboundLocalError: cannot access local variable 'value' where it is not associated with a value

这段代码会直接报错。原因就是Python在编译函数时发现show里有了value = 200,于是把整个函数内的value都标记为局部变量。print(value)想读取局部value,但局部变量在赋值之前根本不存在,就报“未绑定”的错误。

想修改全局变量,必须显式用global声明:

count = 0 def increase(): global count count += 1 increase() increase() print(count) # 2

这里再解释一个很多人搞混的点:如果全局变量是可变对象,函数里原地修改它,其实不需要global:

items = [] def add_item(item): items.append(item) # 不报错,因为这里没有重新绑定 items add_item("a") print(items) # ['a']

区别在于:items.append(item)没有给items做赋值,只是调用对象方法修改对象内部;而count += 1等价于count = count + 1,它给count做了重新赋值,所以必须声明global。

5.3 嵌套函数里想改外层变量:用nonlocal

global管的是模块级变量。如果是在嵌套函数里想修改外层函数里的局部变量,就要用nonlocal。最典型的应用是闭包计数器:

def make_counter(): count = 0 def increment(): nonlocal count count += 1 return count return increment counter = make_counter() print(counter()) # 1 print(counter()) # 2

increment里的count += 1是在给count重新赋值,它想改的是make_counter作用域里的count。没有nonlocal的话,increment会因为同样的“局部变量未绑定”规则直接报错。

nonlocal和global的区别一句话就能讲清:global跳到模块全局,nonlocal跳到最近的外层函数作用域。

5.4 模块级、局部、全局之间的选择建议

函数里直接引用全局变量,代码写起来省事,但隐患很大。全局变量是隐式的共享状态,一个函数改了它,另一个函数读到的值就变了,排查问题时你根本不知道是谁动的手脚。

我的建议是:

  • 能用参数和返回值传递数据,就不要用全局变量。
  • 函数需要修改容器,优先返回新容器,而不是在函数里靠global改一个全局容器。
  • 真正需要全局共享的配置,可以放到模块级但用大写命名,并明确约定只读。
  • 嵌套函数里要累计状态,用闭包加nonlocal,比全局变量安全可控。

这些作用域规则本质上就是在回答“这个名字在哪里有效”的问题。C++里要区分成员变量、局部变量、全局变量,Python里要区分局部、外层、全局、内置,思路是相通的,只是语法和查找方式不同。

6. 赋值相关Bug排查速查表与调试技巧

6.1 最常复现的四个Bug场景

场景一:矩阵行被共享

n = 3 matrix = [[0] * n] * n matrix[0][1] = 1 # 居然整列都变成了 1

原因:多行是同一个对象。应对:用列表推导式[[0] * n for _ in range(n)]。

场景二:字典被意外“加字段”

config = {"debug": False} temp = config temp["debug"] = True # config 跟着变成了 debug: True

原因:temp和config是同一本字典的两个名字。应对:如果连改都不能影响原字典,就config.copy()或copy.deepcopy(config)。

场景三:函数悄悄改了调用方的列表

def clean(data): for i in range(len(data)): data[i] = data[i].strip() records = [" a ", " b "] clean(records) # records 被原地修改了,调用方数据被污染

原因:函数内原地修改可变对象。应对:不希望被改就传入副本,或函数内先copy。

场景四:默认参数累积

def func(x, lst=[]): lst.append(x) return lst

原因:默认列表被所有调用共享。应对:默认值写成None。

6.2 速查表:现象、原因、解决方案

现象根本原因解决方案
改了b,a也跟着变b = a共享同一可变对象按需使用切片、copy()、deepcopy()
函数里改数字,外面没变不可变对象重绑定,旧对象没动把新值return出来再重新赋值
函数里改列表,外面也变函数内对可变对象做原地修改函数内先拷贝,或改为返回新列表
默认参数不断累积数据默认可变对象在函数定义时创建一次默认参数改写成None,内部再初始化
[[0]*n]*n的矩阵整行联动所有行引用同一个列表对象用列表推导式每次新建一行
两个内容相同的列表is却是False它们是两个不同的对象用==判断内容,用is判断身份
函数内读取全局变量却报未绑定函数内后面有赋值,该名字成了局部变量声明global,或避免读取同名全局变量

这张表我建议直接收藏。遇到“变量莫名其妙变了”的bug,先对着这张表查一遍,大概率能快速定位。

6.3 调试技巧:用id()和sys.getrefcount()追踪引用

排查引用类问题有两个非常好用的工具。第一个就是反复强调的id():

print(id(a)) print(id(b))

一旦两个id相同,就说明它们是同一个对象,共享引用坐实了。

第二个是sys.getrefcount(),用来查看对象的引用计数:

import sys a = [1, 2, 3] print(sys.getrefcount(a)) # 2 b = a print(sys.getrefcount(a)) # 3

注意getrefcount本身会临时增加一次引用,所以多出1是正常的。这个工具在追踪“对象为什么没被垃圾回收”时特别有用,但在日常调试共享引用时用id就足够了。

另一个经验是:复现问题一定要做“最小化”。把一个复杂的业务逻辑删到只剩十几行,把可疑的赋值关系单独抽出来跑一遍,很多谜底会瞬间清晰。不要在几千行代码里猜,猜是猜不出来的。

6.4 一些从实践里总结的编码建议

结合我踩过的坑,最后总结几条能直接写进团队规范的编码建议。

第一,可变对象不要随便塞进默认参数。这个前面说过了,是硬伤级错误。

第二,函数返回值要统一。要么总是返回新对象,要么总是原地修改并用None表示无返回值。混着来最坑人:调用者根本不知道这次调用会不会污染自己的数据。

第三,构建矩阵、二维数组这类复合结构时,一律用列表推导式或循环,不要用乘法复用。

第四,给变量起名要有区分度。两个共享同一对象的变量,名字上最好能体现“引用”关系,比如config和config_alias。如果取成temp、tmp、data2,那别人看代码时根本不知道它们是不是同一个东西。

第五,如果想练熟“重赋值”的直觉,可以拿“李白打酒”那类递推题练手。题目里酒量要反复执行“遇店加倍、遇花减一”,本质上就是不断地wine = wine * 2 - 1这类重新绑定。多绕几遍,对变量赋值的理解会明显变扎实。

我在实际项目里被这些变量赋值问题坑过不止三次,最隐蔽的一次是配置字典被多个模块共享,A模块往里面塞了一个临时字段,结果B模块打印配置时莫名其妙多出一项。排查了很久才发现只是赋值共享引用的典型事故。后来我养成了一个习惯:每把一个可变对象交给另一个函数或另一个变量之前,先问自己一句“这里是要共享,还是隔离”。要共享就直接赋值,要隔离就copy()或deepcopy(),没有第三种情况。日常写代码时可以多打印几次id,尤其是数据仿佛“自己变了”的时候,一打一个准。这套关于“Python中变量之间赋值”的思维模型一旦建立起来,后面再看列表推导、函数式编程、闭包、装饰器,都会顺很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询