ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

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 这块内存两个变量各自独立后面再改 ab 纹丝不动。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 10a这个标签被撕下来重新绑定到另一个整数对象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) # hellohello对象没有被修改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, historyNone): if history is None: history [] history.append(user) return history坑二与is的误用。比较两个对象的内容是否相等is比较两个名字是否指向同一个对象。很多人用is判断两个值相同的数字或字符串时而对时而错因为 Python 对小整数和某些短字符串有缓存机制导致a is b可能是 True。但遇到大整数或者新建的字符串结果就是 Falsea 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]]浅拷贝的内层列表仍共享。第五题答案是21nonlocal让 inner 修改了 outer 里的 x。这五道题全部答对且能说出为什么说明你对 Python 变量间赋值已经建立了正确的心理模型。6.3 一套稳定的思考框架最后送你一个我长期使用的思考框架遇到任何 Python 赋值相关问题按顺序过这三个问题第一个问题这个对象是可变的还是不可变的不可变对象无论如何操作都不会出现一个改另一个变的情况直接放心可变对象才需要继续往下检查。第二个问题我接下来要修改对象内部还是要让名字指向一个新对象如果是修改内部append、update、下标赋值所有指向该对象的标签都会看到变化如果是重新绑定、对不可变对象只有当前名字变化。第三个问题我手里的两个名字是共享同一个对象还是各自独立用is验证不要凭感觉。独立对象之间除非嵌套了共享子对象否则互不影响。这套框架帮我在评审同事代码时快速定位了大量隐蔽 bug。其实变量赋值的核心知识点就这么多但它是 Python 里几乎所有莫名其妙改数据问题的总根源。一旦你想通了标签与对象的关系再看任何一段 Python 代码脑子里都能自然地浮现出谁贴着谁的画面。
返回列表