在之前的文章 入门Python装饰器 中,我们了解了函数是Python的第一类对象,因此也可以传递并当作参数,而装饰器是一个函数,它接受另一个函数并扩展这个函数的行为而不显式修改它。与此同时,也看到了如何创建一个简单的装饰器。

在本文中,我们将探索装饰器更高级的特性,包括如何使用以下特性:

  • 类的装饰器 Decorators on classes
  • 应用多个装饰器 Several decorators on one function
  • 带参数的装饰器 Decorators with arguments
  • 带可选参数的装饰器 Decorators that can optionally take arguments
  • 有状态的装饰器 Stateful decorators
  • 作为装饰器的类 Classes as decorators

类的装饰器

在类中使用装饰器有两种不同的方式。第一个非常接近于之前完成的函数:你可以装饰类的方法。这是引入装饰器的动机之一

装饰类的方法

内置装饰器

Python中内置的一些常用的装饰器是 @classmethod@staticmethod@property

@classmethod@staticmethod装饰器用于在类命名空间中定义的方法,但这些方法不连接到该类的特定实例之中。@property装饰器用于为类属性定制gettersetter

下面的Cicle类定义使用@classmethod@staticmethod@property 装饰器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Circle:
def __init__(self, radius):
self._radius = radius

@property
def radius(self):
"""Get value of radius"""
return self._radius

@radius.setter
def radius(self, value):
"""Set radius, raise error if negative"""
if value >= 0:
self._radius = value
else:
raise ValueError("Radius must be positive")

@property
def area(self):
"""Calculate area inside circle"""
return self.pi() * self.radius**2

def cylinder_volume(self, height):
"""Calculate volume of cylinder with circle as base"""
return self.area * height

@classmethod
def unit_circle(cls):
"""Factory method creating a circle with radius 1"""
return cls(1)

@staticmethod
def pi():
"""Value of π, could use math.pi instead though"""
return 3.1415926535

在这个类之中:

  • .cylinder_volume() 是常规方法
  • .radius 是一个可变属性:可以将其设置为不同的值。通过定义setter方法,我们可以进行一些错误测试,以确保不会将其设置为无意义的负数。Properties 作为属性访问,不使用括号。
  • .area是一个不可变的属性:没有.setter()方法的属性不能更改。即使它被定义为一个方法,它也可以被检索为一个没有括号的属性。
  • .unit_circle()是一个类方法。它不局限于一个特定的Circle实例。类方法通常用作工厂方法,可以创建类的特定实例。
  • .pi()是一个静态方法。它实际上并不依赖于Circle类,只是它是其名称空间的一部分。静态方法可以在实例或类上调用。

例如,Circle类可以这样使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
>>> c = Circle(5)
>>> c.radius
5

>>> c.area
78.5398163375

>>> c.radius = 2
>>> c.area
12.566370614

>>> c.area = 100
AttributeError: can't set attribute

>>> c.cylinder_volume(height=4)
50.265482456

>>> c.radius = -1
ValueError: Radius must be positive

>>> c = Circle.unit_circle()
>>> c.radius
1

>>> c.pi()
3.1415926535

>>> Circle.pi()
3.1415926535

自定义类的装饰器

让我们定义一个类,在这个类中,我们使用前面的@debug@timer装饰器装饰它的一些方法:

1
2
3
4
5
6
7
8
9
10
11
from decorators import debug, timer

class TimeWaster:
@debug
def __init__(self, max_num):
self.max_num = max_num

@timer
def waste_time(self, num_times):
for _ in range(num_times):
sum([i**2 for i in range(self.max_num)])

使用这个类,可以看到装饰器的效果:

1
2
3
4
5
6
>>> tw = TimeWaster(1000)
Calling __init__(<time_waster.TimeWaster object at 0x7efccce03908>, 1000)
'__init__' returned None

>>> tw.waste_time(999)
Finished 'waste_time' in 0.3376 secs

装饰整个类

在类上使用装饰器的另一种方法是装饰整个类。例如,这是在Python 3.7中的新dataclasses模块中完成的:

1
2
3
4
5
6
from dataclasses import dataclass

@dataclass
class PlayingCard:
rank: str
suit: str

语法的含义类似于函数装饰器。在上面的例子中,可以通过编写PlayingCard = dataclass(PlayingCard)来完成装饰。

类装饰器的一个常见用法是作为一些元类用例的更简单的替代。在这两种情况下,都可以动态地更改类的定义。

编写类装饰器与编写函数装饰器非常相似。唯一的区别是装饰器将接收一个类作为参数,而不是函数。事实上,上面看到的所有装饰器都可以作为类的装饰器工作。不过,当在类上使用装饰器而不是函数上使用它们时,它们的效果可能不是预想的那样。在下面的示例中,@timer装饰器应用于一个类:

1
2
3
4
5
6
7
8
9
10
from decorators import timer

@timer
class TimeWaster:
def __init__(self, max_num):
self.max_num = max_num

