Python的默认参数把我坑惨了,原来可变对象当默认值等于给自己埋雷

简介: 本文详解Python经典陷阱:可变对象作默认参数。因默认值在函数定义时仅创建一次,`def f(lst=[])` 中的列表会被所有调用共享,导致数据意外累积、污染。推荐统一用 `None` 占位,在函数内初始化,避免隐蔽bug。

讲个真事儿。

去年我写了一个数据清洗的小工具,里面有个函数负责把不合规的记录收集起来:

def collect_errors(record, errors=[]):
   if not is_valid(record):
       errors.append(record)
   return errors

逻辑很简单:传进来一条记录,如果校验不通过,就把它追加到errors列表里,最后返回这个列表。我当时的想法是,调用方可以这样用:

bad_records = collect_errors(record1)
bad_records = collect_errors(record2)

结果跑完整个数据集,我发现bad_records里居然有几十条记录,而且很多都是重复的。我明明只传了最新的那条记录进去,为什么列表里会累积之前的数据?

盯着代码看了半天,我才猛然想起那个经典的Python坑:默认参数在函数定义时只求值一次,如果默认值是可变对象,那么所有调用共享同一个对象。

也就是说,errors=[]这个空列表,在函数定义的时候创建了一次,之后每次调用collect_errors,只要不传第二个参数,用的都是同一个列表。第一次调用追加了记录A,第二次调用追加了记录B,这个列表里就有A和B了。第三次调用追加C,列表里就有A、B、C。

我本来想要的是“每次调用返回一个新的错误列表”,结果得到了一个“全局累积的错误列表”。更可怕的是,这个bug不会报错,不会崩溃,它只会悄悄地把数据搞乱。如果不是我多看了一眼输出,可能上线后才发现数据对不上。

那天我花了一个小时把项目里所有函数的默认参数都检查了一遍。不查不知道,一查吓一跳:有三个函数都用了可变对象当默认值,其中两个已经埋下了数据污染的隐患。

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

这个坑到底是怎么来的?

要理解这个坑,得先搞明白Python是怎么处理默认参数的。

当你定义一个函数时:

def add_item(item, lst=[]):
   lst.append(item)
   return lst

Python在函数定义的时候,就会计算默认参数的值。注意,是定义的时候,不是调用的时候。这个[]在函数被定义的那一刻就被创建了,然后保存在函数对象的__defaults__属性里。

你可以自己验证一下:

def add_item(item, lst=[]):
   lst.append(item)
   return lst

print(add_item.__defaults__)  # ([],)
add_item(1)
print(add_item.__defaults__)  # ([1],)
add_item(2)
print(add_item.__defaults__)  # ([1, 2],)

看到了吗?__defaults__里的那个列表,就是每次调用时用的默认列表。它被修改后,下一次调用看到的还是同一个被修改过的列表。

这就是问题的根源:默认参数只在定义时创建一次,之后所有调用共享这个对象。 如果这个对象是可变的(列表、字典、集合等),那么任何一次调用对它的修改,都会影响到后续所有调用。

为什么Python要这么设计?

你可能会问:为什么不每次调用都重新创建一个默认值?这样不就没事了吗?

原因有两个:性能和语义

从性能角度说,如果每次调用都重新计算默认值,对于简单的不可变对象(比如0""None)来说,开销很小,但对于复杂的表达式来说,开销就大了。比如:

def func(x=calculate_something_expensive()):
   ...

如果每次调用都重新计算,那函数调用会变得很慢。Python选择在定义时计算一次,然后复用。

从语义角度说,Python的默认参数设计初衷是“提供一个默认值”,而不是“提供一个每次调用都新建的工厂”。默认值应该是一个固定的、不变的东西。如果你想要每次调用都新建,应该用None作为占位符,然后在函数内部自己创建。

所以这个设计本身没有错,错的是我们把这些默认值当成了“每次调用都会重新初始化”的东西。

哪些类型是“危险”的?

不是所有默认参数都有问题。关键在于默认值是不是可变对象

