0%

Python 装饰器与闭包

函数是一等公民

虽然《流畅的Python》作者一再强调 Python 不是一门函数式编程语言,但它的的确确具备了一些函数式编程的特性。其中的一个重要特性是:Python 将函数作为一等公民。这与 JavaScript、Scala 等语言一样,意味着在这类语言中:函数与其他数据类型处于同等地位,函数可以定义在函数内部,也可以作为函数的参数和返回值。基于这个特性,我们可以很容易的定义高阶函数。来看一个 JavaScript 的例子:

1
2
3
4
5
const add = function(x) {
return function(y) {
return x + y
}
}

这个函数将一个函数作为了返回值,很明显它是一个高阶函数,那么问题来了:这样定义有什么作用或者是好处呢?事实上,这段代码是 JavaScript 中的一个优雅的函数式编程库 Ramda 对于加法实现的基本思路(还需要可变参数以及参数个数判断)。最终我们可以这样去使用它:

1
2
3
4
5
const R = require('ramda')
R.add(1, 2) // -> 3
const increment = R.add(1) // 返回一个函数
increment(2) // -> 3
R.add(1)(2) // -> 3

既可以像代码第二行一次性传入两个参数,也可以像代码第三、四行分两个阶段传入,这与代码第五行效果一致。我们将这种特性称为函数柯里化(Currying),这样做的好处一是可以代码重用,就像特意将 R.add(1) 取名为 increment 一样,它可以单独地作为一个递增函数;二是可以实现惰性求值,只有当函数收集到了所有所需参数,才进行真正的计算并返回结果,这一点在许多流处理框架中有广泛使用。

Python 中的函数之所以可以作为一等公民,究其原因,是因为 Python 中的一切皆是对象,即 Everything in Python is an object。使用 def 关键字定义的任何函数,都是 function 类的一个实例。

1
2
3
4
5
>>> def func():
... pass
...
>>> type(func)
<class 'function'>

既然函数是对象,那就可以持有属性,这也是为什么 Python 中函数可以持有外部定义的变量(也就是闭包问题)的根本原因。这一点与 Java 和 C++ 这类语言是有本质区别的。以 Java8 为例,虽然 Java8 提供了一些语法糖让我们得以编写所谓的“高阶函数”,但 Java 中的函数(方法)依然不能脱离类或者对象而存在:

1
2
3
4
Arrays.asList(1, 2, 3, 4, 5)
.stream()
.filter(i -> i >= 3)
.forEach(System.out::println);

上述代码第三行接收一个 Lambda 表达式作为参数,第四行接收一个方法引用,看上去函数可以作为参数传入。但实际上,Java 编译器会将它们转换为函数接口(Functional Interfaces)的具体实现,函数接口是 Java8 函数式编程引入的核心概念。例如上述代码中的 System.out::println 方法引用会被实例化为 Consumer 函数接口的具体实现,Consumer 是 Java8 提供的四类函数接口中的一类,称为消费者接口,它有一个 accept 抽象方法接受一个输入且返回值为空,编译器将会用 System.out.println(t) 重写这个方法。

1
2
3
4
5
6
7
8
@FunctionalInterface
public interface Consumer<T> {
void accept(T t);
// ...
}

// Consumer consumer = System.out::println;
// consumer.accept("hello");

所以在 Java8 中,看似函数可以作为参数传入,但实际上传入的依旧是类的实例。如果对 Java8 的函数式编程感兴趣可以参考这篇:Java8 函数接口

言归正传,既然已经清楚了 Python 中可以定义高阶函数,那么接下来就可以探讨一下 Python 怎么使用高阶函数实现装饰器的。但在这之前,不得不提及一下什么是闭包。

闭包

首先注意,只有涉及到嵌套函数才会存在闭包问题。而不要将闭包与匿名函数搞混,是不是匿名函数不是必要条件,只是人们通常将闭包与匿名函数搭配使用罢了(尤其是在 JavaScript 中)。

实际上,闭包是指延伸了作用域的函数,关键在于它能够访问定义体之外定义的非全局变量。听上去有些绕,不过看看下面这段代码就很好理解了:

1
2
3
4
5
6
7
8
9
def make_average():
series = []

def average(new_value):
series.append(new_value)
total = sum(series)
return total / len(series)

