先抛一个问题:a = [1, 2, 3]之后执行b = a,然后a.append(4),请问b打印出来是[1, 2, 3]还是[1, 2, 3, 4]?
我每次拿这个问题问刚学 Python 的朋友,十有八九会答错,剩下答对的人里,又有大半说不出原理。这不是大家笨,而是因为大多数人最初学 Python 变量赋值时,脑子里装的是其他语言的"盒子模型"——把变量当成装数据的盒子,赋值就是把数据复制一份搬到另一个盒子里。放在 Python 里,这个模型从一开始就是错的。
这篇文章就把"Python 中变量之间赋值"这件事彻底掰开揉碎。我会从变量和对象的底层关系讲起,再聊可变对象和不可变对象在赋值行为上的巨大差异,接着覆盖函数传参、浅拷贝深拷贝、常见坑点和自查技巧。不管你是刚写 Python 一两周的新手,还是已经写了半年的初学者,只要把这套机制想明白,以后调试代码时至少能少踩一半的坑。
1. 变量赋值的底层运行机制
1.1 先破除"盒子"思维
C 语言里定义一个变量,相当于在内存里划出一块区域,变量名就是这块区域的地址别名。你往变量里塞值,就是在往这块区域里写数据。做b = a,本质是"将 a 这块内存里的内容复制到 b 这块内存",两个变量各自独立,后面再改 a,b 纹丝不动。
Python 完全不是这套玩法。在 Python 的世界里,变量不是盒子,而是贴在对象上的标签。你写的每一行赋值语句,做的动作都不是"把数据放进变量",而是"把一个名字绑定到一个已经存在的对象上"。
看这段代码:
a = 5 b = a a = 10 print(b) # 5如果用 C 的思维,b = a会把 5 这个值复制一份给 b,所以后面a = 10不影响 b,输出 5。
如果用 Python 的思维,流程是:5这个整数对象先在内存里创建,名字a绑定到它;执行b = a,本质是让b也绑定到同一个对象5上;执行a = 10,a这个标签被撕下来,重新绑定到另一个整数对象10上。全程没有任何一个对象被修改,5始终是5,标签b从头到尾都贴在5上,所以 b 输出 5。
关键点:赋值改变的是"名字指向谁",而不是"对象本身变成什么"。想验证这一点非常简单,用内置函数id()看对象的身份证号:
a = 5 b = a print(id(a), id(b)) # 两个数字完全相同id()返回对象在内存中的唯一标识(CPython 里就是对象的内存地址)。看到两个变量id一样,就能百分之百确认它们绑定的是同一个对象。
1.2 从"指针变量"看 Python 变量的本质
不少人在学 C 语言时接触过"指针变量"这个词,到了 Python 里反而糊涂了。其实可以这么理解:Python 的每个变量,底层都像一个"指针变量"——它保存的不是对象本身,而是对象在内存中的位置信息。但你不需要手动解引用,解释器帮你把变量名 -> 对象的引用这个映射关系维护好了。
但是别把 Python 变量和 C 指针完全画等号。C 的指针有明确的类型,可以自增自减,可以做各种地址运算;Python 的变量没有类型限制,同一个名字今天可以绑整数,明天绑列表,后天绑一个函数对象。它只是当前作用域里查名字的字典条目。
这里有个很实际的现象:Python 会为不同作用域维护一个"名字到对象"的映射表。当你写b = a的时候,做的事情就是在当前作用域的映射表里新增一个条目,条目的值指向与a相同的对象。
a = [1, 2] b = a print(a is b) # Trueis运算符比较的正是两个名字是否指向同一个对象,也就是"两个名字对应的 id 是否一致"。True说明标签a和b贴在了同一个列表对象上。
理解了"变量是标签"这个底层模型之后,接下来要看一个让很多人崩溃的问题:为什么整数这种赋值改一个不影响另一个,而列表这种赋值改了一个却连带另一个?答案藏在对象的可变性上。
2. 不可变对象与可变对象的行为差异
2.1 整数、字符串、元组为什么看起来是"值传递"
Python 对象分两类:不可变对象和可变对象。整数、浮点数、字符串、元组、布尔值,都属于不可变对象。不可变的意思是:对象一旦创建,内容永远无法被修改。
拿整数举例:
x = 10 y = x x = x + 1 print(y) # 10x = x + 1这里看似修改了 x,实际流程是:先计算10 + 1,在内存里产生一个新的整数对象11,然后把标签x从10上撕下来,贴到11上。10这个对象本身从头到尾没变化,标签y还贴在那儿,所以 y 是 10。
字符串同理:
s1 = "hello" s2 = s1 s1 = s1 + " world" print(s2) # "hello""hello"对象没有被修改,s1 = s1 + " world"只是新建了"hello world"对象让 s1 指向它。因为对象不可变,所以不管你怎么操作,都不可能做到"通过 s1 修改 s2 绑定对象的内容"。这种确定性给了程序很大的安全性。
元组也是不可变对象,但它有一个特别容易迷惑人的点:元组里如果装了可变对象,比如列表,那么元组本身的"不可变"只是指不能增删改元组元素,却挡不住元组里那个列表被原地修改:
t = (1, [2, 3]) s = t t[1].append(4) print(s) # (1, [2, 3, 4])标签t和s指向同一个元组对象,元组里的第二个元素又是一个列表对象,这个列表可以通过append原地修改。所以"不可变对象"这个说法要准确理解:它指的是对象的结构不可变(不能给元组添加或删除元素),不保证嵌套在内的可变对象不被改动。这是一个很深的坑,后面讲拷贝时还会再碰到。
2.2 列表、字典、集合为什么"一改全改"
现在看可变对象的典型代表——列表:
a = [1, 2, 3] b = a b.append(4) print(a) # [1, 2, 3, 4]b.append(4)做的事情和重新赋值完全不同。append是列表对象自带的方法,它在对象内部新增了一个元素,没有产生新对象,也没有让 b 改为指向别的东西。b和a本来就指向同一个列表对象,这个对象内部发生了变化,所以透过a这个标签看到的列表自然也跟着变了。
这就像一个公寓楼:a和b都是写着"1号楼"的门牌,你通过 b 门牌找到楼,给楼里加了一层,那 a 门牌找到的还是同一栋楼,当然也能看到新加的那层。
字典和集合同理:
d1 = {"name": "张三"} d2 = d1 d2["age"] = 18 print(d1) # {'name': '张三', 'age': 18} s1 = {1, 2} s2 = s1 s2.add(3) print(s1) # {1, 2, 3}方法update、add、append、extend、pop、remove、clear都是原地修改。而普通赋值运算符=永远是"换标签"。分辨这两类操作,是理解 Python 赋值行为的核心能力。
还有一个必考的细节——增强赋值运算符+=。同样是+=,对不同对象行为完全不同:
# 列表的 += a = [1, 2] b = a a += [3] print(b) # [1, 2, 3],原地修改 # 整数的 += x = 5 y = x x += 1 print(y) # 5,重新绑定原因很简单:可变对象实现了__iadd__方法,+=触发原地修改;不可变对象没有__iadd__,Python 退化成a = a + 增量,即先算出新对象再重新绑定。很多人在代码里写着a = a + [x]和a += [x]却不知道它们有区别,前者构造新列表,后者能复用原有列表。了解这个差异,在性能敏感的循环里会很有用。
理解了可变性差异,接下来进入本文最容易被忽视的角落:函数传参时,变量之间的赋值关系会怎么表现。
3. 函数参数传递与变量作用域
3.1 参数传的是"对象引用",不是值也不是变量
网上争论最多的一个问题:Python 函数传参到底是值传递还是引用传递?答案是都不是,准确说法是"对象引用传递"(call by object reference)。白话讲:函数参数拿到的,是实参变量所指对象的标签的"复印件"。
什么意思?看代码:
def change(lst): lst.append(999) a = [1, 2, 3] change(a) print(a) # [1, 2, 3, 999]调用change(a)时,形参lst复制了a这个标签的绑定关系,也就是说lst和a都指向同一个列表对象。函数体里lst.append(999)修改的是对象本身,所以外部a看到的列表也被改了。
但如果在函数里做的是重新赋值,情况就变了:
def reassign(lst): lst = [100, 200] # 把形参标签撕下来,贴到新列表上 a = [1, 2, 3] reassign(a) print(a) # [1, 2, 3],外部没变化lst = [100, 200]只是让局部变量lst指向一个新对象,原列表对象纹丝未动,a标签还在老地方。这就是"引用传递"和"指针传递"的区别:Python 没有传"变量的地址"进去,传的是"对象引用的值",函数内部重新给形参赋值,不会改变实参的绑定关系。
这个特性经常让初学者写出"以为会修改外部变量"的代码:
def add_one(num): num = num + 1 x = 5 add_one(x) print(x) # 5,因为整数不可变,num = num + 1 只是重新绑定局部标签所以结论可以简单记:如果函数内部对形参调用原地修改方法(append、add、update、sort、reverse 等),外部可变对象会受影响;如果函数内部直接用=给形参赋值,外部永远不会受影响。
3.2 全局变量、局部变量与赋值的纠缠
再往深走一步,函数内用=赋值,还会引发变量作用域的分歧。先看一条经典报错:
x = 10 def func(): x = x + 1 # UnboundLocalError: local variable 'x' referenced before assignment报错原因很反直觉。Python 编译器在编译函数体时,发现x = ...这个赋值操作,就断定x是函数的局部变量。既然是局部变量,那么x = x + 1右边的x也被当成局部变量,而此时它尚未被赋值,于是报错。
这个机制揭示了赋值语句在 Python 中的一个隐藏作用:赋值决定了名字属于哪个作用域。一个名字在函数体任何位置出现赋值,整个函数体内对这个名字的所有引用都按局部变量处理。
如果确实想在函数里修改全局变量,需要用global声明:
x = 10 def func(): global x x = x + 1 func() print(x) # 11嵌套函数里想修改外层函数的变量,则用nonlocal。很多老手写代码时不建议动不动用global,因为全局可变状态会让模块之间的隐式耦合越来越重。一个更常见的替代方案是把要修改的状态放进可变对象里,比如字典或列表,然后在不重新赋值的情况下原地修改。但这种方案也会埋下"共享可变对象"的隐患,后面讲拷贝的时候要特别留意。
作用域问题不只体现在函数与全局之间,还体现在循环和列表推导式里。Python 的for循环不会创建新的作用域,循环变量在循环结束后依然存在;而列表推导式在 Python 3 里有自己的局部作用域,不会泄漏变量名。这种细微差异配合赋值,有时候会变成隐蔽的 bug。
4. 赋值、浅拷贝与深拷贝的正确姿势
4.1 如何创建一个真正独立的副本
前面说的b = a只是让 b 和 a 共享同一个对象。如果我们确实需要两个独立的对象,事情就变得微妙起来——因为"拷贝"有深浅之分。
先说浅拷贝。浅拷贝会创建一个新对象,然后把原对象里的元素引用复制一份到新对象里。对新对象的结构做增删,不会影响原对象;但是新对象里的元素如果也是可变对象,那么它们仍然指向原对象里那些元素。
浅拷贝有几种常见写法:
a = [1, 2, 3] b = a[:] # 切片 c = list(a) # 构造新列表 d = a.copy() # 列表自带 copy 方法上面三种效果等价,b is a、c is a、d is a都是False,说明创建了新对象。对一维列表来说,浅拷贝已经完全够用:
a = [1, 2, 3] b = a[:] a.append(4) print(b) # [1, 2, 3],互不影响但二维列表就翻车了:
a = [[1, 2], [3, 4]] b = a[:] # 浅拷贝 b[0].append(99) print(a) # [[1, 2, 99], [3, 4]]b[0]和a[0]指向的是同一个内部列表对象,所以append直接穿透到 a。浅拷贝只复制了外层容器,内层元素依然是共享引用。
要彻底解决第二层及更深层的共享问题,必须用深拷贝:
import copy a = [[1, 2], [3, 4]] b = copy.deepcopy(a) b[0].append(99) print(a) # [[1, 2], [3, 4]],完全不受影响deepcopy会递归遍历整个对象图,为每一层新建独立对象。代价是速度慢、可能占用更多内存,而且有些对象(比如文件句柄、线程锁)无法深拷贝。所以深拷贝要用在刀刃上,不要无脑对所有对象上来就deepcopy。
4.2 有趣的陷阱:copy.copy和copy.deepcopy的选型
copy模块里还有copy.copy,做的是浅拷贝,和a[:]性质一样。当你面对一个不确定类型的容器时,copy.copy是比切片更通用的浅拷贝方案,因为切片只对列表这类序列有效,而copy.copy对字典、集合和自定义类的实例都有效。
字典的浅拷贝特别迷惑人:
import copy d1 = {"info": {"age": 18}} d2 = d1.copy() # 浅拷贝 d2["info"]["age"] = 30 print(d1) # {'info': {'age': 30}},被穿透了这里d1.copy()创建了新字典对象,但新字典里的"info"键仍然指向原来的字典对象。修改内层["age"],d1 能看到变化。如果不想让嵌套内容共享,依然需要deepcopy。
我在实际项目里总结过一个选型规则:
| 场景 | 推荐做法 |
|---|---|
| 只需要一层容器,且元素全是不可变对象(整数、字符串) | 浅拷贝(a[:]、list(a)) |
| 确定需要完全隔离,且对象图不复杂 | copy.deepcopy(a) |
| 嵌套结构中有不希望被拷贝的特殊对象 | 手工深层构建,避免用 deepcopy |
| 只是想传递数据,不关心后续是否互相影响 | 直接用b = a,不需要拷贝 |
经验之谈:很多数据流型的业务代码里,其实根本不要求拷贝,大家因为担心"互相影响"而无脑 deepcopy,结果把性能拖垮还难以调试。理解变量赋值的本质之后,你会更清楚什么时候该共享、什么时候该隔离。
4.3 需要主动拷贝的几种真实场景
场景一是从函数返回值里提取一份独立数据。比如有个全局配置字典config,你要在某个函数里临时修改它,又不希望污染全局。此时正确做法是local_config = copy.deepcopy(config)再修改,而不是local_config = config然后改。
场景二是缓存。你会把上一次的计算结果存起来,下一次请求直接复用。如果后续逻辑会在结果上追加数据,就必须先把缓存结果拷贝一份,否则缓存会在不知不觉中被改坏。我曾经排查过一个线上问题,就是缓存列表被下游代码 append 了额外数据,导致后面所有请求都拿到越来越长的结果——根因就是少了拷贝这一步。
场景三是接收外部传入的可变结构,比如从接口读取的 list,你要把它存进自己的状态里。如果不拷贝,外部持有同一列表的代码一旦改动,你的状态就跟着变了。这类"边界防护"习惯,是经验丰富的工程师和老手之间一道清晰的分水岭。
5. 常见问题与排查技巧实录
5.1 新手最容易踩的四个经典坑
坑一:可变对象当默认参数。这也是函数定义里最著名的反模式:
def register(user, history=[]): history.append(user) return history print(register("a")) # ['a'] print(register("b")) # ['a', 'b'],第二次调用时 history 不是空列表!默认参数在函数定义时只计算一次,[]是同一个列表对象,每次调用都往同一个对象上 append。正确的写法是:
def register(user, history=None): if history is None: history = [] history.append(user) return history坑二:==与is的误用。==比较两个对象的内容是否相等,is比较两个名字是否指向同一个对象。很多人用is判断两个值相同的数字或字符串,时而对时而错,因为 Python 对小整数和某些短字符串有缓存机制,导致a is b可能是 True。但遇到大整数或者新建的字符串,结果就是 False:
a = 257 b = 257 print(a == b) # True print(a is b) # 极大可能是 False,因为大整数不缓存在业务代码里,判断变量间的赋值关系,应该用is判断身份,用==判断值。把这两个语义分清,很多逻辑错误会迎刃而解。
坑三:[[]] * 5生成的是同一个空列表。这是列表重复的经典大坑:
matrix = [[]] * 5 matrix[0].append(1) print(matrix) # [[1], [1], [1], [1], [1]][[]] * 5创建的 5 个空列表其实是同一个对象,重复的是引用而不是内容。正确写法是matrix = [[] for _ in range(5)],这样才能有 5 个独立列表。
坑四:函数内重新赋值导致外部变量未改。前面已经讲过,lst = ...不会影响外部,必须操作可变对象或者返回新值重新赋值。大量新手写出了这样"无效"的封装函数:
def clean(items): items = [x for x in items if x > 0] data = [1, -2, 3] clean(data) print(data) # [1, -2, 3],没变!这里items = ...只是重新绑定了局部变量,外部 data 完全无感。要么改成items[:] = ...(切片赋值,原地替换所有元素),要么让函数返回新列表:
def clean(items): return [x for x in items if x > 0] data = clean(data)5.2 调试时如何快速判断"是不是同一对象"
遇到变量被莫名修改的问题,第一件事不是猜,而是打印id()或者用is判断身份。
a = [[1, 2], [3, 4]] b = a[:] print(a is b) # False,外层是新对象 print(a[0] is b[0]) # True,内层还是同一个列表看到a[0] is b[0]为 True,立刻就知道浅拷贝的隔离只到第一层。这个技巧在排查"我明明拷贝了为什么还是互相影响"的 bug 时,能节省大量时间。
再提供一个我自己常用的自查清单:
- 这个对象可能在多少个地方被引用?每处引用的代码是否修改了它?
- 我操作的是对象方法(原地修改),还是用
=重新绑定? - 拷贝用的是浅拷贝还是深拷贝?嵌套层级是否超过一层?
- 函数传入的参数,在函数内部有没有调用
append、update、add这类方法?
如果代码是给别人维护的,还有个更稳妥的思路:尽量用不可变对象代替可变对象。能用元组就别用列表,能用字符串拼接新建就别用+=反复修改。不变性会让大量的赋值共享问题根本不存在。
5.3 关于id()的一个补充提醒
id()在 CPython 里返回的是对象的内存地址,但它在对象被回收后可能被复用。所以不要把一个对象的id保存下来并在之后用来判断"是不是原来的对象"——只要中间发生过垃圾回收,原来的对象可能已经没了,新的对象可能恰好占据了同样的id。正确的身份判断应该用a is b这种即时比较,而不是存档id后重新读取对比。这个小细节在长时间运行的服务里尤其重要。
6. 让变量赋值成为你的代码风格优势
6.1 学会有意识地共享与隔离
理解赋值的本质之后,你的编程思维方式会和以前不一样。以前写代码是"我怎么把数据复制一份过去",现在你会开始考虑"这个可变对象我想让谁看到、不想让谁看到"。
在实际开发中,共享并不总是坏事。比如一个只读配置字典,多个模块共享同一个引用,内存友好,也没有副作用。再比如大数组做一次浅拷贝,成本很低,但如果内部元素是可变对象,共享关系还埋在后头。关键在于:你要清楚自己在共享什么,并且让这种共享在代码里被显式表达出来。
我在自己项目里立了几条小规矩,分享出来供参考:
- 命名上区分意图。如果
b = a是故意的共享,我会写成shared_a = a或者加注释# 有意共享;如果目的是独立副本,变量名会带_copy后缀,比如config_copy = copy.deepcopy(config)。 - 函数边界处做防御。外部传入的可变参数,如果只是读取,就放心用;如果要修改,一定在函数最开始拷贝一份,避免影响调用方。
- 避免函数既修改传入对象又返回它。这种接口设计极其容易让人误用,因为调用方常常以为返回值是函数新造的,实际上和传入的是同一个对象。
6.2 几道练手题,验证你真的懂了
光看不练,第二天就忘。我出几道小题,你先在脑子里跑一遍结果,再把代码敲到 Python 里验证。每道题背后都是一个容易出错的知识点:
第一题:
a = [1, 2, 3] b = a b = b + [4] print(a) # ? print(b) # ?第二题:
a = [1, 2, 3] b = a b += [4] print(a) # ? print(b) # ?第三题:
def modify(x): x.append(1) x = [9] a = [] modify(a) print(a) # ?第四题:
a = [[1, 2], [3, 4]] b = a[:] b[0][0] = 100 print(a) # ?第五题:
x = 10 def outer(): x = 20 def inner(): nonlocal x x += 1 inner() print(x) # ? outer()第一题答案是a = [1, 2, 3],b = [1, 2, 3, 4],因为b + [4]新建对象后重新绑定。第二题答案是两者都是[1, 2, 3, 4],因为+=原地修改。第三题答案是a = [1],因为函数内x.append(1)修改了原对象,而x = [9]只是重新绑定局部名。第四题答案是a = [[100, 2], [3, 4]],浅拷贝的内层列表仍共享。第五题答案是21,nonlocal让 inner 修改了 outer 里的 x。
这五道题全部答对,且能说出为什么,说明你对 Python 变量间赋值已经建立了正确的心理模型。
6.3 一套稳定的思考框架
最后送你一个我长期使用的思考框架,遇到任何 Python 赋值相关问题,按顺序过这三个问题:
第一个问题:这个对象是可变的还是不可变的?不可变对象无论如何操作都不会出现"一个改另一个变"的情况,直接放心;可变对象才需要继续往下检查。
第二个问题:我接下来要修改对象内部,还是要让名字指向一个新对象?如果是修改内部(append、update、下标赋值),所有指向该对象的标签都会看到变化;如果是重新绑定(=、+=对不可变对象),只有当前名字变化。
第三个问题:我手里的两个名字是共享同一个对象,还是各自独立?用is验证,不要凭感觉。独立对象之间除非嵌套了共享子对象,否则互不影响。
这套框架帮我在评审同事代码时快速定位了大量隐蔽 bug。其实变量赋值的核心知识点就这么多,但它是 Python 里几乎所有"莫名其妙改数据"问题的总根源。一旦你想通了标签与对象的关系,再看任何一段 Python 代码,脑子里都能自然地浮现出"谁贴着谁"的画面。