资讯动态

Python参数传递机制:传对象引用还是传值?

发布时间:2026/9/18 4:52:17 来源:尧图企业网站定制
如果你去面试 Python 岗位十有八九会被问到一个问题Python 的函数参数到底是传值还是传引用我见过太多人在这个问题上栽跟头。有人说“传值因为我在函数里改 int、改 str外面完全没变化”也有人说“传引用因为我往函数里传一个 list函数里 append 之后外面真的变了”。这两种回答在各自的例子里都解释得通但一旦换个场景立刻翻车。其实 Python 的参数传递机制既不完全是传值也不完全是传引用而是传对象引用call by object reference在学界更常见的叫法是按共享传递call by sharing。这篇内容我不打算只给你一个结论而是从现象到原理从易错点到实战排查一次性把这个机制讲透。无论你是刚学 Python 的新手还是准备跳槽的老手这篇文章都可以直接当成复习资料用。1. 参数传递机制的真相传对象引用1.1 三个经典示例先感受差异我们先不背概念直接看现象。请先记住这三个例子的输出结果你会在后面反复用到。第一个例子传一个整数进函数函数里做加法def change_int(x): x x 1 return x a 10 result change_int(a) print(外部 a:, a) # 外部 a: 10 print(函数返回:, result) # 函数返回: 11外部变量a没有变。很多人看到这里脱口而出“这就是传值”。第二个例子传一个列表进函数函数里 appenddef append_item(lst): lst.append(100) my_list [1, 2, 3] append_item(my_list) print(my_list) # [1, 2, 3, 100]外部列表居然变了。看到这里同一批人又会说“好吧其实还是传引用”。第三个例子再传一个列表进函数但这次函数内部给形参重新赋值def reassign_list(lst): lst [100, 200, 300] my_list [1, 2, 3] reassign_list(my_list) print(my_list) # [1, 2, 3]外部列表又没变。这就很有意思了同样是列表append能改外面重新赋值却改不了。如果你认为 Python 是纯传值就无法解释第二个例子如果你认为是纯传引用就无法解释第三个例子。你必须换一个更精确的模型才能把这三个现象统一起来。1.2 变量是标签不是盒子要理解 Python 的参数传递第一步要纠正一个根深蒂固的直觉在 C 语言里变量像一个盒子里面装着值但在 Python 里变量更像一张便利贴贴在一个对象身上。对象在内存里是真实存在的而变量名只是一个指向对象的引用。我们可以用id()函数来观察对象的“身份”。在 CPython 实现里id()返回对象的内存地址你可以把它理解为对象的唯一编号a 10 print(id(a)) # 比如 140735490589392 a a 1 print(id(a)) # 变成了另一个地址a a 1并不是在原地的10这个对象上做修改而是先计算出11创建了一个新对象然后把标签a贴到新对象上。整数是不可变对象这句话的本质就是你永远无法原地修改整数对象的值。再看列表lst [1, 2, 3] print(id(lst)) # 比如 2202073980736 lst.append(4) print(id(lst)) # 地址没变还是同一个对象append是在原来的对象上做原地修改lst这个标签始终贴在同一块内存上。所以id不变。这就是可变对象和不可变对象最核心的行为差异操作时是创建新对象还是修改原对象。那这和参数传递有什么关系有直接关系。Python 实参传给形参时本质上是把实参对象“贴”了另一张标签也就是形参。形参和实参都是指向同一个对象的标签。1.3 为什么传值和传引用都不准确明确了“变量是标签”之后我们再看上面的三个例子就豁然开朗了。change_int(a)执行时形参x和实参a先同时指向整数10。但x x 1创建了新对象11并让x指向新对象。此时a仍然指向10。所以外部变量没变。append_item(my_list)执行时形参lst和实参my_list同时指向同一个列表对象。lst.append(100)是原地修改所以my_list指向的那个列表对象内容变了。reassign_list(my_list)执行时形参lst一开始也指向同一个列表但函数体内lst [100, 200, 300]创建了一个新列表让lst重新指向新对象。实参my_list仍然指向旧列表所以外部没变。也就是说传值说错在认为函数拿到的是副本无法解释可变对象的原地修改传引用说错在认为函数能直接操作外部变量本身无法解释重新赋值对调用方无影响。真实情况是函数拿到的是对象的引用这个引用本身是按值传递的但引用的目标对象可以共享。生活化类比对象是一间房子变量是贴有名字的门牌函数调用等于是把另一块门牌也挂到同一间房子上。如果你在这间房里摆家具原地修改所有门牌指向的房子都会变如果你把门牌摘下来挂到另一间房子重新赋值原来的房子当然不受影响。2. 不可变对象与可变对象分水岭2.1 不可变对象函数修改时发生了什么Python 里的不可变对象包括整数、浮点数、字符串、元组、frozenset、bytes 等。它们最要命的特点是任何操作都不会改变原对象本身而是生成一个新对象。比如字符串拼接def append_str(s): s world text hello append_str(text) print(text) # hello对字符串来说等价于s s world它创建了一个新字符串然后让形参s指向新字符串。外部text指向的还是原来的字符串所以不受影响。元组类似def try_modify_tuple(t): # t[0] 100 # 这一行会直接 TypeError t t (4, 5) # 创建了一个新元组 data (1, 2, 3) try_modify_tuple(data) print(data) # (1, 2, 3)不可变对象在函数里折腾半天外部一动不动这是很多“传值论”支持者的底气来源。但注意他们只看到了不可变对象的场景并没有看到全局。2.2 可变对象原地修改 vs 重新赋值可变对象包括列表、字典、集合、bytearray以及所有自定义类的实例。对它们做操作时要格外小心因为一部分操作是原地修改另一部分操作是重新赋值。列表常见的原地修改方法有append/extend/insertremove/pop/clearsort/reverse下标赋值lst[0] x切片赋值lst[1:3] [a, b]常见的重新赋值操作有lst lst [x]lst lst * 2lst sorted(lst)字典的原地修改方法有d[key] valued.update(other)d.pop(key)/d.setdefault(key, value)d.clear()d d.copy()这种操作则是重新赋值。再强调一次判断标准只有一个操作后id()是否变化。原地修改id()不变重新赋值id()变。2.3 实际写代码时怎么判断我自己实际写代码时很少去背方法清单而是用一条规则快速判断等号左边是变量名右边是新建对象时是重新绑定调用对象的方法或使用下标赋值时通常是原地修改。例def handle(data): data[count] 1 # 字典是可变对象下标赋值是原地修改 # data {count: 1} # 如果这么写外部不会变化但是有几个例外需要单独记list的等价于extend是原地修改后面会细说dict的|运算符Python 3.9也是原地 updatea a b对不可变对象是重新绑定对可变对象也是重新绑定因为会创建新列表这种判断方式在绝大多数场景都成立能帮你避开最常见的坑。3. 新手最容易踩的 3 个坑3.1 可变默认参数经典中的经典Python 里有个著名的反模式很多教程都拿它当反面教材def wrong_append(item, cache[]): cache.append(item) return cache print(wrong_append(1)) # [1] print(wrong_append(2)) # [1, 2] print(wrong_append(3)) # [1, 2, 3]每次调用函数明明没有传第二个参数结果却越攒越多。原因是默认参数[]只在函数定义时创建一次它不是一个全新的列表。所有不传第二个参数调用的人共享的是同一个列表对象。函数里cache.append(item)是原地修改所以每次调用都会在这个共享列表上留下痕迹。这本质上不是参数传递机制的问题但它是参数传递机制最容易引发的事故现场。解决办法是使用None哨兵def right_append(item, cacheNone): if cache is None: cache [] cache.append(item) return cache print(right_append(1)) # [1] print(right_append(2)) # [2] print(right_append(3)) # [3]如果你在代码审查里看到有人写可变对象当默认参数基本可以让他改成None模式。这不是风格偏好问题而是功能性 bug。3.2的诡异行为操作符对不同类型的行为完全不同这是必须刻进肌肉记忆的知识点。对不可变对象如int、str、tupledef add_one(x): x 1 a 5 add_one(a) print(a) # 5x 1 等价于 x x 1创建了新对象对可变对象如listdef extend_list(lst): lst [4] b [1, 2, 3] extend_list(b) print(b) # [1, 2, 3, 4]对 list 来说 原地扩展同一个操作符两种命运。为什么因为list实现了__iadd__方法lst [4]会调用lst.__iadd__([4])其实现等价于lst.extend([4])是原地操作。而int、str、tuple没有实现原地加法x 1会退化成x x 1创建新对象。如果你在函数里看到一个列表参数执行要清楚它会影响外部。如果你不希望外部受影响应该写成def safe_extend(lst): lst lst [4] # 重新绑定不修改原列表 return lst3.3 副本、切片与共享另一个经典场景是“我不想让函数修改我的数据但函数就是改了”。比如def process(data): data.reverse() return data original [1, 2, 3] result process(original) print(original) # [3, 2, 1]外部被意外修改了如果函数的设计意图是接收列表并返回处理结果但调用方并不想让原数据被改怎么办两个方向一个是调用方传入副本result process(original.copy()) print(original) # [1, 2, 3]另一个是函数内部先做副本再处理def process(data): data data.copy() data.reverse() return data这两种做法没有绝对优劣核心是**团队里要约定清楚函数是否会对传入的可变对象做原地修改。**如果会函数名最好体现出来或者在 docstring 里写清楚不然调用方很容易踩雷。深浅拷贝的问题也要注意。copy()是浅拷贝如果列表里嵌套了列表浅拷贝只复制外层容器内层元素仍然是同一批对象def modify_nested(data): data data.copy() data[0].append(x) # 内层列表还是同一个对象 original [[1, 2], [3, 4]] modify_nested(original) print(original) # [[1, 2, x], [3, 4]]内层还是被改了如果要完全隔离得用copy.deepcopy()。但 deepcopy 开销大而且可能遇到循环引用问题虽然 deepcopy 能处理循环引用但性能和内存消耗不能忽视。所以在设计阶段就该想清楚这个数据到底需不需要被深层独立4. 进阶闭包、装饰器与参数传递结合4.1 闭包捕获的是引用不是值参数传递机制不只出现在函数形参里闭包变量的捕获也是同一个道理。先看一个经典例子def outer(): x 10 def inner(): x 1 return x return inner f outer() print(f()) # 11 print(f()) # 12这里的inner捕获的变量x是outer作用域里的那个变量它捕获的是对象的引用而不是值。x 1对不可变对象会创建新对象但因为闭包里用到了x所以必须声明nonlocal x否则解释器会认为你在inner里新建了一个局部变量x。再比如循环变量捕获的陷阱funcs [] for i in range(3): funcs.append(lambda: i) for f in funcs: print(f()) # 2 2 2不是 0 1 2i是同一个变量循环结束后它的值是 2所有 lambda 函数捕获的都是同一个i的引用而不是循环到某个时刻的“快照值”。这就是为什么很多你没有经验的人会在这段代码上懵圈。如果想让每个函数记住自己循环时的值常见做法是把它作为默认参数funcs [] for i in range(3): funcs.append(lambda ii: i) for f in funcs: print(f()) # 0 1 2这里用到了默认参数在函数定义时绑定的特性ii把当前循环轮次的值作为默认值固定下来。这与前面说的“可变默认参数陷阱”本质上是一个机制的两个面理解了默认参数在定义时创建就能同时理解这两个问题。4.2 装饰器中的参数透传装饰器是 Python 里非常高频的用法而写装饰器时必然要处理参数透传的问题。比如import functools def my_decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): print(before call) result func(*args, **kwargs) print(after call) return result return wrapper这里的*args和**kwargs就是把接收到的参数原样转发给被装饰的函数。由于 Python 参数传递是传对象引用这种转发不会额外复制任何对象只是把实参对象的引用又传了一轮性能开销很小。看到*args的时候很多人会误以为它是“值”的集合。其实args是一个元组里面存的是各个实参对象的引用kwargs是一个字典值同样是引用。所以当你写args[0].append(1)时外面传进来的可变对象依然会被修改。这个细节在写装饰器时尤其重要因为你不一定清楚被装饰函数的调用方是否对数据有额外期望。如果我在装饰器里需要过滤或者修改参数通常会非常谨慎。除非明确知道被装饰函数需要这种行为否则不要直接修改args里引用指向的可变对象。4.3 函数设计的三个建议基于以上机制我写 Python 这么多年沉淀出三个关于函数设计的建议第一默认不修改入参。如果你的函数接收一个列表并需要返回一个处理后的列表优先返回新列表而不是用lst.sort()或lst.reverse()这种原地操作。这样对调用方最安全。第二如果必须修改入参函数名要说清楚。比如sort_inplace(data)、fill_defaults(config)让别人看函数名就知道调用后有副作用。第三对外暴露 API 时尽量使用不可变类型做防御。如果你不希望调用方传进来的数据被意外修改可以在函数入口用tuple()或MappingProxyType包装一层。虽然这会有一点性能开销但在数据完整性比性能重要的场景很值得。这些建议背后的核心就是今天讲的参数传递机制。你理解了“引用可以共享重新赋值会脱离”才会真正认可这些设计原则而不只是把它们当作风控要求背下来。5. 常见问题与排查技巧实录5.1 面试问答速览我把这个问题相关的面试高频问题整理成一个速查表问题答案关键原因Python 是传值还是传引用传对象引用按共享传递形参和实参共享同一对象但形参重新赋值不影响实参为什么函数里修改 int外面不变int 是不可变对象x x 1创建新对象新对象绑定到形参实参仍指向原对象为什么函数里 append外面变了list 是可变对象append是原地修改形参和实参指向同一个列表对象为什么函数里lst [1,2,3]外面不变创建新对象并重新绑定形参实参标签没动默认参数可以写成[]吗不能默认值只在定义时创建一次所有调用共享同一列表对 list 是原地操作吗是因为list实现了__iadd__等价于extend怎么避免函数意外修改传入的 list函数内先copy()/deepcopy()或调用方传副本副本是独立对象原地修改不影响原数据这些问题在真实面试中可能以各种变体出现但核心都指向同一个判断你是否清楚某个操作是创建新对象还是修改原对象。5.2 实战排查三步法在调试代码时如果发现“函数跑完数据不对”我的排查套路基本固定为三步。第一步看操作符。凡是、、、*出现的地方确认操作对象类型以及是否创建了新对象。第二步看方法名。append、extend、sort、reverse、update、pop这类方法十有八九是原地操作。如果看到调用方变量在函数结束后发生了变化优先怀疑这里。第三步用id()确认。实在捋不清楚时在函数入口和出口各打印一次输入对象的id看是否发生变化def debug_process(data): print(入口 id:, id(data)) # 执行一些操作 data.append(1) print(出口 id:, id(data))如果出口id和入口一致说明全程都是原地操作。这能快速定位问题出在哪个环节。5.3 一张表记住“创建新对象” vs “原地修改”最后给一个高频操作的对照表你可以直接截图保存类别创建新对象不影响外部原地修改影响外部整数x x 1、x 1无int 不可变字符串s s a、s a无str 不可变元组t t (1,)无tuple 不可变列表lst lst [1]、lst sorted(lst)lst.append(1)、lst.extend(...)、lst.sort()、lst.reverse()、lst[0]x字典d {**d, k: v}d[k] v、d.update(...)、d.pop(...)集合s s{1}你在写代码前先对照这张表确认一下你会发现 90% 与“参数传递”相关的问题都能提前规避。这个内容想再深入去讲还可以展开到多线程环境下共享可变对象带来的并发问题、用copyreg自定义浅拷贝行为、以及__slots__对实例属性可变性的影响。但核心其实已经讲完了Python 参数传递的本质就是传对象引用理解了“变量是标签”“对象分可变与不可变”“原地修改与重新绑定的区别”这三点你就已经超过了大多数写了两三年 Python 的人。我个人的体会是这个问题不值得死记结论值得理解机制。你越早把它想清楚写代码时越少遇到“明明改了却不生效”或“明明没改却被改了”的灵异事件。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价