return average

关注点放在 series 这个变量。它定义在内层函数 average 之外并在内层函数中做了修改(末尾追加了一个值)。并且,内层函数被当作外层函数的返回值返回。显然,内层函数 average 设计出来是为了多次调用的,然而 series 是在内层函数之外定义的,当多次调用 average 时 series 作用域是否已经消亡了呢?答案是否。看看下面的输出:

1
2
3
4
5
6
7
>>> avg = make_average()  # 返回 average 函数
>>> avg(1) # (1) / 1
1.0
>>> avg(2) # (1 + 2) / 2
1.5
>>> avg(3) # (1 + 2 + 3) / 3
2.0

原因在于,上述代码中的 series 变量声明语句与 average 函数定义体构成了一个闭包,average 函数的作用域延伸到函数外部,换句话说,series 已经绑定到 average 函数对象上了。我们将 series 这种变量称为自由变量(free variable)。可以通过 Python 提供的内省属性访问:

1
2
3
4
5
6
>>> avg.__code__.co_freevars
('series',)
>>> avg.__closure__
(<cell at 0x104301400: list object at 0x1041a9580>,)
>>> avg.__closure__[0].cell_contents
[1, 2, 3]

__code__.co_freevars 以元组形式存放了自由变量的名称。要想访问自由变量的值,需要通过 __closure__ 属性,也就是说,实际上 series 是绑定到 avg.__closure__ 中的。Python 在自由变量之上包装了一个 cell 对象,用 cell_contents 存放其真正的值。

装饰器

装饰器,又称函数装饰器,本质上是一个可调用对象(实现了 __call__ 方法),可以是一个函数或者一个类。它的作用是可以让其他函数或类在不需要做任何代码修改的前提下增加额外功能。装饰器接受一个函数作为参数,即被装饰的函数,可能会对这个函数进行处理然后将它返回,或者替换为另一个函数或可调用对象。

先来看一个最简单的装饰器示例:

1
2
3
4
5
6
7
8
9
10
11
>>> def decorate(func):
... print('running decorator...')
... return func
...
>>> @decorate
... def target():
... print('running target...')
...
running decorator...
>>> target()
running target...

上述代码定义了一个名为 decorate 的装饰器,然后通过 @decorate 标注在 target 函数上表明用它来装饰 target 函数。乍一看,这与 Java 中的注解语法是一样的,但其实两者作用是完全不同的。Java 中的注解只是元数据,不会对被修饰的对象做任何修改,必须通过运行时的反射(getAnnotation 方法)才能发挥它的作用。而在 Python 中,装饰器的作用就是定义一个嵌套函数。你可以理解为,通过装饰器装饰后,target 函数被重新定义为了如下形式:

1
target = decorate(target)

但装饰器与这样直接定义还是有几点区别的。第一点,装饰器是在被装饰的函数定义之后立即执行的,这通常是在导入时(import),也就是 Python 加载模块时发生的。如果你足够细心,就会发现上述代码中的 'running decorator...' 是在 target 函数定义后就被立即打印了,并且调用 target 函数时也没有重复打印。也就是说,函数装饰器在导入模块时立即执行,而被装饰的函数只在明确调用时运行。这突出了 Python 的导入时和运行时的区别。

第二点,函数装饰器既然要体现它的“装饰”语义,就需要接收一个函数作为参数然后返回一个函数,无论返回的函数是原封不动的原函数还是“装饰”后的函数。也就是说,装饰器对于函数调用者是透明的。那么,装饰器返回一个其他类型就没有意义。事实证明,如果返回了其他类型,代码运行将会报出 TypeError 错误(没有找到 __call__ 方法)。而如果只是嵌套函数 decorate(target) 的写法是没有返回类型的限制的。

1
2
3
4
5
def decorate(func):
print('running decorator...')
return 1

# TypeError: 'int' object is not callable

函数装饰器

事实上,大多数装饰器会在内部定义一个函数然后将其返回,原封不动地返回被装饰的函数是没有多大用处的。像这样的双层嵌套函数足以应对绝大多数的装饰器需求了,其最大的好处是:可以支持带有参数的被装饰函数

1
2
3
4
5
def logger(func):
def target(*args, **kwargs):
print(f'[INFO]: the function {func.__name__}() is running...')
return func(*args, **kwargs)
return target

