发布时间:2023-06-24 08:00
一个 Python Bug 干倒了估值 1.6 亿美元的公司
今天在CSDN首页看到这篇文章,不仅感概:
水能载舟,亦能覆舟
作为一家仰仗技术出身的公司,最终却因为技术的问题而断崖式地走向没落,实在令人唏嘘。技术既能让一家公司崛起,但使用不当、糊里糊涂或者想当然地去使用,可能一个小小的问题也足以摧毁一家公司。当然,在这里也不过多地聊这个故事,感兴趣的可以点文章链接详细看看。我就从技术的角度简单剖析下这里面出现的bug及其背后的原理。
这个问题涉及到python函数定义中的可变默认参数,那什么是可变默认参数呢?就拿文章中提到的函数举例:
def foo(l=[]):
l.append(1)
print(l)
在函数foo
中,参数l
是有默认值的,所以是一个默认参数,并且默认值是一个空列表[]
,而列表是属于可变对象,即不可散列的对象,因此参数l
就是一个可变默认参数。
在函数定义中,使用可变(不可散列unhashable)对象作为默认值的参数,就是可变默认参数
那么,在函数中使用可变默认参数会有什么问题呢?就像上面的函数foo
,很多人可能会想当然地认为,当不传递参数,多次调用函数foo()
的时候,输出的结果都是一样的,都是[1]
,但实际上偏偏想法,该函数的行为与我们预期的不符合:
可以看到,后面函数调用的输出确实不符合我们的预期,所以问题在哪里。其实只要搞清楚了python对函数的默认参数的管理机制和调用逻辑,这个问题就迎刃而解了。
首先,我们看看函数参数的默认值存在哪里,使用dir
看看函数对象的属性:
In [7]: dir(foo)
Out[7]:
['__annotations__',
'__call__',
'__class__',
'__closure__',
'__code__',
'__defaults__',
'__delattr__',
'__dict__',
'__dir__',
'__doc__',
'__eq__',
'__format__',
'__ge__',
'__get__',
'__getattribute__',
'__globals__',
'__gt__',
'__hash__',
'__init__',
'__init_subclass__',
'__kwdefaults__',
'__le__',
'__lt__',
'__module__',
'__name__',
'__ne__',
'__new__',
'__qualname__',
'__reduce__',
'__reduce_ex__',
'__repr__',
'__setattr__',
'__sizeof__',
'__str__',
'__subclasshook__']
注意到,这里面有个__defaults__
,就是缺省、默认的意思,输出其内容:
In [8]: foo.__defaults__
Out[8]: ([1, 1, 1, 1, 1],)
但我们调用函数的时候,它的值也会随之改变:
In [9]: foo()
[1, 1, 1, 1, 1, 1]
In [10]: foo.__defaults__
Out[10]: ([1, 1, 1, 1, 1, 1],)
In [11]: foo()
[1, 1, 1, 1, 1, 1, 1]
In [12]: foo.__defaults__
Out[12]: ([1, 1, 1, 1, 1, 1, 1],)
In [13]: foo()
[1, 1, 1, 1, 1, 1, 1, 1]
In [14]: foo.__defaults__
Out[14]: ([1, 1, 1, 1, 1, 1, 1, 1],)
我们重新定义foo
函数,在看看其__defaults__
属性的值:
In [15]: def foo(l=[]):
...: l.append(1)
...: print(l)
...:
In [16]: foo.__defaults__
Out[16]: ([],)
再做个实验,向__defaults__
中的列表对象添加一个数1024
,再调用函数,看看结果如何:
In [17]: foo.__defaults__[0].append(1024)
In [18]: foo.__defaults__
Out[18]: ([1024],)
In [19]: foo()
[1024, 1]
结果不出所料,在函数中向列表添加元素,其实就是向__defaults__
中的列表对象添加元素,也就是说函数中的l
变量和__defaults__
中的第一个元素引用的是同一个列表对象。所以从以上的分析可以看出函数对象的默认参数值确实是存放在其__defaults__
属性中。
到这里,我们应该大致了解了python函数默认参数的一些原理:
python函数的默认参数值在函数定义的时候就会初始化并保存在函数对象的
__defaults__
中。调用该函数对象时,如果有传参,则参数值就是我们传进去的值,如果没有,就会把函数对象的__defaults__
属性中对应的默认参数值赋给该参数。
所以,对于上面函数foo
调用的输出结果也就不难解释了:在函数foo
的定义中,使用了可变的默认参数值(空列表[]
对象),在定义函数时这个列表对象被初始化并保存到foo
函数对象的__defaults__
属性中,后续每一次无传参的调用,__defaults__
中的这个列表对象(实际是列表对象的引用)会被赋值给参数l
,而在函数中向l
中添加元素也导致了__defaults__
中的默认参数值发生了改变,因为它们引用的是同一个列表对象,所以每一次函数调用的输出结果都是在原来的列表上追加了一个元素。通过下图可以看得更直观:
所以在python函数定义中,应该尽量避免使用可变对象作为默认参数值,除非你了解它的行为并且确定这就是你期望的结果。
其实这个问题的更一般的情况是对于python中元组类型的使用和理解。
大家可能都注意到了,函数对象的__defaults__
属性是一个元组:
In [20]: type(foo.__defaults__)
Out[20]: tuple
我们都知道元组是不可变的,但真的是不可变的吗?从上面的例子可以知道,答案是否定的,关键在于元组中的元素是可变对象还是不可变对象。当元组的元素都是不可变对象,则元组也是不可变的。
In [21]: tuple_a = (1, 2.0, 'a')
In [22]: tuple_a[0] = 2
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Input In [22], in <cell line: 1>()
----> 1 tuple_a[0] = 2
TypeError: 'tuple' object does not support item assignment
但如果元组中有元素是可变对象(如列表、字典等),虽然元组本身不可变,对于可变对象,在元组中存储的也只是其引用,该引用也是不可变的,但是却可以通过该引用改变引用的对象。
In [23]: tuple_b = ([], {})
In [24]: tuple_b[0] = [1, 2, 3] # 元组本身不可变
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Input In [24], in <cell line: 1>()
----> 1 tuple_b[0] = [1, 2, 3]
TypeError: 'tuple' object does not support item assignment
# 元组元素引用的对象可以改变
In [25]: tuple_b[0].append(1)
In [26]: tuple_b[1]['hello'] = 'world'
In [27]: tuple_b
Out[27]: ([1], {'hello': 'world'})
所以,同样的建议,在使用元组的过程中,元组中的元素尽量避免用到可变对象,除非你了解它的行为并且确定这就是你期望的结果。