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


任何修补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 对于(函数对象的)绑定的一部分将在定义时发生,并且在函数调用时发生部分(默认参数的赋值),行将是“混合”。

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


2057
2017-07-15 18:00


起源


补充问题 - 适用于可变默认参数的好用法 - Jonathan
我毫不怀疑可变论点违反了普通人最不惊讶的原则,我看到初学者走到那里,然后英勇地用邮件元组替换邮件列表。尽管如此,可变论据仍然与Python Zen(Pep 20)一致,并且属于“对于荷兰人而言显而易见”(由核心python程序员理解/利用)条款。使用doc字符串的推荐解决方法是最好的,但对文档字符串和任何(书面)文档的抵制现在并不少见。就个人而言,我更喜欢装饰师(比如@fixed_defaults)。 - Serge
当我遇到这个问题时,我的论点是:“为什么你需要创建一个函数来返回一个mutable,它可以选择是你可以传递给函数的mutable?它可以改变一个mutable或者创建一个新函数。你为什么需要使用一个函数同时执行这两个操作?为什么要重写解释器以允许您在不向代码添加三行的情况下执行此操作?“因为我们正在谈论重写解释器在这里处理函数定义和唤起的方式。这对于几乎不必要的用例来说有很多工作要做。 - Alan Leuthard
“Python新手希望这个函数总能返回一个只包含一个元素的列表: [5]“我是一个Python新手,我不希望这样,因为很明显 foo([1]) 将返回 [1, 5]不是 [5]。你的意思是新手会期待这个功能 没有参数调用 永远都会回来 [5]。 - symplectomorphic
对于 Python教程中的示例, 为什么是 if L is None: 需要?我删除了这个测试,没有任何区别 - sdaffa23fdsf


答案:


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

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

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


1353
2017-07-17 21:29



对于阅读上述答案的任何人,我强烈建议您花时间阅读链接的Effbot文章。除了所有其他有用的信息之外,关于如何将此语言功能用于结果缓存/记忆的部分非常方便! - Cam Jackson
即使它是一流的物体,人们仍然可以设想一个设计 码 每个默认值与对象一起存储,并在每次调用函数时重新评估。我并不是说那会更好,只是作为一流对象的函数并不能完全排除它。 - gerrit
对不起,但任何被认为是“Python中最大的WTF”的是 绝对是一个设计缺陷。这是bug的来源 大家 在某些时候,因为没有人首先期望这种行为 - 这意味着它不应该以这种方式设计开始。他们,我不在乎他们必须跳过什么箍 应该 设计了Python,以便默认参数是非静态的。 - BlueRaja - Danny Pflughoeft
无论它是否是一个设计缺陷,你的答案似乎暗示这种行为在某种程度上是必要的,自然而明显的,因为函数是一流的对象,而事实并非如此。 Python有闭包。如果使用函数第一行上的赋值替换默认参数,则会对每次调用的表达式求值(可能使用在封闭范围内声明的名称)。完全没有理由认为每次以完全相同的方式调用函数时都会评估默认参数是不可能或合理的。 - Mark Amery
设计并不直接遵循 functions are objects。在您的范例中,提议将实现函数的默认值作为属性而不是属性。 - bukzor


假设您有以下代码

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

def eat(food=fruits):
    ...

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

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

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

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

真正的问题在于可变变量,并且所有语言都在某种程度上存在这个问题。这是一个问题:假设在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当前的方法:在定义函数时评估默认函数参数,并且该对象始终是默认值。我想他们可以使用空列表进行特殊情况,但这种特殊的外壳会引起更多的惊讶,更不用说倒退不兼容了。


231
2017-07-15 18:11



我认为这是一个有争议的问题。您正在处理全局变量。在代码中涉及全局变量的任何地方执行的任何评估现在(正确地)引用(“blueberries”,“mangos”)。默认参数可以像任何其他情况一样。 - Stefano Borini
实际上,我认为我不同意你的第一个例子。我不确定我是否喜欢首先修改这样的初始化程序的想法,但如果我这样做,我希望它的行为与您描述的完全相同 - 将默认值更改为 ("blueberries", "mangos")。 - Ben Blank
默认参数 是 像任何其他情况一样。出乎意料的是,参数是全局变量,而不是本地变量。这又是因为代码是在函数定义时执行的,而不是调用。一旦你得到了,并且课程也是如此,那就非常清楚了。 - Lennart Regebro
我发现这个例子具有误导性而非辉煌性。如果 some_random_function() 附加到 fruits 而不是分配给它的行为 eat()  将 更改。对于当前精彩的设计来说非常重要。如果您使用在其他地方引用的默认参数,然后从函数外部修改引用,那么您就会遇到麻烦。真正的WTF是人们定义一个新的默认参数(列表文字或对构造函数的调用),以及 仍然 得到一点。 - alexis
你刚才明确宣布了 global 并重新分配了元组 - 如果有的话绝对没有什么可惊讶的 eat 之后工作方式不同。 - user3467349


