首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Python的可变默认参数把我坑惨了,原来None和[]的区别这么大

Python的可变默认参数把我坑惨了,原来None和[]的区别这么大

原创
作者头像
风一样的男子
发布2026-09-17 13:24:00
发布2026-09-17 13:24:00
540
举报
文章被收录于专栏:编程教程编程教程

前几天帮同事看一个 bug,逻辑很简单:一个函数负责把用户的操作记录追加到一个列表里,然后返回这个列表。代码大概长这样:

代码语言:javascript
复制
def log_action(action, actions=[]):
    actions.append(action)
    return actions

他写了个小测试,先调 log_action("login"),打印出来是 ["login"],没问题。再调 log_action("logout"),预期是 ["logout"],结果打印出来是 ["login", "logout"]

他盯着屏幕看了半天,说:“我每次调用都传的是新的 action,这个 actions 列表怎么自己长东西了?”

我说你把 actionsid 打出来看看。他打完就沉默了——两次调用,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,拿到的都是同一个列表。

你可以用一行代码验证:

代码语言:javascript
复制
print(log_action.__defaults__)
# ([],)

第一次调用之后:

代码语言:javascript
复制
log_action("login")
print(log_action.__defaults__)
# (['login'],)

第二次调用之后:

代码语言:javascript
复制
log_action("logout")
print(log_action.__defaults__)
# (['login', 'logout'],)

你看到的不是“函数返回了一个列表”,而是“函数把默认参数本身给改了”。这个默认参数是函数对象的一部分,生命周期和函数一样长。函数不销毁,这个列表就不销毁。在 Web 应用里,函数通常是模块级别的,模块加载一次就一直存在,所以这个列表会一直累积下去,直到进程重启。

为什么 None 就能解决问题

改成 None 之后,情况完全不同:

代码语言:javascript
复制
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 本身不可变,真正可变的新列表是在函数调用时才创建的。

不只是列表,字典和集合也一样

这个坑不限于 []。任何可变对象作为默认参数都有同样的问题:

代码语言:javascript
复制
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 用户。

集合也一样:

代码语言:javascript
复制
def track_user(user, seen=set()):
    seen.add(user)
    return seen

同样会累积。

甚至自定义的可变对象也一样:

代码语言:javascript
复制
class Config:
    def __init__(self):
        self.items = []

def setup(config=Config()):
    config.items.append("default")
    return config

这个 Config() 在函数定义时创建一次,之后所有调用共享同一个实例。你往里面加东西,它就一直加。

还有一个更隐蔽的变体:元组本身不可变,但如果元组里包含可变对象,那个可变对象仍然是共享的。

代码语言:javascript
复制
def f(x, data=([],)):
    data[0].append(x)
    return data

这里的元组 ([],) 是默认参数,元组不可变,但里面的列表是可变的。每次调用都会往同一个列表里追加,效果和直接用 [] 一模一样。

为什么 Python 要这样设计

你可能会问:Python 为什么要这么设计?每次调用重新计算默认参数不是更符合直觉吗?

原因其实很实际:默认参数在定义时求值,可以让我们在默认值里使用一些在调用时无法轻松获得的东西

比如:

代码语言:javascript
复制
import time

def log(message, timestamp=time.time()):
    print(f"[{timestamp}] {message}")

这里 time.time() 在函数定义时执行一次,之后所有调用都用同一个时间戳。如果你希望每次调用都记录当前时间,这个写法是错的。但如果你希望记录的是“这个函数是什么时候定义的”,那它就对了。

再比如,默认参数可以引用前面已经定义的参数:

代码语言:javascript
复制
def f(a, b=a+1):
    return b

这种写法只有在定义时求值才能实现,因为 a 在函数调用之前是不存在的。

Python 的设计者选择了“定义时求值”这个语义,因为它更简单、更一致。函数定义是一个执行动作,默认参数是这个动作的一部分。问题不在于这个设计本身,而在于很多人没有意识到可变对象在这个语义下会变成共享状态。

怎么发现这个坑

这个 bug 的特点是:它不会报错,只会让数据悄悄变多

代码能跑,语法没问题,类型也没问题。你只是在某个时刻发现列表里多了不该有的东西,或者字典里出现了其他请求的数据。排查起来往往要绕一大圈。

有几个办法可以帮你快速定位:

第一,打印 id。如果你怀疑某个默认参数有问题,在函数内部打印 id(actions),连续调用两次,如果 id 相同,那就是共享了。

第二,看 __defaults__log_action.__defaults__ 会显示当前默认参数的值。如果调用几次之后发现它变了,说明有人在改它。

第三,用 linter。flake8-bugbear 的 B006 规则专门检测可变默认参数,pylintdangerous-default-value 也会警告。PyCharm 和 VS Code 的 Python 插件通常也会给黄色波浪线。这些警告不是噪音,是真实的风险提示。

第四,写单元测试的时候,连续调用两次,检查第二次的结果是否独立。如果你写的是 assert log_action("a") == ["a"],第一次能过,但第二次 assert log_action("b") == ["b"] 就会失败。这个测试本身就能抓住问题。

None 不是唯一解,但通常是最好的解

None 作为默认值,然后内部判断,这是最惯用的做法。但它有一个前提:None 不是你业务逻辑里的有效值。

如果 None 本身就是一个有意义的输入,比如 actions=None 表示“不记录任何操作”,那你就不能用 None 当哨兵了。这时候可以用一个模块级别的唯一对象:

代码语言:javascript
复制
_MISSING = object()

def log_action(action, actions=_MISSING):
    if actions is _MISSING:
        actions = []
    actions.append(action)
    return actions

object() 创建出来的对象是独一无二的,没有任何其他东西会和它相等。用它当哨兵,既不会和业务值冲突,也不可变。

还有一种情况:你确实想利用这个共享状态做缓存。比如:

代码语言:javascript
复制
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 应用,有个函数用来收集请求上下文里的诊断信息:

代码语言:javascript
复制
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 = [],省下的是几个小时的排查时间。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 默认参数到底在什么时候求值
  • 为什么 None 就能解决问题
  • 不只是列表,字典和集合也一样
  • 为什么 Python 要这样设计
  • 怎么发现这个坑
  • None 不是唯一解,但通常是最好的解
  • 一个真实场景
  • 总结一下
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档