
讲个真事儿。
去年我写了一个数据清洗的小工具,里面有个函数负责把不合规的记录收集起来:
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 lstPython在函数定义的时候,就会计算默认参数的值。注意,是定义的时候,不是调用的时候。这个[]在函数被定义的那一刻就被创建了,然后保存在函数对象的__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__里的那个列表,就是每次调用时用的默认列表。它被修改后,下一次调用看到的还是同一个被修改过的列表。
这就是问题的根源:默认参数只在定义时创建一次,之后所有调用共享这个对象。 如果这个对象是可变的(列表、字典、集合等),那么任何一次调用对它的修改,都会影响到后续所有调用。
你可能会问:为什么不每次调用都重新创建一个默认值?这样不就没事了吗?
原因有两个:性能和语义。
从性能角度说,如果每次调用都重新计算默认值,对于简单的不可变对象(比如0、""、None)来说,开销很小,但对于复杂的表达式来说,开销就大了。比如:
def func(x=calculate_something_expensive()):
...如果每次调用都重新计算,那函数调用会变得很慢。Python选择在定义时计算一次,然后复用。
从语义角度说,Python的默认参数设计初衷是“提供一个默认值”,而不是“提供一个每次调用都新建的工厂”。默认值应该是一个固定的、不变的东西。如果你想要每次调用都新建,应该用None作为占位符,然后在函数内部自己创建。
所以这个设计本身没有错,错的是我们把这些默认值当成了“每次调用都会重新初始化”的东西。
不是所有默认参数都有问题。关键在于默认值是不是可变对象。
安全的默认值(不可变对象):
None0、1、3.14""、"default"True、False()、(1, 2, 3)这些对象一旦创建就不能被修改。你没法在函数里“修改”一个整数或字符串,只能重新赋值。所以即使共享,也不会出问题。
危险的默认值(可变对象):
[]、[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是不可变的,所以它们可以安全地作为默认参数:
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=Nonedef func(arg={}): → 危险,改成arg=Nonedef func(arg=set()): → 危险,改成arg=Nonedef func(arg=time.time()): → 危险,改成arg=Nonedef func(arg=SomeMutableObject()): → 危险,改成arg=Nonedef func(arg=()): → 安全def func(arg=0): → 安全def func(arg=""): → 安全def func(arg=None): → 安全如果你在代码里看到第1到第5种写法,立刻改掉。不要犹豫。
Python的默认参数陷阱,本质上是定义时求值和可变对象共享两个特性叠加的结果。
一句话记住:默认参数只在函数定义时创建一次,如果它是可变的,所有调用共享同一个对象。 想要每次调用都有新的对象,就用None作为默认值,在函数内部创建。
这个坑很小,但后果很严重。它不会让程序崩溃,只会让数据悄悄出错。而数据错误,往往比程序崩溃更难排查。
下次写函数的时候,看到=[]或者={},心里默念一句:这是给自己埋雷。然后老老实实改成=None。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。