代码改变世界

Python 闭包:常见用例和示例

2024-12-17 11:41  abce  阅读(29)  评论(0编辑  收藏  举报

 

在 Python 中,闭包通常是定义在另一个函数内部的函数。这个内部函数抓取在其作用域外定义的对象,并将它们与内部函数对象本身关联起来。由此产生的组合称为闭包。

 

闭包是函数式编程语言的一个常见特性。在 Python 中,闭包非常有用,因为它支持创建基于函数的装饰器,而装饰器是一种强大的工具。

 

了解 Python 中的闭包

闭包是一个函数,它可以保留对其词法作用域(lexical scope)的访问,即使函数在该作用域之外执行也是如此。当外层函数返回内部函数时,就会得到一个具有扩展作用域的函数对象。

 

换句话说,闭包是一种能捕获在其作用域外定义的对象的函数,允许在其主体中使用这些对象。当需要在连续调用之间保留状态信息时,就可以使用闭包。

 

闭包在注重函数式编程的编程语言中很常见,Python 支持闭包,并将其作为其各种特性的一部分。

 

在 Python 中,闭包是在另一个函数中定义并返回的函数。这个内部函数可以保留在内部函数定义之前的非本地作用域中定义的对象。

 

要更好地理解 Python 中的闭包,首先要了解内部函数,因为闭包也是内部函数。

内部函数

在 Python 中,内部函数是在另一个函数内部定义的函数。这种类型的函数可以访问和更新其外层函数中的名称对象,也就是非本地作用域。

 

下面是一个快速示例:

>>> def outer_func():
...     name = "Pythonista"
...     def inner_func():
...         print(f"Hello, {name}!")
...     inner_func()
...

>>> outer_func()
Hello, Pythonista!

>>> greeter = outer_func()
>>> print(greeter)
None

本例中,在模块级或全局作用域定义了 outer_func()。在这个函数中,定义了 name 这个局部变量。然后,定义了另一个名为 inner_func() 的函数。因为第二个函数位于 outer_func() 的主体中,所以它是一个内部函数或嵌套函数。最后,调用内部函数,该函数使用外层函数中定义的 name 变量。

 

调用 outer_func()时,inner_func() 会将 name 值插入到问候语字符串中,并将结果打印到屏幕上。

 

在上例中,定义了一个内部函数,它可以使用外层作用域中的名称对象。然而,当调用外层函数时,并不会获得对内层函数的引用。内层函数和本地名称对象在外层函数之外是不可用的。

函数闭包

所有闭包都是内部函数,但并非所有内部函数都是闭包。要将内部函数转化为闭包,必须从外部函数返回内部函数对象。这听起来像是绕口令,但下面就是如何让 outer_func() 返回闭包对象的方法:

>>> def outer_func():
...     name = "Pythonista"
...     def inner_func():
...         print(f"Hello, {name}!")
...     return inner_func
...

>>> outer_func()
<function outer_func.<locals>.inner_func at 0x1066d16c0>

>>> greeter = outer_func()

>>> greeter()
Hello, Pythonista!

在新版 outer_func()中,将返回 inner_func 函数对象,而不是调用它。调用 outer_func()时,将得到一个函数对象,它是一个闭包,而不是一条问候信息。即使 outer_func()返回后,这个闭包对象也会记住并能访问 name 的值。这就是为什么当你调用 greeter() 时会得到问候信息。

 

要创建一个 Python 闭包,需要以下组件:

1.一个外层或外层函数: 这是一个包含另一个函数(通常称为内层函数)的函数。外层函数可以接受参数并定义变量,内层函数可以访问和更新这些变量。

2.外层函数的局部变量: 这些变量来自外层函数的作用域。Python 保留这些变量,允许在闭包中使用它们,即使在外层函数返回后也是如此。

3.一个内部函数(或嵌套函数): 这是在外层函数内部定义的函数。即使在外层函数返回后,它也可以访问和更新外层函数中的变量。

 

在本节示例中,有一个外层函数、一个局部变量和一个内层函数。从这个组合中获取闭包对象的最后一步是从外部函数返回内部函数对象。

 

值得注意的是,也可以使用 lambda 函数来创建闭包:

>>> def outer_func():
...     name = "Pythonista"
...     return lambda: print(f"Hello, {name}!")
...

>>> greeter = outer_func()
>>> greeter()
Hello, Pythonista!

在 outer_func()的修改版中,我们使用 lambda 函数来构建闭包,其工作方式与原始闭包类似。

捕获变量

正如我们所学到的,闭包保留了其外层作用域中的变量。请看下面的示例:

>>> def outer_func(outer_arg):
...     local_var = "Outer local variable"
...     def closure():
...         print(outer_arg)
...         print(local_var)
...         print(another_local_var)
...     another_local_var = "Another outer local variable"
...     return closure
...

>>> closure = outer_func("Outer argument")

>>> closure()
Outer argument
Outer local variable
Another outer local variable

在这个示例中,当调用 outer_func()时,outer_arg、local_var 和 another_local_var 都被附加到闭包,即使其所在的作用域已不再可用。然而,closure() 可以访问这些变量,因为它们现在是闭包本身的一部分。这就是为什么可以说闭包是一个具有扩展作用域的函数。

 

闭包还可以更新这些变量的值,这可能导致两种情况:变量可以指向不可变或可变对象。

 

要更新指向不可变对象的变量值,需要使用 nonlocal 语句。请看下面的示例:

>>> def make_counter():
...     count = 0
...     def counter():
...         nonlocal count
...         count += 1
...         return count
...     return counter
...

>>> counter = make_counter()

>>> print(counter())
1
>>> print(counter())
2
>>> print(counter())
3

在这个示例中,count 是对一个整数值的引用,它是不可变的。要更新 count 的值,需要使用一条 nonlocal 语句,告诉 Python 你要重用非本地作用域中的变量。

当变量指向一个可变对象时,你可以就地修改变量的值:

>>> def make_appender():
...     items = []
...     def appender(new_item):
...         items.append(new_item)
...         return items
...     return appender
...

>>> appender = make_appender()

>>> print(appender("First item"))
['First item']
>>> print(appender("Second item"))
['First item', 'Second item']
>>> print(appender("Third item"))
['First item', 'Second item', 'Third item']

在本例中,items 变量指向一个列表对象,该对象是可变的。在这种情况下,不必使用 nonlocal 关键字。可以就地修改列表。

 

创建闭包来保留状态

在实践中,可以在几种不同的情况下使用 Python 闭包。在本节中,将了解如何使用闭包创建工厂函数、在函数调用中保持状态以及实现回调,从而使代码更加动态、灵活和高效。

创建工厂函数

可以编写函数,利用一些初始配置或参数来构建闭包。当你需要创建多个具有不同设置的类似函数时,这尤其方便。

 

例如,你想计算数值的不同度数和结果精度的根。在这种情况下,可以编写一个工厂函数,像下面的示例一样返回具有预定义的度数和精度的闭包:

>>> def make_root_calculator(root_degree, precision=2):
...     def root_calculator(number):
...         return round(pow(number, 1 / root_degree), precision)
...     return root_calculator
...

>>> square_root = make_root_calculator(2, 4)
>>> print(square_root(42))
6.4807

>>> cubic_root = make_root_calculator(3)
>>> print(cubic_root(42))
3.48

make_root_calculator() 是一个工厂函数,可以用来创建计算不同数值根的函数。在此函数中,需要将根的度数和所需的精度作为配置参数。

 

然后,定义一个内部函数,将一个数字作为参数,并以所需精度计算指定的根。最后,返回内部函数,创建闭包。

 

可以使用此函数创建闭包,以便计算数值不同度数(degree)的根,如平方根和立方根。请注意,还可以调整结果的精度。

 

构建有状态函数

可以使用闭包在函数调用之间保留状态。这些函数被称为有状态函数,闭包是构建它们的一种方法。

 

例如,你想编写一个函数,从数据流中获取连续的数值并计算它们的累计平均值。在两次调用之间,函数必须跟踪先前传递的值。在这种情况下,可以使用下面的函数:

>>> def cumulative_average():
...     data = []
...     def average(value):
...         data.append(value)
...         return sum(data) / len(data)
...     return average
...

>>> stream_average = cumulative_average()

>>> print(stream_average(12))
12.0
>>> print(stream_average(13))
12.5
>>> print(stream_average(11))
12.0
>>> print(stream_average(10))
11.5

在 cumulative_average() 中,通过 data 局部变量,可以在连续调用该函数返回的闭包对象之间保留状态。

 

接下来,创建一个名为 stream_average() 的闭包,并用不同的数值调用它。请注意该闭包如何记住先前传递的值,并通过添加新提供的值来计算平均值。

提供回调函数

闭包常用于事件驱动型编程,因为你需要创建携带额外上下文或状态信息的回调函数。图形用户界面 (GUI) 编程就是使用回调函数的一个很好的例子。

 

举例来说,假设你想用 Python 的默认 GUI 编程库 Tkinter 创建一个 “Hello, World!”应用程序。该应用程序需要一个标签来显示问候语,还需要一个按钮来触发问候语。下面是这个小程序的代码:

import tkinter as tk

app = tk.Tk()
app.title("GUI App")
app.geometry("320x240")

label = tk.Label(
    app,
    font=("Helvetica", 16, "bold"),
)
label.pack()

def callback(text):
    def closure():
        label.config(text=text)

    return closure

button = tk.Button(
    app,
    text="Greet",
    command=callback("Hello, World!"),
)
button.pack()

app.mainloop()

这段代码定义了一个玩具 Tkinter 应用程序,它由一个带有标签和按钮的窗口组成。单击 “问候 ”按钮时,标签会显示 “Hello, World!”信息。

 

callback() 函数返回一个闭包对象,可以用它来提供按钮的命令参数。该参数接受不带参数的可调用对象。如果需要像示例中那样传递参数,则可以使用闭包。

 

使用闭包编写装饰器

装饰器是 Python 的一项强大功能。可以使用装饰器动态修改函数的行为。在 Python 中,有两种类型的装饰器:

·基于函数的装饰器

·基于类的装饰器

 

基于函数的装饰器是一个将函数对象作为参数并返回另一个具有扩展功能的函数对象的函数。后一个函数对象也是一个闭包。因此,要创建基于函数的装饰器,需要使用闭包。

正如你已经学到的,装饰器允许你修改函数的行为,而无需改变其内部代码。实际上,基于函数的装饰器就是闭包。其显著特点是,它们的主要目标是修改作为参数传递给包含闭包的函数的行为。

 

下面是一个最小装饰器的示例,它在输入函数的功能之上添加了信息:

>>> def decorator(function):
...     def closure():
...         print("Doing something before calling the function.")
...         function()
...         print("Doing something after calling the function.")
...     return closure
...

在本例中,外部函数是装饰器。该函数返回一个闭包对象,通过添加额外功能来修改输入函数对象的原始行为。即使在 decorator() 函数返回后,闭包也可以对输入函数采取操作。

 

下面是如何使用装饰器语法来动态修改一个普通 Python 函数的行为:

>>> @decorator
... def greet():
...     print("Hi, Pythonista!")
...

>>> greet()
Doing something before calling the function.
Hi, Pythonista!
Doing something after calling the function.

在本例中,使用 @decorator 修改了 greet() 函数的行为。请注意,现在当你调用 greet() 时,你将获得它的原始功能和装饰器添加的功能。

 

使用闭包实现内存化

缓存可以避免不必要的重新计算,从而提高算法的性能。Memoization 是一种常见的缓存技术,可防止函数对相同输入运行多次。

 

Memoization 的工作原理是将给定输入参数集的结果存储在内存中,然后在必要时引用。可以使用闭包来实现Memoization。

 

在下面的示例中,我们利用装饰器(也是闭包)来缓存代价高昂的假设计算所产生的值:

>>> def memoize(function):
...     cache = {}
...     def closure(number):
...         if number not in cache:
...             cache[number] = function(number)
...         return cache[number]
...     return closure
...

在这里,memoize() 将一个函数对象作为参数,并返回另一个闭包对象。内部函数仅对未处理的数字运行输入函数。已处理的数字与输入函数的结果一起缓存在缓存字典中。

 

现在,假设你有一个模仿高昂计算的玩具函数:

>>> from time import sleep

>>> def slow_operation(number):
...     sleep(0.5)
...

该函数将代码的执行时间保持在半秒内,以模拟代价高昂的操作。为此,需要使用时间模块中的 sleep() 函数。

 

可以使用以下代码测量函数的执行时间:

>>> from timeit import timeit

>>> timeit(
...     "[slow_operation(number) for number in [2, 3, 4, 2, 3, 4]]",
...     globals=globals(),
...     number=1,
... )
3.0019452000269666

在本代码片段中,将使用 timeit 模块中的 timeit() 函数来计算 slow_operation()函数的执行时间。处理六个输入值的代码需要三秒多一点的时间。可以使用 memoization 来跳过重复的输入值,从而提高计算效率。

 

如下所示,使用 @memoize 来装饰 slow_operation()。然后,运行计时代码:

>>> @memoize
... def slow_operation(number):
...     sleep(0.5)
...

>>> timeit(
...     "[slow_operation(number) for number in [2, 3, 4, 2, 3, 4]]",
...     globals=globals(),
...     number=1,
... )
1.5002289999974892

现在相同的代买花费了一半的时间,就是因为使用 memoization技术。

 

利用闭包实现封装

在面向对象编程(OOP)中,类提供了一种将数据和行为结合到单个实体中的方法。OOP 的一个共同要求是数据封装,这一原则建议保护对象的数据不受外界影响,并防止直接访问。

在 Python 中,实现强大的数据封装可能是一项艰巨的任务,因为私有属性和公有属性之间没有区别。相反,Python 使用命名约定来交流给定类成员是公共的还是非公共的。

 

你可以使用 Python 闭包来实现更严格的数据封装。闭包允许你为数据创建一个私有范围,阻止用户访问该数据。这有助于维护数据完整性,防止被意外修改。

 

举例说明,假设有以下 Stack 类:

class Stack:
    def __init__(self):
        self._items = []

    def push(self, item):
        self._items.append(item)

    def pop(self):
        return self._items.pop()

该Stack类将数据存储在一个名为 _items 的列表对象中,并实现了常用的堆栈操作,如 push 和 pop。

 

下面介绍如何使用该类:

>>> from stack_v1 import Stack

>>> stack = Stack()
>>> stack.push(1)
>>> stack.push(2)
>>> stack.push(3)

>>> stack.pop()
3

>>> stack._items
[1, 2]

类的基本功能可以正常工作。但是,尽管 _items 属性是非公开的,仍可以使用点符号访问其值,就像访问普通属性一样。这种行为使得封装数据以防止直接访问数据变得困难。

 

同样,闭包为实现更严格的数据封装提供了一个技巧。请看下面的代码:

def Stack():
    _items = []

    def push(item):
        _items.append(item)

    def pop():
        return _items.pop()

    def closure():
        pass

    closure.push = push
    closure.pop = pop
    return closure

在这个示例中,编写了一个函数来创建闭包对象,而不是定义一个类。在函数中,定义了一个名为 _items 的局部变量,它将成为闭包对象的一部分。将用这个变量来存储堆栈的数据。然后,定义两个内部函数来实现堆栈操作。

 

closure() 内部函数是闭包的占位符。在该函数的基础上,添加 push() 和 pop() 函数。最后,返回生成的闭包。

 

使用 Stack() 函数的方式与使用 Stack 类的方式基本相同。一个显著的区别是,现在你不能访问 _items:

>>> from stack_v2 import Stack

>>> stack = Stack()
>>> stack.push(1)
>>> stack.push(2)
>>> stack.push(3)

>>> stack.pop()
3

>>> stack._items
Traceback (most recent call last):
    ...
AttributeError: 'function' object has no attribute '_items'

通过 Stack() 函数,可以创建闭包,这些闭包就像 Stack 类的实例一样工作。不过,不能直接访问 _items,这将改善程序数据封装。

 

如果你真的很挑剔,可以使用高级技巧来访问 ._items 的内容:

>>> stack.push.__closure__[0].cell_contents
[1, 2]

.__closure__ 属性返回包含闭包变量绑定的单元元组。单元对象有一个名为 cell_contents 的属性,你可以用它来获取单元的值。

尽管这种技巧可以访问抓取的变量,但在 Python 代码中并不常用。归根结底,如果想实现封装,为什么要破坏它呢?

 

探索闭包的替代方法

到目前为止,已经了解到 Python 闭包可以帮助解决一些问题。然而,要掌握它们在面纱下是如何工作的可能很有挑战性,因此使用替代工具可以使你的代码更容易推理。

 

可以通过实现 __call__() 特殊方法,用一个能产生可调用实例的类来代替闭包。可调用实例是可以像调用函数一样调用的对象。

为了说明,请回到 make_root_calculator() 工厂函数:

>>> def make_root_calculator(root_degree, precision=2):
...     def root_calculator(number):
...         return round(pow(number, 1 / root_degree), precision)
...     return root_calculator
...

>>> square_root = make_root_calculator(2, 4)
>>> square_root(42)
6.4807

>>> cubic_root = make_root_calculator(3)
>>> cubic_root(42)
3.48

该函数返回的闭包在其扩展作用域中保留了 root_degree 和precision参数。可以用下面的类代替这个工厂函数:

class RootCalculator:
    def __init__(self, root_degree, precision=2):
        self.root_degree = root_degree
        self.precision = precision

    def __call__(self, number):
        return round(pow(number, 1 / self.root_degree), self.precision)

该类接收与 make_root_calculator() 相同的两个参数,并将它们转化为实例属性。

 

通过提供 .__call__() 方法,可以将类实例转换为可调用对象,从而可以像普通函数一样调用。下面是如何使用该类构建类似根计算器函数的对象:

>>> from roots import RootCalculator

>>> square_root = RootCalculator(2, 4)
>>> square_root(42)
6.4807

>>> cubic_root = RootCalculator(3)
>>> cubic_root(42)
3.48

>>> cubic_root.root_degree
3

可以得出结论,RootCalculator 的工作原理与 make_root_calculator() 函数基本相同。另外,现在还可以使用 root_degree 等配置参数。