
上个月我接了个订单统计系统的活儿。需求很简单:给每个商品打标签,如果调用时没给标签列表,就自动新建一个空列表来装。
我三下五除二写完了代码:
def add_tag(item, tags=[]):
tags.append(item)
return tags简单测了一下,完全OK。于是部署上线,开始接收真实数据。
第二天,运营同事跑过来跟我说:"你这系统是不是有问题?我明明只给'电子产品'加了三个标签,为什么'包邮'商品里也出现了'电子产品'的标签?"
我一查数据库,惊了——很多商品的标签都串在一起了。查了半天代码,发现问题就出在那行看似人畜无害的def add_tag(item, tags=[]):上。
一个空列表,让我加了两天班。
先来复现一下这个"灵异"现象:
def add_tag(item, tags=[]):
tags.append(item)
return tags
print(add_tag("电子产品")) # ['电子产品']
print(add_tag("包邮")) # ['电子产品', '包邮'] ← 意外!
print(add_tag("今日特价")) # ['电子产品', '包邮', '今日特价']看到第二次的输出,我整个人都不好了:tags明明是空列表,为什么第一次追加的"电子产品"还在里面?
原因只有一个:Python的默认参数是在函数定义时创建的,而不是在函数调用时创建的。
当Python解释器读到def add_tag(item, tags=[]):这一行时,它创建了一个空列表,然后把这个列表固定下来,作为tags的默认值。以后每次调用add_tag(),如果不传tags参数,用的永远都是同一个列表对象。
用id()验证一下:
def add_tag(item, tags=[]):
print(f"tags的内存地址: {id(tags)}")
tags.append(item)
return tags
add_tag("A") # tags的内存地址: 140234567890
add_tag("B") # tags的内存地址: 140234567890 ← 同一个!两次调用的内存地址完全一样,说明它们用的是同一个列表对象。这就是"空列表"不断累积的原因。
def fetch_data(url, params={}):
params['timestamp'] = time.time()
response = requests.get(url, params=params)
return response第一次调用,params是空字典,加上timestamp后发送请求。 第二次调用,params还是那个字典,里面已经有一个timestamp了,你又加了一个新的——变成两个timestamp参数,API直接懵了。
class Order:
def __init__(self, items=[]):
self.items = items
order1 = Order()
order1.items.append("手机")
order2 = Order()
print(order2.items) # ['手机'] —— 不是空列表!你想让每个订单有独立的商品列表,结果所有订单都共享同一个列表。这个Bug尤其隐蔽——因为代码看起来完全没毛病。
def validate(data, errors=[]):
if 'name' not in data:
errors.append('缺少name字段')
if 'age' not in data:
errors.append('缺少age字段')
return errors
errors1 = validate({'name': '张三'}) # ['缺少age字段']
errors2 = validate({'age': 18}) # ['缺少age字段', '缺少name字段'] —— 混了第二次调用的错误列表里包含了第一次的错误,因为用的是同一个列表。
你可能会问:Python为什么要把默认参数设计成"定义时创建",而不是"调用时创建"?
这是有意为之的。
第一,性能考虑。 函数定义只执行一次,如果每次调用都重新创建默认参数(即使是空列表),会带来不必要的开销。虽然对空列表来说开销不大,但如果默认参数是一个复杂的对象,这个设计就能节省不少时间。
第二,这确实是一个特性。 在某些场景下,你确实需要函数调用之间保持状态。比如带缓存的函数:
def fibonacci(n, cache={}):
if n in cache:
return cache[n]
if n <= 1:
return n
cache[n] = fibonacci(n-1) + fibonacci(n-2)
return cache[n]这个代码利用了默认参数的持久性来缓存计算结果,大幅提升了性能。
但问题在于:这个特性对新手不友好,而且在大多数场景下,用默认参数保存状态是过度设计。
正确的写法很简单,所有Python程序员都公认的"最佳实践":
def add_tag(item, tags=None):
if tags is None:
tags = []
tags.append(item)
return tags测试一下:
print(add_tag("电子产品")) # ['电子产品']
print(add_tag("包邮")) # ['包邮']
print(add_tag("今日特价")) # ['今日特价']这次每次调用都创建了全新的列表,各不干扰。
经验法则:只要默认参数是可变对象(列表、字典、集合、自定义类实例),就用None占位,在函数内部重新创建。
不可变对象(字符串、整数、浮点数、元组、布尔值)不需要这个技巧,因为函数内部无法修改它们。
如果你用类型注解,可以这样写:
from typing import Optional, List
def add_tag(item: str, tags: Optional[List[str]] = None) -> List[str]:
if tags is None:
tags = []
tags.append(item)
return tagsPython 3.10+可以用更简洁的写法:
def add_tag(item: str, tags: list[str] | None = None) -> list[str]:
if tags is None:
tags = []
tags.append(item)
return tags| None明确表示这个参数可以是None,类型检查工具不会报警。
class Order:
def __init__(self, items: list | None = None):
if items is None:
items = []
self.items = items
# 现在每个订单的items都是独立的
order1 = Order()
order1.items.append("手机")
order2 = Order()
print(order2.items) # [] —— 正常了场景 | 错误写法 | 正确写法 |
|---|---|---|
默认空列表 | def f(x=[]): | def f(x=None): if x is None: x = [] |
默认空字典 | def f(x={}): | def f(x=None): if x is None: x = {} |
默认空集合 | def f(x=set()): | def f(x=None): if x is None: x = set() |
默认自定义对象 | def f(x=MyClass()): | def f(x=None): if x is None: x = MyClass() |
现代Python工具会自动检测这个陷阱:
W0102: dangerous-default-value如果你用VSCode + Pylance,默认就有相关提示。看到这个警告,立刻改成None模式。
那次加班排查之后,我在公司的代码规范里加了一条:
所有函数的默认参数,如果是可变类型,必须用
None占位,在函数内部重新创建。
从那以后,再也没有因为"空列表"加过班。
记住一句话:Python的默认参数在def时创建,不在调用时创建。可变对象做默认参数,数据会跨调用累积。
这个道理很简单,但如果不理解,一个空列表就能让你加两天班。希望你是那个"不用加班"的人。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。