AFAICS还没有人发布相关部分 文件

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


195
2017-07-10 14:50



短语“这通常不是什么意思”和“解决这个问题的方法”闻起来就像是在记录设计缺陷。 - bukzor
@Matthew:我很清楚,但这不值得陷阱。由于这个原因,您通常会看到样式指南和linters无条件地将可变默认值标记为错误。做同样事情的明确方法是将一个属性填充到函数上(function.data = [])或者更好的是,做一个对象。 - bukzor
@bukzor:需要注意和记录陷阱,这就是为什么这个问题很好并且收到了很多赞成票的原因。与此同时,不一定需要删除陷阱。有多少Python初学者将列表传递给修改它的函数,并且看到变化显示在原始变量中感到震惊?然而,当您了解如何使用它们时,可变对象类型是很棒的。我想这只是归结为对这个特殊陷阱的看法。 - Matthew
短语“这通常不是预期的”意味着“不是程序员实际想要发生的事情”,而不是“不是Python应该做的事情”。 - holdenweb
@oriadam也许您可能想发布一个关于它的问题。也许你做的不同于预期...... - glglgl


我对Python解释器内部工作一无所知(我也不是编译器和解释器方面的专家)所以如果我提出任何不可知或不可能的建议,不要怪我。

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

a = []

你希望得到一个  列表引用 一个

为什么a = [] in

def x(a=[]):

在函数定义上实例化一个新列表而不是在调用上? 这就像你问“用户是否不提供参数 实例 一个新的列表,并使用它,就好像它是由调用者生成的“。 我认为这是模棱两可的:

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

用户,你想要吗? 一个 默认为与您定义或执行时相对应的日期时间 X? 在这种情况下,与前一个一样,我将保持相同的行为,就好像默认参数“assignment”是函数的第一条指令(在函数调用上调用datetime.now())。 另一方面,如果用户想要定义时间映射,他可以写:

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

我知道,我知道:这是一个封闭。或者,Python可能会提供一个关键字来强制定义时绑定:

def x(static a=b):

97
2017-07-15 23:21



你可以这样做:def x(a = None):然后,如果a是None,设置a = datetime.datetime.now() - Anon
我知道,这只是一个例子来解释为什么我更喜欢执行时绑定。 - Utaal
这次真是万分感谢。我真的无法理解为什么这让我感到不安。你做得很漂亮,只需要少量的模糊和混乱。正如有人从C ++中的系统编程出来并且有时天真地“翻译”语言特征一样,这个虚假的朋友把我踢进了大脑时间,就像类属性一样。我理解为什么事情会这样,但我不禁厌恶它,无论它有什么积极的可能性。至少它与我的经历相反,我可能(希望)永远不会忘记它...... - AndreasT
@Andreas一旦你使用Python足够长的时间,你就会开始看到Python将事物解释为类属性的方式是多么合乎逻辑 - 这只是因为C ++(和Java等)语言的特殊怪癖和局限性。 C#...)它对内容有任何意义 class {} 阻止被解释为属于 实例 :)但是当类是第一类对象时,显然自然的事情是它们的内容(在内存中)反映它们的内容(在代码中)。 - Karl Knechtel
在我的书中,规范结构不是怪癖或限制。我知道它可能是笨拙和丑陋的,但你可以称之为某种东西的“定义”。动态语言对我来说似乎有点像无政府主义者:当然每个人都是自由的,但你需要结构才能让某人清空垃圾并铺平道路。猜猜我老了...... :) - AndreasT


嗯,原因很简单,在执行代码时完成绑定,并且执行函数定义,以及......定义函数时。

比较一下:

class BananaBunch:
    bananas = []

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

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

它只是“如何工作”,并且在功能案例中使其工作方式可能很复杂,并且在类的情况下可能不可能,或者至少减慢对象实例化的速度,因为你必须保持类代码并在创建对象时执行它。

是的,这是出乎意料的。但是一旦便士下降,它就完全适合Python的工作方式。事实上,它是一个很好的教学辅助工具,一旦你理解了为什么会发生这种情况,你就会更好地理解python。

这说它应该在任何优秀的Python教程中突出显示。因为正如你所提到的,每个人迟早都会遇到这个问题。


