ARTICLE DETAIL

资讯详情

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

Python变量赋值详解:引用、可变对象与拷贝避坑指南

Python变量赋值详解:引用、可变对象与拷贝避坑指南 刚学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) # Trueid()是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。你改ba纹丝不动。所以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指向整数对象10y x让y也指向10y 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一提交就WAWrong 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也被改了。这个特性用对了可以很省事比如写一个批量处理函数直接往传入的列表里汇总结果。但也很容易成为隐形的数据污染源一个函数本来只想内部用一下列表结果把调用方的数据给改了。如果希望函数内不要影响外部数据有两个思路函数内先拷贝再操作def safe_add_item(lst, item): copied lst[:] # 或 copy.deepcopy(lst) copied.append(item) return copied明确约定函数只返回新对象绝不动参数def add_item(lst, item): return lst [item]在实际团队协作里约定比技巧更重要。函数签名旁边最好写清楚“这个函数会修改传入的列表吗”避免调用者猝不及防。4.4 默认参数陷阱可变对象作为默认值是经典BugPython有一个极其经典的坑几乎所有面试题都会考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, containerNone): 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()这段代码里三层作用域各有一个xinner内部打印时会先找自己的局部变量找到了就用局部那个。但这里有一个极其容易踩的隐藏规则在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这里再解释一个很多人搞混的点如果全局变量是可变对象函数里原地修改它其实不需要globalitems [] 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 嵌套函数里想改外层变量用nonlocalglobal管的是模块级变量。如果是在嵌套函数里想修改外层函数里的局部变量就要用nonlocal。最典型的应用是闭包计数器def make_counter(): count 0 def increment(): nonlocal count count 1 return count return increment counter make_counter() print(counter()) # 1 print(counter()) # 2increment里的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 速查表现象、原因、解决方案现象根本原因解决方案改了ba也跟着变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中变量之间赋值”的思维模型一旦建立起来后面再看列表推导、函数式编程、闭包、装饰器都会顺很多。
返回列表