安全的默认值(不可变对象):

  • None
  • 数字:013.14
  • 字符串:"""default"
  • 布尔值:TrueFalse
  • 元组:()(1, 2, 3)
  • frozenset

这些对象一旦创建就不能被修改。你没法在函数里“修改”一个整数或字符串,只能重新赋值。所以即使共享,也不会出问题。

危险的默认值(可变对象):

  • 列表:[][1, 2, 3]
  • 字典:{}{"key": "value"}
  • 集合:set(){1, 2, 3}
  • 自定义的可变对象

这些对象可以被修改(append、update、add等),一旦修改,所有共享这个默认值的调用都会受到影响。

一个更隐蔽的例子

上面那个collect_errors的例子还算明显,因为返回值就是那个列表。但有些时候,问题更隐蔽。

比如,你写了一个函数,默认参数是个字典,用来存配置:

def process_data(data, config={}):
   config.setdefault("timeout", 30)
   config.setdefault("retries", 3)
   # 用 config 做一些事情
   return do_something(data, config)

看起来没问题,每次调用都会给config填充默认值。但如果第一次调用时传了timeout=60,这个60会被写进默认字典里。第二次调用如果不传config,它拿到的就是{"timeout": 60, "retries": 3},而不是预期的{"timeout": 30, "retries": 3}

这种bug特别难查,因为函数的行为依赖于“之前有没有被调用过”以及“之前调用时传了什么参数”。它把无状态的函数变成了有状态的,完全违背了函数式编程的直觉。

类方法里的默认参数也一样

这个坑不仅存在于普通函数,类的方法也一样:

class TaskManager:
   def add_task(self, task, task_list=[]):
       task_list.append(task)
       return task_list

每次实例化TaskManageradd_task的默认参数都是同一个列表。所有实例共享这个列表。你在一个实例里添加了任务,另一个实例调用时也会看到。

正确的写法是用None

class TaskManager:
   def add_task(self, task, task_list=None):
       if task_list is None:
           task_list = []
       task_list.append(task)
       return task_list

这样每次调用都会创建一个新的列表,实例之间互不影响。

那应该怎么避免?

原则很简单:永远不要用可变对象作为默认参数。 如果你需要一个可变的默认值,用None作为占位符,然后在函数内部创建。

# 错误写法
def func(lst=[]):
   lst.append(1)
   return lst

# 正确写法
def func(lst=None):
   if lst is None:
       lst = []
   lst.append(1)
   return lst

这样每次调用时,如果没有传lst,函数内部会创建一个新的空列表。如果传了,就用传进来的那个。

还有一个更简洁的写法,用or

def func(lst=None):
   lst = lst or []
   lst.append(1)
   return lst

但要注意,lst or []lst是空列表时也会创建新列表。如果你希望传入空列表时保留它(而不是替换成新列表),就不能用or,必须用if lst is None

那默认参数是元组或frozenset呢?

元组和frozenset是不可变的,所以它们可以安全地作为默认参数:

def func(items=()):
   # items 是元组,不能修改,安全
   return list(items)

但要注意,如果元组里包含可变对象,比如([], {}),那这个元组本身虽然不可变,但里面的元素是可变的。你依然不能修改它们,但如果你把里面的列表取出来修改,还是会影响到所有调用。所以最好确保默认参数里的所有东西都是不可变的。

还有一个容易忽略的点:默认参数在定义时求值

这个特性不仅影响可变对象,还影响任何在定义时求值的表达式。比如:

import time

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

你以为每次调用log_message都会打印当前时间?错了。time.time()在函数定义时就被调用了,之后所有调用都用的是同一个时间戳。这个函数只有在定义时的那一秒是“正确”的,之后永远打印那个旧时间。

正确的写法还是用None

def log_message(msg, timestamp=None):
   if timestamp is None:
       timestamp = time.time()
   print(f"[{timestamp}] {msg}")

为什么会有人踩这个坑?

因为大多数编程语言里,函数的默认参数都是每次调用时重新计算的。比如Java、C++、JavaScript,它们的默认参数是在调用时求值的。但Python不一样,它是在定义时求值。这个差异让很多从其他语言转过来的人措手不及。

而且这个坑非常隐蔽:代码看起来完全正常,运行起来也不报错,只有在特定条件下(多次调用、修改默认值)才会暴露。一旦暴露,往往已经造成了数据污染或逻辑错误。

一个检查清单

为了帮你避开这个坑,我总结了一个简单的检查清单:

  1. 看到def func(arg=[]): → 危险,改成arg=None
  2. 看到def func(arg={}): → 危险,改成arg=None
  3. 看到def func(arg=set()): → 危险,改成arg=None
  4. 看到def func(arg=time.time()): → 危险,改成arg=None
  5. 看到def func(arg=SomeMutableObject()): → 危险,改成arg=None
  6. 看到def func(arg=()): → 安全
  7. 看到def func(arg=0): → 安全
  8. 看到def func(arg=""): → 安全
  9. 看到def func(arg=None): → 安全

如果你在代码里看到第1到第5种写法,立刻改掉。不要犹豫。

总结

Python的默认参数陷阱,本质上是定义时求值可变对象共享两个特性叠加的结果。

一句话记住:默认参数只在函数定义时创建一次,如果它是可变的,所有调用共享同一个对象。 想要每次调用都有新的对象,就用None作为默认值,在函数内部创建。

这个坑很小,但后果很严重。它不会让程序崩溃,只会让数据悄悄出错。而数据错误,往往比程序崩溃更难排查。

下次写函数的时候,看到=[]或者={},心里默念一句:这是给自己埋雷。然后老老实实改成=None

目录
相关文章
|
6天前
|
人工智能 运维 BI
阿里云千问办公QwenWork深度解析:基于Qwen3.8,六大核心能力重构企业全自动化工作流与计费选型指南
传统AI办公工具大多停留在对话问答、文档摘要、简单文案生成层面,只能完成单点碎片化任务,无法自主拆解复杂业务流程,很难串联多工具、多文档、外部业务系统完成端到端完整工作交付。很多企业在落地AI办公的时候,需要组合多款不同工具,来回切换界面,手动复制粘贴中间结果,智能化改造落地门槛居高不下。千问办公QwenWork是整合多款智能体产品能力打造的一体化企业办公智能体平台,底层基座依托Qwen3.8大模型,打通桌面端Agent、云端Agent、企业协同Agent三种运行形态,不再局限简单问答,接收业务目标之后自主拆解任务步骤,调用各类工具,处理文档、表格、浏览器自动化、数据查询,直接输出可交付的办公
1511 0
|
6天前
|
人工智能 自然语言处理 安全
阿里云AI数智鉴密:AI 生成内容如何拿到一张"防篡改的身份证"
隐形水印 + C2PA签名:让AI生成内容“持证上岗”。
1133 0
|
15天前
|
人工智能 自然语言处理 安全
阿里云千问办公、Qoder Teams、Qoder CN区别与选择指南:模型能力、适用场景与最新活动参考
本文聚焦阿里云2026年推出的三款自研AI办公产品,清晰拆解千问办公、Qoder Teams、Qoder CN的差异化定位与能力边界:千问办公主打职场全场景提效,支持自然语言指令一键完成PPT生成、数据分析等高频办公任务;Qoder Teams面向程序员团队,深度整合AI代码生成、团队协同与企业知识库能力;Qoder CN则专为金融、政务等强合规场景打造,实现数据不出境与VPC私有化部署。文章同步给出分场景选型指南与最新活动定价,帮助不同类型的企业按需组合产品,实现业务岗、研发岗与强合规场景的AI能力全覆盖。
3788 4
阿里云千问办公、Qoder Teams、Qoder CN区别与选择指南:模型能力、适用场景与最新活动参考
|
3天前
|
SQL 人工智能 前端开发
QoderWake 1.0 正式发布:从桌面里的 Agent,到工作现场的数字员工
QoderWake v1.0正式发布:企业级数字员工团队平台。支持“一句话建岗”,预置10类特训岗位;Waker常驻钉钉/飞书群,@即响应、自动协作、跨任务记忆;具备定时/事件/API多触发方式与统一任务看板;已沉淀27.6万条记忆、12.3万项技能,助力组织实现人机协同增效。
646 0
|
1天前
|
人工智能 API 内存技术
刚刚 DeepSeek V4.1 Flash 开启内测,1 分钟教你用上!
刚刚 DeepSeek 内测群发布了 DeepSeek V4.1 Flash 中间版本内测的消息,这次的模型采用了新的结构,原生支持多模态、能力更强、速度更快、且成本更低。
1354 2
|
7天前
|
网络协议 Linux iOS开发
【2026实测】Wireshark下载+安装+汉化+使用教程(图文版,巨详细)
Wireshark 是一款免费开源的网络协议分析工具,可实时捕获、解析并可视化数据包,助你诊断网络故障、分析通信协议(如HTTP、DNS、TCP等)。支持Windows/macOS/Linux,含中文界面,新手入门便捷。(239字)