不管原函数(被装饰函数)func 接收什么类型的参数,在使用 logger 装饰器时都将被打包成定位参数 *args 和仅限关键字参数 **kwargs,原封不动的传入到装饰器的内部函数 target 中,执行完装饰逻辑后通过 func(*args, **kwargs) 执行原函数。从而能够实现“不修改原有函数接口、不影响原有函数执行”的前提下添加额外功能。如下所示:

1
2
3
4
5
6
7
>>> @logger
... def person(name, age=18):
... print(name, age)
...
>>> person('Jack')
[INFO]: the function person() is running...
Jack 18

除了被装饰函数可以带有参数外,装饰器本身也可以带有参数,如 @logger(Level.INFO) 在装饰器中指定日志等级,根据业务逻辑标注在不同的函数上,从而最大程度的发挥装饰器的灵活性。

接下来,我会结合一个更实用的例子 —— 记录被装饰函数运行时间的计时器,展示如何定义并使用一个带参装饰器。同时,你还将看到闭包问题是如何在装饰器中体现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def clock(unit=TimeUnit.SECONDS):  # ①
def decorate(func):
def wrapper(*args, **kwargs): # ②
start = time.perf_counter()
result = func(*args, **kwargs) # ③
end = time.perf_counter()
arg_str = ', '.join(repr(arg) for arg in args)
if unit == TimeUnit.SECONDS:
print(f'running {func.__name__}({arg_str}): {end - start}s')
else:
print(f'running {func.__name__}({arg_str}): {(end - start) * 1000}ms')
return result
return wrapper
return decorate

带参装饰器比无参装饰器多了一层嵌套,这是一种妥协,原因是装饰器只能且必须接收一个函数作为参数,所以为了使装饰器接收其他参数,不得不在之上再包装一层函数。在上述代码的三层函数中,最外层定义的 clock 函数是参数化装饰器工厂函数,第二层 decorate 函数才是真正的装饰器,wrapper 函数则是执行装饰逻辑的包裹函数(被装饰函数在其中执行)。

此外代码中用带圈数字标注的几个需要注意的点是:

  • ① 最外层的 clock 工厂函数接收一个名为 unit 的时间单位的参数,默认值为秒(这里采用枚举类型);
  • 如果被装饰的函数带参数,只需要把装饰器最内层函数跟被装饰函数的参数列表保持一致即可。这里 wrapper 函数接收任意个定位参数 *args 和仅限关键字参数 **kwargs,写成这样的目的是想体现 clock 计时器的泛用性,你可以在 ③ 处原封不动地将这些参数传给被装饰函数 func 调用;
  • ③ func 实际上是定义在 wrapper 外层的自由变量(作为 decorate 的参数传入),所以它已经被绑定到 wrapper 的闭包中。

③ 处是被装饰函数真正执行的地方,上下两行使用计时器记录并统计了 func 函数运行前后的时间差值,在打印时根据传入 clock 的参数决定打印时间单位采用秒还是毫秒。我们来看看如何使用这个装饰器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
>>> @clock()
... def sleep(secs):
... time.sleep(secs)
...
>>> @clock(unit=TimeUnit.SECONDS)
... def sleep_secs(secs):
... time.sleep(secs)
...
>>> @clock(unit=TimeUnit.MILL_SECONDS)
... def sleep_ms(ms):
... time.sleep(ms / 1000)
...
>>> sleep(0.1)
running sleep(0.1): 0.10221230299998751s
>>> sleep_secs(1)
running sleep_secs(1): 1.000441283999976s
>>> sleep_ms(100)
running sleep_ms(100): 103.84234799994374ms

需要注意,第一个空参装饰器 @clock(),其中的 () 是不能省略的,它使用了 TimeUnit.SECONDS 作为默认参数,这是在 clock 定义处声明的。此外,clock 装饰器中的参数并不是和函数名绑定的,打印的时间单位完全取决于传入 clock 装饰器的参数。比如,也可以让 sleep_ms 按照秒的格式打印时间:

1
2
3
4
5
6
>>> @clock(unit=TimeUnit.SECONDS)
... def sleep_ms(ms):
... time.sleep(ms / 1000)
...
>>> sleep_ms(100)
running sleep_ms(100): 0.10072612899966771s