def waste_time(self, num_times):
for _ in range(num_times):
sum([i**2 for i in range(self.max_num)])

装饰类并不装饰它的方法。回想一下@timer仅仅是TimeWaster = timer(TimeWaster)的缩写。

在这里,@timer只度量实例化类所需的时间:

1
2
3
4
5
>>> tw = TimeWaster(1000)
Finished 'TimeWaster' in 0.0000 secs

>>> tw.waste_time(999)
>>>

之后,我们将看到一个合适的定义类的装饰器的示例,即@singleton,它确保一个类只有一个实例。

嵌套装饰器

可以将多个装饰器堆叠在一起,来应用到一个函数上:

1
2
3
4
5
6
from decorators import debug, do_twice

@debug
@do_twice
def greet(name):
print(f"Hello {name}")

可以将其视为按顺序执行的装饰器。换句话说,@debug调用@do_twice@do_twice调用greet(),或者可以理解为 debug(do_twice(greet())):

1
2
3
4
5
>>> greet("Eva")
Calling greet('Eva')
Hello Eva
Hello Eva
'greet' returned None

更改@debug@do_twice的顺序,观察差异:

1
2
3
4
5
6
from decorators import debug, do_twice

@do_twice
@debug
def greet(name):
print(f"Hello {name}")

在这种情况下,@do_twice也会被应用到@debug上:

1
2
3
4
5
6
7
>>> greet("Eva")
Calling greet('Eva')
Hello Eva
'greet' returned None
Calling greet('Eva')
Hello Eva
'greet' returned None

带参数的装饰器

有时候,向装饰器传递参数是很有用的。例如,@do_twice可以扩展为@repeat(num_times)装饰器。然后,可以将执行被装饰函数的次数作为参数给出。

你可以这样做:

1
2
3
@repeat(num_times=4)
def greet(name):
print(f"Hello {name}")
1
2
3
4
5
>>> greet("World")
Hello World
Hello World
Hello World
Hello World

考虑一下如何做到

1
2
3
4
def repeat(num_times):
def decorator_repeat(func):
... # Create and return a wrapper function
return decorator_repeat

通常情况下,装饰器会创建并返回一个内部包装器函数,因此完整地编写示例将会在内部函数中提供一个内部函数。

1
2
3
4
5
6
7
8
9
def repeat(num_times):
def decorator_repeat(func):
@functools.wraps(func)
def wrapper_repeat(*args, **kwargs):
for _ in range(num_times):
value = func(*args, **kwargs)
return value
return wrapper_repeat
return decorator_repeat

它看起来有点乱,但是我们只在一个额外的def中添加了相同的装饰器模式,这些已经见过很多次了,这个def可以处理装饰器的参数。让我们从最里面的函数开始:

1
2
3
4
def wrapper_repeat(*args, **kwargs):
for _ in range(num_times):
value = func(*args, **kwargs)
return value

这个wrapper_repeat()函数接受任意参数并返回被装饰函数func()的值。这个包装器函数还包含调用被装饰函数num_times次循环。这与之前看到的包装器函数没有什么不同,只是它使用了必须从外部提供的num_times参数。

接下来,便是装饰器函数:

1
2
3
4
5
def decorator_repeat(func):
@functools.wraps(func)
def wrapper_repeat(*args, **kwargs):
...
return wrapper_repeat

同样,decorator_repeat()与前面编写的装饰器函数完全相同,只是命名方式不同。这是因为我们为最外层的函数保留了基名repeat(),这是用户将要调用的函数。

我们会发现,最外层的函数返回对装饰器函数的引用:

1
2
3
4
def repeat(num_times):
def decorator_repeat(func):
...
return decorator_repeat

repeat()函数有一些东西需要我们注意:

  • decorator_repeat()定义为一个内部函数意味着repeat()将引用一个函数对象——decorator_repeat。之前,我们使用没有括号的repeat引用函数对象。在定义带有参数的修饰符时,则需要添加括号。
  • num_times参数似乎没有在repeat()本身中使用。但是通过传递num_times,可以创建一个闭包,其中存储num_times的值,直到wrapper_repeat()稍后使用它为止。

测试一下我们的装饰器函数@repeat(num_times=4)

1
2
3
@repeat(num_times=4)
def greet(name):
print(f"Hello {name}")
1
2
3
4
5
>>> greet("World")
Hello World
Hello World
Hello World
Hello World

这正是我们想要的结果。

带可选参数的装饰器

我们还可以定义既可以使用参数,也可以不使用参数的装饰器。很可能,我们并不需要这样做,但是具有灵活性总归是很好的。

正如在前一节中看到的,当装饰器使用参数时,需要添加一个额外的外部函数。难点在于,我们编写代码要弄清楚装饰器是否被调用了,是否有参数。

由于装饰函数仅在没有参数的情况下调用装饰器时才直接传递,因此该函数必须是可选参数。这意味着装饰器参数必须全部由关键字指定。也可以用特殊的*语法来加强这一点,这意味着以下所有的参数都是只有关键字的:

