news 2026/10/8 3:55:58

Python变量赋值:从标签模型到浅拷贝深拷贝陷阱

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python变量赋值:从标签模型到浅拷贝深拷贝陷阱

先抛一个问题: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) # True

is运算符比较的正是两个名字是否指向同一个对象,也就是"两个名字对应的 id 是否一致"。True说明标签a和b贴在了同一个列表对象上。

理解了"变量是标签"这个底层模型之后,接下来要看一个让很多人崩溃的问题:为什么整数这种赋值改一个不影响另一个,而列表这种赋值改了一个却连带另一个?答案藏在对象的可变性上。

2. 不可变对象与可变对象的行为差异

2.1 整数、字符串、元组为什么看起来是"值传递"

Python 对象分两类:不可变对象和可变对象。整数、浮点数、字符串、元组、布尔值,都属于不可变对象。不可变的意思是:对象一旦创建,内容永远无法被修改。

拿整数举例:

x = 10 y = x x = x + 1 print(y) # 10

x = 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 代码,脑子里都能自然地浮现出"谁贴着谁"的画面。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/8 3:55:48

VHD与VHDX:Windows桌面虚拟化的基石解析与实战

搞桌面的人这些年肯定有同感:虚拟化的话题聊起来,大家第一反应全是Hyper-V、VMware、VDI这一类“大平台”,但真正在底层把桌面虚拟化撑起来的,其实是VHD(Virtual Hard Disk)这个看起来不起眼的文件格式。我…

作者头像 李华
网站建设 2026/10/8 3:55:04

开源MES系统Java Web重做:从若依框架到报工追溯的完整落地指南

简介:一套面向制造业的开源MES系统设计源码,采用Java为后端核心,并整合Vue、JavaScript与HTML构建前端交互,可覆盖订单管理、物料跟踪、生产调度、品质管理等生产全流程,适合制造企业技术人员、Java开发者以及MES系统学…

作者头像 李华
网站建设 2026/10/8 3:54:49

CLI-Anything:用命令行打通AI Agent操作桌面软件的最后一公里

做 Agent 开发的人,八成都有过同一个困惑:Agent 明明能写代码、能查资料、能自己规划任务,但一碰到桌面软件就变回“瞎子”。你想让它帮你打开 Excel 改个格式,它干瞪眼;想让它操作一下某款老旧的专业工具,…

作者头像 李华
网站建设 2026/10/8 3:54:48

Headless Agent CLI的最佳实践:独立代码评审

"让 Agent 在 CI 里全自动修一个安全漏洞,它改了代码,跑了测试,然后把测试也顺手改了。"从那天起,我对无人值守的 Agent 产生了一个基本判断:让它直接编进主干开发流程风险太高,但把它放到流程之…

作者头像 李华
网站建设 2026/10/8 3:54:03

Matlab/Simulink风电并网仿真:背靠背变流器控制与参数整定全解析

做风电并网仿真的人,迟早会遇到背靠背变流器这道坎。2MW永磁直驱风力发电机并网模型,配上一套机侧整流器加网侧逆变器的背靠背结构,再在Matlab/Simulink里把整套控制逻辑跑通,是新能源并网方向最典型的工程练习之一。这个项目看着…

作者头像 李华