Python实践提升-装饰器

Python实践提升-装饰器
在大约十年前, Python Web 开发相关的工作用的是 Django 框架。那时 Django 是整个 Python 生态圈里最流行的开源 Web 开发框架 。

作为最流行的 Web 开发框架,Django 提供了非常强大的功能。它有一个清晰的 MTV(model-template-view,模型—模板—视图)分层架构和开箱即用的 ORM2 引擎,以及丰富到令人眼花缭乱的可配置项。

2object-relational mapping(对象关系映射)的首字母缩写,指一种把数据库中的数据自动映射为程序内对象的技术,比如执行 User.objects.all() 会自动去数据库查询 user 表,并将所有数据自动转换为 User 对象。

但正因为提供了这些强大的功能,Django 的学习与使用成本也非常高。假如你从来没有接触过 Django,想要用它开发一个 Web 网站,得先学习一大堆框架配置、路由视图相关的东西,一晃大半天就过去了。

在 Django 几乎统治了 Python Web 开发的那段日子里,不知从哪一天开始,越来越多的人突然开始谈论起另一个叫 Flask 的 Web 开发框架。

出于好奇,我点开了 Flask 框架的官方文档,很快就被它的简洁性吸引了。举个例子,使用 Flask 开发一个 Hello World 站点,只需要下面这寥寥几行代码:

from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'Hello, World!'

作为对比,假如用 Django 开发这么一个站点,光配置文件 settings.py 里的代码就远比这些多。

虽然在之后的好几个月,在我深入学习使用 Flask 的过程中,发现它有许多值得称道的设计,但在当时,在我刚看到官网的 Hello World 样例代码的那一刻,最吸引我的,其实是那一行路由注册代码:@app.route('/')。

在接触 Flask 之前,虽然我已经使用过装饰器,也自己实现过装饰器,但从来没想过,装饰器原来可以用在 Web 站点中注册访问路由,而且这套 API 看起来居然特别自然、符合直觉。

再后来,我接触到更多和装饰器有关的模块,比如基于装饰器的缓存模块、基于装饰器的命令行工具集 Click 等,如代码清单 8-1 所示。

代码清单 8-1 使用 Click 模块定义的一个简单的命令行工具 3

3通过 click.option() 来定义脚本所需的参数,简单灵活,代码来自官方文档。

import click

@click.command()
@click.option('--count', default=1, help='Number of greetings.')
@click.option('--name', prompt='Your name',
              help='The person to greet.')
def hello(count, name):  
    """Simple program that greets NAME for a total of COUNT times."""
    for x in range(count):
        click.echo('Hello %s!' % name)

if __name__ == '__main__':
    hello()

这些模块和工具,无一例外地使用装饰器实现了简单好用的 API,为我的开发工作带来了极大便利。

不过,虽然 Python 里的装饰器(decorator)很有用,但它本身并不复杂,只是 Python 语言的一颗小小的语法糖。如你所知,这样的装饰器应用代码:

@cache
def function():
    ...
  完全等同于下面这样:

def function():
    ...

function = cache(function)

装饰器并不提供任何独特的功能,它所做的,只是让我们可以在函数定义语句上方,直接添加用来修改函数行为的装饰器函数。假如没有装饰器,我们也可以在完成函数定义后,手动做一次包装和重新赋值。

但正是因为装饰器提供的这一丁点儿好处,“通过包装函数来修改函数”这件事变得简单和自然起来。

在日常工作中,如果你掌握了如何编写装饰器,并在恰当的时机使用装饰器,就可以写出更易复用、更好扩展的代码。在本章中,我将分享一些在 Python 中编写装饰器的技巧,以及几个用于编写装饰器的常见工具,希望它们能助你写出更好的代码。

8.1 基础知识
8.1.1 装饰器基础
  装饰器是一种通过包装目标函数来修改其行为的特殊高阶函数,绝大多数装饰器是利用函数的闭包原理实现的。

代码清单 8-2 所示的 timer 是个简单的装饰器,它会记录并打印函数的每次调用耗时。

代码清单 8-2 打印函数耗时的无参数装饰器 timer

def timer(func):
    """装饰器:打印函数耗时"""

    def decorated(*args, **kwargs):
        st = time.perf_counter()
        ret = func(*args, **kwargs)
        print('time cost: {} seconds'.format(time.perf_counter() - st))
        return ret

    return decorated

