Skip to content

函数默认参数里的列表,为什么越用越长 ​

def add(item, bucket=[]),每次只传一个 item。第一次返回 [1],第二次返回 [1, 2],第三次返回 [1, 2, 3]。没有全局变量,没有类,函数自己「记住」了之前的调用。

这不是默认参数特有的毛病。Python 里变量是贴在对象上的名字,赋值、传参、放进容器都只是多贴一个名字,不复制对象。默认参数、[[0] * 3] * 3、浅拷贝、+=、闭包、类属性上的各种意外,都是同一件事:两个名字指向了同一个可变对象,从一边改了,另一边也变了。

Java 的对象引用也是这样,但 Python 没有基本类型和 final,默认参数的求值时机也和直觉不同,所以写惯 Java 的人更容易在这些地方出错。本文的例子都在 CPython 3.14.8 上运行过。实验见文末。

一、默认参数只求值一次 ​

可变的默认参数函数对象 add__defaults__同一个 list[1, 2, 3]第 1 次调用的 bucket第 2 次调用的 bucket第 3 次调用的 bucket浅拷贝原字典 origname、tags副本 dict(orig)name、tags'v1''v2'同一个 list['x', 'y']换了指向deepcopy 才会把里面的列表也复制一份
图 1 · 左:默认值 [] 在执行 def 时创建一次,挂在函数对象上;每次不传参数的调用都把形参绑定到这同一个列表,所以追加的内容一直累积。右:浅拷贝新建了最外层的字典,但里面的值仍指向原来的对象;改副本里的字符串是换了一个指向,改副本里的列表是改了两边共用的那个

默认值表达式在执行 def 语句时求值一次,结果存在函数对象上(add.__defaults__)。之后每次不传这个参数的调用,形参绑定的都是这同一个对象。实测三次调用之后,add.__defaults__[0] 就是 [1, 2, 3]。

不可变的默认值(数字、字符串、None、元组)没有这个问题,因为它们改不了。可变的默认值用 None 作哨兵,在函数里新建:

python
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 改的是原来的那个。

函数是否修改入参,应该是接口的一部分。不打算修改就不要调用会原地修改的方法;需要一份自己的数据,在入口处复制。

三、看起来新建了,其实是同一个 ​

用乘法造二维列表。

python
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}。

类属性。

python
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。所以同样的写法,在可变类型上是修改,在不可变类型上是重新绑定。

两者叠在一起有一个有名的例子:

python
tup = ([1],)
tup[0] += [2]         # TypeError: 'tuple' object does not support item assignment

实测抛了 TypeError,而 tup 已经变成了 ([1, 2],)。+= 分两步:先对列表做原地修改(成功),再把结果赋回 tup[0](元组不允许,失败)。报了错,修改却生效了。

六、闭包记住的是变量 ​

python
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。

七、判断的方法 ​

  1. 可变对象不作默认参数,不作类属性;dataclass 用 default_factory。
  2. 一个可变对象有没有第二个名字指着它:是不是从参数、缓存、全局配置里拿来的。
  3. 需要自己的一份就在入口处复制,并且想清楚要复制几层。
  4. 比较内容用 ==,判断是不是同一个对象用 is。实测两个内容相同的列表 == 为 True、is 为 False。is 只用于 None 这类单例;小整数和短字符串的 is 结果取决于解释器是否复用了对象,不能依赖。
  5. 调试时用 id(obj) 或 a is b 确认两个名字是不是同一个对象。

多个线程或协程共享同一个可变对象时,问题从「意外地被改了」变成「同时被改」,见 Python 线程能不能用上多核。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权