尧图精选

Python参数传递到底传值还是传引用?对象引用与可变性深度解析

🕒 发布时间:2026/9/18 12:28:15 📁 来源:尧图网络
1. 先拆掉概念Python到底传的是值还是引用很多人第一次认真思考 Python 参数传递都是在面试或者被线上 bug 逼疯的时候。网上搜一圈答案五花八门有人说“传值”因为改 int 不影响外面有人说“传引用”因为改 list 外面跟着变还有人搬出术语“传对象引用”。听起来都对但一到实际问题就露馅。今天我想用这个标题把这件事彻底讲透从 int、str 这些基础类型开始一路走到自定义类把参数传递的每一个坑都摊开来看顺便给出能直接落地的排查方法和改造方案。先给结论Python 参数传递既不是经典的“传值调用”也不是完全意义上的“传引用调用”更准确的说法是“传递对象引用pass by object reference”。这句话不绕后面我会用实例慢慢拆。你只需要先记住三件事第一函数接收的是对象的引用而不是对象的副本第二对象本身是否可变决定了这个引用被“共享”时会产生什么后果第三几乎全部 Python 参数传递 bug都来自“可变对象就地操作”与“重新绑定变量”这两件事的混淆。这个知识点适合什么人看我觉得只要你在用 Python 写函数、写类、传参数就已经是目标读者了。尤其是写过几个项目但还没有系统梳理过这块的人这篇文章可以帮你把零散的“感觉”变成一套清晰的判断逻辑。1.1 一个简单的实验让你记住结论我们从一个最直观的对比开始。打开任意一个 Python 解释器把下面这段代码敲进去def demo(a, b): a a 1 b.append(4) print(函数内部:, a, b) x 10 y [1, 2, 3] demo(x, y) print(函数外部:, x, y)输出结果是什么样的呢函数内部: 11 [1, 2, 3, 4] 函数外部: 10 [1, 2, 3, 4]这个结果几乎概括了 Python 参数传递的全部核心行为。int 类型的 x 传入函数后无论内部怎么算外部完全不受影响list 类型的 y 就不一样了函数里的append操作直接改掉了外部列表。很多教程到这里就结束然后告诉你“基本类型传值复杂类型传引用”。这种说法能解释八成的场景但非常容易误导人因为它会让人产生一种错误预期只要我用的是 list、dict、对象外部就一定被改。事实并非如此。你把上面的append换成重新赋值试试def demo2(a, b): a a 1 b [3, 2, 1] print(函数内部:, a, b) x 10 y [1, 2, 3] demo2(x, y) print(函数外部:, x, y)这次外部 y 没有变。如果“复杂类型传引用”真的成立b 整个替换成新列表外部 y 也该指向新列表才对。这就是很多人边写边疑惑的地方同样是 list 参数为什么append能改外面b [3,2,1]不能答案就在“传递对象引用”这四个字里。1.2 从内存地址看“绑定”和“对象”的真实关系要理解前面那个实验必须区分“变量”和“对象”。Python 里的变量不是一个盒子它更像一个名牌标签贴在某个对象上面。赋值语句做的是“把标签贴到对象上”而不是把数据拷贝进变量。函数传参也一样demo(x, y)做的事情就是把名称 a 和 b 这两个标签分别贴到 x 和 y 当前指向的对象上。用id()函数可以验证这一点。id()返回对象在内存中的唯一标识我习惯叫它“对象地址”def demo3(a, b): print(函数内部 a 的id:, id(a)) print(函数内部 b 的id:, id(b)) x 10 y [1, 2, 3] print(函数外部 x 的id:, id(x)) print(函数外部 y 的id:, id(y)) demo3(x, y)你能看到函数外部和函数内部打印出来的是同一组 id。这说明参数传递的过程本质上就是把函数外部的标签“复印”了一份给函数内部使用复印出来的标签和原标签指向同一个对象。关键差别在哪在之后的操作。我们对基础类型做a a 1时因为 int 是不可变对象a 1会先在内存里创建一个新的整数对象然后让 a 这个标签去贴新对象。函数内部标签换位置了外部标签 x 仍然贴在原来的整数 10 上。对可变 list 做b.append(4)时我们并没有让 b 换标签而是在 b 指向的那个列表对象内部添加了一项于是外部 y 一看还是同一个对象自然看到了变化。这里建议你把下面这张对比存在脑子里操作对参数引用做了什么外部是否可见a a 1重新绑定到新对象不可见b.append(4)修改原对象内部可见b [3,2,1]重新绑定到新对象不可见b[0] 99修改原对象内部可见a 1对不可变对象是重新绑定不可见b [4]对可变对象是原地扩展可见后面的所有坑都能从这张表里推出来。先把它记牢接下来一个个看细节。2. 基础类型不可变对象的“值传递”假象2.1 int、str、tuple 在函数边界上到底发生了什么int、float、str、bool、tuple、frozenset这些都是不可变对象。所谓不可变不是指“这个变量不能改变”而是指“这个对象本身无法被原地修改”。int 对象只有一个值str 对象只有一个字符串tuple 对象一旦创建就不能新增或删除元素。对象本身焊死了所有看起来像“修改”的操作实际上都是生成了新对象。所以当函数参数是这些类型时无论你做a a 1、s s.upper()还是t t (4,)函数内的变量名都会被重新绑定到新对象上函数外部的原变量完全不变。这种表现恰好和很多语言里的“传值调用”一致所以大家习惯把基础类型叫“传值”。字符串尤其典型很多新手会写这样的代码def add_exclamation(text): text text ! return text msg hello add_exclamation(msg) print(msg) # 还是 hello有人会觉得奇怪“我不是把字符串传进去了吗为什么外面没变”原因就是字符串是不可变对象text !创建了新字符串局部变量 text 换了标签外部 msg 当然不受影响。想拿到结果必须把返回值接住msg add_exclamation(msg)基础类型参数的值传递假象本质是“不可变性”制造的而不是“值拷贝”制造的。这个细微差别平时感觉不到但一旦参数变成可变对象就觉得 Python“不守规矩”了。其实规矩从头到尾都没变过传的都是引用只是不可变对象让引用变得没有操作空间。tuple 是这里最容易被忽略的对象。它是不可变的但你可能会在 tuple 里面放一个 listdef modify_tuple(t): t[0].append(100) data ([1, 2], fixed) modify_tuple(data) print(data) # ([1, 2, 100], fixed)这个例子表面上让人崩溃tuple 不是不可变吗为什么里面的 list 被改了关键要理解不可变指的是 tuple 对象的元素个数和元素指向不能变也就是第一个位置仍然指向同一个 list 对象。而它指向的那个 list 对象自己是可以变的。我经常打一个比方一个保险箱是焊死的但保险箱里面放的账本是可以翻开来写字的。tuple 是保险箱list 是账本。参数传递只把保险箱的钥匙复制了一份账本还是原来那本。2.2 为什么这类操作有时“看起来既能改又不能改”运算符是 Python 里最容易被误解的一层尤其是复合赋值。a 1和a a 1在效果上几乎一样但它们背后的魔法方法不同。调用的是__iadd__如果没有实现就退化成__add__。对于不可变对象__iadd__不存在所以一律是创建新对象再重新绑定。肉眼看上去唯一的区别就是无论存不存在__iadd__外部变量都不会变。真正让人困惑的是字面量容器。比如t (4,)tuple 没有__iadd__所以先计算t (4,)生成新 tuple再把 t 绑到这个新对象。外部变量没变这符合直觉。而lst [4]就完全不同了list 实现了__iadd__它会直接调用extend在原有列表内部扩展外部变量立刻能看到变化。于是出现了一个非常经典的面试题变体def surprise(a): a [4] lst [1, 2, 3] surprise(lst) print(lst) # 会变成 [1, 2, 3, 4]很多新手以为a [4]等价于a a [4]于是以为外部不变结果外部列表被原地改掉了。这个行为在对可变对象生效时就是在和“不可变对象的重新绑定”做反方向运动。我建议你用“查魔法方法”的思路记忆先问自己这个对象是否可变再问运算符是否可能原地扩展。只要对象可变且操作走的是__iadd__、append、extend、update、sort这类原地操作外部就一定会受影响。2.3 一个容易被忽视的小坑参数名与外部变量重名这块虽然不算参数传递的特有坑但因为初学者经常会遇到所以还是值得一提。当你写def process(num): num num * 2 return num num 5 process(num) print(num) # 5函数内部的num和外部全局命名的num是两个完全不同的标签。Python 的变量名作用域规则决定了函数内赋值默认创建局部名字外部全局变量不会被碰。很多人会误以为“参数名和调用处的变量名一样就说明指的是同一个东西”这是一种根深蒂固的错觉。那如果我们在函数内部声明global num呢那它操作的就是外部变量不再受参数传递约束的影响。但这类代码能少用就少用全局可变态会让程序变得难以追踪。你在排查参数相关 bug 时第一步应该确认我到底是想修改传入对象还是想通过返回值重建明确这一点后面所有“坑”都能绕开大半。3. 可变对象引用共享带来的连续踩坑3.1 list 与 dict就地修改会捅破函数的“防火墙”很多教程会把函数描述成“黑盒”好像传入数据以后外面就安全了。这是最危险的心理暗示。对于可变对象黑盒完全不成立除非你显式做了拷贝。最常见的情形就是数据处理函数里顺手remove或pop几下外面的原始数据就秃了def clean_data(data): for item in data: if not item.get(active): data.remove(item) return data records [ {name: a, active: True}, {name: b, active: False}, {name: c, active: True}, ] clean_data(records) print(records)这里不仅改了外部数据还会遇到一个更隐秘的问题在遍历 list 的同时删除元素会导致跳过某些元素。这是另一个经典坑先按下不表。从参数传递角度讲data.remove(item)就是典型的“就地修改”外部 records 一定遭殃。dict 的情况也一样。函数内d[key] value会直接作用到外部字典。很多人写配置合并、缓存更新时顺手就把外部字典改了半天查不出 bug。更隐蔽的情况是在类里传字典比如一个对象持有 self.config把 self.config 传给处理函数后函数内部悄悄改了外部对象的配置就变了。正确姿势是如果函数的目的只是“读取并生成新结果”那函数入口处对可变参数做一次浅拷贝如果函数的目的本来就是“修改外部对象”那就要在命名和文档上明确表达出来比如方法名直接叫update_config或者加注释说明“此函数会就地修改传入列表”。接口语义清晰比什么技巧都重要。3.2 典型翻车例排序、去重、清空我单独讲三个非常容易踩的“改造型”操作它们在外观上特别像“返回新对象”实际上全是就地修改足以让人怀疑人生。第一个是list.sort()。它有返回值吗有但这个返回值是Nonedef sort_list(data): return data.sort() nums [3, 1, 2] result sort_list(nums) print(result) # None print(nums) # [1, 2, 3]如果你在函数里写result data.sort()你得到的不是排序后的列表而是None。新手最爱在这里栽跟头。想要新列表要用sorted(data)它才是返回新对象。第二个是set.add()或set.discard()。set 同样是可变对象函数内直接操作集合会改写外部集合。去重场景最容易出问题def unique_extend(source, target): target.update(source) return target base {1, 2, 3} newset unique_extend({3, 4, 5}, base) print(base) # 外部 base 变成了 {1,2,3,4,5}如果你本意是生成一个合并后的新集合那这个函数直接坑了调用方。第三个是list.clear()。它会把整个列表清空外部引用同一个列表的地方也会看到变空的结果。有人会想在函数内“重置数据”def reset(data): data.clear() data.extend([0, 0, 0]) nums [1, 2, 3] reset(nums) print(nums) # [0, 0, 0]这种写法不是不行但必须写在文档里明确告诉调用方“我改了你的列表”。如果你希望留给外部一个全新对象应该改成def reset_better(): return [0, 0, 0]或者对传入对象做data[:] [0, 0, 0]这样仍然是原地替换内容但外部标签指向不变调用方持有的引用还能继续用。要区分这两种需求取决于你希望外部变量指向“同一个列表”还是“新列表”。3.3 看清“重新绑定”和“修改对象”的操作边界说了这么多真正要掌握的能力是对任何一行代码都能判断出它到底属于“重新绑定”还是“修改对象”。我给一个我自己常用的心法看赋值号左边如果是“变量名”那就是重新绑定如果左边是“带中括号或属性的取值表达式”比如lst[0]、obj.field、d[key]那就是修改对象。举几个例子b new_list # 左边是变量名重新绑定 b[0] new_value # 左边是取值表达式修改对象 b.append(new_value) # 调用对象方法修改对象 b b [new_value] # 左边是变量名重新绑定 b [new_value] # 对可变对象修改对象对不可变对象重新绑定这个判断法在排查复杂代码时极其高效。你不需要记住每一行代码的语义只需要盯着“赋值作用于变量名还是作用于对象的某个部分”。作用在变量名上的改动超出函数后自然蒸发作用在对象某个部分上的改动会沿着引用关系一直传导到外部。这里还要提一嘴嵌套对象。obj.field是修改对象但如果field本身是一个可变对象那么即使写成obj obj.fork()也可能只是把外部变量重新绑定到新对象而旧对象的字段仍然被你改过了。排查嵌套结构时要把“外部标签指向哪”和“哪些对象被真正修改”分开列出来才不会漏掉隐藏的副作用。4. 类的层面默认参数、类变量与 self 是最大雷区4.1 默认参数陷阱def foo(items[])为什么总出问题这个话题被讨论了很多年但踩坑的人依然前赴后继。看下面这个函数def add_item(item, items[]): items.append(item) return items first add_item(1) second add_item(2) print(first) # [1, 2] print(second) # [1, 2]为什么会这样原因还是要回到“函数定义时默认值只被创建一次”这个机制。def语句在模块加载时执行一次items[]这行会创建一个列表对象并且把它保存为函数的默认参数。之后每次调用如果调用方没有传 itemsPython 就把这个同一个列表对象的引用传给函数。于是第一次调用append(1)默认列表变成[1]第二次调用append(2)同一个列表变成[1, 2]。两个调用返回的还是同一个对象看着当然全是[1, 2]。这个问题的本质是“可变对象作为默认值”和“参数传递按引用共享”两个机制叠加后的必然结果。解决方案也早就有了标准做法是使用None作为默认值def add_item(item, itemsNone): if items is None: items [] items.append(item) return itemsNone是不可变对象作为默认值不会产生共享问题。每次函数调用时只要外部没传列表就会创建一个全新的空列表。这里有一个细节如果你在if items is None之后执行items.append(item)那 items 是被绑定到一个新列表上的局部变量和外部无关如果外部确实传入了列表那 items 就是外部列表的引用append会修改外部列表。这依然符合可预期的行为而不是默认参数那种“幽灵共享”。很多人在 code review 时看到可变默认值直接打回我觉得这个规则可以再细化一下如果你确实需要利用默认参数的持久性来做缓存那是另一个话题后面专门讲。但一般业务代码里尽量别给可变默认值留生存空间。4.2 类变量被实例悄悄共享类变量是另一个“看着像实例变量其实是全局共享”的隐蔽地雷。最典型的例子class Employee: skills [] def __init__(self, name): self.name name def add_skill(self, skill): self.skills.append(skill) alice Employee(alice) bob Employee(bob) alice.add_skill(Python) bob.add_skill(Java) print(alice.skills) # [Python, Java] print(bob.skills) # [Python, Java]明明只给 alice 加了 Python为什么 bob 也有 Python因为skills是定义在类体里的类变量所有实例共享同一个列表对象。当执行self.skills.append(skill)时由于self.skills在实例上找不到同名属性会向上回溯到类属性。找到以后你不是新建了一个列表给实例而是在共享列表上追加元素。于是所有实例都看到了。这里有一个容易混淆的细节如果你用的是“直接赋值”而不是“就地修改”情况会不一样。比如在__init__里写self.skills []这是给实例对象创建一个全新的、独立的属性它会覆盖类属性名此时每个实例各有一个列表。所以class Employee: skills [] def __init__(self, name): self.name name self.skills [] # 实例自己的列表一旦在__init__中实例化出独立列表每个实例就安全了。这里的判断方法和第 3 节一脉相承self.skills.append(...)是修改对象self.skills []是重新绑定。前者影响共享对象后者截断共享关系。话虽如此我还是建议默认不要写可变类变量。如果想定义常量集合用 tuple 或 frozenset如果想表达“每个实例自己管理的数据”放在__init__里初始化。宁可多几行代码也不要让后人去猜。4.3 传类实例进去后什么算修改自定义类的实例属于可变对象除非你实现了__slots__并且完全禁用了属性修改。所以当你把一个实例传入函数函数内修改它的属性外部一定能看到class Counter: def __init__(self): self.count 0 def increment(c): c.count 1 ctr Counter() increment(ctr) print(ctr.count) # 1很多人学过基础类型“传值”学完可变容器“传引用”到了自定义对象这里又开始迷糊。其实只用一条规则就可以覆盖实例是对象对象可被修改传参传引用引用指向同一个对象所以属性修改外部可见。那如果函数内重新给参数赋值呢def replace_counter(c): c Counter() ctr Counter() replace_counter(ctr) print(ctr.count) # 0外部引用没变和 list 的行为完全一致。区别只在于“操作对象内部”还是“重新绑定变量”。这个统一的模型比背十二种类型的规则要实用得多。自定义类还有一个继承自默认可变容器的坑如果你在__init__里直接把外部传入的 list 赋给self.items那么外部列表和你实例内部的列表就是同一个对象class TaskList: def __init__(self, items): self.items items raw [1, 2, 3] tasks TaskList(raw) raw.append(4) print(tasks.items) # [1, 2, 3, 4]外部 raw 一变实例内部也跟着变。这有时是有意的“共享数据”但更多时候是无意的耦合。如果你期望构造函数持有独立副本在__init__里要做self.items list(items)或self.items items[:]。数据封装做得好的代码往往就是在这些边界位置上多了一次拷贝。5. 实战排查与代码改造5.1 用id()和copy模块快速定位问题遇到参数传递相关 bug 时第一反应不要是猜而是用id()把引用关系打印出来。我习惯写一个小调试片段def debug_ref(label, obj): print(f[{label}] id{id(obj)}, value{obj!r})然后在函数入口、中间操作、函数返回前各打印一次。观察 id 是否发生变化就能判断是“重新绑定”还是“修改对象”。如果 id 一直没变但外部数据变了说明是原地修改如果 id 变了说明函数内部已经换成新对象外部自然不受影响。拷贝边界同样很重要。浅拷贝copy.copy()只能复制最外层容器如果列表里套着可变子对象子对象仍然是共享的a [[1, 2], [3, 4]] b a.copy() b[0].append(99) print(a) # [[1, 2, 99], [3, 4]]要完全隔离得用copy.deepcopy()。但深拷贝不是免费的如果对象里有大文件句柄、线程、网络连接等不可复制的内容直接 deepcopy 会炸。更稳妥的思路是从设计上避免深层共享结构或者在边界处构造新的不可变结构。我在大型数据处理项目里经常是“入口浅拷贝 内部不修改传入对象 返回新对象”三段式效果非常干净。5.2 常见问题速查表我把工作中遇到过的参数传递典型问题整理成一个速查表方便你在 review 或 debug 时对照症状根因解决方案函数内改 int/str外部不变不可变对象重新绑定正常现象如需结果请用 return 接住函数内list.sort()返回 Nonesort 是原地修改无返回新对象用sorted()或先复制再 sort外部 list 被函数意外修改就地操作如 append/remove/pop/extend传入前list.copy()或函数内操作副本多次调用函数默认列表累积数据可变默认参数在定义时创建一次默认参数置None函数内创建新列表多个实例共享同一个属性列表类变量被实例修改在__init__中用self.attr []外部列表一变实例属性跟着变构造时直接引用外部对象__init__中做浅拷贝或深拷贝字典参数被函数填充了额外字段dict 是可变对象就地赋值函数内用dict(data)生成副本遍历列表时删除结果漏删下标前移导致跳过元素用list(filter())或先复制再遍历这张表基本覆盖了日常开发里 90% 的参数传递问题。剩下 10% 来自嵌套结构或多线程共享处理思路从这张表出发也能推到。5.3 安全传参的几种模式真正严谨的代码不只是“避开坑”而是从一开始就建立一套安全模式。我常用的有四种你可以根据场景选择。模式一只读参数加副本隔离。函数需要读取一个列表但不希望影响外部在函数入口做浅拷贝def analyze(data): data list(data) # 之后所有操作都作用于副本 return data[0]如果传入的是自定义对象也可以用copy.copy(obj)但前提是对象支持浅拷贝语义且有足够的可拷贝边界。模式二默认不可变 返回新对象。这是最推荐的处理数据转换的方式def add_item(item, itemsNone): if items is None: items [] return items [item]这个写法不会修改任何外部对象所有修改都体现在返回值里。函数式风格的好处是副作用最小调用方想保留原始数据就保留想接新结果就接新结果。模式三明确设计为“就地修改”。如果你真的需要改外部对象就让接口名把意图说清楚。例如def update_metrics(metrics, new_value): metrics[value] new_value只要调用方看到update_前缀就会知道这是带副作用的函数不会误以为原对象没变。或者用动词 对象名的组合比如increase_counter、add_to_queue让语义暴露副作用。模式四类与外部容器之间做所有权隔离。类初始化时不要把外部容器直接存起来除非你明确想共享。更安全的版本在构造函数里拷贝容器并注释原因class TaskList: def __init__(self, items): # 防止调用方后续修改 items 影响实例内部状态 self.items list(items)这种“所有权边界”的概念是从 Java、Rust 里借鉴来的思维。Python 语言本身不管你但代码应该自己画清楚边界。6. 经验哪些“坑”其实可以有意识利用6.1 用可变默认参数做缓存前面说了那么多“不要用可变默认参数”但还有一种常见技巧是故意利用它函数级缓存。Python 官方文档和不少开源库都这么干过比如用默认字典做记忆化memoizationdef fib(n, cache{0: 1, 1: 1}): if n not in cache: cache[n] fib(n - 1) fib(n - 2) return cache[n]因为默认参数在函数定义时创建一次并且每次调用如果没传 cache都会拿到同一个字典所以它就天然成了跨调用的缓存容器。这种写法能跑但在多人协作的项目里我不太推荐因为它藏了一个外部可观察的状态可读性和可测试性都会打折扣。真要做缓存建议用functools.lru_cachefrom functools import lru_cache lru_cache(maxsizeNone) def fib(n): return 1 if n 1 else fib(n - 1) fib(n - 2)语义更清晰也不会让参数默认值背上“共享状态”的锅。我自己只在快速原型、脚本里用可变默认参数做一次性缓存提交到正式代码库前都会改成装饰器方案。6.2 就地修改还是返回新对象接口设计比技巧更重要参数传递问题到最后其实不是语言问题而是接口契约问题。你需要决定每个函数对外暴露的是“原地操作”语义还是“纯函数”语义。这个决定最好在做设计时就明确而不是写完再猜。我的默认选择是数据转换类操作返回新对象维护类操作允许就地修改但用命名和文档明确标注。比如# 纯函数风格不修改外部 def filter_active(records): return [r for r in records if r[active]] # 就地更新修改外部字典 def update_config(config, key, value): config[key] value查 bug 时如果所有纯函数都没有副作用所有带副作用的函数都一眼能看出来调试范围会缩小很多。另外我习惯在函数 docstring 的第一行写上“会不会修改传入参数”。这一行字看似多余但在三个月后重新读代码时能帮你免去从函数体里找副作用的时间。6.3 代码审查时重点看什么最后分享一点我在 code review 时针对参数传递问题的检查清单。先看函数签名里有没有可变默认参数有就停下来问一句你是故意用缓存还是不小心再看函数体里有没有对参数调用原地方法比如 append、extend、update、sort、reverse、pop、remove、insert 之类如果有确认函数命名是否暗示了副作用然后看类初始化时是否直接保存外部传入的容器如果是确认是否需要隔离开最后看调用链一个列表被传入三个函数之后外部那个变量还是不是你期望的内容。这套检查做下来大部分参数传递 bug 都能在提交前被拦截。我在多个项目里推行过这个清单效果很好线上因为引用共享导致的问题明显减少。Python 给了我们很大的自由而成熟的工程实践就是把自由化为清晰的边界。参数传递看着是个小知识点但弄懂它你会在写函数、设计类、排查共享状态问题时都少走很多弯路。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →