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

简介: Python中可变默认参数(如`def f(x, lst=[])`)是经典陷阱:默认值在函数定义时创建并复用,导致多次调用共享同一对象,引发意外状态累积。正确做法是用`None`作默认值,调用时再创建新对象。

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

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 里最经典的坑之一:可变默认参数。你以为每次调用都会创建一个新的列表,实际上这个列表在函数定义的时候就创建好了,之后所有调用共享同一个对象。你往里面塞东西,它就一直涨,永远不会重置。

代理 IP 使用小技巧 让你的数据抓取效率翻倍 (87).png

默认参数到底在什么时候求值

要理解这个坑,得先搞清楚 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 规则专门检测可变默认参数,pylintdangerous-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 = [],省下的是几个小时的排查时间。

目录
相关文章
|
1天前
|
存储 关系型数据库 MySQL
对账差了三毛钱,查完我把全部金额字段从DOUBLE改成了DECIMAL
一次财务对账差三毛钱的排查,牵出金额字段用浮点数的老坑。从IEEE 754为什么存不准0.1讲起,用同一批金额把FLOAT、DOUBLE、DECIMAL三种类型实测对比,再给出金额字段的选型、聚合与改表做法,附避坑清单。
|
1天前
|
IDE 开发工具
额度翻倍!还有四天
Qoder推出Sonus模型专属福利:9月16-19日,付费用户每日12:00可领1000 Credits,用于全球顶级Sonus模型——支持编程、长程任务、专业软件操作及公式表格处理。登录Qoder各端活动入口即可领取。
111 0
|
1天前
|
人工智能 安全 数据可视化
阿里云百炼 Agent Studio 产品手册全新发布:企业级 Agent 全栈服务平台
阿里云百炼正式发布《阿里云百炼 Agent Studio 产品手册》。手册围绕企业级 Agent“开发—运行—进化—商业化”全生命周期,系统呈现 Agent Studio 的可视化编排、托管运行、RAG/Memory/Parser X、MCP/Skill/Connector、硬件 Agent、安全治理与商业闭环等能力。新用户可获得限时免费体验额度,具体以官网活动页为准。
116 1
|
2天前
|
缓存 人工智能 自然语言处理
通义千问Qwen大模型全系列模型深度解析:能力矩阵、行业落地、选型定价与API实操完整教程(Max/Plus/Flash/Coder/Omni)
随着生成式AI从单点Demo走向企业规模化落地,很多团队在选型大模型时容易陷入困惑:不知道该选用旗舰模型还是轻量模型,分不清文本、视觉、全模态、编程模型各自适用场景,不清楚不同档位模型的价格差异,也不熟悉API集成、上下文缓存、批量推理等工程化手段。通义千问Qwen并非单一模型,而是一套分层完整的大模型家族,覆盖旗舰复杂推理、均衡长文本、高并发轻量任务、代码开发、图像视频理解、全模态音视频交互等多个品类,同时提供在线API调用、开源本地部署两种方案。本文将完整梳理Qwen全系列模型能力矩阵,拆解每一类模型的技术特点、适用业务场景,介绍金融、制造、政务、电商、软件研发等行业落地案例,详细讲解按量
248 0
|
2天前
|
存储 弹性计算 人工智能
阿里云服务器租赁费用价格表(2026年9月最新手搓)ECS、轻量和GPU服务器报价单
2026年阿里云服务器最新优惠:轻量应用服务器低至38元/年(2核2G+200M峰值带宽),ECS经济型99元/年(2核2G+3M带宽),企业级u1实例199元/年(2核4G+5M+80G ESSD)。香港轻量25元/月起,GPU及高配ECS按需可选。价格实时更新,续费同价,限时抢购!
131 0
|
2月前
|
人工智能 缓存 前端开发
刚刚 Kimi K3 炸裂发布,号称 Claude 和 GPT 的国产平替,夯爆了!
新模型 Kimi K3 实战项目测评,跟 Claude Fable 5 和 GPT-5.6 相比到底怎么样?前端和全栈工程能力如何?DeepSeek 2.0 时刻来了?
655 1
|
3月前
|
JSON 前端开发 NoSQL
【AgentScope Java新手村系列】(11)中断与恢复
中断与恢复 — AgentStateStore 按 sessionId 持久化上下文,浏览器关闭后秒级恢复对话与 todo 状态。
469 1
|
7月前
|
存储 前端开发 JavaScript
如何避免密钥在前端硬编码?
如何避免密钥在前端硬编码?
832 154
|
3月前
|
数据采集 Web App开发 JavaScript
房源信息采集:链家/贝壳等房产网站的反爬策略应对方案
本文详解链家/贝壳房产数据采集的反爬困境与实战方案:针对IP封禁、滑块验证、JS动态渲染及“幽灵房”假数据等难题,提出OpenClaw驱动真实浏览器+站大爷高匿隧道代理+请求频率与指纹伪装三重防护策略,兼顾稳定性与合规性。(239字)
563 0