协慌网

登录 贡献 社区

“最小的惊讶” 和可变的默认论证

任何修补 Python 足够长的人都被以下问题咬伤(或撕成碎片):

def foo(a=[]):
    a.append(5)
    return a

Python 新手会期望这个函数总是返回一个只包含一个元素的列表: [5] 。结果却非常不同,而且非常惊人(对于新手来说):

>>> foo()
[5]
>>> foo()
[5, 5]
>>> foo()
[5, 5, 5]
>>> foo()
[5, 5, 5, 5]
>>> foo()

我的一位经理曾经第一次遇到这个功能,并称其为该语言的 “戏剧性设计缺陷”。我回答说这个行为有一个潜在的解释,如果你不理解内部,那确实非常令人费解和意想不到。但是,我无法回答(对自己)以下问题:在函数定义中绑定默认参数的原因是什么,而不是在函数执行时?我怀疑经验丰富的行为是否具有实际用途(谁真的在 C 中使用静态变量,没有繁殖错误?)

编辑

Baczek 做了一个有趣的例子。再加上你的大部分评论和尤其是 Utaal,我进一步阐述了:

>>> def a():
...     print("a executed")
...     return []
... 
>>>            
>>> def b(x=a()):
...     x.append(5)
...     print(x)
... 
a executed
>>> b()
[5]
>>> b()
[5, 5]

对我而言,似乎设计决策是相对于放置参数范围的位置:在函数内部还是 “与它一起”?

在函数内部进行绑定意味着当调用函数时, x有效地绑定到指定的默认值,而不是定义,这会产生一个深层次的缺陷: def线在绑定的一部分意义上是 “混合” 的(函数对象的)将在定义时发生,并且在函数调用时发生部分(默认参数的赋值)。

实际行为更加一致:执行该行时,该行的所有内容都会得到评估,这意味着在函数定义中。

答案

实际上,这不是设计缺陷,并不是因为内部或性能。
它只是因为 Python 中的函数是第一类对象,而不仅仅是一段代码。

一旦你以这种方式思考,那么它就完全有意义了:一个函数是一个被定义的对象; 默认参数是一种 “成员数据”,因此它们的状态可能会从一个调用更改为另一个调用 - 与任何其他对象完全相同。

无论如何,Effbot 对 Python中的默认参数值中出现这种行为的原因有一个非常好的解释。
我发现它非常清楚,我真的建议阅读它以更好地了解函数对象的工作原理。

假设您有以下代码

fruits = ("apples", "bananas", "loganberries")

def eat(food=fruits):
    ...

当我看到吃的声明时,最不令人惊讶的是认为如果没有给出第一个参数,它将等于元组("apples", "bananas", "loganberries")

但是,假设后面的代码,我会做类似的事情

def some_random_function():
    global fruits
    fruits = ("blueberries", "mangos")

然后,如果默认参数在函数执行而不是函数声明中被绑定,那么我会惊讶地发现水果已被改变(以非常糟糕的方式)。这比发现上面的foo函数改变列表更令人惊讶的 IMO。

真正的问题在于可变变量,并且所有语言都在某种程度上存在这个问题。这是一个问题:假设在 Java 中我有以下代码:

StringBuffer s = new StringBuffer("Hello World!");
Map<StringBuffer,Integer> counts = new HashMap<StringBuffer,Integer>();
counts.put(s, 5);
s.append("!!!!");
System.out.println( counts.get(s) );  // does this work?

现在,我的地图在放入地图时是否使用StringBuffer键的值,还是通过引用存储键?无论哪种方式,有人都感到惊讶; 无论是谁试图获取对象走出的人Map使用相同的一个值,他们把它与,或谁似乎无法的人,即使他们使用的关键是字面上相同检索其对象用于将其放入映射的对象(这实际上是 Python 不允许将其可变内置数据类型用作字典键的原因)。

你的例子是一个很好的例子,Python 新人会感到惊讶和被咬。但是我认为,如果我们 “修复” 了这个问题,那么这只会产生一种不同的情况,即他们会被咬住,而这种情况甚至会更不直观。而且,在处理可变变量时总是如此; 你总是遇到一些情况,根据他们正在编写的代码,某人可能直观地期望一种或相反的行为。

我个人喜欢 Python 当前的方法:在定义函数时评估默认函数参数,并且该对象始终是默认值。我想他们可以使用空列表进行特殊情况,但这种特殊的外壳会引起更多的惊讶,更不用说倒退不兼容了。

AFAICS 尚未发布文档的相关部分:

执行函数定义时,将评估默认参数值。这意味着在定义函数时,表达式被计算一次,并且每个调用使用相同的 “预先计算” 值。这对于理解默认参数何时是可变对象(例如列表或字典)尤其重要:如果函数修改对象(例如,通过将项附加到列表),则默认值实际上被修改。这通常不是预期的。解决这个问题的方法是使用 None 作为默认值,并在函数体中显式测试它 [...]