可变函数参数默认值的好处是什么?

138

在Python中,将可变对象设置为函数参数的默认值是一个常见的错误。以下示例摘自David Goodger的优秀文章

>>> def bad_append(new_item, a_list=[]):
        a_list.append(new_item)
        return a_list
>>> print bad_append('one')
['one']
>>> print bad_append('two')
['one', 'two']

这种情况发生的原因在这里解释了。

现在我有一个问题:这种语法是否有良好的用例?

我的意思是,如果每个遇到它的人都会犯同样的错误,调试它,理解问题,然后从那时起尝试避免它,那么这种语法有什么用处呢?


18
这种行为不是一种“设计选择”,而是这种语言工作方式的结果——从简单的工作原理开始,尽可能地减少例外情况。对我来说,当我开始“用Python思考”时,这种行为变得很自然——如果它没有发生,我会感到惊讶。 - jsbueno
3
我也曾想过这个问题。这个例子在网络上到处都是,但它根本没有意义——如果你想改变传递的列表并且有默认值是没有意义的,或者你想返回一个新的列表并且应该在进入函数时立即复制。我无法想象同时做这两件事情非常有用的情况。 - Mark Ransom
1
就我个人而言,我在我的回答中使用它们来回答Efficient way of having a function only execute once in a loop这个问题。 - martineau
3
我刚刚找到一个更加现实的例子,它没有我之前抱怨的问题。默认值是类的 __init__ 函数的参数,该函数将其设置为实例变量;这是一个完全合理的需求,但如果使用可变类型作为默认值,就会出现严重问题。参考链接:http://stackoverflow.com/questions/43768055/python-class-instance-variable-isolation - Mark Ransom
3
根据你的定义,(在确定性的)计算机上永远不会出现任何错误。只要花足够的时间研究内部机制,每个错误都是有意义的。老实说,我们可以将这种行为称为Python中极少数的设计缺陷之一。 - Eric Duminil
显示剩余6条评论
8个回答

94

您可以使用它在函数调用之间缓存值:

def get_from_cache(name, cache={}):
    if name in cache: return cache[name]
    cache[name] = result = expensive_calculation()
    return result

但通常最好使用类来完成这样的事情,因为你可以拥有额外的属性来清除缓存等。


25
...或一个记忆化装饰器。 - Daniel Roseman
50
@functools.lru_cache(maxsize=None) - Katriel
4
如果您有不可哈希的值,则无法使用lru_cache - Synedraacus
9
这个食谱也是这样。 - martineau
7
并非必须如此。如果你的一些参数是可哈希的,而另一些不是,你可以使用这个方法只为可哈希的参数提供缓存。然而,lru_cache 要求所有参数都是可哈希的。 - Synedraacus
@Synedraacus 这听起来像是制造 Heisenbugs 的配方。如果你的函数中有些输入是可变的,那么整个函数就无法缓存。最好的做法是“冻结”各种数据结构或使用持久化的数据类型(例如使用 pyrsistent)。 - DeusXMachina

23

标准答案在这个页面:http://effbot.org/zone/default-values.htm

它还提到了三种可变默认参数的“好”用例:

  • 在回调函数中将局部变量绑定到外部变量的当前值
  • 缓存/记忆化
  • 本地重新绑定全局名称(用于高度优化的代码)

2
似乎“将本地变量绑定到回调中外部变量的当前值”只是Python中另一个设计缺陷的解决方法。 - emclain
1
Effbot在http://web.archive.org/web/20201112004749/https://www.effbot.org/zone/default-values.htm上的存档页面。 - undefined

18

也许您不会改变可变参数,但确实希望使用可变参数:

def foo(x, y, config={}):
    my_config = {'debug': True, 'verbose': False}
    my_config.update(config)
    return bar(x, my_config) + baz(y, my_config)

(是的,我知道在这种特定情况下你可以使用config=(),但我发现那个不够清晰,也不够通用。)


11
请确保在函数中不要改变(mutate)不要返回(return)这个默认值,否则函数之外的代码可能会对它进行更改并影响所有函数调用。 - Andrey Semakin

12
import random

def ten_random_numbers(rng=random):
    return [rng.random() for i in xrange(10)]

使用 random 模块作为默认的随机数生成器,它实际上是一个可变的单例对象。


7
但是这也不是一个非常重要的使用情景。 - Evgeni Sergeev
4
我认为Python中的“一次获取引用”和其他编程语言中每个函数调用时“查找random”的行为没有区别。两者最终都会使用相同的对象。 - nyanpasu64
random虽然不可变。导入random,然后print(hash(random))。模块、类(类型而非实例)和函数被视为不可变的。这是许多记忆化和依赖注入机制的工作原理。 - DeusXMachina
请注意:在Python中,“mutable”具有一些非常具体的含义(技术上来说,一切都是“mutable”,这是Python的特性)。也许“frozen”是一个更好的术语。任何服从Hashable__hash__/__eq__)接口的对象都可以被视为被冻结的。仅仅因为一个对象具有副作用并不意味着它是可变的:socket.socket是另一个具有副作用的一流哈希对象的例子。 - DeusXMachina

