前几天帮同事看一个 bug,逻辑很简单:一个函数负责把用户的操作记录追加到一个列表里,然后返回这个列表。代码大概长这样:
def log_action(action, actions=[]):
actions.append(action)
return actions
他写了个小测试,先调 log_action("login"),打印出来是 ["login"],没问题。再调 log_action("logout"),预期是 ["logout"],结果打印出来是 ["login", "logout"]。
他盯着屏幕看了半天,说:“我每次调用都传的是新的 action,这个 actions 列表怎么自己长东西了?”
我说你把 actions 的 id 打出来看看。他打完就沉默了——两次调用,id 一模一样。
这就是 Python 里最经典的坑之一:可变默认参数。你以为每次调用都会创建一个新的列表,实际上这个列表在函数定义的时候就创建好了,之后所有调用共享同一个对象。你往里面塞东西,它就一直涨,永远不会重置。
默认参数到底在什么时候求值
要理解这个坑,得先搞清楚 Python 的 def 语句到底干了什么。
很多人下意识觉得,默认参数是在每次调用函数的时候才计算的。比如 def f(x, y=[]),每次调用 f 的时候,Python 会创建一个新的空列表给 y。这个直觉很自然,但它是错的。
Python 的规则是:默认参数在函数定义时求值,而且只求值一次。
def 是一个可执行语句。当 Python 解释器执行到这一行的时候,它会创建一个函数对象,同时计算所有默认参数的值,把这些值绑定到函数对象的 __defaults__ 属性上。之后每次调用这个函数,如果调用者没有提供对应的参数,Python 就直接从 __defaults__ 里取出那个已经存在的对象来用。
也就是说,def log_action(action, actions=[]) 这行代码执行的时候,[] 就被创建了。这个列表对象从此就挂在 log_action.__defaults__ 里,成了函数的一部分。每次调用 log_action 不传 actions,拿到的都是同一个列表。
你可以用一行代码验证:
print(log_action.__defaults__)
# ([],)
第一次调用之后:
log_action("login")
print(log_action.__defaults__)
# (['login'],)
第二次调用之后:
log_action("logout")
print(log_action.__defaults__)
# (['login', 'logout'],)
你看到的不是“函数返回了一个列表”,而是“函数把默认参数本身给改了”。这个默认参数是函数对象的一部分,生命周期和函数一样长。函数不销毁,这个列表就不销毁。在 Web 应用里,函数通常是模块级别的,模块加载一次就一直存在,所以这个列表会一直累积下去,直到进程重启。
为什么 None 就能解决问题
改成 None 之后,情况完全不同:
def log_action(action, actions=None):
if actions is None:
actions = []
actions.append(action)
return actions
这里 None 是默认参数。None 是不可变对象,每次调用不传 actions,拿到的都是同一个 None。但关键是,函数内部判断了 if actions is None,然后当场创建一个新的空列表。
这个新列表是在每次调用时创建的,是调用栈上的局部变量。函数返回后,如果没有其他引用,它就会被垃圾回收。下一次调用再创建一个新的,和上一次没有任何关系。
两次调用,两个不同的列表对象,互不干扰。问题解决。
这里的核心区别不是 None 和 [] 哪个“更好”,而是求值时机不同。[] 作为默认参数,在函数定义时求值一次;None 作为默认参数,虽然也在定义时求值,但 None 本身不可变,真正可变的新列表是在函数调用时才创建的。
不只是列表,字典和集合也一样
这个坑不限于 []。任何可变对象作为默认参数都有同样的问题:
def add_to_cache(key, value, cache={}):
cache[key] = value
return cache
第一次调用 add_to_cache("a", 1) 返回 {"a": 1},第二次调用 add_to_cache("b", 2) 返回 {"a": 1, "b": 2}。这个 cache 会一直膨胀,而且所有调用者共享同一份数据。如果这是在处理用户请求,A 用户的数据会泄漏给 B 用户。
集合也一样:
def track_user(user, seen=set()):
seen.add(user)
return seen
同样会累积。
甚至自定义的可变对象也一样:
class Config:
def __init__(self):
self.items = []
def setup(config=Config()):
config.items.append("default")
return config
这个 Config() 在函数定义时创建一次,之后所有调用共享同一个实例。你往里面加东西,它就一直加。
还有一个更隐蔽的变体:元组本身不可变,但如果元组里包含可变对象,那个可变对象仍然是共享的。
def f(x, data=([],)):
data[0].append(x)
return data
这里的元组 ([],) 是默认参数,元组不可变,但里面的列表是可变的。每次调用都会往同一个列表里追加,效果和直接用 [] 一模一样。
为什么 Python 要这样设计
你可能会问:Python 为什么要这么设计?每次调用重新计算默认参数不是更符合直觉吗?
原因其实很实际:默认参数在定义时求值,可以让我们在默认值里使用一些在调用时无法轻松获得的东西。
比如:
import time
def log(message, timestamp=time.time()):
print(f"[{timestamp}] {message}")
这里 time.time() 在函数定义时执行一次,之后所有调用都用同一个时间戳。如果你希望每次调用都记录当前时间,这个写法是错的。但如果你希望记录的是“这个函数是什么时候定义的”,那它就对了。
再比如,默认参数可以引用前面已经定义的参数:
def f(a, b=a+1):
return b
这种写法只有在定义时求值才能实现,因为 a 在函数调用之前是不存在的。
Python 的设计者选择了“定义时求值”这个语义,因为它更简单、更一致。函数定义是一个执行动作,默认参数是这个动作的一部分。问题不在于这个设计本身,而在于很多人没有意识到可变对象在这个语义下会变成共享状态。
怎么发现这个坑
这个 bug 的特点是:它不会报错,只会让数据悄悄变多。
代码能跑,语法没问题,类型也没问题。你只是在某个时刻发现列表里多了不该有的东西,或者字典里出现了其他请求的数据。排查起来往往要绕一大圈。
有几个办法可以帮你快速定位:
第一,打印 id。如果你怀疑某个默认参数有问题,在函数内部打印 id(actions),连续调用两次,如果 id 相同,那就是共享了。
第二,看 __defaults__。log_action.__defaults__ 会显示当前默认参数的值。如果调用几次之后发现它变了,说明有人在改它。
第三,用 linter。flake8-bugbear 的 B006 规则专门检测可变默认参数,pylint 的 dangerous-default-value 也会警告。PyCharm 和 VS Code 的 Python 插件通常也会给黄色波浪线。这些警告不是噪音,是真实的风险提示。
第四,写单元测试的时候,连续调用两次,检查第二次的结果是否独立。如果你写的是 assert log_action("a") == ["a"],第一次能过,但第二次 assert log_action("b") == ["b"] 就会失败。这个测试本身就能抓住问题。
None 不是唯一解,但通常是最好的解
用 None 作为默认值,然后内部判断,这是最惯用的做法。但它有一个前提:None 不是你业务逻辑里的有效值。
如果 None 本身就是一个有意义的输入,比如 actions=None 表示“不记录任何操作”,那你就不能用 None 当哨兵了。这时候可以用一个模块级别的唯一对象:
_MISSING = object()
def log_action(action, actions=_MISSING):
if actions is _MISSING:
actions = []
actions.append(action)
return actions
object() 创建出来的对象是独一无二的,没有任何其他东西会和它相等。用它当哨兵,既不会和业务值冲突,也不可变。
还有一种情况:你确实想利用这个共享状态做缓存。比如:
def fib(n, cache={0: 0, 1: 1}):
if n not in cache:
cache[n] = fib(n-1) + fib(n-2)
return cache[n]
这个写法是故意让 cache 在调用间共享,用来做记忆化。它能工作,但不推荐。因为缓存会无限增长,没有淘汰机制,而且多线程环境下不安全。Python 标准库提供了 functools.lru_cache,专门做这件事,更安全也更清晰。没必要自己用可变默认参数硬扛。
一个真实场景
我之前见过一个 Flask 应用,有个函数用来收集请求上下文里的诊断信息:
def collect_diagnostics(info, diagnostics=[]):
diagnostics.append(info)
if len(diagnostics) > 10:
send_to_monitoring(diagnostics)
diagnostics.clear()
return diagnostics
表面上看,它限制最多收集 10 条,满了就发送并清空。逻辑没问题。
但实际上,这个 diagnostics 是全局共享的。多个请求同时进来,A 请求的诊断信息会混进 B 请求的列表里。更糟的是,当列表满 10 条触发 send_to_monitoring 时,发送的是所有请求混在一起的数据,根本分不清是谁的。
后来他们改成 diagnostics=None,每个请求独立创建列表,问题就消失了。
这个 bug 在生产环境跑了很久才被发现,因为大多数时候请求量不大,列表没满,不会触发发送。直到某天流量上来了,监控数据开始错乱,才有人注意到。
总结一下
可变默认参数的问题,本质上是函数定义时求值和可变对象共享这两个特性叠加的结果。
[]、{}、set() 这些字面量在 def 语句执行时被创建一次,之后所有调用共享同一个对象。你修改它,就是在修改函数对象的一部分。下一次调用看到的,是上一次调用留下的状态。
None 之所以能解决问题,不是因为 None 更特殊,而是因为它在函数内部触发了一次新的创建。每次调用创建一个新的列表,用完就丢,互不影响。
写 Python 函数的时候,只要默认参数是列表、字典、集合或者任何自定义可变对象,就应该条件反射地改成 None,然后在函数体第一行判断并初始化。这不是风格偏好,是避免共享状态的基本功。
一行 if actions is None: actions = [],省下的是几个小时的排查时间。