函数默认参数里的列表,为什么越用越长
def add(item, bucket=[]),每次只传一个item。第一次返回[1],第二次返回[1, 2],第三次返回[1, 2, 3]。没有全局变量,没有类,函数自己「记住」了之前的调用。
这不是默认参数特有的毛病。Python 里变量是贴在对象上的名字,赋值、传参、放进容器都只是多贴一个名字,不复制对象。默认参数、[[0] * 3] * 3、浅拷贝、+=、闭包、类属性上的各种意外,都是同一件事:两个名字指向了同一个可变对象,从一边改了,另一边也变了。
Java 的对象引用也是这样,但 Python 没有基本类型和 final,默认参数的求值时机也和直觉不同,所以写惯 Java 的人更容易在这些地方出错。本文的例子都在 CPython 3.14.8 上运行过。实验见文末。
一、默认参数只求值一次
[] 在执行 def 时创建一次,挂在函数对象上;每次不传参数的调用都把形参绑定到这同一个列表,所以追加的内容一直累积。右:浅拷贝新建了最外层的字典,但里面的值仍指向原来的对象;改副本里的字符串是换了一个指向,改副本里的列表是改了两边共用的那个默认值表达式在执行 def 语句时求值一次,结果存在函数对象上(add.__defaults__)。之后每次不传这个参数的调用,形参绑定的都是这同一个对象。实测三次调用之后,add.__defaults__[0] 就是 [1, 2, 3]。
不可变的默认值(数字、字符串、None、元组)没有这个问题,因为它们改不了。可变的默认值用 None 作哨兵,在函数里新建:
def add(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket实测三次调用分别返回 [1]、[2]、[3]。同样的问题也出现在「默认值是函数调用」的写法里:def log(msg, at=datetime.now()) 里的时间是模块加载时的时间,不是每次调用时的。
二、赋值和传参不复制
| 代码 | 结果 |
|---|---|
b = a,然后 b.append(3) | a 也是 [1, 2, 3],a is b 为 True |
函数里 x.append(99) | 调用方的列表变成 [1, 99] |
函数里 x = x + [99] | 调用方的列表仍是 [1] |
后两行解释了「Python 是值传递还是引用传递」这个问题:传进来的是对象本身的引用。在函数里修改这个对象,调用方看得到;让形参改指另一个对象(重新赋值),只是换了函数里这个名字的指向,调用方不受影响。x + [99] 创建了一个新列表,x.append 改的是原来的那个。
函数是否修改入参,应该是接口的一部分。不打算修改就不要调用会原地修改的方法;需要一份自己的数据,在入口处复制。
三、看起来新建了,其实是同一个
用乘法造二维列表。
grid = [[0] * 3] * 3
grid[0][0] = 1 # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]外层的 * 3 把同一个内层列表的引用重复了三次,三行是同一个对象。用列表推导式逐行创建:[[0] * 3 for _ in range(3)],实测只有第一行变化。内层的 [0] * 3 没问题,因为整数不可变。
dict.fromkeys(keys, []) 同理:所有键共用那一个列表,实测只向 'a' 追加,'b' 的值也变了。用字典推导式 {k: [] for k in keys}。
类属性。
class Cart:
items = [] # 属于类,所有实例共享实测 c1.add('book') 之后,c2.items 也是 ['book']。实例自己的数据要在 __init__ 里赋给 self。dataclass 对这个错误有保护:字段写成 items: list = [],类定义时就抛 ValueError,要求改用 field(default_factory=list),实测这样各实例互不影响。
四、浅拷贝只复制一层
dict(orig)、list(orig)、orig.copy()、切片 orig[:] 都是浅拷贝:新建最外层的容器,里面放的还是原来那些对象的引用。
| 操作 | 原对象 |
|---|---|
dict(orig) 之后,改副本的 name,并向副本的 tags 列表追加 | name 没变,tags 多了一项 |
copy.deepcopy(orig) 之后向副本的 tags 追加 | 不变 |
给副本的键重新赋值,只改了副本这一层的指向;向副本里的列表追加,改的是两边共用的那个列表。从缓存、配置或者函数默认值里取出一个嵌套结构,「复制一份再改」时如果用的是浅拷贝,就会把共享的那份改掉,而且是在离出错现象很远的地方。
deepcopy 递归复制每一层,并且会保持原有的共享关系:原对象里两个位置指向同一个列表,副本里这两个位置也指向同一个(新的)列表。它的代价是慢,并且对持有文件、连接、锁的对象没有意义。更省事的做法是让共享的数据不可变:用元组和 frozenset 代替列表和集合,用 @dataclass(frozen=True),需要修改时生成新对象。
五、+= 有两种行为
| 代码 | 别名看到的 |
|---|---|
x = y = [1],然后 x += [2] | y 是 [1, 2]:列表的 += 原地修改 |
p = q = [1],然后 p = p + [2] | q 是 [1]:+ 创建新列表 |
s = t = (1,),然后 s += (2,) | t 是 (1,):元组不可变,+= 创建新对象并重新绑定 |
x += y 先尝试调用原地修改的 __iadd__,没有才退回 x = x + y。所以同样的写法,在可变类型上是修改,在不可变类型上是重新绑定。
两者叠在一起有一个有名的例子:
tup = ([1],)
tup[0] += [2] # TypeError: 'tuple' object does not support item assignment实测抛了 TypeError,而 tup 已经变成了 ([1, 2],)。+= 分两步:先对列表做原地修改(成功),再把结果赋回 tup[0](元组不允许,失败)。报了错,修改却生效了。
六、闭包记住的是变量
fs = [lambda: i for i in range(3)]
[f() for f in fs] # [2, 2, 2]每个 lambda 引用的是变量 i 本身,调用时才去读它的值,那时循环已经结束,i 是 2。循环里创建回调、注册处理函数时都会遇到。把当时的值固定下来的办法是用默认参数(它在定义时求值,正好利用第一节的规则):lambda i=i: i,实测得到 [0, 1, 2];或者用 functools.partial。
七、判断的方法
- 可变对象不作默认参数,不作类属性;
dataclass用default_factory。 - 一个可变对象有没有第二个名字指着它:是不是从参数、缓存、全局配置里拿来的。
- 需要自己的一份就在入口处复制,并且想清楚要复制几层。
- 比较内容用
==,判断是不是同一个对象用is。实测两个内容相同的列表==为 True、is为 False。is只用于None这类单例;小整数和短字符串的is结果取决于解释器是否复用了对象,不能依赖。 - 调试时用
id(obj)或a is b确认两个名字是不是同一个对象。
多个线程或协程共享同一个可变对象时,问题从「意外地被改了」变成「同时被改」,见 Python 线程能不能用上多核。
配套实验
- codesphere-labs/engineering/python-aliasing-and-copy:CPython 3.14.8 上可变默认参数、赋值与传参、嵌套列表、浅拷贝与深拷贝、
+=、闭包、类属性与dataclass的输出,与预期逐行比较(验证记录)
参考资料
- Python 3.14 文档:Data model(对象、值与类型;
__iadd__) - Python 3.14 文档:Function definitions(默认参数在定义时求值)
- Python 3.14 文档:copy(浅拷贝与深拷贝)
- Python 3.14 文档:dataclasses(可变默认值)
- Python FAQ:Why did changing list 'y' also change list 'x'?