“最少惊讶” 和可变默认参数

长时间修改 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 中使用了静态变量,却没有滋生 bug?)

编辑

巴切克举了一个有趣的例子。连同您的大多数评论,特别是 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 的 Default Parameter Values 中都很好地解释了这种现象的原因。
我发现它很清晰,我真的建议您阅读它,以更好地了解函数对象的工作原理。

假设您有以下代码

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

def eat(food=fruits):
    ...

当我看到 eat 的声明时,最令人吃惊的事情是认为,如果没有给出第一个参数,它将等于元组("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 作为默认值,并在函数正文中明确测试它。

我对 Python 解释器的内部运作一无所知(而且我也不是编译器和解释器的专家),所以如果我提出任何不明智或不可能的事情,也不要怪我。

假设 python 对象是可变的,我认为在设计默认参数时应考虑到这一点。实例化列表时:

a = []

你希望得到通过引用新的列表a

为什么在a=[]

def x(a=[]):

在函数定义而不是调用上实例化一个新列表?就像您要问 “如果用户不提供参数,则实例化一个新列表并像调用方产生的那样使用它”。我认为这是模棱两可的:

def x(a=datetime.datetime.now()):

用户,是否要将a默认设置为定义或执行x时的日期时间?在这种情况下,与上一个示例一样,我将保持相同的行为,就好像默认参数 “赋值” 是该函数的第一条指令datetime.now()在函数调用上调用datetime.now() )一样。另一方面,如果用户想要定义时间映射,则可以编写:

b = datetime.datetime.now()
def x(a=b):

我知道,我知道:那是一个封闭。另外,Python 可以提供一个关键字来强制定义时间绑定:

def x(static a=b):

好吧,原因很简单:绑定是在执行代码时完成的,而函数定义是在执行时定义的。

比较一下:

class BananaBunch:
    bananas = []

    def addBanana(self, banana):
        self.bananas.append(banana)

此代码遭受完全相同的意外情况。 bananas 是一个类属性,因此,当您向其中添加内容时,它将被添加到该类的所有实例中。原因是完全一样的。

只是 “它是如何工作的”,要使其在函数情况下以不同的方式工作可能会很复杂,而在类情况下则可能是不可能的,或者至少会大大减慢对象实例化,因为您必须保留类代码并在创建对象时执行。

是的,这是意外的。但是一旦一分钱下降,它就完全适合 Python 的工作方式。实际上,这是一种很好的教学手段,一旦您了解了为什么会发生这种情况,您就会更好地使用 python。

也就是说,它应该在任何优秀的 Python 教程中都非常突出。因为正如您提到的,每个人迟早都会遇到此问题。

你为什么不自省?

我真的惊讶,没有人对可调用对象执行 Python 提供的深刻的自省(适用23 )。

给定一个简单的小函数func定义为:

>>> def func(a = []):
...    a.append(5)

当 Python 遇到它时,它要做的第一件事就是对其进行编译,以便为此函数创建一个code对象。完成此编译步骤后, Python 计算 *,然后默认参数(此处为空列表[] )存储在函数对象本身中 。正如上面提到的最高答案:列表a现在可以视为函数func成员

因此,让我们进行一些自省,前后检查清单如何在函数对象扩展。我为此使用Python 3.x ,对于 Python 2 同样适用(在 Python 2 中使用__defaults__func_defaults ;是的,两个名称相同)。

执行前的功能:

>>> def func(a = []):
...     a.append(5)
...

Python 执行此定义后,它将采用指定的任何默认参数(此处为a = [] ), 并将其__defaults__函数对象的__defaults__属性中 (相关部分:Callables):

>>> func.__defaults__
([],)

好的,所以就像预期的那样,将空列表作为__defaults__的单个条目。

执行后功能:

现在执行以下功能:

>>> func()

现在,让我们再次看看那些__defaults__

>>> func.__defaults__
([5],)

吃惊吗对象内部的值改变了!现在,对该函数的连续调用将简单地追加到该嵌入式list对象:

>>> func(); func(); func()
>>> func.__defaults__
([5, 5, 5, 5],)

因此,出现“缺陷”的原因是因为默认参数是函数对象的一部分。这里没有什么奇怪的事情,这一切都令人惊讶。

解决此问题的常见方法是使用None作为默认值,然后在函数体内进行初始化:

def func(a = None):
    # or: a = [] if a is None else a
    if a is None:
        a = []

由于函数体每次执行新生活,你总能得到一个全新的空列表,如果没有参数传递的a


为了进一步验证列表__defaults__是一样的,在功能使用func你可以改变你的函数返回的id列表中的a函数体内部使用。然后,将其比作列表__defaults__ (位置[0]__defaults__ ),你会看到这些确实是指的同一个列表实例:

