← 返回题目列表

Python 函数为什么不要使用可变对象作为默认参数?

高频 中等 第 10 / 22 题 更新于 2026/07/25
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

这样每次调用没有传列表时,都会在函数内部创建新的列表。

面试回答要抓住两点:

  1. 默认参数求值时机是函数定义时。
  2. 可变默认值会在多次调用间共享状态。

完整版教学

一、默认参数是在定义函数时创建的

看这个例子:

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,不会改函数对象里保存的默认整数。可变对象的问题在于 appendupdateadd 这类方法会修改同一个对象内容。

默认值是否常见安全原因
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 或专用哨兵占位,在函数体里创建本次调用需要的新对象;只有当你明确需要共享状态并写清楚意图时,才考虑其他方案。