AIGC标识 Python 里 def f(x=[]) 为什么每次调用都共用同一个列表?拆开 __defaults__ 和函数对象创建时那一次求值

几乎每个 Python 教程都会警告「别用可变对象当默认参数」,但多数只给结论不给机制:为什么是共享的?那个列表到底存在哪儿?什么时候被求值一次?

一句话结论:默认参数不是每次调用时创建的,而是在 def 语句执行、函数对象被创建那一刻求值一次,之后作为 __defaults__ 元组挂在函数对象上,被所有调用共享。[] 从来不在字节码常量池里,它活在函数对象里——所以 f() 调一万次,改的都是同一个列表。

下面数据来自本机 CPython 3.13.14 实测(d54-probe.py)。

一、先复现:三次调用,同一个列表

def f(x=[]):
    x.append(len(x))
    return x

r1, r2, r3 = f(), f(), f()
print(r1, r2, r3, r1 is r2 is r3)
print(f.__defaults__, id(f.__defaults__[0]))

实测输出:

[0, 1, 2] [0, 1, 2] [0, 1, 2] True
([0, 1, 2],) 2821577192256

注意 r1、r2、r3 打印出来都是 [0, 1, 2]——不是「三次分别得到 [0]、[0,1]、[0,1,2]」,而是三个变量指向同一个对象,最后状态就是 [0, 1, 2]。r1 is r2 is r3 为 True。

再验证一次:

print(id(f()) == id(f.__defaults__[0]))
True

每调用一次 f(),返回的正是 __defaults__[0] 那一个列表。所谓「默认参数被污染」,本质是「函数对象的一个属性被修改了」。

二、那个列表到底存在哪儿

很多人以为默认值会像字符串、数字一样进 co_consts。实测不是:

print(f.__code__.co_consts)
print(f.__code__.co_varnames, f.__code__.co_argcount)
print(type(f.__defaults__), len(f.__defaults__))
(None,)
('x',) 1
<class 'tuple'> 1

co_consts 里只有 None(函数体隐式的返回值),找不到 []。

默认值实际存放在函数对象的 __defaults__ 属性里,是一个 tuple;x=[] 对应 f.__defaults__[0],就是那个列表。函数被调用时,解释器把 __defaults__ 里的值填进局部变量槽位 x——填的是引用,不是拷贝。

这也解释了为什么不可变默认值(整数、字符串、None、元组)不会出问题:它们本来就没法原地修改,共享也无害。出事的只有 list / dict / set 这类可变对象。

keyword-only 参数放在另一个地方

def k(*, opt=[]):
    opt.append(1)
    return opt

print(k.__defaults__, k.__kwdefaults__)
print(k(), k(), k() is k())
None {'opt': [1, 1, 1, 1]}
[1, 1, 1, 1] [1, 1, 1, 1] True

* 之后的 keyword-only 默认值不进 __defaults__,而是 __kwdefaults__(一个 dict)。机制一样:函数对象上挂一份,所有调用共享。

三、求值时机:def 执行那一刻,不是调用那一刻

默认值表达式在 def 语句执行时求值一次。用一个会变的值最容易看出来:

import time

def h(t=time.time()):
    return t

a, b = h(), h()
time.sleep(0.01)
c = h()
print(a, a == b, a == c)

实测:

1794110094.2900052 True True

隔了 10.9 毫秒再调,h() 拿到的还是定义时刻那个时间戳。time.time() 只跑了一次,跑在 def h(...) 被执行的时候。

这一点在做配置、缓存、随机数默认值时特别容易踩:

def make_id(prefix=uuid.uuid4().hex[:8]):  # 所有调用共享同一个前缀
    ...

想要每次都新算,得把求值挪进函数体。

顺带理解 def 的本质:def 是可执行语句,不是编译期声明。它每次被执行都会新建一个函数对象、重新求值一次默认值:

def outer():
    def inner(x=[]):
        return x
    return inner

a, b = outer(), outer()
print(a() is b())   # False —— 两次 def 各建了一个列表

四、运行时能换掉默认值吗?能

__defaults__ 是可写的:

f.__defaults__ = ([100],)
print(f(), f())
[100, 1, 2] [100, 1, 2]

替换之后,后续调用用的是新列表。这个能力主要用于测试 mock 和框架注入,业务代码里别用——它让函数行为取决于「谁在什么时候改过它」,比默认参数陷阱更难查。

