首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Python的默认参数把我坑惨了,原来可变对象当默认值等于给自己埋雷

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

原创
作者头像
风一样的男子
发布于 2026-09-10 14:31:46
发布于 2026-09-10 14:31:46
1490
举报
文章被收录于专栏:编程教程编程教程

讲个真事儿。

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

代码语言:javascript
复制
def collect_errors(record, errors=[]):
    if not is_valid(record):
        errors.append(record)
    return errors

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

代码语言:javascript
复制
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不会报错,不会崩溃,它只会悄悄地把数据搞乱。如果不是我多看了一眼输出,可能上线后才发现数据对不上。

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

这个坑到底是怎么来的?

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

当你定义一个函数时:

代码语言:javascript
复制
def add_item(item, lst=[]):
    lst.append(item)
    return lst

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

你可以自己验证一下:

代码语言:javascript
复制
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)来说,开销很小,但对于复杂的表达式来说,开销就大了。比如:

代码语言:javascript
复制
def func(x=calculate_something_expensive()):
    ...

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

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

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

哪些类型是“危险”的?

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

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

  • None
  • 数字:0、1、3.14
  • 字符串:""、"default"
  • 布尔值:True、False
  • 元组:()、(1, 2, 3)
  • frozenset

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

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

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

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

一个更隐蔽的例子

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

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

代码语言:javascript
复制
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特别难查,因为函数的行为依赖于“之前有没有被调用过”以及“之前调用时传了什么参数”。它把无状态的函数变成了有状态的,完全违背了函数式编程的直觉。

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

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

代码语言:javascript
复制
class TaskManager:
    def add_task(self, task, task_list=[]):
        task_list.append(task)
        return task_list

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

正确的写法是用None:

代码语言:javascript
复制
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作为占位符,然后在函数内部创建。

代码语言:javascript
复制
# 错误写法
def func(lst=[]):
    lst.append(1)
    return lst

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

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

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

代码语言:javascript
复制
def func(lst=None):
    lst = lst or []
    lst.append(1)
    return lst

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

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

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

代码语言:javascript
复制
def func(items=()):
    # items 是元组,不能修改,安全
    return list(items)

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

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

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

代码语言:javascript
复制
import time

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

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

正确的写法还是用None:

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

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

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

目录
  • 这个坑到底是怎么来的?
  • 为什么Python要这么设计?
  • 哪些类型是“危险”的?
  • 一个更隐蔽的例子
  • 类方法里的默认参数也一样
  • 那应该怎么避免?
  • 那默认参数是元组或frozenset呢?
  • 还有一个容易忽略的点:默认参数在定义时求值
  • 为什么会有人踩这个坑?
  • 一个检查清单
  • 总结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档