Python 函数为什么不要使用可变对象作为默认参数?
简化版
Python 的默认参数在函数定义时求值,而不是每次调用时重新创建。如果默认值是列表、字典这类可变对象,多次调用会共享同一个对象,导致数据“串味”。常见写法是默认用 None,函数内部再创建新对象。
详细版
错误示例:
def add_item(item, items=[]):
items.append(item)
return items
print(add_item("a")) # ['a']
print(add_item("b")) # ['a', 'b']
print(add_item("c")) # ['a', 'b', 'c']
很多人以为每次调用都会创建一个新的 [],实际不是。items=[] 在函数定义时创建一次,后续没有传 items 的调用都会复用它。
推荐写法:
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
这样每次调用没有传列表时,都会在函数内部创建新的列表。
面试回答要抓住两点:
- 默认参数求值时机是函数定义时。
- 可变默认值会在多次调用间共享状态。
完整版教学
一、默认参数是在定义函数时创建的
看这个例子:
def f(x=[]):
x.append(1)
return x
当 Python 执行到 def f(...) 这行时,会创建函数对象,并计算默认参数表达式。也就是说,[] 在函数定义阶段就已经创建好了。
后续调用:
f()
f()
f()
如果没有传入 x,都会使用同一个默认列表。
可以用 __defaults__ 直接观察这个共享对象:
def f(x=[]):
x.append(1)
return x
print(f.__defaults__) # ([],)
f()
print(f.__defaults__) # ([1],)
f()
print(f.__defaults__) # ([1, 1],)
这不是解释器“忘了清空列表”,而是函数对象本来就持有那一个默认列表。三次调用如果都不传 x,都会拿到同一个对象,结果自然会累积。
二、为什么不可变默认参数通常没问题
下面这种写法通常没问题:
def power(x, n=2):
return x ** n
因为整数是不可变对象,函数内部不会原地修改 n。字符串、数字、None 这类不可变默认值一般是安全的。
风险集中在:
[]{}set()- 自定义可变对象
- 某些会保存状态的对象
不可变对象安全的原因不是“默认参数每次重新创建”,而是你不能原地修改它。比如 n += 1 对整数来说会创建新整数并重新绑定局部变量 n,不会改函数对象里保存的默认整数。可变对象的问题在于 append、update、add 这类方法会修改同一个对象内容。
| 默认值 | 是否常见安全 | 原因 |
|---|---|---|
None | 是 | 单例哨兵,不保存业务状态 |
0、""、False | 通常是 | 不可变,不能原地累积 |
[]、{}、set() | 否 | 多次调用共享同一个可变对象 |
datetime.now() | 谨慎 | 定义时求值,不是每次调用时间 |
三、推荐模式:None 作为哨兵值
常见写法:
def collect(value, bucket=None):
if bucket is None:
bucket = []
bucket.append(value)
return bucket
为什么用 is None,不是 == None?
因为 None 是单例对象,用身份判断最准确,也不会被对象自定义的 __eq__ 干扰。
如果 None 本身也是合法业务值,可以使用专门的哨兵对象:
_MISSING = object()
def func(value=_MISSING):
if value is _MISSING:
...
这里用 is None 是身份判断,避免被自定义 __eq__ 干扰。用 _MISSING = object() 的场景也很常见:当 None 本身是合法业务值时,不能再用 None 表示“调用方没有传”。例如缓存接口里,缓存命中的结果可能就是 None,这时必须用独立哨兵区分。
_MISSING = object()
def get_config(key, default=_MISSING):
if default is _MISSING:
raise KeyError(key)
return default
记忆钩子:默认参数表达式只在
def执行时算一次;函数调用时只是复用那份默认值,不会自动重置。
四、可变默认参数有没有合理用途
它不是语法 bug,而是语言特性。在少数场景中,可以故意利用它缓存状态:
def fib(n, cache={0: 0, 1: 1}):
if n not in cache:
cache[n] = fib(n - 1) + fib(n - 2)
return cache[n]
但生产代码里不建议这样写,原因是意图不明显,容易被维护者当成错误,而且缓存生命周期和清理策略都藏在函数默认值里。更清晰的方式是使用 functools.lru_cache 或显式缓存对象。
from functools import lru_cache
@lru_cache(maxsize=1024)
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
数字上看,fib(35) 如果没有缓存会重复计算大量子问题;缓存确实有价值。但把 cache={} 藏在默认参数里,别人很难知道它会跨调用保存状态,也不方便限制最多 1024 个条目或清空缓存。
五、和闭包、类属性的共同点
可变默认参数坑,本质是“共享同一个对象”。类似的共享状态还有:
class A:
items = []
这里 items 是类属性,也会被多个实例共享,除非实例上重新绑定。闭包里捕获的列表也可能被多次调用共享。面试时可以顺带提一句:Python 很多坑都和“对象共享引用”有关,不是默认参数这一处孤立规则。
class Bag:
items = []
a = Bag()
b = Bag()
a.items.append("x")
print(b.items) # ['x']
这个例子和默认参数很像:看起来每个调用或每个实例都该有自己的列表,实际却共用了定义阶段创建的那个对象。修复思路也类似:把共享状态显式化,或者在每次需要独立状态时创建新对象。
六、调用流程拆开看
模块加载
|
v
执行 def add_item(item, items=[])
|
v
创建函数对象,并创建一个默认列表 []
|
v
第 1 次 add_item("a") -> 使用默认列表,append "a"
第 2 次 add_item("b") -> 还是同一列表,append "b"
第 3 次 add_item("c") -> 还是同一列表,append "c"
如果改成 items=None,流程就变了:默认值仍然只创建一次,但它是不可变的哨兵 None;每次调用进入函数体后,items is None 时都会执行 items = [],这才创建本次调用独享的新列表。真正“每次创建”的动作发生在函数体内,不发生在默认参数表达式里。
七、常见误区与追问
- 误区:默认参数会在每次函数调用时重新求值。 Python 是定义函数时求值一次,调用时复用函数对象保存的默认值。
- 误区:只有列表有这个坑。 字典、集合、自定义可变对象、部分状态型对象都可能共享状态。
- 误区:用
items=[]没问题,只要函数最后清空。 异常、提前返回、并发调用和维护者修改都可能破坏这个脆弱约定,写法本身就不清晰。 - 追问:为什么推荐
is None?None是单例,用身份判断稳定,不会被对象自定义相等性干扰。 - 追问:如果
None是合法值怎么办? 用_MISSING = object()这类专用哨兵对象,再用is判断。 - 追问:可变默认参数能不能故意做缓存? 语法上可以,但生产代码更推荐
lru_cache或显式缓存,因为意图、容量和清理策略更清楚。
八、加强记忆
把这题记成“默认值跟着函数对象走”:def 执行时默认参数表达式求值一次,函数对象把结果保存下来;列表、字典、集合这种可变对象会跨调用共享内容,所以容易串数据。稳妥写法是用 None 或专用哨兵占位,在函数体里创建本次调用需要的新对象;只有当你明确需要共享状态并写清楚意图时,才考虑其他方案。