标题: 讨论Python函数默认参数的坑 创建: 2022-09-01 10:44 更新: 2023-08-10 10:18 链接: https://scz.617.cn/python/202209011044.txt 最近看到这篇 一个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做默认参数, 所以从未碰上过这个坑。另一方面,Digg的程序员应该没有做恰当的单元测试。 f()这个现象颠覆了我对Python作用域的认知。bluerust提到,f()的形参L是个局部 变量,传默认的[]给L,该[]的作用域应该在f()中,f()结尾并没有return(L)增加L 的引用计数,离开f()时L为何未被回收销毁?我俩的直觉都是应该回收销毁。 bluerust吐槽,f()形同"闭包",闭包的特点是离开作用域时作用域内的对象不会回 收销毁。只看作用域的话,f()默认形参L更像是C语言函数中的静态局部变量。 网上有很多文章讲这个坑,但不刻意搜的话,并不"常见"。 Python Mutable Defaults Are The Source of All Evil - [2018-08-14] https://florimond.dev/en/posts/2018/08/python-mutable-defaults-are-the-source-of-all-evil/ 文中提到 Do not use mutable default arguments in Python. In Python, when passing a mutable value as a default argument in a function, the default argument is mutated anytime that value is mutated. Here, "mutable value" refers to anything such as a list, a dictionnary or even a class instance. The solution is simple, use None as a default and assign the mutable value inside the function. 作者意思是,这样改写 -------------------------------------------------------------------------- def f_2 ( L=None ) : if L is None : L = [] L.append( 1 ) print( L ) print( hex( id( L ) ) ) f_2() f_2() f_2() print( hex( id( [0] ) ) ) f_2([0]) -------------------------------------------------------------------------- 我平时写代码就这么写的,但确实不知道前面那个坑,只是简单地喜欢用None、True 这类非可变对象做默认参数。 上述代码依次输出 [1] 0xb765ef48 [1] 0xb765ef48 [1] 0xb765ef48 0xb765ef48 [0, 1] 0xb765ef48 5次地址均相同,应该是回收再分配所致。 网友「轩辕御龙」提到,Python函数实际也是对象,函数默认参数会保存在它的 "__defaults__"字段里,所以在整个程序生命周期里函数默认参数都没有回收,大概 是这样? 我没细究过,简单测了一下,他这个说法可能是对的。 -------------------------------------------------------------------------- def f_3 ( L=[] ) : L.append( 1 ) print( L ) print( f_3.__defaults__[0] ) print( f"&f_3={id(f_3):#x} &f_3.__defaults__[0]={id(f_3.__defaults__[0]):#x} &L={id(L):#x}" ) f_3() f_3() f_3() f_3([0]) -------------------------------------------------------------------------- 上述代码依次输出 [1] [1] &f_3=0xb7659b68 &f_3.__defaults__[0]=0xb765ef88 &L=0xb765ef88 [1, 1] [1, 1] &f_3=0xb7659b68 &f_3.__defaults__[0]=0xb765ef88 &L=0xb765ef88 [1, 1, 1] [1, 1, 1] &f_3=0xb7659b68 &f_3.__defaults__[0]=0xb765ef88 &L=0xb765ef88 [0, 1] [1, 1, 1] &f_3=0xb7659b68 &f_3.__defaults__[0]=0xb765ef88 &L=0xb7589028 上例中f_3默认参数L完全对应f_3.__defaults__[0],地址完全一样。 list这种是明显的可变对象,布尔常量、整型常量、字符串常量、None、tuple这些 算不可变对象。函数默认参数是不可变对象时,一般不会踩这个坑。 稍微扩展一下,Python2中所谓常量数字也是对象,即PyIntObject,对于Python2, [-5,256]区间的整数已经预先创建好PyIntObject。利用ctypes可以修改这些不可变 对象,若修改了[-5,256]区间的整数对象,将影响整个系统。Python3没有 PyIntObject,只有PyLongObject。下面是Python3的测试代码: -------------------------------------------------------------------------- from ctypes import * # # offset需要调整成PyIntObject.ob_ival的偏移,32位与64位无差别。对于Python3, # 需要定位PyLongObject.ob_digit的偏移,一般是0x18。 # # Python2 2 # Python3 3 # offset = sizeof( c_size_t ) * 3 addr = id( 200 ) + offset # # c_long是4字节,c_longlong才是8字节 # n = c_long.from_address( addr ) # c_long(200) print( n ) n.value = 1000 # c_long(1000) print( n ) # 1000 print( 200 ) # 1001 print( 200 + 1 ) -------------------------------------------------------------------------- >>> print( n ) c_long(200) >>> print( n ) c_long(1000) >>> print( 200 ) 1000 >>> print( 200 + 1 ) 1001 Python3测试环境中常量200已经被改成1000了,对象200不再对应数值200。从汇编级 很好理解上述现象,万物皆对象,万物皆内存,不可变对象只是常规意义上的不可变。