在 Python 的函数定义中,默认参数的值是在函数定义那一刻被求值的,而不是每次调用时重新创建。当默认参数指向一个可变对象(如列表、字典、集合)时,所有未显式传参的调用都会操作同一个对象,从而导致跨调用的状态残留。这种特性经常让初学者写出带有隐蔽 bug 的代码。

一、问题现场:被共享的列表
我们先看一段最常见的错误写法。开发者希望函数每次调用都返回一个全新的空列表,并在其中追加内容,但实际表现却出乎意料。
def append_item(item, target=[]):
target.append(item)
return target
print(append_item(1)) # 输出 [1]
print(append_item(2)) # 输出 [1, 2]
print(append_item(3)) # 输出 [1, 2, 3]
上面代码中,第二次调用 append_item(2) 时并没有传入 target,于是沿用了第一次调用所使用的同一个列表对象。由于列表是可变的,append 方法直接修改了原对象,使得后续调用不断累积历史数据。
这种问题在编写工具函数、Web 接口的参数默认值,或类方法的可选配置时尤其危险。因为调用方通常假设每次调用都是相互独立的,而实际行为却像在用全局变量。当函数被分散在多个模块调用时,bug 的复现路径会变得极难追踪。
二、底层原理:默认参数绑定时机
要理解这一现象,需要明确 Python 中函数也是对象,而默认参数保存在函数对象的 __defaults__ 属性里。以下代码可以验证这一点:
def demo(x=[]):
return x
print(demo.__defaults__) # 输出 ([],)
lst = demo()
lst.append(10)
print(demo.__defaults__) # 输出 ([10],)
从输出可以看出,默认参数列表在定义函数时就已经生成,并作为函数对象的一部分常驻内存。每次调用若未提供实参,Python 只是把 __defaults__ 中的那个对象传进去,并不会重新执行等号右边的表达式。
与之相对,不可变对象(如整数、字符串、元组)即使被共享也没有风险,因为不可变对象无法被原地修改。例如默认值为 0 或 'init' 时,多次调用之间不会相互干扰。这也解释了为什么官方文档明确建议:默认参数应使用 None 这类不可变占位符。
三、正确写法与替代方案
最被广泛接受的写法是使用 None 作为默认值,在函数体内部判断并创建新的可变对象:
def append_item(item, target=None):
if target is None:
target = []
target.append(item)
return target
print(append_item(1)) # 输出 [1]
print(append_item(2)) # 输出 [2]
print(append_item(3)) # 输出 [3]
这种结构保证了每次调用且未传参时,都会执行 target = [] 来生成独立列表。代码可读性高,也符合 Python 之禅中“显式优于隐式”的原则。
如果默认参数逻辑较复杂,也可以借助类型注解和文档字符串说明意图,或者将可变状态的初始化推迟到调用层。对于类设计,推荐把可变默认值放在实例属性中,通过 __init__ 方法赋值,而不是写在方法签名上,这样能彻底避免共享问题。
四、常见误区与排查建议
有人误以为把默认参数写成元组就能解决所有问题,但若元组里嵌套了列表,嵌套列表依旧是可变的,同样会产生共享。例如 def f(x=([],)) 中的内层列表仍会被多次调用共用。
def wrong(x=([],)):
x[0].append(1)
return x
print(wrong()) # 输出 ([1],)
print(wrong()) # 输出 ([1, 1],)
排查这类 bug 时,可以打印函数 __defaults__ 的 id,或者直接在调试器中观察默认对象地址是否随调用发生变化。静态检查工具如 pylint 也会对可变默认参数给出警告,建议在团队规范中强制开启相关规则。
总之,Python 默认参数不使用可变对象,不是风格偏好,而是对语言求值模型的必要尊重。掌握这一点,能让你写出更健壮、更易维护的函数接口。