Python 可变默认参数的陷阱
什么是默认参数?
在定义函数时,我们可以为参数指定一个默认值。当调用者没有提供该参数时,函数就会使用这个预设的值。这让函数调用变得更灵活。
def greet(name="世界"):
return f"你好, {name}!"
print(greet()) # 输出: 你好, 世界!
print(greet("小明")) # 输出: 你好, 小明!
看起来一切都很完美,但当你把默认值设置为可变对象(如列表、字典、集合)时,故事就变得惊悚起来了。
可变对象与不可变对象速览
| 类型 | 可变性 | 例子 |
|---|---|---|
| 不可变 | 否 | int, str, tuple |
| 可变 | 是 | list, dict, set |
关键区别: 可变对象的内容可以原地修改,而不可变对象每次修改都会创建一个新对象。
陷阱:默认参数只初始化一次
Python 在定义函数时(而非每次调用时)就对默认参数进行求值。这意味着,如果默认值是一个可变对象,那么这个对象会被所有函数调用共享。
看一个经典翻车现场:
def add_item(item, target=[]):
target.append(item)
return target
print(add_item(1)) # 输出: [1]
print(add_item(2)) # 预期: [2],实际输出: [1, 2]
print(add_item(3)) # 预期: [3],实际输出: [1, 2, 3]
发生了什么?
- 函数定义时,
target=[]创建了一个列表对象,并将其绑定到函数对象的一个属性上(add_item.__defaults__)。 - 第一次调用
add_item(1):没有传入target,使用了那个唯一的列表对象,append(1)后列表变为[1]。 - 第二次调用
add_item(2):依然使用同一个列表对象,此时它已经是[1],再追加2就变成了[1, 2]。 - 这个共享的列表对象会在程序的整个生命周期里持续累积数据,除非你显式传入新的列表。
这通常不是你想要的行为,它会导致难以察觉的 bug。另一个常见例子是字典:
def set_config(key, value, config={}):
config[key] = value
return config
print(set_config("theme", "dark")) # {'theme': 'dark'}
print(set_config("lang", "zh")) # {'theme': 'dark', 'lang': 'zh'} 配置被污染了!
深入原理:__defaults__ 和 __kwdefaults__
每个函数对象都有一个 __defaults__ 属性,它是一个元组,按位置顺序存储了所有具有默认值的参数值。
def buggy(a, b=[]):
pass
print(buggy.__defaults__) # 输出: ([],)
如果修改了该列表,你可以直接在 __defaults__ 中看到变化:
buggy.__defaults__[0].append(99)
print(buggy.__defaults__) # 输出: ([99],)
对于关键字参数,默认值存储在 __kwdefaults__ 中(一个字典)。
核心规律: 默认参数的值是存储在函数对象中的“静态”数据,而不是每次调用时动态生成的新实例。
对比不可变默认参数
如果把默认值设为不可变对象(如整数、字符串、元组),则不会出现共享副作用,因为任何“修改”操作实际上都会创建新对象,而不会改变原对象。
def safe(num=0):
num += 1
return num
print(safe()) # 1
print(safe()) # 1 (每次都从默认值 0 开始)
num += 1 对于整数而言是创建了一个新的整数 1,并赋值给局部变量 num,并不会修改 __defaults__ 中的 0 对象。
最佳实践:如何避开陷阱?
永远不要使用可变对象作为默认参数。使用 None 作为哨兵值,然后在函数体内部动态创建可变对象。
✅ 正确的写法:
def add_item(item, target=None):
if target is None:
target = [] # 每次调用都获得全新的列表
target.append(item)
return target
print(add_item(1)) # [1]
print(add_item(2)) # [2],完美!
对于字典:
def set_config(key, value, config=None):
if config is None:
config = {}
config[key] = value
return config
为什么这样能奏效?
None 是不可变对象,每次函数调用时 target 如果没有传入参数,它就被赋值为同一个 None 对象。但我们在函数体内部立即检查并创建了新的可变对象,这就保证了每次调用都使用独立的新实例。
特殊情况:故意利用该行为
极少数情况下,你可以有意识地利用这个“静态变量”效应来实现缓存或记忆功能,但务必清晰注释,否则会让维护者感到困惑。
def memoize_factorial(n, cache={}):
if n in cache:
return cache[n]
if n <= 1:
result = 1
else:
result = n * memoize_factorial(n-1, cache)
cache[n] = result
return result
即便如此,更推荐的方法是使用 functools.lru_cache 或闭包,而不是依赖默认可变参数。
检查清单与快速记忆
- 默认参数仅在函数定义时计算一次。
- 可变对象默认值会在多次调用间共享状态。
- 杜绝写法:
def f(x, lst=[]):或def f(x, d={}):。 - 推荐写法:
def f(x, lst=None):并在函数体内if lst is None: lst = []。 - 用注释说明意图,尤其是当你真的想借助这种特性时。
常见面试题速答
问:以下代码输出什么?
def magic(a, b=[]):
b.append(a)
return b
print(magic(1))
print(magic(2))
答: [1] 然后 [1, 2]。因为列表 b 是同一个对象,在两次调用间被追加。
问:如何修复?
答: 将默认参数改为 None,在函数内部判断 if b is None: b = []。
总结
可变默认参数的陷阱是 Python 学习者几乎必踩的一个坑。理解了函数定义时的求值规则以及对象可变性的概念后,你就能轻松绕开它。记住那条黄金法则:永远用 None 代替可变对象作为默认值,并在函数体内部创建新的实例。 这样你的代码将变得可预测、无副作用,调试起来也轻松得多。