最近看了这篇

一个Python Bug干倒了估值1.6亿美元的公司 - 苏宓 [2022-08-31]
https://mp.weixin.qq.com/s/d9fI1hTfX5IrXAjRI_n4tg

故事铺垫很长,本文直奔主题讨论。要点是Digg公司当年的代码中有个函数

def get_user_by_ids ( ids=[] )

中译文中说「Python只在函数第一次被评估时初始化默认参数,这意味着每次无参调用函数时都会使用同一个列表」,然后给了个测试用例

def f ( L=[] ) :
    L.append( 1 )
    print( L )

f()
f()
f()

上述代码依次输出

[1]
[1, 1]
[1, 1, 1]

初看时我将信将疑,心说别不是Python2的BUG,于是直接用Python 3.9测,还真是。

琢磨了一下,这应该与传list时实际传的是指针(引用)相关。向bluerust谈及此现象,他怀疑中译文译错了,于是我去找了英文原文

Digg's v4 launch: an optimism born of necessity - Will Larson [2018-07-02]
https://lethain.com/digg-v4/

英文原文中确实这么写的

It set default values for both parameters as empty lists. This is a super reasonable thing to do! However, Python only initializes default parameters when the function is first evaluated, which means that the same list is used for every call to the function.

观察一下默认形参是布尔型的情形

def f_other ( B=False ) :
    print( B )

f_other()
f_other( True )
f_other()

上述代码依次输出

False
True
False

bluerust决定检查形参L与B的地址

def f_1 ( L=[] ) :
    L.append( 1 )
    print( L )
    print( hex( id( L ) ) )

def f_other_1 ( B=False ) :
    print( f"B={B}" )
    print( hex( id( B ) ) )

def f_other_2 ( I=0x41414141 ) :
    print( f"I={I:#x}" )
    print( hex( id( I ) ) )

f_1()
f_1()
f_1()
f_1([0])

f_other_1()
f_other_1( True )
f_other_1()

print( hex( id( True ) ) )
print( hex( id( False ) ) )

f_other_2()
f_other_2( 0x51201314 )
f_other_2()

上述代码依次输出

[1]
0xb765efe8
[1, 1]
0xb765efe8
[1, 1, 1]
0xb765efe8      // 前3次调用L地址始终未变
[0, 1]
0xb765ef48

B=False
0x857e7d0       // B地址未变
B=True
0x857e7e0
B=False
0x857e7d0       // B地址未变

0x857e7e0       // 常量True的地址
0x857e7d0       // 常量False的地址

I=0x41414141
0xb764dd10      // I地址未变
I=0x51201314
0xb768f938
I=0x41414141
0xb764dd10      // I地址未变

从地址看,「Python只在函数第一次被评估时初始化默认参数」的说法好像没毛病,f_other_1()形参B是布尔型,f_other_2()形参I是整型,以默认参数调用时,地址均未变,想必就是只初始化了一次所致?

对于进行过大规模开发的Python程序员而言,这可能是常识,我和bluerust孤陋寡闻了。他很少用默认参数,我用过布尔型、整型这类默认参数,没用过list做默认参数,所以从未碰上过这个坑。

f()这个现象颠覆了我对Python作用域的认知。bluerust提到,f()的形参L是个局部变量,传默认的[]给L,该[]的作用域应该在f()中,f()结尾并没有return(L)增加L的引用计数,离开f()时L为何未被回收销毁?我俩的直觉都是应该回收销毁。

bluerust吐槽,f()形同"闭包",闭包的特点是离开作用域时作用域内的对象不会回收销毁。

讨论

我踩过这个坑。当时也是摸不着头脑。这个问题很难有统一的描述,不像traceback xxxerror 这样,丢到搜索引擎很快就能就能找到答案。我一初学者费了很大的周章才明白:
原来python函数的参数默认值赋值只在函数定义时运行一次,后面不再改变。
不那么抽象的解释就是:
当你在python函数的参数里用到了列表,你就要闯祸了。例如 def myfun(L=[]):
你以为每次调用函数的时候,他都会给你一个崭新的空列表L=[]。但事实上列表L 就像老油锅底一样被反复端上来用,你甚至能捞出上个食客倒进去的菜。
python官方tutorial文档的解决方案是

def myfun(L=none):
    if L is none:
        L = []

这些在官方python tutorial 4.7章节 More on Defining Functions里有写到。我当时还做了笔记。然后一个下午就这样没了。