如果你去面试 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没有变。很多人看到这里,脱口而出“这就是传值”。
第二个例子,传一个列表进函数,函数里 append:
def 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, cache=None): 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、tuple):
def add_one(x): x += 1 a = 5 add_one(a) print(a) # 5,x += 1 等价于 x = x + 1,创建了新对象对可变对象(如list):
def 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 i=i: i) for f in funcs: print(f()) # 0 1 2这里用到了默认参数在函数定义时绑定的特性:i=i,把当前循环轮次的值作为默认值固定下来。这与前面说的“可变默认参数陷阱”本质上是一个机制的两个面,理解了默认参数在定义时创建,就能同时理解这两个问题。
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 的人。我个人的体会是,这个问题不值得死记结论,值得理解机制。你越早把它想清楚,写代码时越少遇到“明明改了却不生效”或“明明没改却被改了”的灵异事件。