8

我知道这是一个老问题,但为了好玩,我想在此线程中添加一个用例。我经常为TensorFlow/Keras编写自定义函数和层,将我的脚本上传到服务器,在那里训练模型(使用自定义对象),然后保存并下载模型。为了加载这些模型,我需要提供一个包含所有这些自定义对象的字典。

在像我这样的情况下,你可以在包含这些自定义对象的模块中添加一些代码:

custom_objects = {}

def custom_object(obj, storage=custom_objects):
    storage[obj.__name__] = obj
    return obj

那么,我只需修饰需要放入字典中的任何类/函数即可。

@custom_object
def some_function(x):
    return 3*x*x + 2*x - 2

此外,假设我想要将我自定义的损失函数存储在与我自定义的Keras层不同的字典中。使用functools.partial使我可以轻松访问一个新的装饰器。
import functools
import tf

custom_losses = {}
custom_loss = functools.partial(custom_object, storage=custom_losses)

@custom_loss
def my_loss(y, y_pred):
    return tf.reduce_mean(tf.square(y - y_pred))

3

编辑(澄清):可变默认参数问题是更深层次设计选择的一个症状,即默认参数值存储为函数对象的属性。您可能会问为什么做出这样的选择;像往常一样,这样的问题很难正确回答。但是它确实有好处:

优化性能:

def foo(sin=math.sin): ...

在闭包中获取对象的值而不是变量。

callbacks = []
for i in range(10):
    def callback(i=i): ...
    callbacks.append(callback)

10
整数和内置函数是不可变的。 - Reinstate Monica
2
@Jonathan:在剩下的例子中仍然没有可变默认参数,还是我没看到? - Reinstate Monica
2
@Jonathan:我的观点不是这些是可变的。而是Python系统用于存储默认参数的方式——在编译时定义在函数对象上的方式——可能会很有用。这意味着可变默认参数的问题,因为在每次函数调用时重新评估参数将使这个技巧无效。 - Katriel
2
@katriealex:好的,但请在您的答案中说明您假设参数需要重新评估,并说明为什么这样做会有问题。挑剔一点:默认参数值不是在编译时存储的,而是在函数定义语句执行时存储的。 - Reinstate Monica
@WolframH:是的 :P!虽然这两个经常重合。 - Katriel

1

一个可变的默认参数,如果从未被调用代码实际使用,可以用来创建一个哨兵值。内置的 Python 深度复制 可以做到这一点

可变参数用于确保该值对该函数是唯一的:由于在编译 deepcopy 时必须创建一个新列表,并且它在其他地方不可访问,因此该对象不能出现在任何其他地方。不可变对象往往会被 intern,而空列表很容易创建。通常,像这样的哨兵对象将明确地单独创建,但我想这种方式避免了命名空间污染(即使使用前导下划线名称)。


-3
针对可变默认参数值的好用途问题,我提供以下示例:
可变默认参数对于编写易于使用、可导入的自定义命令非常有用。这种可变默认方法相当于在函数中拥有私有静态变量,您可以在第一次调用时进行初始化(非常类似于类),但无需使用全局变量,也无需使用包装器,并且无需实例化已导入的类对象。从某种意义上说,它是优雅的,我希望您会同意。
考虑以下两个示例:
def dittle(cache = []):

    from time import sleep # Not needed except as an example.

    # dittle's internal cache list has this format: cache[string, counter]
    # Any argument passed to dittle() that violates this format is invalid.
    # (The string is pure storage, but the counter is used by dittle.)

     # -- Error Trap --
    if type(cache) != list or cache !=[] and (len(cache) == 2 and type(cache[1]) != int):
        print(" User called dittle("+repr(cache)+").\n >> Warning: dittle() takes no arguments, so this call is ignored.\n")
        return

    # -- Initialize Function. (Executes on first call only.) --
    if not cache:
        print("\n cache =",cache)
        print(" Initializing private mutable static cache. Runs only on First Call!")
        cache.append("Hello World!")
        cache.append(0)
        print(" cache =",cache,end="\n\n")
    # -- Normal Operation --
    cache[1]+=1 # Static cycle count.
    outstr = " dittle() called "+str(cache[1])+" times."
    if cache[1] == 1:outstr=outstr.replace("s.",".")
    print(outstr)
    print(" Internal cache held string = '"+cache[0]+"'")
    print()
    if cache[1] == 3:
        print(" Let's rest for a moment.")
        sleep(2.0) # Since we imported it, we might as well use it.
        print(" Wheew! Ready to continue.\n")
        sleep(1.0)
    elif cache[1] == 4:
        cache[0] = "It's Good to be Alive!" # Let's change the private message.