>>> def func(a = []): 
...     a.append(5)
...     return id(a)
>>>
>>> id(func.__defaults__[0]) == func()
True

具备内省的力量!


*要在函数编译期间验证 Python 是否评估默认参数,请尝试执行以下命令:

def bar(a=input('Did you just see me without calling the function?')): 
    pass  # use raw_input in Py2

您会注意到,在构建函数并将其绑定到名称bar的过程完成之前,将调用input()

我曾经认为在运行时创建对象是更好的方法。我现在不太确定,因为您确实失去了一些有用的功能,尽管不管是为了防止新手混淆,还是值得的。这样做的缺点是:

1. 表现

def foo(arg=something_expensive_to_compute())):
    ...

如果使用了调用时评估,那么每次使用不带参数的函数时都会调用昂贵的函数。您要么为每个调用付出昂贵的代价,要么需要在外部手动缓存该值,从而污染您的名称空间并增加冗长性。

2. 强制绑定参数

一个有用的技巧是在创建 lambda 时将 lambda 的参数绑定到变量的当前绑定。例如:

funcs = [ lambda i=i: i for i in range(10)]

这将返回分别返回 0、1、2、3 ... 的函数列表。如果更改了行为,则它们会将i绑定到i调用时值,因此您将获得一个全部返回9的函数的列表。

否则,实现此目的的唯一方法是使用 i 绑定创建另一个闭包,即:

def make_func(i): return lambda: i
funcs = [make_func(i) for i in range(10)]

3. 内省

考虑一下代码:

def foo(a='test', b=100, c=[]):
   print a,b,c

我们可以使用inspect模块获取有关参数和默认值的信息,

>>> inspect.getargspec(foo)
(['a', 'b', 'c'], None, None, ('test', 100, []))

该信息对于文档生成,元编程,装饰器等非常有用。

现在,假设可以更改默认行为,使其等效于:

_undefined = object()  # sentinel value

def foo(a=_undefined, b=_undefined, c=_undefined)
    if a is _undefined: a='test'
    if b is _undefined: b=100
    if c is _undefined: c=[]

但是,我们失去了自省的能力,无法看到默认参数 。由于尚未构造对象,因此,如果不实际调用函数,就无法拥有它们。我们最好的办法是存储源代码,并将其作为字符串返回。

捍卫 Python 的 5 分

  1. 简单性 :从以下意义上讲,行为很简单:大多数人只会陷入一次陷阱,而不是几次。

  2. 一致性 :Python 始终传递对象,而不传递名称。显然,默认参数是函数标题的一部分(而不是函数主体)。因此,应该在模块加载时(并且仅在模块加载时,除非嵌套)进行评估,而不是在函数调用时进行评估。

  3. 用途 :正如 Frederik Lundh 在对“Python 中的默认参数值” 的解释中所指出的那样,当前行为对于高级编程可能非常有用。 (请谨慎使用。)

  4. 足够的文档 :在最基本的 Python 文档中,该教程在“更多关于定义函数”部分的第一小节中以“重要警告” 的形式大声宣布该问题。警告甚至使用黑体字,很少在标题之外使用。 RTFM:阅读精美的手册。

  5. 元学习 :陷入陷阱实际上是一个非常有用的时刻(至少如果您是一个反思型学习者),因为您随后将更好地理解上面的 “一致性” 这一点,并且将教给您很多有关 Python 的知识。

此行为很容易通过以下方式解释:

  1. 函数(类等)声明仅执行一次,创建所有默认值对象
  2. 一切都通过引用传递

所以:

def x(a=0, b=[], c=[], d=0):
    a = a + 1
    b = b + [1]
    c.append(1)
    print a, b, c
  1. a不变 - 每个分配调用都会创建一个新的 int 对象 - 打印新对象
  2. b不变 - 从默认值构建并打印新数组
  3. c更改 - 对同一对象执行操作 - 并打印

您要问的是为什么这样:

def func(a=[], b = 2):
    pass

在内部不等同于此:

def func(a=None, b = None):
    a_default = lambda: []
    b_default = lambda: 2
    def actual_func(a=None, b=None):
        if a is None: a = a_default()
        if b is None: b = b_default()
    return actual_func
func = func()

除了显式调用 func(None,None)的情况外,我们将忽略它。

换句话说,为什么不存储默认参数,而不是评估默认参数,并在调用函数时对其进行评估?

一个答案可能就在那里 - 它可以有效地将具有默认参数的每个函数转换为闭包。即使全部隐藏在解释器中,而不是完全关闭,数据也必须存储在某个地方。它将变慢,并使用更多的内存。