在上面的代码中,timer 装饰器接收待装饰函数 func 作为唯一的位置参数,并在函数内定义了一个新函数:decorated。

在写装饰器时,我一般把 decorated 叫作“包装函数”。这些包装函数通常接收任意数目的可变参数 (*args, **kwargs),主要通过调用原始函数 func 来完成工作。在包装函数内部,常会增加一些额外步骤,比如打印信息、修改参数等。

当其他函数应用了 timer 装饰器后,包装函数 decorated 会作为装饰器的返回值,完全替换被装饰的原始函数 func。

random_sleep() 使用了 timer 装饰器:

@timer
def random_sleep():
    """随机睡眠一小会儿"""
    time.sleep(random.random())

调用结果如下:

>>> random_sleep()
time cost: 0.8360576540000002 seconds ➊

❶ 由 timer 装饰器打印的耗时信息

timer 是一个无参数装饰器,实现起来较为简单。假如你想实现一个接收参数的装饰器,代码会更复杂一些。

代码清单 8-3 给 timer 增加了额外的 print_args 参数。

代码清单 8-3 增加 print_args 的有参数装饰器 `timer

def timer(print_args=False):
    """装饰器:打印函数耗时

    :param print_args: 是否打印方法名和参数,默认为 False
    """

    def decorator(func):
        def wrapper(*args, **kwargs):
            st = time.perf_counter()
            ret = func(*args, **kwargs)
            if print_args:
                print(f'"{func.__name__}", args: {args}, kwargs: {kwargs}')
            print('time cost: {} seconds'.format(time.perf_counter() - st))
            return ret

        return wrapper

    return decorator

可以看到,为了增加对参数的支持,装饰器在原本的两层嵌套函数上又加了一层。这是由于整个装饰过程发生了变化所导致的。

具体来说,下面的装饰器应用代码:

@timer(print_args=True)
def random_sleep(): ...

展开后等同于下面的调用:

_decorator = timer(print_args=True) ➊
random_sleep = _decorator(random_sleep)

❶ 先进行一次调用,传入装饰器参数,获得第一层内嵌函数 decorator

❷ 进行第二次调用,获取第二层内嵌函数 wrapper

在应用有参数装饰器时,一共要做两次函数调用,所以装饰器总共得包含三层嵌套函数。正因为如此,有参数装饰器的代码一直都难写、难读。但不要紧,在 8.1.4 节中,我会介绍如何用类来实现有参数装饰器,减少代码的嵌套层级。

8.1.2 使用 functools.wraps() 修饰包装函数
  在装饰器包装目标函数的过程中,常会出现一些副作用,其中一种是丢失函数元数据。

在前一节的例子里,我用 timer 装饰了 random_sleep() 函数。现在,假如我想读取 random_sleep() 函数的名称、文档等属性,就会碰到一件尴尬的事情——函数的所有元数据都变成了装饰器的内层包装函数 decorated 的值:

>>> random_sleep.__name__
'decorated'
>>> print(random_sleep.__doc__)
None

对于装饰器来说,上面的元数据丢失问题只能算一个常见的小问题。但如果你的装饰器会做一些更复杂的事,比如为原始函数增加额外属性(或函数)等,那你就会踏入一个更大的陷阱。

举个例子,现在有一个装饰器 calls_counter,专门用来记录函数一共被调用了多少次,并且提供一个额外的函数来打印总次数,如代码清单 8-4 所示。

代码清单 8-4 记录函数调用次数的装饰器

calls_counter

def calls_counter(func):
    """装饰器:记录函数被调用了多少次

    使用 func.print_counter() 可以打印统计到的信息
    """
    counter = 0

    def decorated(*args, **kwargs):
        nonlocal counter
        counter += 1
        return func(*args, **kwargs)

    def print_counter():
        print(f'Counter: {counter}')

    decorated.print_counter = print_counter ➊
    return decorated

❶ 为被装饰函数增加额外函数,打印统计到的调用次数

装饰器的执行效果如下:

>>> random_sleep()
>>> random_sleep()
>>> random_sleep.print_counter()
Counter: 2

在单独使用 calls_counter 装饰器时,程序可以正常工作。但是,当你把前面的 timer 与 calls_counter 装饰器组合在一起使用时,就会出现问题:

@timer
@calls_counter
def random_sleep():
    """随机睡眠一小会儿"""
    time.sleep(random.random())

调用效果如下:

>>> random_sleep()
function took: 0.36080002784729004 seconds

>>> random_sleep.print_counter()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'function' object has no attribute 'print_counter'

虽然 timer 装饰器仍在工作,函数执行时会打印耗时信息,但本该由 calls_counter 装饰器给函数追加的 print_counter 属性找不到了。

为了分析原因,首先我们得把上面的装饰器调用展开成下面这样的语句:

random_sleep = calls_counter(random_sleep) ➊
random_sleep = timer(random_sleep)

❶ 首先,由 calls_counter 对函数进行包装,此时的 random_sleep 变成了新的包装函数,包含 print_counter 属性

❷ 使用 timer 包装后,random_sleep 变成了 timer 提供的包装函数,原包装函数额外的 print_counter 属性被自然地丢掉了

要解决这个问题,我们需要在装饰器内包装函数时,保留原始函数的额外属性。而 functools 模块下的 wraps() 函数正好可以完成这件事情。

使用 wraps(),装饰器只需要做一点儿改动:

from functools import wraps


def timer(func):

    @wraps(func)def decorated(*args, **kwargs):
        ...

    return decorated

❶ 添加 @wraps(wrapped) 来装饰 decorated 函数后,wraps() 首先会基于原函数 func 来更新包装函数 decorated 的名称、文档等内置属性,之后会将 func 的所有额外属性赋值到 decorated 上

在 timer 和 calls_counter 装饰器里增加 wraps 后,前面的所有问题都可以得到圆满的解决。

首先,被装饰函数的名称和文档等元数据会保留:

>>> random_sleep.__name__
'random_sleep'
>>> random_sleep.__doc__
'随机睡眠一小会儿'

calls_counter 装饰器为函数追加的额外函数也可以正常访问了:

>>> random_sleep()
function took: 0.9187359809875488 seconds
>>> random_sleep()
function took: 0.8986420631408691 seconds
>>> random_sleep.print_counter()
Counter: 2

正因为如此,在编写装饰器时,切记使用 @functools.wraps() 来修饰包装函数。

8.1.3 实现可选参数装饰器
  假如你用嵌套函数来实现装饰器,接收参数与不接收参数的装饰器代码有很大的区别——前者总是比后者多一层嵌套。

# 1. 接收参数的装饰器:2 层嵌套
def delayed_start(duration=1):
    def decorator(func):
        def wrapper(*args, **kwargs):
            ...
        return wrapper
    return decorator

# 2. 不接收参数的装饰器:1 层嵌套
def delayed_start(func):
    def wrapper(*args, **kwargs):
        ...
    return wrapper

当你实现了一个接收参数的装饰器后,即便所有参数都是有默认值的可选参数,你也必须在使用装饰器时加上括号:

@delayed_start(duration=2)@delayed_start()

❶ 使用装饰器时提供参数

❷ 不提供参数,也需要使用括号调用装饰器

有参数装饰器的这个特点提高了它的使用成本——如果使用者忘记添加那对括号,程序就会出错。

那么有没有什么办法,能让我们省去那对括号,直接使用 @delayed_start 这种写法呢?答案是肯定的,利用仅限关键字参数,你可以很方便地做到这一点。

代码清单 8-5 里的 delayed_start 装饰器就定义了可选的 duration 参数。

代码清单 8-5 定义了可选参数的装饰器 delayed_start

def delayed_start(func=None, *, duration=1):"""装饰器:在执行被装饰函数前,等待一段时间

    :param duration: 需要等待的秒数
    """

    def decorator(_func):
        def wrapper(*args, **kwargs):
            print(f'Wait for {duration} second before starting...')
            time.sleep(duration)
            return _func(*args, **kwargs)

        return wrapper
    if func is None:return decorator
    else:
        return decorator(func)

❶ 把所有参数都变成提供了默认值的可选参数

❷ 当 func 为 None 时,代表使用方提供了关键字参数,比如 @delayed_start(duration=2),此时返回接收单个函数参数的内层子装饰器 decorator

❸ 当位置参数 func 不为 None 时,代表使用方没提供关键字参数,直接用了无括号的 @ delayed_start 调用方式,此时返回内层包装函数 wrapper

这样定义装饰器以后,我们可以通过多种方式来使用它:

# 1. 不提供任何参数
@delayed_start
def hello(): ...

# 2. 提供可选的关键字参数
@delayed_start(duration=2)
def hello(): ...

# 3. 提供括号调用,但不提供任何参数
@delayed_start()
def hello(): ...

把参数变为可选能有效降低使用者的心智负担,让装饰器变得更易用。标准库 dataclasses 模块里的 @dataclass 装饰器就使用了这个小技巧。
8.1.4 用类来实现装饰器(函数替换)
  绝大多数情况下,我们会选择用嵌套函数来实现装饰器,但这并非构造装饰器的唯一方式。事实上,某个对象是否能通过装饰器(@decorator)的形式使用只有一条判断标准,那就是 decorator 是不是一个可调用的对象。

函数自然是可调用对象,除此之外,类同样也是可调用对象。

>>> class Foo:
...     pass
...
>>> callable(Foo)True

❶ 使用 callable() 内置函数可以判断某个对象是否可调用

如果一个类实现了 call 魔法方法,那么它的实例也会变成可调用对象:

>>> class Foo:
...     def __call__(self, name):...         print(f'Hello, {name}')
...
>>> foo = Foo()
>>> callable(foo)
True
>>> foo('World') ➋
Hello, World

call 魔法方法是用来实现可调用对象的关键方法

❷ 调用类实例时,可以像调用普通函数一样提供额外参数

基于类的这些特点,我们完全可以用它来实现装饰器。

如果按装饰器用于替换原函数的对象类型来分类,类实现的装饰器可分为两种,一种是“函数替换”,另一种是“实例替换”。下面我们先来看一下前者。

函数替换装饰器虽然是基于类实现的,但用来替换原函数的对象仍然是个普通的包装函数。这种技术最适合用来实现接收参数的装饰器。

代码清单 8-6 用类的方式重新实现了接收参数的 timer 装饰器。

代码清单 8-6 用类实现的 timer 装饰器

class timer:
    """装饰器:打印函数耗时

    :param print_args: 是否打印方法名和参数,默认为 False
    """

    def __init__(self, print_args):
        self.print_args = print_args

    def __call__(self, func):
        @wraps(func)
        def decorated(*args, **kwargs):
            st = time.perf_counter()
            ret = func(*args, **kwargs)
            if self.print_args:
                print(f'"{func.__name__}", args: {args}, kwargs: {kwargs}')
            print('time cost: {} seconds'.format(time.perf_counter() - st))
            return ret

        return decorated

还记得我之前说过,有参数装饰器一共得提供两次函数调用吗?通过类实现的装饰器,其实就是把原本的两次函数调用替换成了类和类实例的调用。

(1) 第一次调用:_deco = timer(print_args=True) 实际上是在初始化一个 timer 实例。

(2) 第二次调用:func = _deco(func) 是在调用 timer 实例,触发 call 方法。

相比三层嵌套的闭包函数装饰器,上面这种写法在实现有参数装饰器时,代码更清晰一些,里面的嵌套也少了一层。不过,虽然装饰器是用类实现的,但最终用来替换原函数的对象,仍然是一个处在 call 方法里的闭包函数 decorated。

虽然“函数替换”装饰器的代码更简单,但它和普通装饰器并没有本质区别。下面我会介绍另一种更强大的装饰器——用实例来替换原函数的“实例替换”装饰器。

8.1.5 用类来实现装饰器(实例替换)
  和“函数替换”装饰器不一样,“实例替换”装饰器最终会用一个类实例来替换原函数。通过组合不同的工具,它既能实现无参数装饰器,也能实现有参数装饰器。

实现无参数装饰器

用类来实现装饰器时,被装饰的函数 func 会作为唯一的初始化参数传递到类的实例化方法 init 中。同时,类的实例化结果——类实例(class instance),会作为包装对象替换原始函数。

代码清单 8-7 实现了一个延迟函数执行的装饰器。

代码清单 8-7 实例替换的无参数装饰器 DelayedStart

class DelayedStart:
    """在执行被装饰函数前,等待 1 秒钟"""

    def __init__(self, func):
        update_wrapper(self, func) ➊
        self.func = func

    def __call__(self, *args, **kwargs):print(f'Wait for 1 second before starting...')
        time.sleep(1)
        return self.func(*args, **kwargs)

    def eager_call(self, *args, **kwargs):"""跳过等待,立刻执行被装饰函数"""
        print('Call without delay')
        return self.func(*args, **kwargs)

❶ update_wrapper 与前面的 wraps 一样,都是把被包装函数的元数据更新到包装者(在这里是 DelayedStart 实例)上

❷ 通过实现 call 方法,让 DelayedStart 的实例变得可调用,以此模拟函数的调用行为

❸ 为装饰器类定义额外方法,提供更多样化的接口

执行效果如下:

>>> @DelayedStart
... def hello():
...     print("Hello, World.")

>>> hello
<__main__.DelayedStart object at 0x100b71130>
>>> type(hello)
<class '__main__.DelayedStart'>
>>> hello.__name__ ➊
'hello'

>>> hello() ➋
Wait for 1 second before starting...
Hello, World.
>>> hello.eager_call() ➌
Call without delay
Hello, World.

❶ 被装饰的 hello 函数已经变成了装饰器类 DelayedStart 的实例,但是因为 update_wrapper 的作用,这个实例仍然保留了被装饰函数的元数据

❷ 此时触发的其实是装饰器类实例的 call 方法

❸ 使用额外的 eager_call 接口调用函数

实现有参数装饰器

同普通装饰器一样,“实例替换”装饰器也可以支持参数。为此我们需要先修改类的实例化方法,增加额外的参数,再定义一个新函数,由它来负责基于类创建新的可调用对象,这个新函数同时也是会被实际使用的装饰器。

在代码清单 8-8 中,我为 DelayedStart 增加了控制调用延时的 duration 参数,并定义了 delayed_start() 函数。

代码清单 8-8 实例替换的有参数装饰器 delayed_start

class DelayedStart:
    """在执行被装饰函数前,等待一段时间
    :param func: 被装饰的函数
    :param duration: 需要等待的秒数
    """

    def __init__(self, func, *, duration=1): ➊
        update_wrapper(self, func)
        self.func = func
        self.duration = duration

    def __call__(self, *args, **kwargs):
        print(f'Wait for {self.duration} second before starting...')
        time.sleep(self.duration)
        return self.func(*args, **kwargs)

    def eager_call(self, *args, **kwargs): ...

def delayed_start(**kwargs):
    """装饰器:推迟某个函数的执行"""
    return functools.partial(DelayedStart, **kwargs)

❶ 把 func 参数以外的其他参数都定义为“仅限关键字参数”,从而更好地区分原始函数与装饰器的其他参数

❷ 通过 partial 构建一个新的可调用对象,这个对象接收的唯一参数是待装饰函数 func,因此可以用作装饰器

使用样例如下:

@delayed_start(duration=2)
def hello():
    print("Hello, World.")

相比传统做法,用类来实现装饰器(实例替换)的主要优势在于,你可以更方便地管理装饰器的内部状态,同时也可以更自然地为被装饰对象追加额外的方法和属性。

8.1.6 使用 wrapt 模块助力装饰器编写
  在编写通用装饰器时,我常常会遇到一类麻烦事。

如代码清单 8-9 所示,我实现了一个自动注入函数参数的装饰器 provide_number,它在装饰函数后,会在后者被调用时自动生成一个随机数,并将其注入为函数的第一个位置参数。

代码清单 8-9 注入数字的装饰器 provide_number

import random

def provide_number(min_num, max_num):
    """
    装饰器:随机生成一个在 [min_num, max_num] 范围内的整数,
    并将其追加为函数的第一个位置参数
    """

    def wrapper(func):
        def decorated(*args, **kwargs):
            num = random.randint(min_num, max_num)
            # 将 num 追加为第一个参数,然后调用函数
            return func(num, *args, **kwargs)

        return decorated

    return wrapper

使用效果如下:

>>> @provide_number(1, 100)
... def print_random_number(num):
...     print(num)
...
>>> print_random_number()
57

@provide_number 装饰器的功能看上去很不错,但当我用它来修饰类方法时,就会碰上“麻烦事”:

>>> class Foo:
...     @provide_number(1, 100)
...     def print_random_number(self, num):
...         print(num)
...
>>> Foo().print_random_number()
<__main__.Foo object at 0x100f70460>
print_random_number()

如你所见,类实例中的 print_random_number() 方法并没有打印我期望中的随机数字 num,而是输出了类实例 self 对象。

这是因为类方法(method)和函数(function)在工作机制上有细微的区别。当类实例方法被调用时,第一个位置参数总是当前绑定的类实例 self 对象。因此,当装饰器向 *args 前追加随机数时,其实已经把 *args 里的 self 挤到了 num 参数所在的位置,从而导致了上面的问题。

为了修复这个问题,provide_number 装饰器在追加位置参数时,必须聪明地判断当前被修饰的对象是普通函数还是类方法。假如被修饰的对象是类方法,那就得跳过藏在 *args 里的类实例变量,才能正确将 num 作为第一个参数注入。

假如要手动实现这个判断,装饰器内部必须增加一些烦琐的兼容代码,费工费时。幸运的是,wrapt 模块可以帮我们轻松处理好这类问题。

wrapt 是一个第三方装饰器工具库,利用它,我们可以非常方便地改造 provide_number 装饰器,完美地解决这个问题。

使用 wrapt 改造过的装饰器如代码清单 8-10 所示。

代码清单 8-10 基于 wrapt 模块实现的 provide_number 装饰器

import wrapt


def provide_number(min_num, max_num):
    @wrapt.decorator
    def wrapper(wrapped, instance, args, kwargs):
        # 参数含义:
        #
        # - wrapped:被装饰的函数或类方法
        # - instance:
        # - 如果被装饰者为普通类方法,则该值为类实例
        # - 如果被装饰者为 classmethod 类方法,则该值为类
        # - 如果被装饰者为类/函数/静态方法,则该值为 None
        #
        # - args:调用时的位置参数(注意没有 * 符号)
        # - kwargs:调用时的关键字参数(注意没有 ** 符号)
        #
        num = random.randint(min_num, max_num)
        # 无须关注 wrapped 是类方法还是普通函数,直接在头部追加参数
        args = (num,) + args
        return wrapped(*args, **kwargs)

return wrapper

新装饰器可以完美兼容普通函数与类方法两种情况:

>>> print_random_number()
22
>>> Foo().print_random_number()
93

使用 wrapt 模块编写的装饰器,除了解决了类方法兼容问题以外,代码嵌套层级也比普通装饰器少,变得更扁平、更易读。如果你有兴趣,可以参阅 wrapt 模块的官方文档了解更多信息。

8.2 编程建议
8.2.1 了解装饰器的本质优势
  当我们向其他人介绍装饰器时,常常会说:“装饰器为我们提供了一种动态修改函数的能力。”这么说有一定道理,但是并不准确。“动态修改函数”的能力,其实并不是由装饰器提供的。假如没有装饰器,我们也能在定义完函数后,手动调用装饰函数来修改它。

装饰器带来的改变,主要在于把修改函数的调用提前到了函数定义处,而这一点儿位置上的小变化,重塑了读者理解代码的整个过程。

比如,当人们读到下面的函数定义语句时,马上就能明白:“哦,原来这个视图函数需要登录才能访问。”

@login_requried
def view_function(request):
    ...

所以,装饰器的优势并不在于它提供了动态修改函数的能力,而在于它把影响函数的装饰行为移到了函数头部,降低了代码的阅读与理解成本。

为了充分发挥这个优势,装饰器特别适合用来实现以下功能。

(1) 运行时校验:在执行阶段进行特定校验,当校验通不过时终止执行。

适合原因:装饰器可以方便地在函数执行前介入,并且可以读取所有参数辅助校验。
代表样例:Django 框架中的用户登录态校验装饰器 @login_required。
  (2) 注入额外参数:在函数被调用时自动注入额外的调用参数。

适合原因:装饰器的位置在函数头部,非常靠近参数被定义的位置,关联性强。
代表样例:unittest.mock 模块的装饰器 @patch。
  (3) 缓存执行结果:通过调用参数等输入信息,直接缓存函数执行结果。

适合原因:添加缓存不需要侵入函数内部逻辑,并且功能非常独立和通用。
代表样例:functools 模块的缓存装饰器 @lru_cache。
  (4) 注册函数:将被装饰函数注册为某个外部流程的一部分。

适合原因:在定义函数时可以直接完成注册,关联性强。
代表样例:Flask 框架的路由注册装饰器 @app.route。
  (5) 替换为复杂对象:将原函数(方法)替换为更复杂的对象,比如类实例或特殊的描述符对象(见 12.1.3 节)。

适合原因:在执行替换操作时,装饰器语法天然比 foo = staticmethod(foo) 的写法要直观得多。
代表样例:静态类方法装饰器 @staticmethod。
  在设计新的装饰器时,你可以先参考上面的常见装饰器功能列表,琢磨琢磨自己的设计是否能很好地发挥装饰器的优势。切勿滥用装饰器技术,设计出一些天马行空但难以理解的 API。吸取前人经验,同时在设计上保持克制,才能写出更好用的装饰器。

8.2.2 使用类装饰器替代元类
  Python 中的元类(metaclass)是一种特殊的类。就像类可以控制实例的创建过程一样,元类可以控制类的创建过程。通过元类,我们能实现各种强大的功能。比如下面的代码就利用元类统一注册所有 Validator 类:

_validators = {}

class ValidatorMeta(type):
    """元类:统一注册所有校验器类,方便后续使用"""

    def __new__(cls, name, bases, attrs):
        ret = super().__new__(cls, name, bases, attrs)
        _validators[attrs['name']] = ret
        return ret


class StringValidator(metaclass=ValidatorMeta):
    name = 'string'

class IntegerValidator(metaclass=ValidatorMeta):
    name = 'int'

查看注册结果:

>>> _validators
{'string': <class '__main__.StringValidator'>, 'int': <class '__main__.IntegerValidator'>}

虽然元类的功能很强大,但它的学习与理解成本非常高。其实,对于实现上面这种常见需求,并不是非使用元类不可,使用类装饰器也能非常方便地完成同样的工作。

类装饰器的工作原理与普通装饰器类似。下面的代码就用类装饰器实现了 ValidatorMeta 元类的功能:

def register(cls):
    """装饰器:统一注册所有校验器类,方便后续使用"""
    _validators[cls.name] = cls
    return cls

@register
class StringValidator:
    name = 'string'

@register
class IntegerValidator:
    name = 'int'

相比元类,使用类装饰器的代码要容易理解得多。

除了上面的注册功能以外,你还可以用类装饰器完成许多实用的事情,比如实现单例设计模式、自动为类追加方法,等等。

虽然类装饰器并不能覆盖元类的所有功能,但在许多场景下,类装饰器可能比元类更合适,因为它不光写起来容易,理解起来也更简单。像广为人知的标准库模块 dataclasses 里的 @ dataclass 就选择了类装饰器,而不是元类。

8.2.3 别弄混装饰器和装饰器模式
  1994 年出版的经典软件开发著作《设计模式:可复用面向对象软件的基础》中,一共介绍了 23 种经典的面向对象设计模式。这些设计模式为编写好代码提供了许多指导,影响了一代又一代的程序员。

在这 23 种设计模式中,有一种“装饰器模式”。也许是因为装饰器模式和 Python 里的装饰器使用了同一个名字:装饰器(decorator),导致经常有人把它俩当成一回事儿,认为使用 Python 里的装饰器就是在实践装饰器模式。

但事实上,《设计模式》一书中的“装饰器模式”与 Python 里的“装饰器”截然不同。

装饰器模式属于面向对象领域。实现装饰器模式,需要具备以下关键要素:

设计一个统一的接口;
编写多个符合该接口的装饰器类,每个类只实现一个简单的功能;
通过组合的方式嵌套使用这些装饰器类;
通过类和类之间的层层包装来实现复杂的功能。
  代码清单 8-11 是我用 Python 实现的一个简单的装饰器模式。

代码清单 8-11 装饰器模式示例

class Numbers:
    """一个包含多个数字的简单类"""

    def __init__(self, numbers):
        self.numbers = numbers

    def get(self):
        return self.numbers


class EvenOnlyDecorator:
    """装饰器类:过滤所有偶数"""

    def __init__(self, decorated):
        self.decorated = decorated

    def get(self):
        return [num for num in self.decorated.get() if num % 2 == 0]


class GreaterThanDecorator:
    """装饰器类:过滤大于某个数的数"""

    def __init__(self, decorated, min_value):
        self.decorated = decorated
        self.min_value = min_value

    def get(self):
        return [num for num in self.decorated.get() if num > self.min_value]


obj = Numbers([42, 12, 13, 17, 18, 41, 32])
even_obj = EvenOnlyDecorator(obj)
gt_obj = GreaterThanDecorator(even_obj, min_value=30)
print(gt_obj.get())

执行结果如下:

[42, 32]

从上面的代码中你能发现,装饰器模式和 Python 里的装饰器毫不相干。如果硬要找一点儿联系,它俩可能都和“包装”有关——一个包装函数,另一个包装类。

所以,请不要混淆装饰器和装饰器模式,它们只是名字里刚好都有“装饰器”而已。

8.2.4 浅装饰器,深实现
  在编写装饰器时,人们很容易产生这样的想法:“我的装饰器要实现某个功能,所以我要把所有逻辑都放在装饰器里实现。”抱着这样的想法去写代码,很容易写出异常复杂的装饰器代码。

在编写了许多装饰器后,我发现了一种更好的代码组织思路,那就是:浅装饰器,深实现。

举个例子,流行的第三方命令行工具包 Click 里大量使用了装饰器。但如果你查看 Click 包的源码,就会发现 Click 的所有装饰器都在一个不到 400 行代码的 decorators.py 文件中,里面的大部分装饰器的代码不超过 10 行,如代码清单 8-12 所示。

代码清单 8-12 @click.command 装饰器源码

def command(name=None, cls=None, **attrs):
    if cls is None:
        cls = Command

    def decorator(f):
        cmd = _make_command(f, name, attrs, cls)
        cmd.__doc__ = f.__doc__
        return cmd

    return decorator

即便是 Click 的核心装饰器 @command,也只有短短 8 行代码。它所做的,只是简单地把被装饰函数替换为 Command 实例,而所有核心逻辑都在 Command 实例中。

这样的装饰器很浅,只做一些微小的工作,但这样的代码扩展性其实更强。

因为归根结底,装饰器其实只是一类特殊的 API,一种提供服务的方式。比起把所有核心逻辑都放在装饰器内,不如让装饰器里只有一层浅浅的包装层,而把更多的实现细节放在其他函数或类中。

这样做之后,假如你未来需要为模块增加装饰器以外的其他 API,比如上下文管理器,就会发现自己之前写的大部分核心代码仍然可以复用,因为它们并没有和装饰器耦合。

8.3 总结
  在本章中,我分享了一些与装饰器有关的知识。

装饰器是 Python 为我们提供的一颗语法糖,它和“装饰器模式”没有任何关系。任何可调用对象都可以当作装饰器来使用,因此,除了最常见的用嵌套函数来实现装饰器外,我们也可以用类来实现装饰器。

在装饰器包装原始函数的过程中,会产生“元数据丢失”副作用,你可以通过 functools.wraps() 来解决这个问题。

用类实现的装饰器分为两种:“函数替换”与“实例替换”。后者可以有效地实现状态管理、追加行为功能。在实现有参数“实例替换”装饰器时,你需要定义一个额外的函数来配合装饰器类。

在编写装饰器时,第三方工具包 wrapt 非常有用,借助它能写出更扁平的装饰器,也更容易兼容装饰函数与类方法两种场景。

装饰器是一个有趣且非常独特的语言特性。虽然它不提供什么无法替代的功能,但在 API 设计领域给了我们非常大的想象空间。发挥想象力,同时保持克制,也许这就是设计出人人喜爱的装饰器的秘诀。

以下是本章要点知识总结。

(1) 基础与技巧

装饰器最常见的实现方式,是利用闭包原理通过多层嵌套函数实现
在实现装饰器时,请记得使用 wraps() 更新包装函数的元数据
wraps() 不光可以保留元数据,还能保留包装函数的额外属性
利用仅限关键字参数,可以很方便地实现可选参数的装饰器
  (2) 使用类来实现装饰器

只要是可调用的对象,都可以用作装饰器
实现了 call 方法的类实例可调用
基于类的装饰器分为两种:“函数替换”与“实例替换”
“函数替换”装饰器与普通装饰器没什么区别,只是嵌套层级更少
通过类来实现“实例替换”装饰器,在管理状态和追加行为上有天然的优势
混合使用类和函数来实现装饰器,可以灵活满足各种场景
  (3) 使用 wrapt 模块

使用 wrapt 模块可以方便地让装饰器同时兼容函数和类方法
使用 wrapt 模块可以帮你写出结构更扁平的装饰器代码
  (4) 装饰器设计技巧

装饰器将包装调用提前到了函数被定义的位置,它的大部分优点也源于此
在编写装饰器时,请考虑你的设计是否能很好发挥装饰器的优势
在某些场景下,类装饰器可以替代元类,并且代码更简单
装饰器和装饰器模式截然不同,不要弄混它们
装饰器里应该只有一层浅浅的包装代码,要把核心逻辑放在其他函数与类中

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值