说实话,这个争论在Python圈子里基本是“月经帖”:有人信誓旦旦说Python传值,有人搬出官方文档说是传引用,两边都能拿代码例子证明自己没错。我早年也因为这个问题在代码review时跟同事吵过一架,最后两个人拿着各自的例子去查资料才搞清楚——Python其实走的是第三条路:传对象引用(call by object reference),也有人叫它“共享传参”(call by sharing)。
这篇文章我打算把这套机制彻底拆开讲明白,从底层原理讲到默认参数陷阱,再讲到多类型参数组合顺序,最后给出一套真实项目里的排查思路。不管你是刚学Python的新手,还是已经写了两三年、偶尔被参数改值问题坑过的老手,这篇文章都能帮你省掉不少排查时间。
1. 先搞清楚:Python到底传的是值还是引用?
1.1 两个经典现象,让多少人争论不休
先看两段代码。
第一段,传一个整数:
python复制def add_one(x):
x = x + 1
a = 10
add_one(a)
print(a) # 输出 10
调用完函数之后,a 还是 10,函数内部的 x 怎么加都不会影响到外部的 a。看起来像是“按值传递”。
第二段,传一个列表:
python复制def append_item(lst):
lst.append(99)
items = [1, 2, 3]
append_item(items)
print(items) # 输出 [1, 2, 3, 99]
这次 items 被函数改动了,多了个 99。看起来又像是“按引用传递”。
两个现象放在一起,谁都没法完全说服谁。其实问题不在“值”或“引用”这两个词,而在很多人对Python变量模型的理解本身就偏了。要搞懂这个,必须回到Python对“变量”的定义上。
1.2 官方文档怎么说:一切都是对象引用
Python官方文档里有个非常核心的观点:Python中一切皆对象,变量本身不是存储数据的盒子,而是贴在对象上的名字标签(名字绑定)。a = 10 意思是创建一个值为 10 的整数对象,然后把名字 a 绑定到这个对象上。以后再写 a = 20,并不是把原来那个 10 改成 20,而是创建一个新的整数对象 20,然后把 a 重新绑定上去。
所以函数参数传递的时候,传的既不是“值的副本”,也不是“C++那种变量地址”,而是对象的引用本身。说得直白一点:函数拿到的是同一个对象,不是复制品。
我用快递打个比方。你寄了一个包裹,快递员手里拿的是包裹单号,不是包裹的复印件,也不是仓库钥匙。快递员可以通过单号找到包裹、打开包装、往里面塞东西,这是“修改对象”;但如果快递员在系统里重新录了一个新单号,仓库里原来的包裹不会变,这是“重新绑定”。函数参数传递就是这种关系,关键在于:你拿着单号是去改包裹内容,还是把单号改掉。
1.3 用id()和is看透本质
光说不练假把式,写段代码验证一下。id() 函数返回对象的唯一标识,你可以把它理解成对象在Python解释器里的“内存地址”。
python复制def inspect(x):
print("函数内一开始的 id:", id(x))
x = 123 # 重新绑定
print("重新赋值后的 id:", id(x))
a = 456
print("函数外 a 的 id:", id(a))
inspect(a)
print("函数外 a 的值:", a)
运行结果大概是这样的(每次运行地址值不同,但关系一致):
code复制函数外 a 的 id: 140736269873552
函数内一开始的 id: 140736269873552
重新赋值后的 id: 140736269867760
函数外 a 的值: 456
看到没,函数内部一开始拿到的对象和外部 a 是同一个(id 一样),说明传的是引用;但执行 x = 123 之后,x 指向了一个新对象(id 变了),而外部的 a 依然指向原来的 456。
顺便说下 is 和 == 的区别:is 比较的是两个变量是不是指向同一个对象(即 id 是否相等),== 比较的是两个对象的值是否相等。以后排查参数问题时,is 能帮你判断“这是同一个对象还是只是值碰巧相同”,比单纯看值有用得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 可变对象与不可变对象在传参时的表现差异
2.1 不可变对象:表现出来像“按值传递”
整数、浮点数、字符串、元组、frozenset 这些都属于不可变对象。所谓不可变,就是对象一旦创建,内容就不能被修改。你要“改”一个不可变对象,实际能做的是创建一个新对象,然后把变量绑定到新对象上。
这就是为什么第一节里 add_one 函数内部的 x = x + 1 不影响外部变量:x + 1 生成了一个新的整数对象,x 重新绑定了,但外部的 a 还在原来的对象上。
字符串也是一样:
python复制def add_suffix(s):
s += "!"
return s
text = "hello"
add_suffix(text)
print(text) # 输出 hello
函数内部 s 变成 hello! 了,但外部的 text 还是 hello,因为 += 对不可变对象来说就是生成新对象然后重新绑定。
这里有个容易忽略的坑:元组本身不可变,但它里面装的元素可变。比如:
python复制t = ([1, 2], 3)
def change(t):
t[0].append(99)
change(t)
print(t) # 输出 ([1, 2, 99], 3)
你可以说“元组是只读的盒子,但盒子里放的列表是活的”。判断是否影响外部,永远不要只看最外层类型,要看实际操作的是哪个对象。
2.2 可变对象:真实影响外部
list、dict、set 以及自定义类的实例都是可变对象。函数内部通过对象提供的方法或索引赋值去修改内容时,外部看到的对象还是原来那个,所以修改自然对外可见。
最常见也最容易出事的是字典:
python复制def enable_debug(config):
config["debug"] = True
cfg = {"name": "demo"}
enable_debug(cfg)
print(cfg) # 输出 {'name': 'demo', 'debug': True}
我在真实项目里就被这个坑过:一个全局配置字典传给下游某个处理函数,那个函数里顺手加了几个内部字段,结果后面所有依赖配置的模块都多了一堆莫名其妙的键,日志排查了很久才发现。
自定义对象更明显:
python复制class Counter:
def __init__(self):
self.count = 0
def increment(c):
c.count += 1
counter = Counter()
increment(counter)
print(counter.count) # 输出 1
c.count += 1 虽然是“重新绑定属性”,但 c 本身指向的对象没变,所以外部的 counter 依然能看到变化。
2.3 关键区分:重新绑定 vs 原地修改
我总结过一句话:判断函数是否影响外部,别看函数长什么样,只看它操作的对象是“被替换”还是“被修改”。
代码对比最直观:
python复制def use_binding(lst):
lst = [99, 100] # 重新绑定:只是让局部变量指向新对象
def use_mutation(lst):
lst.append(99) # 原地修改:直接改变了传入对象的内容
items = [1, 2, 3]
use_binding(items)
print(items) # [1, 2, 3]
use_mutation(items)
print(items) # [1, 2, 3, 99]
这里还有两个高频迷惑操作,一个是 +=,一个是切片赋值。
python复制def add_by_plus(lst):
lst += [1] # 对 list 来说,+= 是原地修改,影响外部
def add_by_new(lst):
lst = lst + [1] # 生成了新列表再绑定,不影响外部
a = [1, 2]
add_by_plus(a)
print(a) # [1, 2, 1]
b = [1, 2]
add_by_new(b)
print(b) # [1, 2]
造成差异的原因:lst += [1] 底层调用的是 list.extend 这类原地方法;而 lst = lst + [1] 是先算一个新列表再赋值。两者表面都是“加一个元素”,本质完全不同。
我把常见操作按“是否会影响到外部”整理成一张表,贴在下面:
| 操作 | 是否影响外部 | 原因 |
|---|---|---|
lst.append(x) / extend / insert |
是 | 原地修改 |
lst += [x] |
是 | 原地修改(list 的 __iadd__) |
lst = lst + [x] |
否 | 生成新对象后重新绑定 |
lst.sort() |
是 | 原地排序 |
sorted(lst) |
否 | 返回新列表 |
lst[:] = [x] |
是 | 切片赋值是原地替换元素 |
d["k"] = v / dict.update() |
是 | 原地修改 |
s.add(x) |
是 | 原地修改 |
number = number + 1 |
否 | 不可变对象,重新绑定 |
s += "!"(字符串) |
否 | 生成新字符串对象 |
做代码审查或者排查线上问题时,先对着这张表过一遍,能解决七成以上的参数修改问题。
3. 令人迷惑的默认参数陷阱
3.1 可变默认参数的经典坑
Python 默认参数有一个极其经典的坑,我敢说每个写Python的人早晚都会踩一次:
python复制def append_to(element, target=[]):
target.append(element)
return target
print(append_to(1)) # [1]
print(append_to(2)) # [1, 2]
print(append_to(3)) # [1, 2, 3]
第二次调用明明没有传 target,结果却是 [1, 2]。很多人第一次看到时都懵了:默认的参数不该每次都是空列表吗?
问题出在默认参数只在函数定义时被求值一次。def append_to(element, target=[]) 这条语句执行时,Python 创建了一个空列表对象,把它绑定为 target 的默认值。之后每次调用如果不传 target,用的都是这同一个列表对象。第一次 append 了 1,第二次还是这个列表,再 append 2,于是越攒越多。
3.2 官方推荐:None哨兵写法
修复方式很简单,也是官方推荐的标准写法:
python复制def append_to(element, target=None):
if target is None:
target = []
target.append(element)
return target
print(append_to(1)) # [1]
print(append_to(2)) # [2]
print(append_to(3)) # [3]
用 None 作为哨兵,函数内部每次判断 target is None 就新建一个列表,彻底避免了共享对象的问题。
这里我特别提一下:一定要用 is None,不要用 == None。原因有两个:
None在 Python 中是单例对象,全局只有一个,所以is判断是身份比较,速度快且绝对准确。==是值比较,如果传入对象重载了__eq__方法,可能出现“看着像 None 但不是 None”的诡异情况。
3.3 默认参数为什么在定义时求值
理解默认参数为什么在定义时求值,得先明白一个事实:在 Python 里,def 是一段普通的执行语句。当解释器执行到 def 时,它会创建一个函数对象,同时把默认参数表达式立即求值并绑定到函数对象上。真正等到函数被调用时,Python 并不会重新计算默认参数表达式。
这种设计的好处是性能:如果默认参数每次调用都重新求值,那 def f(x=[]) 这种写法每次调用都要新建列表,开销不小。坏处就是可变默认值的坑。
生活化类比一下:这就像办会员卡时填的默认地址,办卡那天系统就把地址锁定了,之后每次消费如果不改地址,商家都按那个老地址发货。你以为每次都是新地址,其实系统里存的是同一个。
这也是为什么我在代码规范里会对团队强调一句话:可变对象永远不要出现在默认参数里。如果确实需要一个不可变的默认集合,可以用 tuple 代替,比如 def f(target=()) 就没问题,因为元组不可变,不存在共享修改的隐患。
4. 多类型参数的解析顺序与常见组合
4.1 参数类型总览
Python 函数的参数类型比很多人想的要丰富,至少有四类:
- 位置参数:按调用时的顺序传入,比如
def f(a, b)中的a、b。 - 关键字参数:调用时用
名字=值传入,比如f(b=2, a=1),顺序可以打乱。 *args:收集所有多余的位置参数,打包成一个元组。**kwargs:收集所有多余的关键字参数,打包成一个字典。- 仅关键字参数(keyword-only):在某些条件下,即使你没有给它默认值,它也只能用关键字方式传入,不能按位置传。
先看个完整的例子:
python复制def show_info(name, *args, age=18, **kwargs):
print("name =", name)
print("args =", args)
print("age =", age)
print("kwargs =", kwargs)
show_info("Tom", "extra1", "extra2", age=20, city="Beijing", job="Engineer")
输出:
code复制name = Tom
args = ('extra1', 'extra2')
age = 20
kwargs = {'city': 'Beijing', 'job': 'Engineer'}
这里的关键点:
name是普通位置参数,必须提供。"extra1"、"extra2"被*args收走,打包成元组。age因为有默认值,并且位于*args之后,它变成了仅关键字参数——这意味着你不能写show_info("Tom", 20)把 20 当age传入,这个 20 会被*args收走。必须写成age=20才行。city、job这类多余的关键字参数被**kwargs收走,打包成字典。
4.2 参数组合规则与常见写错
Python 对参数顺序有硬性语法规定,合法的声明顺序是:
python复制def func(普通位置参数, *args, 仅关键字参数, **kwargs):
pass
更精确地说,所有参数必须按下面的顺序排列:
| 顺序 | 参数类型 | 示例 |
|---|---|---|
| 1 | 普通位置参数(可带默认值) | a, b=1 |
| 2 | *args |
*args |
| 3 | 仅关键字参数(可带默认值) | c, d=1 |
| 4 | **kwargs |
**kwargs |
写错顺序会直接报 SyntaxError。最常见的错误是有人想把 *args 放到带默认值的参数之后:
python复制def wrong(a=1, *args): # 语法允许但不推荐
pass
这个语法上其实不报错,但 a 带默认值放在 *args 前,容易让人误以为 a 可以省略。实际调用时如果不传 a,a 就取默认值 1,多余的位置参数全部进 *args,行为很容易混乱。建议普通参数一律放最前面。
另一个常见问题:**kwargs 之后不能再跟任何参数,否则报 SyntaxError。想把仅关键字参数放在 **kwargs 后面是做不到的,必须在它前面。
4.3 解包传参的细节
除了定义时的 *args 和 **kwargs,调用时也有对应的解包语法,很多人容易混淆。
可迭代对象解包为位置参数:
python复制def add(a, b, c):
return a + b + c
nums = [1, 2, 3]
print(add(*nums)) # 相当于 add(1, 2, 3)
*nums 把列表里的三个元素“拆开”,一一对应到 a、b、c。元组、字符串等所有可迭代对象都能这样解包。
字典解包为关键字参数:
python复制def show(name, age):
print(f"{name} is {age} years old")
info = {"name": "Tom", "age": 30}
show(**info) # 相当于 show(name="Tom", age=30)
这里要注意,解包只是“按元素传递”,跟第 2 节说的“对象引用传递”是两码事。解包是把集合里的元素取出来作为独立的参数传进去,并不会把整个列表或字典传进去。
我最后提醒一个容易踩的细节:f(**{"a": 1, "a": 2}) 这种写法能正常执行,因为字典字面量先求值合并成了 {"a": 2},等价于 f(a=2);但如果直接写 f(a=1, a=2),解释器会在编译阶段直接报 SyntaxError。同一个关键字传两次,Python 不允许。
5. 真实项目中的参数传递经验与问题排查
5.1 三个最容易踩的坑与排查套路
做项目这几年,我归纳了三个和参数传递相关的典型事故:
坑一:共享配置被下游函数污染
某个模块定义了一个全局字典 config,传给一个内部函数做数据处理,那个函数里顺手 config["xxx"] = yyy,导致后面所有读 config 的地方行为全变了。这种问题的隐蔽之处在于函数名和数据本身看起来毫无关系,排查起来很费劲。
坑二:默认参数共享
某个函数定义了 def process(data, result=[]),第一次调用 OK,第二次调用发现 result 里莫名其妙带着上一次的数据。症状很像是“缓存残留”,其实是默认参数共享。
坑三:函数内外同名变量带来的错觉
python复制def demo(lst):
lst = lst + [1] # 重新绑定,不影响外部
data = [1, 2]
demo(data)
print(data) # [1, 2]
因为函数内外都叫 lst,容易误以为“我对 lst 做了操作,外部 data 也应该变”。其实局部变量名跟外部变量名是否相同根本不重要,关键看操作是重新绑定还是原地修改。
我的排查套路一般分三步:
- 打印
id()和type():确定函数内外拿到的到底是不是同一个对象。 - 最小化复现:把业务代码删到只剩传参和报错相关的部分,看问题是否还能出现。
- 检查函数内所有对参数对象的操作:逐个看是原地方法(append / update / setitem)还是赋值运算符,对照上面的表格判断。
5.2 防御性拷贝:什么时候该用、什么时候别用
既然函数会修改可变对象,那么不想被改怎么办?最直接的办法是传参前做拷贝:
python复制import copy
original = {"name": "demo", "tags": [1, 2]}
result = process(copy.deepcopy(original))
copy.copy() 是浅拷贝,copy.deepcopy() 是深拷贝。浅拷贝只拷贝最外层,如果对象里嵌套了 list 或 dict,内层仍然是共享引用,函数依然可能修改内层对象。深拷贝会递归复制所有内容。
但拷贝不是万能的,它的开销很大。我在处理百万级数据时,每次 deepcopy 都可能让程序运行时间翻好几倍,这时候就要权衡:
- 如果函数只是读数据、不改数据,没必要拷贝,浪费。
- 如果函数确实会改对象,且这个对象后面还要用,优先考虑改成“函数内不修改原对象”的设计,比如读取后生成新对象返回。
- 如果对象被多个模块共享,修改风险高,或者数据量不大但很容易出错,那进函数前 copy 一次很值得。
我个人的经验准则是:能用设计解决的就不要用拷贝解决。比如函数内部明确只读,就把类型标注写成 Sequence 或 Mapping 这类只读接口,并在 docstring 里写明“本函数不会修改传入对象”;如果实在要改,优先返回新对象而不是原地改参数。
5.3 让传参更健壮的几个好习惯
最后分享几个实际操作中总结出来的习惯,帮我减少了很多传参问题:
用类型标注表达意图
python复制def merge_config(base: dict, extra: dict) -> dict:
# 加上标注后,读代码的人一眼就知道参数类型
...
类型标注不会改变运行时行为,但是最好的“文档”,能让意图更清晰。现代编辑器(VS Code、PyCharm 等)也会基于标注做静态检查,很多类型不匹配的问题在写代码阶段就能发现。
凡是可变对象,默认参数一律 None 哨兵
这是团队代码规范里最硬的一条。不管是谁,写 def f(x=[]) 出现在 code review 里都会被直接打回。
对外 API 尽量用关键字参数
对外提供的函数,如果参数超过三四个,建议让调用方用关键字传入,避免记错顺序。运行时如果需要强制某些参数只能按关键字传,可以把它们放在 * 之后:
python复制def send_request(url, *, method="GET", timeout=10):
...
这里的 * 是一个裸参数,它本身不接收值,只表示后面的 method 和 timeout 都是仅关键字参数。调用 send_request("http://...", "POST") 会直接报错,必须写 send_request("http://...", method="POST")。这在写公共接口时特别好用。
团队约定:改或不改,文档里说清楚
我现在的习惯是,函数如果会修改传入的可变对象,docstring 里必须写明;不会修改的,也尽量注明“只读操作”。这个习惯花的时间很少,但能让接手代码的同事少踩很多坑。
我自己做 Python 开发这几年,因为参数传递引发的线上事故至少遇到过五六次。记忆最深的是一次数据清洗任务:上游把一个大列表传给下游函数,下游有个临时需求直接 remove 了几个元素,结果上游所有后续逻辑全部错位。当时排查到凌晨,最后发现问题的一瞬间,脑子里蹦出来的就是“对象引用 + 原地修改”这两个词。
说真的,搞懂 Python 的传参机制,最大的价值不在于面试时能背出“传对象引用”这个术语,而在于遇到 bug 时你能第一时间判断出该往哪个方向查。以后再遇到函数改动了外部变量,先别急着怀疑是“玄学”,打印一下 id(),对照一下“重新绑定 vs 原地修改”,绝大多数问题都能在半分钟内定位。
