讲个真事儿。
去年我写了一个数据清洗的小工具,里面有个函数负责把不合规的记录收集起来:
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不会报错,不会崩溃,它只会悄悄地把数据搞乱。如果不是我多看了一眼输出,可能上线后才发现数据对不上。
那天我花了一个小时把项目里所有函数的默认参数都检查了一遍。不查不知道,一查吓一跳:有三个函数都用了可变对象当默认值,其中两个已经埋下了数据污染的隐患。
这个坑到底是怎么来的?
要理解这个坑,得先搞明白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- 数字:
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的例子还算明显,因为返回值就是那个列表。但有些时候,问题更隐蔽。
比如,你写了一个函数,默认参数是个字典,用来存配置:
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
每次实例化TaskManager,add_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不一样,它是在定义时求值。这个差异让很多从其他语言转过来的人措手不及。
而且这个坑非常隐蔽:代码看起来完全正常,运行起来也不报错,只有在特定条件下(多次调用、修改默认值)才会暴露。一旦暴露,往往已经造成了数据污染或逻辑错误。
一个检查清单
为了帮你避开这个坑,我总结了一个简单的检查清单:
- 看到
def func(arg=[]):→ 危险,改成arg=None - 看到
def func(arg={}):→ 危险,改成arg=None - 看到
def func(arg=set()):→ 危险,改成arg=None - 看到
def func(arg=time.time()):→ 危险,改成arg=None - 看到
def func(arg=SomeMutableObject()):→ 危险,改成arg=None - 看到
def func(arg=()):→ 安全 - 看到
def func(arg=0):→ 安全 - 看到
def func(arg=""):→ 安全 - 看到
def func(arg=None):→ 安全
如果你在代码里看到第1到第5种写法,立刻改掉。不要犹豫。
总结
Python的默认参数陷阱,本质上是定义时求值和可变对象共享两个特性叠加的结果。
一句话记住:默认参数只在函数定义时创建一次,如果它是可变的,所有调用共享同一个对象。 想要每次调用都有新的对象,就用None作为默认值,在函数内部创建。
这个坑很小,但后果很严重。它不会让程序崩溃,只会让数据悄悄出错。而数据错误,往往比程序崩溃更难排查。
下次写函数的时候,看到=[]或者={},心里默念一句:这是给自己埋雷。然后老老实实改成=None。