Python 填坑之——只运算一次的默认形参

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

问题引入

前一阵子,在利用 Python 实现对服务器某接口进行请求时,需要传入 datetime ,该接口以传入的时间作为查询数据的依据。

于是我定义了类似下边这样一个函数:

1
2
3
def query(_time: datetime = datetime.now()):
# to do something
pass

我想要每隔一段时间进行一次查询,因此我在运行过程中多次调用 query(),但是结果却出乎意料,服务器返回的内容总是相同!

原因解释

调试分析

经过排查,很快找出了答案:

在每次调用时,_time 参数的值都是 "首次运行该 py 脚本的时间",并且在之后的调用中,这个值没有再改变。

举个栗子:

我在 "2022-01-09 10:00:00" 首次运行了 py 脚本,此时,_time 就被设置为了 "2022-01-09 10:00:00"。

之后,我在 "2022-01-09 10:15:00" 调用 query(),但此时,_time 的值仍为 "2022-01-09 10:00:00"。

追根溯源

究其根本原因,在于:默认参数语句,总是在 def 关键字定义函数的时候被求值,且仅执行这一次。

这可以在 Python docs 查阅到:https://docs.python.org/zh-cn/3.9/reference/compound_stmts.html#function-definitions

当一个或多个 形参 具有 "形参 = 表达式" 这样的形式时,该函数就被称为具有“默认形参值”。

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

这一点在默认形参为可变对象,例如列表或字典的时候尤其需要重点理解:如果函数修改了该对象(例如向列表添加了一项),则实际上默认值也会被修改。 这通常不是人们所预期的。 绕过此问题的一个方法是使用 None 作为默认值,并在函数体中显式地对其进行测试。

Python 是动态语言,它定义函数时,也像定义普通变量一样,进行了一个名字到函数对象的绑定。

当 Python 执行一个 def 表达式(也就是函数定义)的时候,会利用一些已有的环境片段(比如说编译好的函数体代码,对应__code__;当前命名空间的环境,对应__globals__)来构建一个新的函数对象。

Python 在这么做的时候,也会对默认参数进行求值(只求一次),并当做一个属性放到函数对象里。

解决方式

解决方案也很简单,在调用函数时,人为传入当前时间即可。即使用query(datetime.now())进行调用,如此一来,_time 参数的值就会被取代。

另外,为了防止下次调用时因为忘记传入参数而再次造成错误,我去掉了形参的默认值。

案例分析

接下来我们再看一个例子。

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,可以发现,ID 没有改变。

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

此处的 data = [],就如上文所说,作为了一个属性,被保存在了 function 这个对象里面。

因此,function() 函数在不同的调用中,一直在使用同一个列表对象。我们执行data.append(1),就不断地在向同一个列表里面添加元素。

这也就是官方文档所说的 “这一点在默认形参为可变对象,例如列表或字典的时候尤其需要重点理解:如果函数修改了该对象(例如向列表添加了一项),则实际上默认值也会被修改。

正确利用姿势

基于这种特性,一些大佬玩出了新花样。

姿势一

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

1
2
3
4
import math

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

姿势二

结果缓存 / 记忆:

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

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

The End

参考链接: Python 函数的默认参数的那些 "坑"