Python 函数里的空列表,为什么会记住上一次调用
写一个收集任务名称的小函数时,我们可能把 items=[] 当作“每次没有传入列表,就创建一个空列表”。第一次运行结果正常,第二次却带出了上一次的数据。若类似函数运行在长期存活的服务中,这种隐式共享还可能让不同请求的内容混在一起。
1. 问题出在对象的创建时机
下面的代码只依赖 Python 标准语法,可以直接运行:
def collect(name, items=[]):
items.append(name)
return items
first = collect('build')
print(first) # ['build']
second = collect('test')
print(second) # ['build', 'test']
print(first is second) # True
print(first) # ['build', 'test']
函数的默认参数表达式在执行函数定义时求值,不是在每次调用时重新求值。这个列表因此被保存在函数的默认值中;之后省略 items 的调用都指向同一个对象。append() 又会原地修改列表,于是数据跨调用累积。
还要留意最后一行:变量 first 并不是第一轮结果的快照,它仍然引用那个列表。因此第二次调用后,重新读取 first 也会看到两个名称。只检查返回内容,容易漏掉这种对象别名关系。
2. 用 None 表达“调用者没有提供容器”
如果约定 None 表示未提供列表,可以在函数体里创建默认对象:
def collect(name, items=None):
if items is None:
items = []
items.append(name)
return items
first = collect('build')
second = collect('test')
print(first) # ['build']
print(second) # ['test']
print(first is second) # False
provided = []
result = collect('release', provided)
print(result is provided) # True
print(provided) # ['release']
此时,省略参数的两次调用分别得到两个列表。显式传入列表时,函数仍然修改调用者提供的对象。这是一个需要写进接口说明的行为:修复默认参数共享,并不等于所有输入都会被复制。
判断条件应使用 items is None,而不是 if not items。空列表虽然是假值,但它也是调用者明确传入的有效容器。如果把它替换成新列表,就会改变“向指定容器追加”的语义。同样,items = items or [] 也有这个问题。
3. 如果不允许修改输入,就明确复制
有些接口希望返回新结果,并保留输入列表原样。这时可以采用另一种约定:
def collected(name, items=None):
result = [] if items is None else list(items)
result.append(name)
return result
source = ['build']
result = collected('test', source)
print(source) # ['build']
print(result) # ['build', 'test']
print(result is source) # False
list(items) 是浅复制:外层列表变了,内部若包含字典或其他列表,嵌套对象仍然共享。它适合这里存放名称字符串的场景;若后续会修改嵌套对象,需要另外定义复制边界,不能仅凭外层对象不同就认为完全隔离。
这个问题也适用于默认字典和集合等可变对象。默认参数使用不可变对象时,通常不会产生同样的原地累积;但把可变列表包在元组里,也不会让内部列表变得不可变。若 None 本身是合法业务值,应使用独立哨兵对象区分“没有传参”和“明确传入 None”。
验证这类函数时,至少覆盖连续两次省略参数、显式传入空容器、检查输入是否被修改这三种情况。把对象归属说明白,才能判断某次修改究竟是接口承诺,还是意外的数据串用。
- 点赞
- 收藏
- 关注作者
评论(0)