函数闭包 vs 可调用类

19

在许多情况下,有两种实现选择:闭包和可调用类。例如,

class F:
  def __init__(self, op):
    self.op = op
  def __call__(self, arg1, arg2):
    if (self.op == 'mult'):
      return arg1 * arg2
    if (self.op == 'add'):
      return arg1 + arg2
    raise InvalidOp(op)

f = F('add')
或者
def F(op):
  if op == 'or':
    def f_(arg1, arg2):
      return arg1 | arg2
    return f_
  if op == 'and':
    def g_(arg1, arg2):
      return arg1 & arg2
    return g_
  raise InvalidOp(op)

f = F('add')

在做出选择时,应考虑哪些因素?

我能想到两个:

  • 似乎闭包的性能始终更好(无法想到反例)。

  • 我认为在某些情况下闭包无法胜任工作(例如,如果其状态会随时间变化)。

我的理解正确吗?还可以添加什么吗?


1
“使用什么更好?”请定义您要优化的标准。更好在哪方面?更小?更快?更多使用Oracle许可产品?您所说的“更好”是什么意思? - S.Lott
1
@max,实际上闭包可以有状态(它们可以捕获周围的任何东西,包括局部变量)。 - ony
1
@S.Lott,我理解问题大致是这样的:“为什么你更喜欢可调用类而不是闭包?” - ony
参见:https://dev59.com/TnE95IYBdhLWcg3wApLl - Ian Clelland
@S.Lott:实际上,我正在尝试找出每当这个问题出现时我应该考虑什么。因此,我并不想将讨论限制在一个特定的情况下。我猜我想要考虑的方面包括性能、清晰度、灵活性和可靠性-至少是这样。 - max
显示剩余5条评论
6个回答

16

闭包运行更快。类更加灵活(即不仅有__call__方法,还有更多的方法可用)。


