木鸟杂记

分布式系统,数据库,存储

Python函数的默认参数的那些"坑"

python-default-parameter.png

引子

栽在 Python 的默认参数的“坑”中几次之后打算专门弄一篇博客来说一下这个事情。但是最近看到一篇很好地英文文章Default Parameter Values in Python,Fredrik Lundh | July 17, 2008 | based on a comp.lang.python post),鞭辟入里。珠玉在前,就不舞文弄墨了。当然,也算是偷个懒,在这里简单翻译一下,希望更多的人能看到。

以下是翻译,意译,加了一些私货,不严格跟原文保持一致,语法特性以 Python3 为准。

作者:木鸟杂记 https://www.qtmuniao.com, 转载请注明出处

正文

Python 处理默认参数值的方式是少数的几个能绊倒大部分初学者的问题之一(虽然一般只会绊倒一次)。

Python 做出这种让人费解的行为,往往是因为你把一个“可变”对象当做了函数的默认参数。即,一个可以原地进行改变的对象,比如说列表或者字典。

一个栗子:

1
2
3
4
5
6
7
8
9
10
>>> def function(data=[]):
... data.append(1)
... return data
...
>>> function()
[1]
>>> function()
[1, 1]
>>> function()
[1, 1, 1]

如代码所示,返回值列表变得的越来越长,而不是想象中的每次都是 [1] 。试着查看一下每次返回的列表的 ID,发现竟然没有变过。

1
2
3
4
5
6
>>> id(function())
12516768
>>> id(function())
12516768
>>> id(function())
12516768

原因也很简单,function() 函数在不同函数调用中一直在使用同一个列表对象。我们的修改(data.append(1))变成了粘滞操作。

为什么会这样

答案就是:默认参数语句,总是在 def 关键字定义函数的时候被求值,且仅执行这一次。可以查阅 Python 语言参考(The Python Language Reference) 的相关章节:

https://docs.python.org/zh-cn/3.7/reference/compound_stmts.html#function-definitions

默认形参值会在执行函数定义时按从左至右的顺序被求值。这意味着当函数被定义时将对表达式求值一次,相同的“预计算”值将在每次调用时被使用。

需要注意的是,以关键字 def 开头的函数签名在 Python 中是个可执行语句,默认参数就是在def 表达式中被求值的。如果你执行 def 表达式多次,Python 就会每次为你创建一个新的函数对象(默认参数自然也会重新计算)。在接下来的例子中我们将会认识到这一点。

那么要如何做

一个临时的变通办法是,当然其他人也提到了:用一个无意义值当做默认参数仅用来占位,而不是每次都直接修改该默认参数。None 就是这样一个常用占位符:

1
2
3
4
def myfunc(value=None):
if value is None:
value = []
# modify value here

如果你需要处理任意类型的数据(包括 None 在内),可以用一个哨兵实例:

1
2
3
4
5
6
sentinel = object()

def myfunc(value=sentinel):
if value is sentinel:
value = expression
# use/modify value here

当然在一些旧的代码里,object 还没有被引入 Python 的时候,下面语句也常被使用创建一个值为非假(not false)唯一实例:

1
sentinel = ['placeholder']

因为 [] 每次执行时,都会创建一个新的实例。

正确利用姿势

值得一提的是,一些高级 Python 代码常常反而会利用此特性。例如,你想通过一个循环来创建一堆按钮,你可能会这么做:

1
2
3
4
for i in range(10):
def callback():
print "clicked button", i
UI.Button("button %s" % i, callback)

却不幸的发现所有回调函数都打印出了同一个值(在上面例子中,大概率都是9)。其原因是,在 Python 的内层嵌套作用域中,绑定的是外层变量本身,而非其值。因此所有的回调函数都会看到变量 i 的最后的值。可以通过在内层函数调用时,对参数进行显式传递来解决这一问题。

1
2
3
4
for i in range(10):
def callback(i=i):
print "clicked button", i
UI.Button("button %s" % i, callback)

i=i 语句,利用 def 语句在每次执行时都会重新进行绑定的特性,将当前外层 i 的值,绑定到局部变量(也就是形参) i 上。

还有两个其他可能的用途,一是结果缓存/记忆:

1
2
3
4
5
6
7
def calculate(a, b, c, memo={}):
try:
value = memo[a, b, c] # return already calculated value
except KeyError:
value = heavy_calculation(a, b, c)
memo[a, b, c] = value # update the memo dictionary
return value

这种使用姿势在某些递归函数中非常有用(比如记忆化搜索)。

二是,对于需要高度优化的代码,可以将全局变量绑定到局部来优化性能:

1
2
3
4
import math

def this_one_must_be_fast(x, sin=math.sin, cos=math.cos):
...

详细说下原理

当 Python 执行一个 def 表达式(也就是函数定义)的时候,会利用一些已有的环境片段(比如说编译好的函数体代码,对应__code__;当前命名空间的环境,对应__globals__)来构建一个新的函数对象。Python 在这么做的时候,也会对默认参数进行求值,并当做一个属性放到函数对象里。

当然,这些环境通过函数对象的属性都能访问到:

1
2
3
4
5
6
7
8
9
10
>>> function.__name__
'function'
>>> function.__code__
<code object function at 00BEC770, file "<stdin>", line 1>
>>> function.__defaults__
([1, 1, 1],)
>>> function.__globals__
{'function': <function function at 0x00BF1C30>,
'__builtins__': <module '__builtin__' (built-in)>,
'__name__': '__main__', '__doc__': None}

既然你可以访问当默认值,那么你当然可以修改它:

1
2
3
4
5
>>> function.__defaults__[0][:] = []
>>> function()
[1]
>>> function.__defaults__
([1],)

不过,你最好别这么干(修改一些你不了解的的东西,比如私有变量或者系统变量,会导致一些神奇的后果)。

另一个对默认参数进行重置的方法就是重新执行同样的 def 函数定义语句,也即,把 function 定义再执行一次。当你这么做时,Python 就会为编译函数体重新创建一个代码对象,重新对默认参数进行求值,然后将该函数对象再一次绑定到 function 这个名字上。不过,再强调一次,只要在你明确知道某种写法会产生什么后果时,再去做。

当然也可以通过 new 模块中的 function 类去定义你自己的函数对象(不过,在 Python3 中 new 模块已经被舍弃了)

小结

一切根源在于 Python 是动态语言,它定义函数时,也像定义普通变量一样,进行了一个名字到函数对象的绑定。并且只在绑定的时候执行函数头里的赋值语句,并将参数保存为函数对象的一部分(即其属性)。之后通过改名字进行函数调用的时候,只是执行函数体(通过 __code__ 指向的代码片段)的语句。

而在函数不是第一等公民静态语言中,函数定义是在编译阶段做的,不能在运行时多次重复绑定。在每次函数调用时,形参实参都会进行一次结合,默认参数会被重新进行赋值。


我是青藤木鸟,一个喜欢摄影、专注大规模数据系统的程序员,欢迎关注我的公众号:“木鸟杂记”,有更多的分布式系统、存储和数据库相关的文章,欢迎关注。 关注公众号后,回复“资料”可以获取我总结一份分布式数据库学习资料。 回复“优惠券”可以获取我的大规模数据系统付费专栏《系统日知录》的八折优惠券。

我们还有相关的分布式系统和数据库的群,可以添加我的微信号:qtmuniao,我拉你入群。加我时记得备注:“分布式系统群”。 另外,如果你不想加群,还有一个分布式系统和数据库的论坛(点这里),欢迎来玩耍。

wx-distributed-system-s.jpg