ARTICLE DETAIL

资讯详情

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

从Python变量赋值到引用与拷贝:彻底理解可变对象和不可变对象

从Python变量赋值到引用与拷贝:彻底理解可变对象和不可变对象 1. 从一次答疑说起变量赋值为什么总是意外频出大概两年前有个刚学Python的读者在后台给我留言问了一个看起来特别基础的问题。他写了两行代码a [1, 2, 3] b a b.append(4) print(a)他猜输出是[1, 2, 3]因为我明明只改了b啊。但实际输出是[1, 2, 3, 4]a也跟着变了。他又试了试整数x 10 y x y 20 print(x) # 10这次x没变。同样是赋值一个跟着变一个不跟着变搞得他一头雾水。类似的困惑我在很多地方都见过有人用b a之后修改b导致原数据被污染于是到处找深复制的写法有人把列表传进函数在函数里做操作后外面的列表也被改了排查半天不知道问题出在哪还有人拿着C语言的指针概念往Python上套越套越乱。这些问题的根源其实都指向同一个底层认知——Python里变量之间赋值到底做了什么。如果你也有过类似的困惑或者你正在教别人Python、带新人写代码这篇内容就是为你准备的。我会从变量赋值的本质讲起把号背后发生的事掰开揉碎然后用实际场景演示那些意外是怎么产生的最后给出一套在真实项目里用得上的应对方案。看完之后你不仅不会再被这种问题坑到还能在别人被坑的时候讲清楚原理。2. 变量是标签不是盒子Python引用的底层逻辑2.1 盒子模型为什么是错的很多人初学编程时脑子里都有一个盒子模型变量是一个盒子赋值就是把数据装进盒子里。a [1, 2, 3]就是一个叫a的盒子里面装了列表[1, 2, 3]b a就是把a盒子里的东西复制了一份放进b盒子。这个模型在C语言里勉强说得过去其实也不完全对但在Python里从一开始就是错的。Python官方的文档和教程里一直强调一个说法变量是名字对象才有数据。更准确地说变量是绑定到对象上的一个标签或名字。当你写a [1, 2, 3]时Python做的事情是在内存中创建了一个列表对象[1, 2, 3]把名字a绑定到这个对象上。关键点在于变量本身不存数据数据在对象里。变量只是指向对象的引用。你在交互式环境里可以用id()函数验证这一点a [1, 2, 3] print(id(a)) # 输出形如 140346278876608 的内存地址编号id()返回的是对象在内存中的唯一标识你可以把它理解成对象的门牌号。变量a这个标签贴在了这个门牌号对应的对象上。2.2 一次赋值两个名字同一个对象现在再来看b a。这一步做了什么它不是把a指向的对象复制一份而是把b这个新标签也贴到了同一个对象上。此时内存里只有一个列表对象但是有两个名字a和b都指向它。你可以用id()再验证一次a [1, 2, 3] b a print(id(a) id(b)) # True两个变量指向同一个门牌号说明两个名字操作的是同一个对象。所以当你执行b.append(4)时修改的是那个唯一的列表对象。a和b都指向它因此无论你用哪个名字去查看看到的都是修改后的内容。这个逻辑其实跟现实生活很像你和家人共享同一个衣柜衣柜只有一个但可以有我的衣柜和你的衣柜两个叫法。你往衣柜里挂了一件衣服不管是谁来打开衣柜看到的都是挂进去之后的样子。2.3 整数赋值为什么看起来不一样那为什么y 20之后x还是10这是初学者最容易卡住的地方。x 10创建了整数对象10x指向它y x让y也指向10这个对象。到这里为止跟列表的例子是一样的。但紧接着y 20这一句做的事情是让y重新指向另一个整数对象20。它从头到尾没有去改那个10对象——整数对象本身也不可修改不可变类型。x仍然指向10所以x的值当然还是10。换句话说y 20不是在改y里面的值而是在把y这个标签撕下来贴到另一个对象上。我之前在答疑时经常用一句话总结变量赋值不是把数据复制一份给新变量而是让新变量也指向同一个数据。你后续对某个变量做的重新赋值本质是改变了这个变量的指向而不是修改它原来指向的那个对象除非你在原地修改对象本身。理解这一点是看懂后面所有意外的前提。3. 可变对象与不可变对象为什么有的坑深有的坑浅3.1 两类对象的本质差异既然变量是标签那改数据这件事就分两种情况。第一种是让标签指向新对象比如y 20这个对任何类型都一样只是换了个指向。第二种是在对象内部做修改比如列表的append、sort、下标赋值lst[0] 99或者字典的新增键值对d[k] v。能不能做第二种操作取决于这个对象是可变还是不可变。可变对象列表list、字典dict、集合set、自定义类实例等支持原地修改内部元素。一旦多个变量指向同一个可变对象通过任何一个变量做修改其他变量看到的内容都会跟着变。不可变对象整数int、浮点数float、字符串str、元组tuple、布尔值bool、None等不支持原地修改。看起来x x 1确实把x从1变成了2但实际过程是创建了一个新的整数对象2然后让x指向它。原来的1对象始终没变只是没了引用等待被垃圾回收。3.2 实际场景列表别名的连锁反应工作中最常见的问题就是列表的别名效应。举个我实际遇到过的例子有一次写数据清洗脚本我从原始数据里抽出了一个子集打算在这个子集上做标准化处理怕污染原数据特意先执行了subset original觉得这样总安全了吧结果处理完发现原始数据也被改了。原因跟开头那个例子一模一样subset original只是给同一份列表多贴了一个标签。对subset做sort()、reverse()或下标赋值都是在原地操作那唯一的数据对象。在Python里如果你需要一份独立的可操作副本正确做法是copy模块或切片比如subset original.copy()或subset original[:]。后面我会专门用一节来讲这个。同样的情况也出现在字典上。d2 d1之后再对d2添加键值对d1也会跟着多出键。这个比列表更隐蔽因为字典的别名很多时候发生在你不经意间比如把字典作为函数的参数传进去。3.3 不可变对象为何省心字符串和整数这类不可变对象在多个变量赋值时不会产生这种连锁修改的烦恼。s1 hellos2 s1之后无论你怎么对s2操作——其实所有的操作都不会修改原字符串而是生成新字符串——s1始终是hello。你顶多让s2重新指向别处但s1的指向不会因此改变。这种特性让不可变对象在多变量共享时天然安全也是为什么Python里很多默认参数、字符串拼接都倾向用不可变类型的原因之一。但要注意不可变这个帽子不能乱扣。比如元组本身不可变但如果元组里面装了一个列表那个列表仍然是可变的你依然可以通过t[0].append(...)去修改它。这个我再往后的章节会结合结构体、混合数据的场景细说。从这一节你应该能提炼一个判断方法赋值之后另一个变量会不会被误伤取决于两个变量指向的对象本身是否支持原地修改以及你到底做了重新指向还是原地修改。4. 函数参数传递中的赋值陷阱默认参数与就地修改4.1 参数传递的本质就是赋值很多人学函数的时候会听到一句模糊的话Python的参数传递是传引用——然后就开始纠结是传值还是传引用。以我们前面建立的概念来看其实特别清爽调用函数、把实参传给形参本质上就是在执行一次变量赋值。def f(x)定义了函数内部的名字x调用f(a)时把实参a传给形参x就等于执行了x a。就这么简单。所以函数内部对参数的修改会不会影响外部完全可以用同一套规则判断如果函数内部对参数执行了重新指向如x 20相当于给局部名字x重新贴了个标签外部实参的指向不受影响。如果函数内部对参数指向的对象做了原地修改如x.append(4)外部实参就能看到变化。我在帮读者排查代码时见过大量这类问题。有一次一个人写了个函数想把传入列表的元素全部清零def clear_list(lst): lst []然后他发现外面的原列表纹丝不动。很自然lst []只是让函数内部的参数名lst指向了一个新列表跟外部实参没有关系。想在外面生效要么lst.clear()在原来对象上清空要么用返回值拿到新列表。4.2 可变默认参数的经典大坑Python开发者几乎都会遇到的另一个问题是可变默认参数。看这个函数def add_item(item, container[]): container.append(item) return container第一次调用add_item(a)返回[a]第二次调用add_item(b)返回什么你以为还是[b]或者顶多[a, b]也能接受但第三次、第四次……列表会一直累积因为默认参数[]在函数定义时被创建了一次之后每次调用如果不传container用的都是同一个列表对象。这个同一个对象就会被反复append最终积累出你完全不想要的数据。为什么会这样根源就在于我们前面说的变量是标签。函数定义时参数container的默认值绑定到了那个空列表对象上后续调用若没传入container就直接把container重新指向了那个早已存在的对象。它是可变的所以每次往里加数据都会保留下来。解决办法有两个方向一是默认参数用不可变对象比如None函数内部再判断是否创建新列表def add_item(item, containerNone): if container is None: container [] container.append(item) return container二是不让默认参数承担可变容器的职责。从根上说这个坑的排查方法就是前面那套思路先看这个对象是可变还是不可变再看你是在原地修改还是重新绑定。只要这两点判断对了参数传递再复杂也能理清。4.3 函数内部该不该动外部对象在实际项目里我习惯在写函数之前就想清楚这个函数对传入对象是只读还是可写。只读场景比如统计一个列表的均值、打印一个字典的内容函数内部只访问不修改那传引用完全没问题效率还高。可写场景比如写一个工具函数要保留修改结果你可以选择(1) 明确在函数内做原地修改并在文档字符串里注明该函数会修改传入对象(2) 在函数内部先做一个副本再操作外部原始数据保持不动。这两种方式各有适用场景推荐一个原则宁可多copy一次也别让调用方发现传入的数据被偷偷改了。我在给团队做代码审查时经常看到有人把个子集传给清理函数以为只是处理副本结果源头数据被改了排查起来非常痛苦。5. copy与deepcopy需要独立副本时的正确姿势5.1 浅拷贝第一层独立深处仍共享既然b a只是贴标签想要一份真正独立的数据副本时怎么办Python的copy模块提供了两个函数copy.copy()浅拷贝和copy.deepcopy()深拷贝。浅拷贝的含义是创建一个新的容器对象然后把原容器里的元素的引用复制到新容器中。对一维列表来说列表本身是新的但它里面的每个元素如果要元素还是可变对象那就是引用了共享。import copy original [1, 2, 3] shallow copy.copy(original) shallow.append(4) print(original) # [1, 2, 3] 不受影响这个例子看起来浅拷贝完全够用。但看这个original [[1, 2], [3, 4]] shallow copy.copy(original) shallow[0].append(99) print(original) # [[1, 2, 99], [3, 4]] 内部子列表被改了浅拷贝只复制了最外层列表子列表还是共享的那个对象。所以你修改shallow[0]指向的子列表original[0]指向的还是同一个子列表自然能看到变化。如果你要用copy.copy()解决列表别名问题先确认你的列表里的元素是否全是不可变对象整数、字符串等。如果是浅拷贝足够了如果里面有列表、字典或其他自定义可变对象浅拷贝只能保第一层不受影响。5.2 深拷贝递归拷贝所有层级深拷贝做的事情是递归地遍历整个对象结构遇到可变对象就新建一个副本直到最底层。这样得到的副本和原对象之间不再共享任何可变对象。import copy original [[1, 2], [3, 4]] deep copy.deepcopy(original) deep[0].append(99) print(original) # [[1, 2], [3, 4]] 完全不受影响深拷贝适合的场景是数据是嵌套的结构体、字典套列表、列表套字典或者自定义类的实例相互引用而且你需要对它进行任意修改而不影响原数据。比如在数据分析和机器学习里原始特征数据、配置字典经常需要深拷贝出一份来做实验性修改。不过深拷贝也有代价。它比浅拷贝慢得多而且可能遇到一些无法拷贝的对象比如文件句柄、网络连接、包含锁的对象。所以我在工程里的习惯是默认用浅拷贝只有确认内部还有可变对象需要隔离时才用深拷贝。不要所有地方都无脑deepcopy那样既慢又不一定成功。5.3 切片、copy()和list()的区别除了copy.copy()Python还提供了几种创建列表副本的简便写法b a[:]切片本质相当于浅拷贝一维列表b list(a)调用list构造器也是浅拷贝b a.copy()列表的copy方法。在只包含不可变元素的一维列表场景下a[:]、list(a)、a.copy()和copy.copy(a)效果相同。但如果遇到嵌套结构它们全部是浅拷贝都要按前面判断。另外提醒一句如果那个对象本身就是不可变类型比如整数、字符串那根本不需要copy直接赋值引用很安全因为共享不可变对象不会产生任何副作用。甚至即使你复制了Python也可能会复用同一个对象因为不可变对象本身不需要额外保护。5.4 用is和id验证拷贝结果很多人区分不清判相等和is判同一觉得我复制的副本明明内容一样啊为什么is比较是False。其实这两个操作符对应的维度不同比的是值两个对象的内容是否相等is比的是身份两个变量是否指向同一个对象即id是否相同。a [1, 2, 3] b a.copy() print(a b) # True值相等 print(a is b) # False不是同一个对象理解is之后你在调试时就能很直观地验证我给a赋值起了别名我做的复制到底成功没有。实际操作中建议把id()和is当成一个趁手的检查工具看到两个变量指向同一个对象时就用它们验证。6. 赋值运算符与特殊赋值场景从链式到解包6.1 链式赋值和增量赋值Python里除了最普通的a b还有很多其他形式的赋值它们同样遵循变量是标签的逻辑。链式赋值a b [1, 2, 3]等价于先让b指向列表对象再让a也指向同一个对象。所以a和b是别名关系修改a的元素b也会看到。如果是链式赋值给不可变对象x y 100那x和y初始都指向100后续重新赋值不会互相影响。增量赋值a [4, 5]和a a [4, 5]有区别吗对列表来说有。a [4, 5]会原地扩展列表它依赖列表的__iadd__方法等价于a.extend([4, 5])不会创建新列表而a a [4, 5]会创建新的列表对象再让a指向它。如果你有多处变量别名指向同一个列表用会同时修改到它们指向的共享对象而则不会。这个差异在实际运行中影响很大我写爬虫时处理URL列表、写数据处理时合并行数据都遇到过因误用而改动源数据的情况。6.2 解包赋值、函数多返回值与传参元组解包a, b b, a是一个很实用的技巧它背后的逻辑是先把右侧的两个对象取出来再分别赋给左侧的两个名字。a, b b, a不是逐个赋值的小聪明右侧先求值再绑定所以能安全交换。函数返回值也可以用来解包赋值比如left, right split_data(data)本质和left, right (obj1, obj2)一样。函数参数也可以利用这种机制做关键字解包def f(**kwargs)会把所有关键字参数收进一个字典调用时f(**d)会把字典的键值对展开作为关键字参数。这些操作的底层依然是名字绑定到对象的逻辑。6.3 变量作用域中的赋值行为赋值还会牵涉到作用域。函数内部的赋值默认会创建一个局部名字除非你在函数内用global声明。这个局部名字和外部同名变量其实不是同一个绑定关系。举个例子var 10 def change(): var 20 change() print(var) # 10函数内部的var 20在函数作用域里新建了一个局部标签var并绑到了20对象上外部那个var标签仍然指向10对象。如果希望修改全局的就必须在函数内声明global var。这个设计不是为了让程序绕路而是为了保护全局环境不被函数内部随意污染。类里类似的情况则用self.xxx yyy相当于在实例对象的属性命名空间里给xxx绑定一个名字。本质上还是一种赋值逻辑只是作用域从模块级、函数级换到了对象属性空间。理解了赋值是标签绑定作用域相关的困惑会少一半。7. 实操排查流程遇到变量一起变了怎么定位7.1 三问定位法如果你在实际代码中怀疑遇到了别名导致的数据变动别急着搜解决方案先按下面的顺序快速定位问题这两个变量是绑定到同一个对象吗用print(id(a), id(b))或者print(a is b)看一下。如果is返回True那它们共享一个对象任何原地修改都会互相影响。你现在做的操作是重新绑定还是原地修改在代码里找到对b的那条语句是b new_value重新绑定不影响a还是b.append(...)、b[0] ...、b.update(...)原地修改影响a这个对象是可变还是不可变如果是不可变对象原地修改根本不存在别的变量不可能被意外改动如果是可变对象再看它结构里是否嵌套了其他可变对象因为嵌套那层的引用共享更容易被忽略。大多数踩坑问题走到第三步就找着根了。7.2 一个综合排查案例我把这个排查思路做成一个小案例你可以直接跟着走一遍。假设有一段代码records [{name: alice, scores: [90, 85]}, {name: bob, scores: [70, 95]}] def add_score(record, score): record[scores].append(score) return record first records[0] add_score(first, 99) print(records)问题为first加成绩为什么records也跟着变按三问法来第一问first records[0]first is records[0]为True指向同一个字典第二问record[scores].append(score)是list.append原地修改第三问这个字典是可变对象它的scores值也是一个可变list。所以答案很明确从first到records经过两条引用链都指向共享数据修改自然会影响原列表。想要不受影响要么在取first时深拷贝一份要么在add_score内部对scores做copy后再append。这个案例很典型我经常用它给新人演示如何把感觉变量乱了转化为可验证的引用关系判断。7.3 防御性编程的几个习惯以我自己的经验等出了bug再定位总归要花时间不如写代码时就用几个小习惯减少这类问题的概率函数边界处做拷贝传给外部工具函数、尤其要修改数据的函数前先拷贝一份再传函数内部尽量不露痕迹地修改副本。参数注解和文档写明是否修改入参在函数docstring里写一句会原地修改传入的list下次别人调用时一眼看到。默认参数坚持用不可变类型占位None是首选不要用[]或{}。保持嵌套结构清晰能不用多层可变嵌套就不用深拷贝的成本高且容易误用。实在需要嵌套至少清楚每一层归谁所有。写测试哪怕只是简单的断言比如调用函数后原列表长度不变也能在早期发现隐患。8. 从赋值理解到更深一层Python数据模型的入门钥匙赋值这个知识点在语法上很浅但它牵涉到的名字绑定思想其实是你深入理解Python数据模型的一把钥匙。引用、可变性、垃圾回收、内存管理这些概念全都能从变量是标签这个最基本的比喻延伸开去。比如循环引用怎么被垃圾回收、弱引用weakref为什么在某些缓存场景里特别有用、函数对象作为一等公民为什么能塞进字典、装饰器为什么能拿到函数名——这些东西本质上都依赖同一个模型名字就是指向对象的标签对象图之间通过引用互相连接。你要是彻底理解了赋值再去看dict的键值存储、类的属性查找、甚至Python的导入机制模块名绑定到模块对象会发现它们的底层逻辑高度统一。再往深一点__setitem__、__getitem__、__iadd__这些魔术方法定义的对象行为也都是在对象如何被绑定、如何被原地修改的框架下运作的。比如你自定义一个类重写了__iadd__那a b是原地改还是新建完全由你决定。这个决定权恰恰体现Python语言设计上的灵活性但也要求开发者对赋值语义有足够清晰的认知。这也是为什么我在带人学Python、写教程时特别强调把赋值这一课学透的原因。它不只是入门知识点而是贯穿全程的底层语法范式。很多人后期看源码、看框架源码觉得费劲一个常见原因就是对引用传递和可变性没有形成直觉每看到一行objA objB都要停下来猜。所以最后想说的是别觉得变量的赋值太简单不值一看恰恰是这些最基础的地方决定了你后面能走多深。把标签和对象这两个概念刻在脑子里再去读那些复杂的框架代码你的理解速度会完全不一样。至少我带了这么多轮新人谁这个点想通了谁后续调试代码的速度就会明显快一截这就是基础知识的复利。
返回列表