3
我不同意这个观点。你可以在表示 Python 闭包的对象中添加任何你想要的内容(出于我不知道的某些原因)。由于对象是 Python 语言的主要值,无论通过将代码片段作为对象还是通过构造某些其他类的对象来获取值,它们都是对象。由于 Python 是一种动态语言,几乎任何对象都可以被修改(实际上,我以前认为闭包不属于这些对象之一)。 - ony
6
抱歉,你的评论似乎没有对Python有一个良好的理解。例如,你可以给一个类添加一个__len__方法,或者将其作为函数属性附加到闭包中,但是"len(obj)"只能在前者中起作用。 - Raymond Hettinger
6
一个人在他们的封闭空间里做什么是他们自己的事情 :-) - Raymond Hettinger
2
@Yuval。更快意味着它运行更快。原因是闭包变量的查找速度比实例变量快(请参见https://code.activestate.com/recipes/577834)。此外,调用方法涉及创建绑定方法,但对于闭包来说这并不是必要的。您可以运行timeit.py来确信这是真的 :-) - Raymond Hettinger
2
@batMan 你说得对,OP的例子并没有形成闭包;然而,大部分关于性能的推理仍然成立。类风格的访问会产生属性查找开销,而嵌套函数风格使用本地变量和/或闭包单元变量,这两者都比属性访问更快。 - Raymond Hettinger
显示剩余9条评论

4
我认为类方法更易于一目了然,因此更易于维护。由于这是良好的Python代码的前提之一,所以我认为在所有条件相等的情况下,使用类而不是嵌套函数更好。这是Python灵活性的一个例子,使得语言违反了“应该有一个,并且最好只有一个明显的方法来做某事”的编码规则。
无论哪种方法,性能差异都应该是可以忽略不计的 - 如果您的代码在这个级别上需要性能,那么您肯定应该对其进行分析并优化相关部分,可能会将一些代码重写为本地代码。
但是,如果有一个紧密的循环使用状态变量,评估闭包变量应该比评估类属性稍微快一些。当然,这可以通过在进入循环之前在类方法中插入像op = self.op这样的行来解决,从而使循环内的变量访问变成局部变量 - 这将避免每次访问时进行属性查找和获取。同样,性能差异应该可以忽略不计,如果您需要这么多额外的性能并且正在使用Python编码,则您有一个更严重的问题。

我曾经认为,通过增加代码使用次数来优化较小的代码片段可能会提高性能。但在大多数情况下,高级算法具有更大的潜力来挤出更多的性能。 - ony
这些函数会因为被重复调用而在分析器中显示出占用CPU时间最多的位置。我曾经想过,由于闭包不需要查找和检查 op,所以它可能会更快。但是我同意,考虑到我使用的是Python,我可能不应该尝试这种优化... - max
我刚刚花了一些时间研究这个问题:https://dev59.com/EV_Va4cB1Zd3GeqPUpNE,并发现对于更大、更复杂的代码使用作用域函数会带来进一步的不便。对于只计算一些表达式的小函数,像你的例子一样,我认为我可能会一直使用函数。 - jsbueno
谢谢您。尽管嵌套类的概念至少在10年前就存在于许多语言中,包括Java和Python,但我看到很少有开发人员实际在生产中编写它们,原因正如您所说:这些类难以维护。 - Harvey Lin

4

我知道这是一篇较老的帖子,但未列出的一个因素是在Python(pre-nonlocal)中,您无法修改包含在引用环境中的本地变量。(在您的示例中,这种修改并不重要,但从技术上讲,无法修改这样的变量意味着它不是一个真正的闭包。)

例如,以下代码无法工作:

def counter():
    i = 0
    def f():
        i += 1
        return i
    return f

c = counter()
c()

上面的c调用将引发UnboundLocalError异常。

可以通过使用可变对象(例如字典)轻松解决此问题:

def counter():
    d = {'i': 0}
    def f():
        d['i'] += 1
        return d['i']
    return f

c = counter()
c()     # 1
c()     # 2

当然,那只是一种权宜之计。

不是非常直观的解决方案,但您始终可以使用 f.i = 0f.i += 1,因为函数可以存储自己的属性(func_dict,任何人都可以吗?)。但是这会使闭包的变量“透明”,并且根据定义,不再是闭包。 - knight
2
在“i += 1”这一行之前添加“nonlocal i”这一行。 - chairam

4
请注意,由于我之前测试代码中发现了一个错误,我的原始答案是不正确的。以下是经过修订后的版本。
我创建了一个小程序来测量运行时间和内存消耗。我创建了以下可调用类和闭包:
class CallMe:
    def __init__(self, context):
        self.context = context

    def __call__(self, *args, **kwargs):
        return self.context(*args, **kwargs)

def call_me(func):
    return lambda *args, **kwargs: func(*args, **kwargs)

我计时了接受不同数量参数的简单函数调用(带1个参数的math.sqrt(),带2个参数的math.pow()和带12个参数的max())。

我在Linux x64上使用了CPython 2.7.10和3.4.3+。 我只能在Python 2上进行内存分析。我使用的源代码可以在这里找到。

我的结论如下:

  • 闭包要比等效的可调用类更快:在Python 2上大约快3倍,但在Python 3上只快1.5倍。这种缩小是因为闭包变慢了,而可调用类变得更慢了。
  • 闭包占用的内存比等效的可调用类少:大约是2/3的内存(仅在Python 2上测试过)。
  • 虽然这不是最初问题的一部分,但有趣的是,通过闭包进行的调用的运行时间开销与对math.pow()的调用相当,而通过可调用类进行的调用则是前者的两倍。

这些都是非常粗略的估计,它们可能因硬件,操作系统和要比较的函数而有所不同。但是,它可以让您了解使用每种可调用类型的影响。

因此,这支持(与我之前写的相反),@RaymondHettinger给出的答案是正确的,并且在间接调用时应优先使用闭包,至少在不影响可读性的情况下。同时,感谢@AXO指出我原始代码中的错误。


2
在你的代码中,你错误地使用了类版本来测量闭包时间,反之亦然。(你有 a = CallMe(pow)b = call_me(pow),然后你使用 a 来测量闭包时间,而使用 b 来测量类时间)。 - AXO
1
@AXO谢谢您的评论。我已经修改了代码和我的回答。 - Yuval

1

赫廷格先生的回答在十年后的Python3.10仍然是正确的。对于任何想知道的人:

from timeit import timeit
class A: # Naive class
    def __init__(self, op):
        if op == "mut":
            self.exc = lambda x, y: x * y
        elif op == "add":
            self.exc = lambda x, y: x + y
    def __call__(self, x, y):
        return self.exc(x,y)

class B: # More optimized class
    __slots__ = ('__call__')
    def __init__(self, op):
        if op == "mut":
            self.__call__ = lambda x, y: x * y
        elif op == "add":
            self.__call__ = lambda x, y: x + y

def C(op): # Closure
    if op == "mut":
        def _f(x,y):
            return x * y
    elif op == "add":
        def _f(x,t):
            return x + y
    return _f

a = A("mut")
b = B("mut")
c = C("mut")
print(timeit("[a(x,y) for x in range(100) for y in range(100)]", globals=globals(), number=10000)) 
# 26.47s naive class
print(timeit("[b(x,y) for x in range(100) for y in range(100)]", globals=globals(), number=10000)) 
# 18.00s optimized class
print(timeit("[c(x,y) for x in range(100) for y in range(100)]", globals=globals(), number=10000)) 
# 12.12s closure

使用闭包在调用次数较高的情况下似乎可以提供显著的速度提升。然而,类具有广泛的自定义功能,有时是更好的选择。


-1
我会用类似下面的代码重新编写 `class` 的示例:
class F(object):
    __slots__ = ('__call__')
    def __init__(self, op):
        if op == 'mult':
            self.__call__ = lambda a, b: a * b
        elif op == 'add':
            self.__call__ = lambda a, b: a + b
        else:
            raise InvalidOp(op)

在我的Python 3.2.2机器上,这个函数每次运行需要0.40微秒(函数本身需要0.31微秒,因此慢了29%)。如果不使用object作为基类,则每次运行需要0.65微秒(即比基于object的方式慢55%)。由于某种原因,在__call__中检查op的代码几乎与在__init__中完成相同的结果。使用object作为基类并在__call__内部进行检查,每次运行需要0.61微秒。

你会使用类的原因可能是多态性。

class UserFunctions(object):
    __slots__ = ('__call__')
    def __init__(self, name):
        f = getattr(self, '_func_' + name, None)
        if f is None: raise InvalidOp(name)
        else: self.__call__ = f

class MyOps(UserFunctions):
    @classmethod
    def _func_mult(cls, a, b): return a * b
    @classmethod
    def _func_add(cls, a, b): return a + b

我以为从 class object 派生只在 Python 3 之前需要,你在使用 Python 3.2?我不确定指定 object 作为基类的影响是什么... - max
据我所记,object作为一个基类与使用__slots__有些关联。当我想要获得更加精简的对象(更少的动态特性)时,我通常会使用它们。 - ony
在Python 3中,所有东西都是从“object”派生的 - 没有必要指定它。只有在Python 2.x中需要它才能获得新式类行为。 - Ethan Furman

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