7-3-装饰器
1. 什么是装饰器
装饰是为函数和类指定管理代码的一种方式。装饰器本身的形式是处理其他的可调用对象的可调用的对象(如函数)。Python装饰器以两种相关的形式呈现:
- 函数装饰器:在函数定义的时候进行名称重绑定,提供一个逻辑层来管理函数和方法或随后对它们的调用。
- 类装饰器:在类定义的时候进行名称重绑定,提供一个逻辑层来管理类,或管理随后调用它们所创建的示例。
简而言之,装饰器提供了一种方法,在函数和类定义语句的末尾插入自动运行代码——对于函数装饰器,在 def 的末尾;对于类装饰器,在 class 的末尾。
1.1 管理调用和实例
它通过针对随后的调用安装包装器对象来实现这一点:
- 函数装饰器安装包装器对象,以在需要的时候拦截随后的函数调用并处理它们。
- 类装饰器安装包装器对象,以在需要的时候拦截随后的实例创建调用并处理它们。
1.2 管理函数和类
- 函数装饰器也可以用来管理函数对象,而不是随后对它们的调用——例如,把一个函数注册到一个API。
- 类装饰器也可以用来直接管理类对象,而不是实例创建调用——例如,用新的方法扩展类。
2. 基础知识
2.1 函数装饰器
函数装饰器主要只是一种语法糖:通过在一个函数的 def 语句的末尾来运行另一个函数,把最初的函数名重新绑定到结果。
用法
函数装饰器是一种关于函数的运行时声明,函数的定义需要遵守此声明。装饰器在紧挨着定义一个函数或方法的 def 语句之前的一行编写,并且它由 @ 符号以及紧随其后的对于元函数的一个引用组成——这是管理另一个函数的一个函数(或其他的可调用对象)。
在编码方面,函数装饰器自动将如下的语法:
1 | # 装饰器函数 |
映射为这一对等的形式,其中装饰器是一个单参数的可调用对象,它返回与 F 具有相同数目的参数的一个可调用对象:
1 | def F(arg): |
这一自动名称重绑定在 def 语句上有效,不管它针对一个简单的函数或是类中的一个方法。当随后调用 F 函数的时候,它自动调用装饰器所返回的对象,该对象可能是实现了所需的包装逻辑的另一个对象,或者是最初的函数本身。
换句话说,装饰实际把如下的第一行映射为第二行:
1 | func(6, 7) |
实现
装饰器自身是一个返回可调用对象的可调用对象。装饰器可以是任意类型的可调用对象,并且返回任意类型的可调用对象:函数和类的任何组合都可以使用,尽管一些组合更适合于特定的背景。
例如,要在一个函数创建之后接入装饰协议以管理函数,我们需要编写如下形式的装饰器:
1 | def decorator(F): |
由于最初的装饰函数分配回给其名称,这么做将直接向函数的定义添加创建之后的步骤。这样的一个结构可能会用来把一个函数注册到一个API、赋值函数属性,等等。
更典型的用法,是插入逻辑以拦截对函数的随后调用,我们可以编写一个装饰器来返回和最初函数不同的一个对象:
1 | def decorator(F): |
这个装饰器在装饰的时候调用,并且当随后调用最初的函数名的时候,它所返回的调用对象将被调用。装饰器自身接受被装饰的函数,返回的调用对象会接受随后传递给被装饰函数的名称的任何参数。这和类方法的工作方式相同:隐含的实例对象只是在返回的可调用对象的第一个参数中出现。
更概括地说,有一种常用的编码模式可以包含这一思想——装饰器返回了一个包装器,包装器把最初的函数保持到一个封闭的作用域中:
1 | def decorator(F): # 在 @ 装饰 |
当随后调用名称 func 的时候,它确实调用装饰器所返回的 wrapper 函数;随后 wrapper 函数可能会运行最初的 func,因为它在一个封闭的作用域中仍然可以使用。当以这种方式编码的时候,每个装饰的函数都会产生一个新的作用域来保持状态。
为了对类做同样的事情,我们可以重载调用操作,并且使用实例属性而不是封闭的作用域:
1 | class decorator: |
现在,随后再调用 func 的时候,它确实会调用装饰器所创建的实例的 __call__ 运算符重载方法;然后,__call__ 方法可能运行最初的 func,因为它在一个实例属性中仍然可用。当按照这种方式编写代码的时候,每个装饰的函数都会产生一个新的实例来保持状态。
支持方法装饰
关于前面的基于类的代码的细微的一点是,尽管它对于拦截简单函数调用有效,但当它应用于类方法函数的时候,并不是很有效:
1 | class decorator: |
当按照这种方式编码的时候,装饰的方法重绑定到装饰器类的一个实例,而不是一个简单的函数。
这一点带来的问题是,当装饰器的 __call__ 方法随后运行的时候,其中的 self 接收 decorator 类实例,并且类 C 的实例不会包含到一个 *args 中。这使得有可能把调用分派给最初的方法——即保持了最初的方法函数的装饰器对象,但是,没有实例传递给它。
为了支持函数和方法,嵌套函数的替代方法工作得更好:
1 | def decorator(F): # F 是没有实例的 func 或方法 |
当按照这种方法编写的 wrapper 在其第一个参数里接收了 C 类实例的时候,它可以分派到最初的方法和访问状态信息。
嵌套函数可能是支持函数和方法的装饰的最直接方式,但是不一定是唯一的方式,例如描述符。
2.2 类装饰器
用法
在语法上,装饰器是返回一个可调用对象的一个单参数的函数,类装饰器语法:
1 | # 装饰类 |
等同于下面的语法——类自动地传递给装饰器函数,并且装饰器的结果返回来分配给类名:
1 | class C: |
随后调用类名会创建一个实例,该实例会触发装饰器所返回的可调用对象,而不是调用最初的类自身。
实现
由于类装饰器也是返回一个可调用对象的一个可调用对象,因此大多数函数和类的组合已经足够了。
尽管先编码,但装饰器的结果是当随后创建一个实例的时候才运行的。例如,要在一个类创建之后直接管理它,返回最初的类自身:
1 | def decorator(C): |
不是插入一个包装器层来拦截随后的实例创建调用,而是返回一个不同的可调用对象:
1 | def decorator(C): |
下面的实例插入一个对象来拦截一个类实例的未定义的属性:
1 | def decorator(cls): |
spam
在这个例子中,装饰器把类的名称重新绑定到另一个类,这个类在一个封闭的作用域中保持了最初的类,并且当调用它的时候,创建并嵌入了最初的类的一个实例。当随后从该实例获取一个属性的时候,包装器的 __getattr__ 拦截了它,并且将其委托给最初的类的嵌入的实例。此外,每个被装饰的类都创建一个新的作用域,它记住了最初的类。
类装饰器通常可以编写为一个创建并返回可调用的对象的“工厂”函数,或者使用 __init__ 或 __call__ 方法来拦截所有调用操作的类,或者是由此产生的一些组合。工厂函数通常在封闭的作用域引用中保持状态,类通常在属性中保持状态。
2.3 装饰器嵌套
为了支持多步骤的扩展,装饰器语法允许我们向一个装饰的函数或方法添加包装器逻辑的多个层。当使用这一功能的时候,每个装饰器必须出现在自己的一行中。这种形式的装饰器语法:
1 |
|
如下这样运行:
1 | def f(...): |
2.4 装饰器参数
函数装饰器和类装饰器似乎都能接受参数,尽管实际上这些参数传递给了真正返回装饰器的一个可调用对象,而装饰器反过来又返回一个可调用对象。例如,如下代码:
1 |
|
自动地映射到其对等的形式,其中装饰器是一个可调用对象,它返回实际的装饰器。返回的装饰器反过来返回可调用的对象,这个对象随后运行以调用最初的函数名:
1 | def F(arg): |
装饰器参数在装饰发生之前就解析了,并且它们通常用来保持状态信息供随后的调用使用。
装饰器参数往往意味着可调用对象的3个层级:接受装饰器参数的一个可调用对象,它返回一个可调用对象以作为装饰器,该装饰器返回一个可调用对象来处理对最初的函数或类的调用。
装饰器参数可以用来提供属性初始化值、调用跟踪信息标签、验证属性名称等等。
3. 编写函数装饰器
3.1 跟踪调用
如下代码定义并应用一个函数装饰器,来统计对装饰的函数的调用次数,并且针对每一次调用打印跟踪信息:
1 | class tracer: |
1 | spam(1, 2, 3) |
call 1 to spam
6
1 | spam('a', 'b', 'c') |
call 2 to spam
abc
1 | spam.calls |
2
1 | spam |
<__main__.tracer at 0x284968697b8>
运行的时候, tracer 类和装饰的函数分开保存,并且拦截对装饰的函数随后的调用,以便添加一个逻辑层来统计和打印每次调用。
装饰的时候, spam 实际上是 tracer 类的一个实例。对于函数调用, @ 装饰语法可能比修改每次调用来说明额外的逻辑层要更加方便,并且它避免了意外地直接调用最初的函数。
3.2 状态信息保持选项
有多种方法来实现状态保持:实例属性、全局变量、非局部变量和函数属性。
类实例属性
上例使用类实例属性来显式地保存状态。包装的函数和调用计数器都是针对每个实例的信息——每个装饰都有自己的拷贝。
尽管对于装饰函数有用,但是当应用于方法的时候,这种编码方案也有问题。
封闭作用域和全局作用域
封闭 def 作用域引用和嵌套的 def 常常可以实现相同的效果,特别是对于装饰的最初函数这样的静态数据。
把计数器移出到共同的全局作用域允许像这样修改它们,也意味着它们将为每个包装的函数所共享。
封闭作用域和 nonlocal
共享全局状态可能是我们在某些情况下想要的。如果我们真的想要一个针对每个函数的计数器,要么像前面那样使用类,要么使用 nonlocal 语句。由于这一语句允许修改封闭的函数作用域变量,所以它们可以充当针对每次装饰的、可修改的数据:
1 | def tracer(func): |
call 1 to spam
6
call 2 to spam
15
call 1 to eggs
65536
call 2 to eggs
256
函数属性
可以把任意属性分配给函数以附加它们,使用 func.attr=value
就可以了:
1 | def tracer(func): |
这种方法有效,只是因为名称 wrapper 保持在封闭的 tracer 函数的作用域中。当我们随后增加 wrapper.calls 时,并不是在修改名称 wrapper 本身,因此,不需要 nonlocal 声明。
这种方案几乎作为一个脚注来介绍,因为它比 Python 3.X 中的 nonlocal 要隐晦得多,并且可能留待其他方案无济于事的情况下使用更好。
3.3 类错误之一:装饰类方法
第一个版本的 tracer 装饰器对类方法的装饰失效了:
1 | class tracer: |
问题的根源在于, tracer 类的 __call__
方法的 self —— 它是一个 tracer 实例,还是一个 Person 实例?
我们真的需要将其编写为两者都是: tracer 用于装饰器状态, Person 用于指向最初的方法。实际上,self 必须是 tracer 对象,以提供对 tracer 的状态信息的访问。
当我们用 __call__
把装饰方法名重绑定到一个类实例对象的时候,Python 只向 self 传递了 tracer 实例;它根本没有在参数列表中传递 Person 主体。此外,由于 tracer 不知道我们要用方法调用处理的 Person 实例的任何信息,没有办法创建一个带有一个实例的绑定的方法,因此,没有办法正确地分配调用。
出现这种情况是因为:当一个方法名绑定只是绑定到一个简单的函数,Python 向 self 传递了隐含的主体实例;当它是一个可调用类的实例的时候,就传递这个类的实例。从技术上讲,当方法是一个简单函数的时候,Python只是创建了一个绑定的方法对象,其中包含了主体实例。
使用嵌套函数来装饰方法
如果想要函数装饰器在简单函数和类方法上都能工作,最直接的解决方法在于使用前面介绍的状态保持方法之一——把自己的函数装饰器编写为嵌套的 def ,以便对于包装器类实例和主体类实例都不需要依赖于单个的 self 实例参数。
如下的替代方案使用 Python 3.X 的 nonlocal。由于装饰的方法重新绑定到简单的函数而不是实例对象,所以Python正确地传递了 Person 对象作为第一个参数,并且装饰器将其从 *args 中的第一项传递给真正的、装饰的方法的 self 参数:
1 | def tracer(func): |
call 1 to spam
6
call 2 to spam
15
call 1 to eggs
4294967296
methods...
Bob Smith Sue Jones
call 1 to giveRaise
110000
call 1 to lastName
call 2 to lastName
Smith Jones
使用描述符装饰方法
描述符可能是分配给对象的一个类属性,该对象带有一个 __get__
方法,当引用或获取该属性的时候自动运行该方法。
描述符也能够拥有 __set__
和 __del__
访问方法,由于描述符的 __get__
方法在调用的时候接收描述符类和主体类实例,因此当我们需要装饰器的状态以及最初的类实例来分派调用的时候,它很适合于装饰方法:
1 | class tracer(object): |
call 1 to spam
6
call 2 to spam
15
Bob Smith Sue Jones
call 1 to giveRaise
装饰的函数只调用其 __call__
,而装饰的方法首先调用其 __get__
来解析方法名获取(在instance.method上); __get__
返回的对象保持主体类实例并且随后调用以完成调用表达式,由此触发 __call__
。
首先运行 tracer.__get__
,因为 Person 类的 giveRaise 属性已经通过函数装饰器重新绑定到了一个描述符。然后,调用表达式触发返回的 wrapper 对象的 __call__
方法,它返回来调用 tracer.__call__
。
此外,也可以使用一个嵌套的函数和封闭的作用域引用来实现同样的效果:
1 | class tracer(object): |
在两种编码中,基于描述符的方法也比嵌套函数的选项要细致得多,因此,它可能是这里的又一种选择。
如果你想要装饰器在简单函数和类方法上都有效,最好使用基于嵌套函数的编码模式,而不是带有调用拦截的类。
3.4 计时调用
下一个装饰器将对一个装饰的函数的调用进行计时——既有针对一次调用的时间,也有所有调用的总的时间。该装饰器应用于两个函数,以便比较列表解析和 map 内置调用所需的时间:
1 | import time |
listcomp: 0.00000, 0.00000
listcomp: 0.00320, 0.00320
listcomp: 0.04349, 0.04669
listcomp: 0.09522, 0.14191
[0, 2, 4, 6, 8]
allTime = 0.14190587963565804
mapcall: 0.00001, 0.00001
mapcall: 0.00877, 0.00878
mapcall: 0.07259, 0.08136
mapcall: 0.14008, 0.22144
[0, 2, 4, 6, 8]
allTime = 0.22144107479007857
map/comp = 1.56
3.5 添加装饰器参数
前面小节介绍的计时器装饰器有效,但是如果它更加可配置的话,那会更好——例如,提供一个输出标签并且可以打开或关闭跟踪消息,这些在一个通用目的的工具中可能很有用。装饰器参数在这里派上了用场:对它们适当编码后,我们可以使用它们来指定配置选项,这些选项可以根据每个装饰的函数而编码。可以像下面这样添加标签:
1 | def timer(label=''): |
这段代码添加了一个封闭的作用域来保持一个装饰器参数,以便随后真正调用的时候使用。当定义了 listcomp 函数的时候,它真的调用 decorator(timer的结果,在真正装饰发生之前运行),带有其封闭的作用域内可用的 label 值。也就是说, timer 返回 decorator,后者记住了装饰器参数和最初的函数,并且返回一个可调用的对象,这个可调用对象在随后的调用时调用最初的函数。
我们可以把这种结构用于定时器之中,来允许在装饰的时候传入一个标签和一个跟踪控制标志:
1 | import time |
外围的 timer 函数在装饰发生前调用,并且它只是返回 Timer 类作为实际的装饰器。在装饰时,创建了 Timer 的一个实例来记住装饰函数自身,而且访问了位于封闭的函数作用域中的装饰器参数。
使用装饰器参数计时
1 |
|
[CCC]==> listcomp: 0.00000, 0.00000
[CCC]==> listcomp: 0.00317, 0.00318
[CCC]==> listcomp: 0.04281, 0.04599
[CCC]==> listcomp: 0.09221, 0.13820
[0, 2, 4, 6, 8]
allTime = 0.13819843180158387
[MMM]==> mapcall: 0.00001, 0.00001
[MMM]==> mapcall: 0.00512, 0.00512
[MMM]==> mapcall: 0.06361, 0.06874
[MMM]==> mapcall: 0.14834, 0.21707
[0, 2, 4, 6, 8]
allTime = 0.21707428492118197
**map/comp = 1.571
这个计时函数装饰器可以用于任何函数,在模块中和在交互模式下都可以。但是,要将其应用于类方法上,需要进行细小的改写。简而言之,正如我们在本章前面的“类错误之一:装饰类方法”小节所介绍的,必须避免使用一个嵌套的类。
4. 编写类装饰器
4.1 单体类
由于类装饰器可以拦截实例创建调用,所以它们可以用来管理一个类的所有实例,或者扩展这些实例的接口。以下代码实现了传统的单体编码模式,其中最多只有一个类的一个实例存在。其单体函数为管理的属性定义并返回一个函数,并且 @ 语法自动在这个函数中包装了一个主体类:
1 | instances = {} |
Bob 400
Bob 400
42 42
当 Person 或 Spam 类稍后用来创建一个实例的时候,装饰器提供的包装逻辑层把实例构建调用指向了 onCall ,以针对每个类管理并分享一个单个实例,而不管进行了多少次构建调用。
使用 nonlocal 语句来改变封闭的作用域名称,我们在这里可以编写一个更为自包含的解决方案——后面的替代方案实现了同样的效果,它为每个类使用了一个封闭作用域,而不是为每个类使用一个全局表入口:
1 | def singleton(aClass): |
我们也可以用函数属性或类编写一个自包含的解决方案。前者对每个类使用一个 onCall 函数,对象命名空间和封闭作用域有着相同作用;后者对每个类使用一个实例,而不是使用一个封闭作用域或全局表:
1 | def singleton(aClass): |
4.2 跟踪对象接口
类装饰器基本上可以在实例上安装一个包装器逻辑层,来以某种方式管理对其接口的访问。
当获取未定义的属性名的时候, __getattr__
会运行;我们可以使用这个钩子来拦截一个控制器类中的方法调用,并将它们传递给一个嵌入的对象。
类装饰器为编写这种 __getattr__
技术来包装一个完整接口提供了一个替代的、方便的方法:
1 | def Tracer(aClass): |
Trace: display
Spam!Spam!Spam!Spam!Spam!Spam!Spam!Spam!
[1]
Trace: name
Bob
Trace: pay
2000
Trace: name
Sue
Trace: pay
6000
Trace: name
Bob
Trace: pay
2000
[4, 2]
通过拦截实例创建调用,这里的类装饰器允许我们跟踪整个对象接口,例如,对其任何属性的访问。
应用装饰器至内置类型
我们也可以使用装饰器来包装一个内置的类型,例如列表,只要我们的子类允许装饰器语法,或者手动地执行装饰:
1 |
|
Trace: append
1 | x.wrapped |
[4, 5, 6, 7]
4.3 为什么使用装饰器
为什么我们只是展示不使用装饰器的方法来实现单体呢?从负面的角度讲,类装饰器有两个潜在的缺陷:
- 类型修改
- 当插入包装器的时候,一个装饰器函数或类不会保持其最初的类型——其名称重新绑定到一个包装器对象,在使用对象名称或测试对象类型的程序中,这可能会很重要。在单体的例子中,装饰器和管理函数的方法都为实例保持了最初的类类型;在跟踪器的代码中,没有一种方法这么做,因为需要有包装器。
- 额外调用
- 通过装饰添加一个包装层,在每次调用装饰对象的时候,会引发一次额外调用所需的额外性能成本——调用是相对耗费时间的操作,因此,装饰包装器可能会使程序变慢。在跟踪器代码中,两种方法都需要每个属性通过一个包装器层来指向;单体的示例通过保持最初的类类型而避免了额外调用。
装饰器有 3 个主要优点:
- 明确的语法
- 装饰器使得扩展明确而显然。它们的 @ 比可能在源文件中任何地方出现的特殊代码要容易识别。此外,装饰器允许函数和实例创建调用使用所有 Python 程序员所熟悉的常规语法。
- 代码可维护性
- 装饰器避免了在每个函数或类调用中重复扩展的代码。由于它们只出现一次,在类或者函数自身的定义中,它们排除了冗余性并简化了未来的代码维护。
- 一致性
- 装饰器使得程序员忘记使用必需的包装逻辑的可能性大大减少。这主要得益于两个优点——由于装饰是显式的并且只出现一次,出现在装饰的对象自身中,与必须包含在每次调用中的特殊代码相比较,装饰器促进了更加一致和统一的API使用。
4.4 直接管理函数和类
假设你需要被另一个应用程序使用的方法或类注册到一个API,以便随后处理(可能该API随后将会调用该对象,以响应事件)。这一思路如下的简单实现定义了一个装饰器,它既应用于函数也应用于类,把对象添加到一个基于字典的注册中。由于它返回对象本身而不是一个包装器,所以它没有拦截随后的调用:
1 | registry = {} |
Registry:
spam => <function spam at 0x0000020A6D2AA2F0> <class 'function'>
ham => <function ham at 0x0000020A6D2AA840> <class 'function'>
Eggs => <class '__main__.Eggs'> <class 'type'>
Manual calls:
4
8
16
Registry calls:
spam => 4
ham => 8
Eggs => 16
函数装饰器也可能用来处理函数属性,并且类装饰器可能动态地插入新的类属性,或者甚至新的方法:
1 | def decorate(func): |
True
1 | def annotate(text): # 值是装饰器参数 |
(3, 'spam data')
1 |