1
2
3
4
5
6
7
8
def name(_func=None, *, kw1=val1, kw2=val2, ...):  # 1
def decorator_name(func):
... # Create and return a wrapper function.

if _func is None:
return decorator_name # 2
else:
return decorator_name(_func) # 3

在这里,_func参数充当标记,注意调用装饰器时是否带有参数:

  1. 如果name调用时没有参数,装饰后的函数将作为_func传入。如果调用name时带有参数,那么_func将为None,并且一些关键字参数可能已经从它们的默认值更改了。参数列表中的*表示剩余的参数,并且不是位置参数。
  2. 这个例子中,带参数调用装饰器的情况下,返回一个装饰器函数,该函数可以读取并返回一个函数。
  3. 没有带参数调用装饰器,则立即将装饰器应用到函数上。

若想在在上一节的@repeat装饰器中使用这个样板,可以编写以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
def repeat(_func=None, *, num_times=2):
def decorator_repeat(func):
@functools.wraps(func)
def wrapper_repeat(*args, **kwargs):
for _ in range(num_times):
value = func(*args, **kwargs)
return value
return wrapper_repeat

if _func is None:
return decorator_repeat
else:
return decorator_repeat(_func)

与原始的@repeat进行比较。唯一的变化是添加了_func参数以及在末尾添加了if-else

Python Cookbook一书中9.6节 带可选参数的装饰器 展示了一种替代方法,使用functools.partial()

这些例子表明,@repeat现在可以在有或没有参数的情况下使用:

1
2
3
4
5
6
7
@repeat
def say_whee():
print("Whee!")

@repeat(num_times=3)
def greet(name):
print(f"Hello {name}")

回想一下num_times的默认值是2:

1
2
3
4
5
6
7
8
>>> say_whee()
Whee!
Whee!

>>> greet("Penny")
Hello Penny
Hello Penny
Hello Penny

有状态的装饰器

有时候,有一个可以跟踪状态的装饰器是很有用的。作为一个简单的例子,我们将创建一个装饰器,统计调用函数的次数。

入门Python装饰器的开头,我们简单讨论了基于给定参数返回值的纯函数。有状态装饰器正好相反,返回值将取决于当前状态以及给定的参数。

在下一节中,将看到如何使用类来保持状态。但在简单的情况下,您也可以使用函数属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import functools

def count_calls(func):
@functools.wraps(func)
def wrapper_count_calls(*args, **kwargs):
wrapper_count_calls.num_calls += 1
print(f"Call {wrapper_count_calls.num_calls} of {func.__name__!r}")
return func(*args, **kwargs)
wrapper_count_calls.num_calls = 0
return wrapper_count_calls

@count_calls
def say_whee():
print("Whee!")

状态(调用函数的次数)存储在包装器函数的函数属性.num_calls中。下面是使用它的效果:

1
2
3
4
5
6
7
8
9
10
>>> say_whee()
Call 1 of 'say_whee'
Whee!

>>> say_whee()
Call 2 of 'say_whee'
Whee!

>>> say_whee.num_calls
2

作为装饰器的类

维护状态的典型方法是使用类。在本节中,您将看到如何使用类作为装饰器重写前一节中的@count_calls示例。

回想一下,装饰器语法@my_decorator只是表示func = my_decorator(func)的一种更简单的方法。因此,如果my_decorator是一个类,那么它需要在.__init__()方法中将func作为参数。此外,该类需要可调用,以便它能够代替已装饰过的函数。

要使类可调用,需要实现特殊的.__call__()方法:

1
2
3
4
5
6
7
class Counter:
def __init__(self, start=0):
self.count = start

def __call__(self):
self.count += 1
print(f"Current count is {self.count}")

.__call__()方法在每次尝试调用类的实例时执行:

1
2
3
4
5
6
7
8
9
>>> counter = Counter()
>>> counter()
Current count is 1

>>> counter()
Current count is 2

>>> counter.count
2

因此,一个装饰器类的典型实现,需要实现.__init__().__call__():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import functools

class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.num_calls = 0

def __call__(self, *args, **kwargs):
self.num_calls += 1
print(f"Call {self.num_calls} of {self.func.__name__!r}")
return self.func(*args, **kwargs)

@CountCalls
def say_whee():
print("Whee!")

.__init__()方法必须存储对函数的引用,并且可以执行任何其他必要的初始化。.__call__()方法被调用而不是已装饰函数。它做的事情与我们前面示例中的wrapper()函数基本相同。注意,应该需要使用functools.update_wrapper()函数,而不是@functools.wrapper

这个@CountCalls装饰器的工作原理与前一节相同:

1
2
3
4
5
6
7
8
9
10
>>> say_whee()
Call 1 of 'say_whee'
Whee!

>>> say_whee()
Call 2 of 'say_whee'
Whee!

>>> say_whee.num_calls
2