# =================== MAIN ======================        
if __name__ == "__main__":

    for cnt in range(2):dittle() # Calls can be loop-driven, but they need not be.

    print(" Attempting to pass an list to dittle()")
    dittle([" BAD","Data"])
    
    print(" Attempting to pass a non-list to dittle()")
    dittle("hi")
    
    print(" Calling dittle() normally..")
    dittle()
    
    print(" Attempting to set the private mutable value from the outside.")
    # Even an insider's attempt to feed a valid format will be accepted
    # for the one call only, and is then is discarded when it goes out
    # of scope. It fails to interrupt normal operation.
    dittle([" I am a Grieffer!\n (Notice this change will not stick!)",-7]) 
    
    print(" Calling dittle() normally once again.")
    dittle()
    dittle()

如果您运行此代码,您将看到dittle()函数在第一次调用时内部化,但在其他调用中不会内部化,它使用私有静态缓存(可变默认值)来在调用之间进行内部静态存储,拒绝试图劫持静态存储,对恶意输入具有弹性,并且可以根据动态条件(这里是函数被调用的次数)进行操作。

使用可变默认值的关键是不要做任何重新分配内存中变量的操作,而是始终在原地更改变量。

为了真正看到这种技术的潜在力量和有用性,请将此第一个程序保存到当前目录下,命名为“DITTLE.py”,然后运行下一个程序。它导入并使用我们的新dittle()命令,无需记住任何步骤或编程难题即可使用。

这是我们的第二个例子。将其编译并作为新程序运行。

from DITTLE import dittle

print("\n We have emulated a new python command with 'dittle()'.\n")
# Nothing to declare, nothing to instantize, nothing to remember.

dittle()
dittle()
dittle()
dittle()
dittle()

现在这不是非常流畅和简洁吗?这些可变默认值确实非常方便。

========================

经过一段时间的思考,我不确定我是否清楚地说明了使用可变默认方法和常规方式完成相同事情之间的区别。

常规方式是使用一个可导入的函数来包装一个类对象(并使用全局变量)。因此,为了比较,这里提供了一个基于类的方法,试图做与可变默认方法相同的事情。

from time import sleep

class dittle_class():

    def __init__(self):
        
        self.b = 0
        self.a = " Hello World!"
        
        print("\n Initializing Class Object. Executes on First Call only.")
        print(" self.a = '"+str(self.a),"', self.b =",self.b,end="\n\n")
    
    def report(self):
        self.b  = self.b + 1
        
        if self.b == 1:
            print(" Dittle() called",self.b,"time.")
        else:
            print(" Dittle() called",self.b,"times.")
        
        if self.b == 5:
            self.a = " It's Great to be alive!"
        
        print(" Internal String =",self.a,end="\n\n")
            
        if self.b ==3:
            print(" Let's rest for a moment.")
            sleep(2.0) # Since we imported it, we might as well use it.
            print(" Wheew! Ready to continue.\n")
            sleep(1.0)

cl= dittle_class()

def dittle():
    global cl
    
    if type(cl.a) != str and type(cl.b) != int:
        print(" Class exists but does not have valid format.")
        
    cl.report()

# =================== MAIN ====================== 
if __name__ == "__main__":
    print(" We have emulated a python command with our own 'dittle()' command.\n")
    for cnt in range(2):dittle() # Call can be loop-driver, but they need not be.
    
    print(" Attempting to pass arguments to dittle()")
    try: # The user must catch the fatal error. The mutable default user did not. 
        dittle(["BAD","Data"])
    except:
        print(" This caused a fatal error that can't be caught in the function.\n")
    
    print(" Calling dittle() normally..")
    dittle()
    
    print(" Attempting to set the Class variable from the outside.")
    cl.a = " I'm a griefer. My damage sticks."
    cl.b = -7
    
    dittle()
    dittle()

将这个基于类的程序保存在您当前的目录中,命名为DITTLE.py,然后运行以下代码(与早期相同)。
from DITTLE import dittle
# Nothing to declare, nothing to instantize, nothing to remember.

dittle()
dittle()
dittle()
dittle()
dittle()

通过比较这两种方法,使用函数中的可变默认值的优点应该更加清晰。可变默认值方法不需要全局变量,它的内部变量不能直接设置。而可变方法只接受一个有知识的传递参数进行单个周期,然后就忽略了它,类方法则因为其内部变量直接暴露给外部而被永久地改变。至于哪种方法更容易编程?我认为这取决于您对这些方法的熟悉程度和您的目标的复杂性。

我不知道为什么在第二个例子中你需要使用 global。尽管如此,我认为第二个例子比第一个更易读。即使最终结果在功能上是相同的,使用 class 向读者传递了一个信号,“我有一些状态需要保持在一起”。但是,你确实回答了问题,所以我要赞扬你。实际上,我会说这是一个很好的反例,说明为什么几乎总是不明智地使用可变参数。 - DeusXMachina

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接