72
2017-07-15 18:54



如果每个实例的不同,则它不是类属性。类属性是CLASS上的属性。由此得名。因此,它们对于所有实例都是相同的。 - Lennart Regebro
他没有要求描述Python的行为,他要求的理由。 Python中没有任何东西只是“它如何工作”;这一切都是出于某种原因所做的事情。 - Glenn Maynard
我给出了理由。 - Lennart Regebro
我不会说这是“这是一个很好的教学辅助工具”,因为它不是。 - Geo
@Geo:除此之外。它可以帮助您理解Python中的许多内容。 - Lennart Regebro


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

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=[]

但是,我们已经失去了内省的能力,并且看到了默认参数 。因为没有构造对象,所以我们不能在没有实际调用函数的情况下抓住它们。我们能做的最好的事情是存储源代码并将其作为字符串返回。


50
2017-07-16 10:05



如果每个都有一个函数来创建默认参数而不是值,你也可以实现内省。检查模块将调用该功能。 - yairchu
@SilentGhost:我在谈论是否更改了行为以重新创建它 - 创建它一次是当前行为,以及为什么存在可变的默认问题。 - Brian
@yairchu:这假设结构是安全的(即没有副作用)。不应该反思args 做 什么,但评估任意代码最终可能会产生影响。 - Brian
不同的语言设计通常只意味着以不同的方式写作。你的第一个例子很容易写成:_expensive = expensive(); def foo(arg = _expensive),如果你具体的话 别 希望它重新评估。 - Glenn Maynard
@Glenn - 这就是我所指的“外部缓存变量” - 它有点冗长,但你最终会在命名空间中添加额外的变量。 - Brian


防御Python的5分

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

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

  3. 用处:正如弗雷德里克伦德在他的解释中指出的那样 的 “Python中的默认参数值”, 当前行为对于高级编程非常有用。 (谨慎使用。)

  4. 足够的文档:在最基本的Python文档中, 该教程,该问题被大声宣布为 一个 “重要警告” 在里面 第一 章节 “更多关于定义功能”。 警告甚至使用粗体, 很少在标题之外应用。 RTFM:阅读精细手册。

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


47
2018-03-30 11:18



我花了一年的时间才发现这种行为搞砸了我的生产代码,最终删除了一个完整的功能,直到我偶然碰到这个设计缺陷。我正在使用Django。由于登台环境没有太多请求,因此该错误从未对QA产生任何影响。当我们上线并收到许多同时请求时 - 一些实用功能开始覆盖彼此的参数!制作安全漏洞,漏洞和什么不是。 - oriadam
@oriadam,没有冒犯,但我想知道你是如何学习Python而不是遇到这个问题的。我现在正在学习Python,这可能存在陷阱 官方Python教程中提到的 与第一次提到默认参数一起。 (正如在这个答案的第4点中所提到的那样。)我认为道德是 - 而不是非同情地 - 阅读 官方文件 用于创建生产软件的语言。 - Wildcard
此外,如果除了我正在进行的函数调用之外还调用了未知复杂度的函数,那将是令人惊讶的(对我而言)。 - Vatine


你为什么不反省?

 惊讶没有人进行过Python提供的富有洞察力的内省(2 和 3 适用于)callables。

给出一个简单的小功能 func 定义为:

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

当Python遇到它时,它要做的第一件事是编译它以创建一个 code 这个功能的对象。这个编译步骤完成后, 蟒蛇 评估板* 接着 商店 默认参数(空列表 [] 这里)在函数对象本身。正如最佳回答所述:列表 a 现在可以被视为一个 会员 功能 func

所以,让我们做一些内省,一个前后检查列表如何扩展  功能对象。我在用着 Python 3.x 为此,对于Python 2同样适用(使用 __defaults__ 要么 func_defaults 在Python 2中;是的,同一件事的两个名字)。

执行前的功能:

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

在Python执行此定义后,它将采用指定的任何默认参数(a = [] 在这里)和 把它们塞进去 __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

你会注意到的, input() 在构建函数并将其绑定到名称之前调用 bar 是。


43
2017-12-09 07:13



是 id(...) 最后一次验证需要,或者是 is 运营商回答同样的问题? - das-g
@ DAS-克 is 我会用的很好 id(val) 因为我认为它可能更直观。 - Jim Fasarakis Hilliard
@JimFasarakisHilliard我会添加一个提示 input。喜欢 input('Did you just see me without calling me?')。它使它更清晰imo。 - Ev. Kounis
@ Ev.Kounis我喜欢它!感谢您指出了这一点。 - Jim Fasarakis Hilliard