五、同一个坑的另外两张脸

类属性里的可变对象

class Bag:
    items = []

b1, b2 = Bag(), Bag()
b1.items.append('x')
print(b2.items, b1.items is b2.items)
['x'] True

根因完全一样:items 是类对象上的一个列表,实例查找时共享。正确写法是放进 __init__:

class Bag:
    def __init__(self):
        self.items = []

闭包里的 late binding

funcs = [lambda: i for i in range(3)]
print([g() for g in funcs])   # [2, 2, 2],不是 [0, 1, 2]

这个和默认参数不是同一机制(是闭包变量晚绑定),但常被一起当成「Python 变量作用域玄学」。解法之一恰好就是默认参数:

funcs = [lambda i=i: i for i in range(3)]
print([g() for g in funcs])   # [0, 1, 2]

这里利用的正是本文这条规则:i=i 的默认值在每次 def(lambda)执行时求值并冻结到函数对象上。

六、正确写法,以及「性能借口」不成立

标准解法是 None 哨兵:

def g(x=None):
    if x is None:
        x = []
    x.append(1)
    return x

print(g(), g(), g())
[1] [1] [1]

三次互不相同(not (g() is g()) 为 True)。None 不可变,共享无害;每次进函数体才新建列表。

有人会问:每次建列表不是更慢吗?实测一下就别拿这个当借口了:

import timeit
print(timeit.timeit(f, number=1000000))   # 共享 append
print(timeit.timeit(g, number=1000000))   # 每次建表
0.143 s
0.178 s

100 万次调用,差 0.035 秒,约合每次 35 纳秒。为了 35 纳秒换一个「调用之间互相污染」的语义 bug,怎么算都不划算。何况 f 那个版本列表会无限增长,实际场景里内存和逻辑早就先崩了。

另外提醒:if x is None 不要写成 if not x。后者会把调用方显式传进来的空列表、空字典、0、False 一并吞掉,属于另一种语义错误。

七、为什么 Python 要这么设计

不是疏忽,是几个设计决定的自然结果:

设计 后果
函数是一等对象 默认值只能是对象身上的状态,不能是「调用时临时算的局部变量」
def 是可执行语句 默认值表达式必须在 def 执行时求值,且只求一次
默认值按引用传递 填进局部槽位的是引用,可变对象原地改就会留痕
co_consts 只存不可变常量 可变默认值无法编译进常量池,只能挂到函数对象

换句话说,如果每次调用都重新求值默认值,那 def 就得在调用时重新执行一遍——函数对象也就不再是「一个确定的对象」了。CPython 选择让函数对象携带完整状态,代价就是把这条规则交给程序员记住。

八、排错清单(实测踩过的七条)

  1. f() 越调列表越长 → 可变默认值共享;改 None 哨兵。
  2. 在 co_consts 里找不到 [] → 它在 f.__defaults__,不在字节码常量池。
  3. 默认值是 time.time() / uuid4() 却永远不变 → 只在 def 执行时算一次,挪进函数体。
  4. keyword-only 默认值改不动 __defaults__ → 它在 __kwdefaults__。
  5. if not x 吞掉了调用方传的空列表 → 用 if x is None。
  6. 类属性 list 被所有实例共享 → 移到 __init__ 里赋给 self。
  7. 改了 f.__defaults__ 后行为诡异 → 检查是不是有框架/测试代码替换过它。

九、小结

一句话:def f(x=[]) 里的 [] 在 def 执行时创建一次,作为 __defaults__[0] 挂在函数对象上,之后每次调用都把同一个引用交给 x。

所以这个问题的正确心智模型不是「默认参数很危险」,而是「默认值是函数对象的状态」。想清楚这一点,类属性共享、闭包 late binding、__kwdefaults__、运行时替换默认值这些看起来不相干的现象,其实是同一套对象模型在不同位置的投影。

排查时也不用猜:print(f.__defaults__) 和 print(f.__code__.co_consts) 两行,就能把值到底存在哪儿钉死。


运行环境:本机 CPython 3.13.14。本文所有输出均由 d54-probe.py 实测取得,未引用外部基准数据。

posted @ 2026-11-08 12:39  小小张说故事  阅读(3)  评论(0)    收藏  举报