类装饰器

前面提到,装饰器本质上是一个可调用对象。到目前为止,给出的示例都是函数类型的装饰器,函数当然是可调用对象。但如果阅读 Python 源码,会发现许多装饰器是用类定义的,比如内置模块中的 property、classmethod 和 staticmethod 类。这些类都可调用对象(callable),对于用户来说,自定义一个类装饰器需要让这个类实现 __call__ 方法,这样解释器在运行时会将这个类绑定为 Callable 类的子类。

1
2
3
4
5
6
7
8
9
10
11
12
>>> callable(property)
True
>>> callable(staticmethod)
True
>>> class Foo:
... def __call__(self): ...
...
>>> callable(Foo())
True
>>> from collections.abc import Callable
>>> issubclass(Foo, Callable)
True

对于不含参数的类装饰器来说,除了需要实现 __call__ 方法之外,唯一要做的就是在构造函数 __init__ 中初始化被装饰函数。下面定义了一个基于类的无参装饰器。

1
2
3
4
5
6
7
class Logger:
def __init__(self, func):
self._func = func

def __call__(self, *args, **kwargs):
print(f'[INFO]: the function {self._func.__name__}() is running...')
return self._func(*args, **kwargs)

函数类型的装饰器是将装饰逻辑定义在嵌套函数的内部函数中,而无参类装饰器则是将装饰逻辑定义在类中的 __call__ 方法内,类装饰器同样可以装饰带有参数的函数。两者的区别只不过是,定义函数装饰器时被装饰函数 func 作为参数传入,定义类装饰器时 func 作为属性传入。类装饰器同样是以 @ + 类名 的形式标注在被装饰函数上:

1
2
3
4
5
6
7
8
>>> from class_decorator import Logger
>>> @Logger
... def person(name, age=18):
... print(name, age)
...
>>> person('Jack')
[INFO]: the function person() is running...
Jack 18

定义类形式的装饰器与函数形式的装饰器并无太大差别,本质上 Python 解释器都将它们作为可调用对象进行处理。只不过现在最外层的装饰器工厂函数变成了类,传入的装饰器的参数变成了类的属性;而第二层对应的是 __call__ 方法,接收被装饰函数作为参数;__call__ 方法内还需定义执行装饰逻辑的包裹函数。用类改写的日志装饰器的代码如下所示:

1
2
3
4
5
6
7
8
9
class Logger:
def __init__(self, level='INFO'):
self._level = level

def __call__(self, func):
def wrapper(*args, **kwargs):
print(f'[{self._level}]: the function {func.__name__}() is running...')
return func(*args, **kwargs)
return wrapper

使用时可以指定日志的输出级别:

1
2
3
4
5
6
7
>>> @Logger('Debug')
... def person(name, age=18):
... print(name, age)
...
>>> person('Jack')
[Debug]: the function person() is running...
Jack 18

面向切面的程序设计

面向切面的程序设计是一种程序设计思想,旨在将横切关注点与业务主体进行分离。横切关注点指的是一些具有横越多个模块的行为,使用传统的软件开发方法不能够达到有效的模块化的一类特殊关注点。通俗点说,面向切面编程就是使得解决特定领域问题的代码从业务逻辑中独立出来。业务逻辑的代码中不再含有针对特定领域问题代码的调用,业务逻辑同特定领域问题的关系通过切面来封装、维护。

联系到本文所编写的几个装饰器,日志记录 logger、性能测试 clock 计时器,这些都是较为常见的横切关注点。试想一下,如果需要记录多个函数的运行时间,在这些函数内部硬编码计时代码是否合适?显然,这不仅会造成代码重复,更关键的是破坏了函数的存粹性(将不该属于它的计时功能强加于它),造成了代码的紧耦合。现在有了装饰器,只需要在需要计时的函数之上添加 @clock 标注即可,计时器的逻辑统一在装饰器中定义和维护,实现了与业务代码的解耦。

因此,装饰器非常适用于有切面需求的场景,诸如:插入日志、性能测试、事务处理、缓存、权限校验等。装饰器是解决这类问题的绝佳设计。通过装饰器,我们可以抽离出与函数功能本身无关的代码到装饰器中,从而实现面向切面编程。

参考