Python 使用装饰器改进代码详解

在本章中,我们将探讨装饰师,并了解他们在我们希望改进设计的许多情况下是如何有用的。我们将首先探讨什么是装饰器,它们是如何工作的,以及它们是如何实现的。

有了这些知识,我们将重新回顾我们在前几章中学习的关于软件设计的一般良好实践的概念,并了解装饰师如何帮助我们遵守每一条原则。

本章的目标如下:

修饰符很久以前在 Python 的 PEP-318 中被引入,作为一种机制,当函数和方法必须在原始定义之后进行修改时,可以简化它们的定义方式。

我们首先必须了解,在 Python 中,函数与其他任何东西一样都是常规对象。这意味着您可以将它们分配给变量,通过参数传递它们,甚至对它们应用其他函数。典型的做法是编写一个小函数,然后对其应用一些变换,生成该函数的新修改版本(类似于数学中函数组合的工作方式)。

引入 decorator 的最初动机之一是,由于使用了classmethodstaticmethod等函数来转换方法的原始定义,因此它们需要额外的一行,在单独的语句中修改函数的原始定义。

更一般来说,每次我们必须对函数应用转换时,我们必须使用modifier函数调用它,然后将其重新分配给最初定义函数的相同名称。

例如,如果我们有一个名为original的函数,然后我们有一个在其上改变original行为的函数,名为modifier,我们必须编写如下内容:

def original(...):
    ...
original = modifier(original) 

注意我们是如何更改函数并将其重新分配给相同的名称的。这是令人困惑的、容易出错的(假设有人忘记重新分配函数,或者确实重新分配了函数,但不是在函数定义之后的一行中,而是在很远的地方),而且很麻烦。因此,该语言中添加了一些语法支持。

前面的示例可以改写如下:

@modifier
def original(...):
   ... 

这意味着 decorator 只是调用 decorator 后面的任何内容作为 decorator 本身的第一个参数的语法糖,结果将是 decorator 返回的任何内容。

decorators 的语法显著提高了可读性,因为现在代码的读者可以在一个地方找到函数的整个定义。请记住,仍然允许像以前那样手动修改函数。

通常,避免在不使用 decorator 语法的情况下将值重新分配给已经设计的函数。特别是,如果函数被重新分配给其他对象,而这发生在代码的远程部分(远离函数最初定义的位置),这将使代码更难阅读。

根据 Python 术语和我们的示例,modifier是我们所称的装饰器,而original是装饰函数,通常也被称为包装的对象。

虽然功能最初是为方法和函数设计的,但实际语法允许修饰任何类型的对象,因此我们将探讨应用于函数、方法、生成器和类的装饰器。

最后一点需要注意的是,尽管 decorator 的名称是正确的(毕竟,decorator 正在修改、扩展或在包装函数的顶部工作),但不要将其与 decorator 设计模式混淆。

函数装饰器

函数可能是 Python 对象的最简单的表示形式,可以修饰。我们可以在函数上使用 decorator 对其应用各种逻辑我们可以验证参数、检查前提条件、完全更改行为、修改其签名、缓存结果(创建原始函数的记忆版本)等等。

例如,我们将创建一个实现retry机制的基本装饰器,控制特定的域级异常并重试一定次数:

# decorator_function_1.py
class ControlledException(Exception):
    """A generic exception on the program's domain."""
def retry(operation):
    @wraps(operation)
    def wrapped(*args, **kwargs):
        last_raised = None
        RETRIES_LIMIT = 3
        for _ in range(RETRIES_LIMIT):
            try:
                return operation(*args, **kwargs)
            except ControlledException as e:
                logger.info("retrying %s", operation.__qualname__)
                last_raised = e
        raise last_raised
    return wrapped 

@wraps的使用现在可以忽略,因为它将在有效装饰-避免常见错误一节中介绍。

for循环中使用_意味着这个数字被分配给了一个我们目前不感兴趣的变量,因为它在for循环中没有被使用(Python 中的一个常见习惯用法是命名被忽略的_值)。

retry装饰器不带任何参数,因此可以轻松应用于任何函数,如下所示:

@retry
def run_operation(task):
    """Run a particular task, simulating some failures on its execution."""
    return task.run() 

run_operation之上的@retry的定义只是 Python 为执行run_operation = retry(run_operation)提供的语法糖。

在这个有限的示例中,我们可以看到如何使用 decorator 创建一个通用的retry操作,在某些条件下(在本例中,表示为可能与超时相关的异常),该操作将允许多次调用修饰代码。

类的装饰器

类在 Python 中也是对象(坦率地说,几乎所有东西在 Python 中都是一个对象,很难找到反例;但是,在技术上有一些细微差别)。这意味着同样的考虑也适用;它们还可以通过参数传递、分配给变量、询问某些方法或进行转换(修饰)。

类修饰符是在 PEP-3129 中引入的,它们与我们刚才探讨的函数修饰符有着非常相似的考虑。唯一的区别是,在为这种装饰器编写代码时,我们必须考虑到我们正在接收一个类作为包装方法的参数,而不是另一个函数。

当我们在第 2 章Python 代码中看到dataclasses.dataclass修饰符时,我们已经看到了如何使用类修饰符。在本章中,我们将学习如何编写自己的类装饰器。

一些实践者可能会争辩说,装饰类是一件相当复杂的事情,这种情况可能会危及可读性,因为我们将在类中声明一些属性和方法,但在幕后,装饰者可能会应用更改,从而呈现一个完全不同的类。

这一评估是正确的,但只有在这种技术被严重滥用的情况下。客观上,这与装饰功能没有什么不同;毕竟,类和函数一样,只是 Python 生态系统中的另一种对象类型。我们将在标题为decorators 和关注点分离的部分中与 decorators 一起回顾这个问题的利弊,但现在,我们将探讨 decorator 特别适用于类的好处:

  • 重用代码和 DRY 原则的所有好处。类装饰器的一个有效案例是强制多个类符合某个接口或标准(通过在将应用于多个类的装饰器中只编写一次这些检查)。
  • 我们可以创建更小或更简单的类,这些类稍后将由装饰器进行增强。
  • 如果我们使用 decorator,那么我们需要应用于某个类的转换逻辑将更容易维护,而不是像元类这样更复杂的方法(通常是不受欢迎的)。

在装饰器的所有可能应用中,我们将探索一个简单的示例来展示装饰器可以用于哪些方面。请记住,这不是类装饰器的唯一应用程序类型,而且我向您展示的代码也可以有许多其他解决方案,都有其优缺点,但我选择装饰器是为了说明它们的有用性。

回顾监控平台的事件系统,我们现在需要转换每个事件的数据并将其发送到外部系统。但是,在选择如何发送数据时,每种类型的事件都可能有其自身的特殊性。

特别是,登录名的event可能包含敏感信息,例如我们想要隐藏的凭据。其他字段,如timestamp也可能需要一些转换,因为我们希望以特定格式显示它们。遵守这些要求的第一次尝试将非常简单,就像拥有一个映射到每个特定事件并知道如何序列化它的类一样:

class LoginEventSerializer:
    def __init__(self, event):
        self.event = event
    def serialize(self) -> dict:
        return {
            "username": self.event.username,
            "password": "**redacted**",
            "ip": self.event.ip,
            "timestamp": self.event.timestamp.strftime("%Y-%m-%d 
             %H:%M"),
        }
@dataclass
class LoginEvent:
    SERIALIZER = LoginEventSerializer
    username: str
    password: str
    ip: str
    timestamp: datetime
    def serialize(self) -> dict:
        return self.SERIALIZER(self).serialize() 

这里,我们声明一个将直接与登录事件映射的类,其中包含逻辑,隐藏password字段,并根据需要格式化timestamp

虽然这是一个不错的选择,但随着时间的推移,我们希望扩展我们的系统,我们会发现一些问题:

  • 类太多:随着事件数量的增长,序列化类的数量将以相同的数量级增长,因为它们是一对一映射的。
  • 解决方案不够灵活:如果我们需要重用部分组件(例如,我们需要在另一种类型的事件中隐藏密码),我们必须将其提取到一个函数中,但也要从多个类中重复调用,这意味着我们毕竟没有重用那么多代码。
  • 样板文件serialize()方法必须出现在所有事件类中,调用相同的代码。尽管我们可以将其提取到另一个类中(创建 mixin),但它似乎不是继承的好用途。

另一种解决方案是动态构造一个对象,给定一组过滤器(转换函数)和一个事件实例,可以通过将过滤器应用于其字段来序列化该对象。然后,我们只需要定义函数来转换每种类型的字段,而序列化程序是通过组合这些函数创建的。

一旦我们有了这个对象,我们就可以装饰这个类,以便添加serialize()方法,该方法本身只调用这些Serialization对象:

from dataclasses import dataclass
def hide_field(field) -> str:
    return "**redacted**"
def format_time(field_timestamp: datetime) -> str:
    return field_timestamp.strftime("%Y-%m-%d %H:%M")
def show_original(event_field):
    return event_field
class EventSerializer:
    def __init__(self, serialization_fields: dict) -> None:
        self.serialization_fields = serialization_fields
    def serialize(self, event) -> dict:
        return {
            field: transformation(getattr(event, field))
            for field, transformation
            in self.serialization_fields.items()
        }
class Serialization:

    def __init__(self, **transformations):
        self.serializer = EventSerializer(transformations)
    def __call__(self, event_class):
        def serialize_method(event_instance):
            return self.serializer.serialize(event_instance)
        event_class.serialize = serialize_method
        return event_class
@Serialization( 
    username=str.lower, 
    password=hide_field, 
    ip=show_original, 
    timestamp=format_time, 
) 
@dataclass 
class LoginEvent: 
    username: str 
    password: str 
    ip: str 
    timestamp: datetime 

请注意,装饰器如何让用户更容易知道如何处理每个字段,而无需查看另一个类的代码。通过阅读传递给类装饰器的参数,我们知道,username和 IP 地址将保持不变,password将被隐藏,timestamp将被格式化。

现在,类的代码不需要定义serialize()方法,也不需要从实现它的 mixin 扩展,因为 decorator 将添加它。这可能是创建类装饰器的唯一理由,因为否则,Serialization对象可能是LoginEvent的类属性,但它通过向类添加新方法来改变类的事实使其不可能。

其他类型的装饰

既然我们知道了@修饰符语法的含义,我们就可以得出结论,可以装饰的不仅仅是函数、方法或类;实际上,任何可以定义的东西,比如生成器、协同程序,甚至是已经被装饰过的对象,都可以被装饰,这意味着装饰器可以被堆叠起来。

前面的示例演示了如何链接装饰器。我们首先定义了类,然后将@dataclass应用于它,将其转换为数据类,充当这些属性的容器。之后,@Serialization将对该类应用该逻辑,从而生成一个新类,其中添加了新的serialize()方法。

现在我们知道了装饰器的基本原理,以及如何编写它们,我们可以继续讨论更复杂的示例。在下一节中,我们将看到如何使用更灵活的参数装饰器,以及实现它们的不同方法。

通过刚才的介绍,我们现在了解了装饰器的基本知识:它们是什么,以及它们的语法和语义。

现在我们对更高级的装饰器的使用感兴趣,这将帮助我们更干净地构造代码。

我们将看到,我们可以使用 decorator 将关注点分离为更小的函数,并重用代码,但为了有效地这样做,我们希望参数化 decorator(否则,我们将重复代码)。为此,我们将探讨如何将参数传递给装饰器的不同选项。

在那之后,我们可以看到一些很好地使用装饰器的例子。

将参数传递给装饰器

在这一点上,我们已经将装饰器视为 Python 中强大的工具。然而,如果我们能够将参数传递给它们,使它们的逻辑更加抽象,它们可能会更加强大。

有几种实现装饰器的方法可以接受参数,但我们将讨论最常见的方法。第一个是将 decorator 创建为嵌套函数,并具有新的间接级别,使 decorator 中的所有内容都更深一层。第二种方法是为 decorator 使用一个类(即,实现仍然充当 decorator 的可调用对象)。

一般来说,第二种方法更倾向于可读性,因为从对象的角度考虑比使用闭包的三个或更多嵌套函数更容易。然而,为了完整性,我们将对两者进行探讨,您可以决定什么最适合当前的问题。

具有嵌套函数的装饰器

粗略地说,装饰器的一般思想是创建一个返回另一个函数的函数(在函数编程中,将其他函数作为参数的函数称为高阶函数,这与我们在这里讨论的概念相同)。装饰器主体中定义的内部函数将被调用。

现在,如果我们希望将参数传递给它,那么我们需要另一个间接级别。第一个函数将接受参数,在该函数中,我们将定义一个新函数,它将是 decorator,而 decorator 又将定义另一个新函数,即作为装饰过程的结果返回的函数。这意味着我们将至少有三层嵌套函数。

如果到目前为止还不清楚,请不要担心。在回顾即将到来的示例之后,一切都将变得清晰。

我们看到的第一个例子是装饰器在一些函数上实现了重试功能。这是个好主意,只是有个问题;我们的实现不允许我们指定重试次数,相反,这在 decorator 中是一个固定的数字。

现在,我们希望能够指示每个实例将有多少次重试,也许我们甚至可以为这个参数添加一个默认值。为了做到这一点,我们需要另一个级别的嵌套函数,首先是参数,然后是装饰器本身。

这是因为我们现在将有以下形式的东西:

@retry(arg1, arg2,... ) 

这必须返回一个 decorator,因为@语法将把计算结果应用于要修饰的对象。从语义上讲,它可以转换为如下内容:

 <original_function> = retry(arg1, arg2, ....)(<original_function>) 

除了所需的重试次数外,我们还可以指出我们希望控制的异常类型。支持新需求的新版本代码可能如下所示:

_DEFAULT_RETRIES_LIMIT = 3
    def with_retry(
        retries_limit: int = _DEFAULT_RETRIES_LIMIT,
        allowed_exceptions: Optional[Sequence[Exception]] = None,
    ):
        allowed_exceptions = allowed_exceptions or
        (ControlledException,) # type: ignore
        def retry(operation):
            @wraps(operation)
            def wrapped(*args, **kwargs):
                last_raised = None
                for _ in range(retries_limit):
                    try:
                        return operation(*args, **kwargs)
                    except allowed_exceptions as e:
                        logger.warning(
                            "retrying %s due to %s",
                            operation.__qualname__, e
                        )
                        last_raised = e
                raise last_raised
            return wrapped
        return retry 

下面是如何将此装饰器应用于函数的一些示例,显示了它接受的不同选项:

# decorator_parametrized_1.py
@with_retry()
def run_operation(task):
    return task.run()
@with_retry(retries_limit=5)
def run_with_custom_retries_limit(task):
    return task.run()
@with_retry(allowed_exceptions=(AttributeError,))
def run_with_custom_exceptions(task):
    return task.run()
@with_retry(
    retries_limit=4, allowed_exceptions=(ZeroDivisionError, AttributeError)
)
def run_with_custom_parameters(task):
    return task.run() 

使用嵌套的函数来实现装饰器可能是我们首先想到的。这在大多数情况下都很有效,但正如您可能已经注意到的,缩进会不断增加,对于我们创建的每个新函数,很快就会导致太多嵌套函数。此外,函数是无状态的,因此以这种方式编写的装饰器不一定像对象那样保存内部数据。

有一种不同的实现装饰器的方法,它不使用嵌套函数,而是使用对象,正如我们在下一节中探讨的那样。

装饰对象

前面的示例需要三个级别的嵌套函数。第一个是一个函数,它接收我们想要使用的装饰器的参数。在这个函数中,其余的函数是使用这些参数的闭包,以及装饰器的逻辑。

一个更简洁的实现是使用一个类来定义装饰器。在这种情况下,我们可以在__init__方法中传递参数,然后在名为__call__的神奇方法上实现装饰器的逻辑。

装饰器的代码与以下示例中的代码类似:

_DEFAULT_RETRIES_LIMIT = 3
class WithRetry:
    def __init__(
        self,
        retries_limit: int = _DEFAULT_RETRIES_LIMIT,
        allowed_exceptions: Optional[Sequence[Exception]] = None,
    ) -> None:
    self.retries_limit = retries_limit
    self.allowed_exceptions = allowed_exceptions or
(ControlledException,)
    def __call__(self, operation):
        @wraps(operation)
        def wrapped(*args, **kwargs):
            last_raised = None
            for _ in range(self.retries_limit):
                try:
                    return operation(*args, **kwargs)
                except self.allowed_exceptions as e:
                logger.warning(
                    "retrying %s due to %s",
                    operation.__qualname__, e
                )
                    last_raised = e
            raise last_raised
      return wrapped 

这个装饰器可以像前一个一样应用,如下所示:

@WithRetry(retries_limit=5)
def run_with_custom_retries_limit(task):
    return task.run() 

注意 Python 语法在这里是如何起作用的,这一点很重要。首先,我们创建对象,因此在应用@操作之前,将使用传递给它的参数创建对象。这将创建一个新对象,并使用这些参数对其进行初始化,如init方法中所定义。在此之后,将调用@操作,因此此对象将包装名为run_with_custom_reries_limit的函数,这意味着它将被传递给call魔术方法。

在这个调用 magic 方法中,我们定义了 decorator 的逻辑,就像我们通常所做的那样,我们包装原始函数,用我们想要的逻辑返回一个新函数。

具有默认值的装饰器

在前面的示例中,我们看到了一个接受参数的装饰器,但这些参数具有默认值。以前的 decorator 的编写方式将确保它们能够工作,只要用户在使用 decorator 时不会忘记使用括号进行函数调用。

例如,如果我们只需要默认值,这将起作用:

@retry()
def my function(): ... 

但这不会:

@retry
def my function(): ... 

您可能会争论这是否必要,并接受(可能有适当的文档)第一个示例是如何使用 decorator,而第二个示例是不正确的。这很好,但需要密切关注,否则会发生运行时错误。

当然,如果 decorator 使用的参数没有默认值,那么第二种语法就没有意义,而且只有一种可能,这可能会使事情变得更简单。

或者,您可以使装饰器同时使用这两种语法。正如你可能已经猜到的,这需要额外的努力,而且你应该一如既往地权衡它是否值得。

让我们用一个简单的示例来说明这一点,该示例使用带参数的装饰器将参数注入函数。我们定义了一个接受两个参数的函数和一个接受相同参数的装饰器,其思想是不带参数地调用该函数,并让它使用装饰器传递的参数:

 @decorator(x=3, y=4)
        def my_function(x, y):
            return x + y
        my_function()  # 7 

但当然,我们为装饰器的参数定义了默认值,因此我们可以不使用值来调用它。我们也可以不用括号来称呼它。

最简单、最幼稚的写作方式是用一个有条件的词来区分这两种情况:

def decorator(function=None, *, x=DEFAULT_X, y=DEFAULT_Y):
    if function is None:  # called as `@decorator(...)`

        def decorated(function):
            @wraps(function)
            def wrapped():
                return function(x, y)

            return wrapped

        return decorated
    else:  # called as `@decorator`

        @wraps(function)
        def wrapped():
            return function(x, y)

        return wrapped 

请注意关于装饰器签名的一些重要信息:参数仅为关键字。这大大简化了 decorator 的定义,因为在无参数调用函数时,我们可以假设函数为None(否则,如果我们按位置传递值,传递的第一个参数将与函数混淆)。如果我们想更加小心,而不是使用None(或任何 sentinel 值),我们可以检查参数类型,断言我们期望的类型的函数对象,然后相应地移动参数,但这会使装饰器更加复杂。

另一种选择是提取包装装饰器的一部分,然后应用函数的部分应用程序(使用functools.partial。为了更好地解释这一点,让我们使用一个中间状态,并使用一个lambda函数来显示如何应用装饰器的参数,以及装饰器的参数如何“移位”:

def decorator(function=None, *, x=DEFAULT_X, y=DEFAULT_Y):
    if function is None:
        return lambda f: decorator(f, x=x, y=y)

    @wraps(function)
    def wrapped():
        return function(x, y)

    return wrapped 

这与前面的例子类似,因为我们有wrapped函数的定义(它是如何被修饰的)。然后,如果没有提供函数,我们将返回一个新函数,该函数将函数作为参数(f),并返回应用该函数并绑定其余参数的 decorator。然后,在第二个递归调用中,函数将存在,而将返回常规的 decorator 函数(wrapped)。

您可以通过更改函数部分应用的lambda定义来实现相同的结果:

return partial(decorator, x=x, y=y) 

如果这对于我们的用例来说太复杂,我们总是可以决定让装饰器的参数采用强制值。

在任何情况下,将装饰器的参数定义为仅关键字(不管它们是否具有默认值)都可能是一个好主意。这是因为,一般来说,在应用装饰器时,没有太多关于每个值正在执行的操作的上下文,并且使用位置值可能不会产生一个非常有意义的表达式,因此最好更具表现力,并将参数名称与值一起传递。

如果您使用参数定义装饰器,则更倾向于将其设置为仅关键字。

类似地,如果我们的 decorator 不打算获取参数,并且我们想明确地说明这一点,那么我们可以使用我们在第 2 章Pythonic Code中学习的语法,将我们的 decorator 接收的函数定义为一个单位置参数。

对于我们的第一个示例,语法是:

def retry(operation, /): ... 

但请记住,这并不是严格推荐的,只是一种让您明确如何调用装饰程序的方法。

协同程序的装饰器

正如在简介中所解释的,因为 Python 中几乎所有的东西都是对象,所以几乎所有的东西都可以被修饰,这也包括协同路由。

然而,这里有一个警告,也就是说,如前几章所述,Python 中的异步编程在语法上引入了一些差异。因此,这些语法差异也将被带到 decorator 中。

简单地说,如果我们要为一个协程编写一个装饰器,我们可以简单地适应新的语法(记住等待包装的协程并将包装的对象定义为一个协程本身,这意味着内部函数可能必须使用“async def”,而不仅仅是“def”)。

问题是我们是否想拥有一个广泛适用于函数和协同程序的装饰器。在大多数情况下,创建两个 decorator 是最简单(也许也是最好)的方法,但是如果我们想为用户公开一个更简单的界面(通过记住更少的对象),我们可以创建一个瘦包装器,就像两个内部(未公开)decorator 的调度器一样。这就像创建一个立面,但有一个装饰师。

对于为函数和协同程序创建装饰器有多困难,没有一般的规则,因为这取决于我们想要在装饰器本身中加入的逻辑。例如,在下面的代码中,有一个 decorator 更改它接收的函数的参数,这对常规函数或协同例程都有效:

X, Y = 1, 2

def decorator(callable):
    """Call <callable> with fixed values"""

    @wraps(callable)
    def wrapped():
        return callable(X, Y)

    return wrapped

@decorator
def func(x, y):
    return x + y

@decorator
async def coro(x, y):
    return x + y 

不过,对协同程序做一个区分是很重要的。装饰程序将接收协同程序作为其callable参数,然后使用参数调用它。这将创建协同路由对象(将进入事件循环的任务),但它不会等待它,这意味着调用await coro()的人最终将等待装饰程序包装的协同路由。这意味着,在像这样的简单情况下,我们不需要用另一个协程替换该协程(尽管这通常是推荐的)。

但是,这取决于我们需要做什么。如果我们需要一个timing函数,那么我们必须等待函数或协程完成以测量时间,为此我们必须调用await,这意味着包装器对象反过来必须是一个协程(但不是主装饰器)。

下面的代码说明了此示例,它使用了一个装饰器,有选择地决定如何包装调用方函数:

import inspect
def timing(callable):
    @wraps(callable)
    def wrapped(*args, **kwargs):
        start = time.time()
        result = callable(*args, **kwargs)
        latency = time.time() - start
        return {"latency": latency, "result": result}

    @wraps(callable)
    async def wrapped_coro(*args, **kwargs):
        start = time.time()
        result = await callable(*args, **kwargs)
        latency = time.time() - start
        return {"latency": latency, "result": result}

    if inspect.iscoroutinefunction(callable):
        return wrapped_coro

    return wrapped 

协同路由需要第二个包装器。如果我们没有它,那么代码将有两个问题。首先,调用callable(没有await)实际上不会等待操作完成,这意味着结果将不正确。更糟糕的是,字典上的result键的值不是结果本身,而是创建的协同程序。因此,响应将是一个字典,任何试图调用该字典的人都将尝试等待字典,这将导致错误。

作为一般规则,您应该将装饰对象替换为另一个同类对象,即,将函数替换为函数,将协程替换为另一个协程。

我们仍将研究最近添加到 Python 中的最后一个增强,它解除了其语法的一些限制。

装饰器的扩展语法

Python3.9 为装饰师引入了一种新颖性,即 PEP-614(https://www.python.org/dev/peps/pep-0614/ ),因为允许使用更通用的语法。在这个增强之前,调用 decorators 的语法(在@之后)被限制在非常有限的表达式中,并且不是每个 Python 表达式都被允许。

随着这些限制的解除,我们现在可以编写更复杂的表达式并在我们的装饰器中使用它们,如果我们认为这样可以节省一些代码行的话(但一如既往,要小心不要过于复杂,得到一个更紧凑但更难读的行)。

例如,我们可以简化一些嵌套函数,这些函数通常用于记录函数调用及其参数的简单装饰器。在这里(仅供说明),我用两个lambda表达式替换了嵌套函数定义,这是典型的装饰器:

def _log(f, *args, **kwargs):
    print(f"calling {f.__qualname__!r} with {args=} and {kwargs=}")
    return f(*args, **kwargs)

@(lambda f: lambda *args, **kwargs: _log(f, *args, **kwargs))
def func(x):
    return x + 1 
>>> func(3)
calling 'func' with args=(3,) and kwargs={} 

PEP 文档引用了一些示例说明此功能何时有用(例如简化 no-op 函数以计算其他表达式,或者避免使用 eval 函数)。

本书对此特性的建议与所有可以实现更紧凑语句的情况一致:编写更紧凑版本的代码,只要不影响可读性。如果 decorator 表达式变得难以阅读,那么最好使用更详细但更简单的方法来编写两个或多个函数。

在本节中,我们将了解一些利用装饰器的常见模式。当装饰师是一个好的选择时,这些是常见的情况。

从装饰器可用于的无数应用程序中,我们将列举一些最常见或最相关的应用程序:

  • 转换参数:更改函数的签名以公开更好的 API,同时封装下面如何处理和转换参数的细节。我们必须小心使用装饰器,因为这只是一个好的特性,当它是故意的。这意味着,如果我们显式地使用 decorator 为具有相当复杂的函数提供良好的签名,那么这是通过 decorator 实现更干净代码的一种好方法。另一方面,如果一个函数的签名因为装饰程序而被无意中更改,那么这就是我们想要避免的事情(我们将在本章末尾讨论如何避免)。
  • 跟踪代码:记录函数及其参数的执行情况。您可能熟悉提供跟踪功能的多个库,并且经常公开诸如装饰器之类的功能以添加到我们的函数中。这是一个很好的抽象,提供了一个很好的接口,可以将代码与外部各方集成在一起,而不会造成太多的中断。此外,这是一个很好的灵感来源,因此我们可以作为装饰者编写自己的日志记录或跟踪功能。
  • 验证参数:装饰符可用于以透明的方式验证参数类型(例如,对照预期值或其注释)。通过使用装饰器,我们可以按照契约设计的思想,为我们的抽象强制执行先决条件。
  • 实现重试操作:以类似于我们在上一节中探讨的示例的方式。
  • 通过将一些(重复的)逻辑移到装饰器中来简化类:这与干式原理有关,我们将在本章末尾重新讨论。

在以下部分中,我将更详细地讨论其中一些主题。

自适应函数签名

在面向对象的设计中,有时会出现具有不同接口的对象需要交互的情况。这个问题的一个解决方案是适配器设计模式,我们将在第 7 章生成器、迭代器和异步 PRPGraming中讨论,当我们回顾一些主要设计模式时。

然而,本节的主题是类似的,因为有时我们需要调整的不是对象,而是函数签名。

设想一个场景,在这个场景中,您使用的是遗留代码,并且有一个模块包含许多用复杂签名定义的函数(许多参数、样板等)。最好有一个更干净的界面来与这些定义交互,但是改变很多函数意味着一个主要的重构。

我们可以使用装饰器将更改中的差异保持在最小。

有时,我们可以使用装饰器作为代码和正在使用的框架之间的适配器,例如,如果该框架具有上述考虑因素。

想象一下,一个框架希望调用我们定义的函数,维护一个特定的接口:

def resolver_function(root, args, context, info): ... 

现在,我们到处都有这个签名,并决定最好从所有这些参数中创建一个抽象来封装它们,并公开我们在应用程序中需要的行为。

现在我们有很多函数,它们的第一行重复了一遍又一遍地创建同一对象的样板,然后函数的其余部分只与我们的域对象交互:

def resolver_function(root, args, context, info):
    helper = DomainObject(root, args, context, info)
    ...
    helper.process() 

在本例中,我们可以让一个装饰器更改函数的签名,这样我们就可以在直接传递helper对象的情况下编写函数。在这种情况下,装饰器的任务是截取原始参数,创建域对象,然后将helper对象传递给我们的函数。然后我们定义我们的函数,假设我们只接收我们需要的对象,并且已经初始化。

也就是说,我们希望以以下形式编写代码:

@DomainArgs
def resolver_function(helper):
    helper.process()
   ... 

这也是另一种方式,例如,如果我们拥有的遗留代码使用了太多的参数,并且我们总是解构已经创建的对象,因为重构遗留代码会有风险,那么我们可以使用装饰器作为中间层。

这样做的目的是,使用 decorator 可以帮助您编写具有更简单、更紧凑签名的函数。

验证参数

我们之前提到过可以使用装饰器来验证参数(甚至在契约式设计DbC)的理念下强制执行一些先决条件或后决条件),因此您可能已经有了这样的想法:在处理或操作参数时,使用装饰器有些常见。

特别是,在某些情况下,我们发现自己反复创建类似的对象或应用类似的转换,希望将其抽象掉。大多数情况下,我们可以通过简单地使用装饰器来实现这一点。

跟踪代码

在本节中讨论跟踪时,我们将参考一些更一般的内容,这些内容与处理我们希望监视的函数的执行有关。这可能是指我们希望:

  • 跟踪函数的执行(例如,通过记录它执行的行)
  • 监控功能的某些指标(如 CPU 使用率或内存占用)
  • 测量函数的运行时间
  • 调用函数时的日志,以及传递给它的参数

在下一节中,我们将探索一个简单的 decorator 示例,该 decorator 记录函数的执行情况,包括其名称和运行时间。

尽管装饰器是 Python 的一个重要特性,但如果使用不当,它们也会出现问题。在本节中,我们将看到一些常见的问题,以避免创建有效的装饰器。

保留有关原始包装对象的数据

将装饰器应用于函数时,最常见的问题之一是原始函数的某些属性或属性没有得到维护,从而导致不希望的、难以跟踪的副作用。

为了说明这一点,我们展示了一个 decorator,该 decorator 在函数即将运行时负责日志记录:

# decorator_wraps_1.py
def trace_decorator(function):
    def wrapped(*args, **kwargs):
        logger.info("running %s", function.__qualname__)
        return function(*args, **kwargs)
    return wrapped 

现在,让我们假设我们有一个应用了这个装饰器的函数。我们最初可能会认为,该函数的任何内容都不会相对于其原始定义进行修改:

@trace_decorator
def process_account(account_id: str):
    """Process an account by Id."""
    logger.info("processing account %s", account_id)
    ... 

但也许会有变化。

decorator 不应该改变原始函数的任何内容,但是,事实证明,由于它包含一个缺陷,它实际上修改了它的名称和 docstring 等属性。

让我们尝试获取此函数的help

>>> help(process_account)
Help on function wrapped in module decorator_wraps_1:
wrapped(*args, **kwargs) 

让我们检查一下它的名称:

>>> process_account.__qualname__
'trace_decorator.<locals>.wrapped' 

而且,原始函数的注释也丢失了:

>>> process_account.__annotations__
{} 

我们可以看到,由于 decorator 实际上正在将原始函数更改为新函数(称为wrapped),因此我们实际上看到的是该函数的属性,而不是原始函数的属性。

如果我们对多个具有不同名称的函数应用这样的修饰符,它们最终都将被调用wrapped,这是一个主要问题(例如,如果我们想要记录或跟踪函数,这将使调试更加困难)。

另一个问题是,如果我们在这些函数上放置带有测试的 docstring,它们将被 decorator 的测试覆盖。因此,当我们使用doctest模块调用代码时(正如我们在第 1 章简介、代码格式和工具中看到的),我们想要的测试的 docstring 将不会运行。

不过,解决办法很简单。我们只需在内部函数(wrapped中应用 wrapps decorator,告诉它它实际上是一个包装函数:

# decorator_wraps_2.py
def trace_decorator(function):
    @wraps(function)
    def wrapped(*args, **kwargs):
        logger.info("running %s", function.__qualname__)
        return function(*args, **kwargs)
    return wrapped 

现在,如果我们检查属性,我们将首先获得我们所期望的。检查函数的帮助,如下所示:

>>> from decorator_wraps_2 import process_account
>>> help(process_account)
Help on function process_account in module decorator_wraps_2:
process_account(account_id)
    Process an account by Id. 

并验证其限定名称是否正确,如下所示:

>>> process_account.__qualname__
'process_account' 

最重要的是,我们恢复了可能在 docstring 上进行的单元测试!通过使用wraps装饰器,我们还可以访问__wrapped__属性下原始的、未修改的函数。虽然它不应该在生产中使用,但当我们想要检查函数的未修改版本时,它在一些单元测试中可能会派上用场。

一般来说,对于简单的装饰者,我们使用functools.wraps的方式通常遵循以下一般公式或结构:

def decorator(original_function):
    @wraps(original_function)
    def decorated_function(*args, **kwargs):
        # modifications done by the decorator ...
        return original_function(*args, **kwargs)
    return decorated_function 

在创建装饰器时,始终使用应用于包装函数的functools.wraps,如前一公式所示。

处理装饰师的副作用

在本节中,我们将了解到避免装饰者体内的副作用是明智的。在某些情况下,这可能是可以接受的,但底线是,如果有疑问,出于前面解释的原因,决定反对。除了要装饰的函数之外,装饰器需要做的所有事情都应该放在最内部的函数定义中,否则在导入时会出现问题。尽管如此,有时需要(甚至需要)在导入时运行这些副作用,而其正面适用。

我们将看到这两种方法的示例,以及每种方法的适用范围。如果有疑问,请谨慎行事,并将所有副作用延迟到调用wrapped函数后的最后一刻。

接下来,我们将看到在wrapped函数之外放置额外的逻辑不是一个好主意。

装饰师对副作用的错误处理

让我们想象一下一个装饰器的情况,该装饰器创建的目的是在函数开始运行时记录日志,然后记录其运行时间:

def traced_function_wrong(function):
    logger.info("started execution of %s", function)
    start_time = time.time()
    @wraps(function)
    def wrapped(*args, **kwargs):
        result = function(*args, **kwargs)
        logger.info(
            "function %s took %.2fs",
            function,
            time.time() - start_time
        )
        return result
    return wrapped 

现在,我们将把 decorator 应用于常规函数,认为它可以正常工作:

@traced_function_wrong
def process_with_delay(callback, delay=0):
    time.sleep(delay)
    return callback() 

这个装饰器有一个微妙但关键的缺陷。

首先,让我们导入函数,多次调用它,看看会发生什么:

>>> from decorator_side_effects_1 import process_with_delay
INFO:started execution of <function process_with_delay at 0x...> 

只需导入函数,我们就会发现有问题。日志行不应该在那里,因为函数没有被调用。

现在,如果我们运行这个函数,看看它需要多长时间才能运行,会发生什么?实际上,我们希望多次调用同一个函数会得到类似的结果:

>>> main()
...
INFO:function <function process_with_delay at 0x> took 8.67s
>>> main()
...
INFO:function <function process_with_delay at 0x> took 13.39s
>>> main()
...
INFO:function <function process_with_delay at 0x> took 17.01s 

每次我们运行相同的功能,所需的时间就越来越长!此时,您可能已经注意到(现在很明显)错误。

记住 decorator 的语法。@traced_function_wrong实际上是指:

process_with_delay = traced_function_wrong(process_with_delay) 

这将在导入模块时运行。因此,函数中设置的时间将是导入模块的时间。连续调用将计算从运行时间到原始开始时间的时间差。它还将在错误的时刻记录,而不是在实际调用函数时。

幸运的是,修复也很简单,我们只需将代码移到wrapped函数中即可延迟其执行:

def traced_function(function):
    @functools.wraps(function)
    def wrapped(*args, **kwargs):
        logger.info("started execution of %s", function.__qualname__)
        start_time = time.time()
        result = function(*args, **kwargs)
        logger.info(
            "function %s took %.2fs",
            function.__qualname__,
            time.time() - start_time
        )
        return result
    return wrapped 

有了这个新版本,以前的问题就解决了。

如果装饰者的行为有所不同,结果可能会更糟。例如,如果它要求您记录事件并将它们发送到外部服务,那么它肯定会失败,除非在导入之前已经运行了配置,这是我们无法保证的。即使我们可以,这也是一种坏习惯。如果 decorator 有任何其他类型的副作用,例如从文件读取、解析配置等,同样适用。

需要有副作用的装饰

有时,对装饰师的副作用是必要的,我们不应该将其执行推迟到最后一分钟,因为这是装饰师工作所需机制的一部分。

当我们不想延迟 decorators 的副作用时,一个常见的场景是当我们需要将对象注册到模块中可用的公共注册表时。

例如,回到我们之前的event系统示例,我们现在只想在模块中提供一些事件,而不是所有事件。在事件层次结构中,我们可能希望有一些中间类,它们不是我们希望在系统上处理的实际事件,而是它们的一些派生类。

我们可以通过一个 decorator 显式地注册每个类,而不是根据是否要处理它来标记每个类。

在本例中,我们为与用户活动相关的所有事件创建了一个类。然而,这只是我们实际需要的事件类型的中间表,即UserLoginEventUserLogoutEvent

EVENTS_REGISTRY = {}
def register_event(event_cls):
    """Place the class for the event into the registry to make it     accessible in the module.
    """
    EVENTS_REGISTRY[event_cls.__name__] = event_cls
    return event_cls
class Event:
    """A base event object"""
class UserEvent:
    TYPE = "user"
@register_event
class UserLoginEvent(UserEvent):
    """Represents the event of a user when it has just accessed the system."""
@register_event
class UserLogoutEvent(UserEvent):
    """Event triggered right after a user abandoned the system.""" 

当我们查看前面的代码时,EVENTS_REGISTRY似乎是空的,但是从这个模块导入一些东西后,它将填充register_event装饰器下的所有类:

>>> from decorator_side_effects_2 import EVENTS_REGISTRY
>>> EVENTS_REGISTRY
{'UserLoginEvent': decorator_side_effects_2.UserLoginEvent,
 'UserLogoutEvent': decorator_side_effects_2.UserLogoutEvent} 

这可能看起来很难阅读,甚至有误导性,因为EVENTS_REGISTRY将在运行时有其最终值,就在模块导入之后,我们无法通过查看代码轻松预测其值。

虽然这是事实,但在某些情况下,这种模式是合理的。事实上,许多 web 框架或著名的库都使用它来工作、公开对象或使其可用。也就是说,如果您要在自己的项目中实现类似的东西,请注意这种风险:大多数情况下,首选替代解决方案。

同样,在这种情况下,装饰者并没有改变wrapped对象或以任何方式改变其工作方式。然而,这里需要注意的是,如果我们要做一些修改并定义一个修改wrapped对象的内部函数,那么可能仍然需要在其外部注册结果对象的代码。

注意在之外使用了这个词。它不一定意味着以前,它只是不属于同一个闭包的一部分;但是它在外部范围内,所以它不会延迟到运行时。

创建始终有效的装饰器

装饰师可能会应用几个不同的场景。还有一种情况是,我们需要对属于这些不同多个场景的对象使用相同的装饰器,例如,如果我们想重用装饰器并将其应用于函数、类、方法或静态方法。

如果我们创建 decorator,只考虑只支持我们想要装饰的第一种类型的对象,我们可能会注意到同一个 decorator 在不同类型的对象上并不同样有效。一个典型的例子是,我们创建一个要在函数上使用的装饰器,然后我们想将它应用于类的方法,结果却发现它不起作用。如果我们为一个方法设计了 decorator,然后希望它也适用于静态方法或类方法,则可能会出现类似的情况。

在设计 decorator 时,我们通常考虑重用代码,因此我们也希望将该 decorator 用于函数和方法。

用签名*args**kwargs定义我们的装饰器将使它们在所有情况下都能工作,因为这是我们可以拥有的最通用的签名类型。但是,有时我们可能不想使用它,而是根据原始函数的签名定义 decorator 包装函数,主要原因有两个:

  • 它与原始函数相似,因此可读性更高。
  • 它实际上需要处理一些参数,所以接收*args**kwargs并不方便。

考虑我们在代码库中有许多函数需要从一个参数创建一个特定对象的情况。例如,我们反复传递一个字符串,并用它初始化驱动程序对象。然后,我们认为我们可以通过使用一个装饰器来消除重复,该装饰器将负责相应地转换此参数。

在下一个示例中,我们假设DBDriver是一个知道如何连接和运行数据库操作的对象,但它需要一个连接字符串。我们在代码中使用的方法被设计为接收包含数据库信息的字符串,并要求我们始终创建一个DBDriver 实例。decorator 的想法是,它将自动取代这个转换。函数将继续接收字符串,但 decorator 将创建一个DBDriver并将其传递给函数,因此在内部我们可以假设我们直接接收到所需的对象。

在函数中使用此函数的示例如下所示:

# src/decorator_universal_1.py
from functools import wraps
from log import logger
class DBDriver:
    def __init__(self, dbstring: str) -> None:
        self.dbstring = dbstring
    def execute(self, query: str) -> str:
        return f"query {query} at {self.dbstring}"
def inject_db_driver(function):
    """This decorator converts the parameter by creating a ``DBDriver``
    instance from the database dsn string.
    """
    @wraps(function)
    def wrapped(dbstring):
        return function(DBDriver(dbstring))
    return wrapped
@inject_db_driver
def run_query(driver):
    return driver.execute("test_function") 

很容易验证,如果我们将一个字符串传递给函数,我们将通过一个实例DBDriver得到结果,因此装饰器按预期工作:

>>> run_query("test_OK")
'query test_function at test_OK' 

但是现在,我们想在类方法中重用同一个装饰器,我们发现了同样的问题:

class DataHandler:
    @inject_db_driver
    def run_query(self, driver):
        return driver.execute(self.__class__.__name__) 

我们尝试使用此装饰器,但却发现它不起作用:

>>> DataHandler().run_query("test_fails")
Traceback (most recent call last):
  ...
TypeError: wrapped() takes 1 positional argument but 2 were given 

有什么问题?

类中的方法定义了一个额外的参数-self

方法只是一种特殊类型的函数,它接收self(它们所定义的对象)作为第一个参数。

因此,在这种情况下,修饰符(设计为仅使用一个参数,名为dbstring)将解释self是所述参数,并在self处调用传递字符串的方法,而在第二个参数(即我们正在传递的字符串)处不调用任何内容。

为了解决这个问题,我们需要创建一个对方法和函数同样有效的 decorator,我们通过将其定义为一个 decorator 对象来实现协议描述符。

描述符在第 7 章生成器、迭代器和异步编程中有充分的解释,所以现在,我们可以将其作为一个让装饰器工作的方法。

解决方案是通过实现__get__方法,将 decorator 实现为类对象,并将该对象作为描述:

from functools import wraps
from types import MethodType
class inject_db_driver:
    """Convert a string to a DBDriver instance and pass this to the 
       wrapped function."""
    def __init__(self, function) -> None:
        self.function = function
        wraps(self.function)(self)
    def __call__(self, dbstring):
        return self.function(DBDriver(dbstring))
    def __get__(self, instance, owner):
        if instance is None:
            return self
        return self.__class__(MethodType(self.function, instance)) 

关于描述符的详细信息将在*第 6 章*中解释,通过描述符从我们的对象中获得更多信息,但就本例而言,我们现在可以说,它实际上是将其修饰的可调用对象重新绑定到方法,这意味着它将函数绑定到对象,然后用这个新的 callable 重新创建 decorator。

对于函数,它仍然有效,因为它根本不会调用__get__方法。

现在,我们对装饰器、如何编写装饰器以及避免常见问题有了更多的了解,是时候将装饰器提升到一个新的层次,看看我们如何利用所学知识来实现更好的软件了。

在前面的部分中,我们简要地讨论了这个主题,但是这些部分更接近于代码示例,因为建议提到了如何使代码的特定行(或部分)更具可读性。

从现在开始讨论的主题与更一般的设计原则有关。我们在前几章中已经讨论了其中的一些想法,但这里的展望是了解我们如何使用装饰器来实现这些目的。

组合重于继承

我们已经简要地讨论过,一般来说,最好是组合而不是继承,因为继承会带来一些使代码的组件更耦合的问题。

设计模式:可重用面向对象软件的元素(DESIG01)一书中,围绕设计模式的大部分思想都基于以下思想:

喜欢组合而不是类继承

在*第 2 章*Python 代码中,我介绍了使用魔术方法__getattr__动态解析对象属性的想法。我还举了一个例子,例如,如果外部框架需要,它可以用来根据命名约定自动解析属性。让我们探讨解决此问题的两种不同版本。

在本例中,假设我们正在与一个框架交互,该框架的命名约定是调用前缀为“resolve_”的属性来解析属性,但我们的域对象只有那些没有“resolve_”前缀的属性。

显然,我们不想为我们拥有的每个属性编写大量名为“resolve_x”的重复方法,因此第一个想法是利用前面提到的__getattr__神奇方法,并将其放置在基类中:

class BaseResolverMixin:
    def __getattr__(self, attr: str):
        if attr.startswith("resolve_"):
            *_, actual_attr = attr.partition("resolve_")
        else:
            actual_attr = attr
        try:
            return self.__dict__[actual_attr]
        except KeyError as e:
            raise AttributeError from e

@dataclass
class Customer(BaseResolverMixin):
    customer_id: str
    name: str
    address: str 

这会奏效,但我们能做得更好吗?

我们可以设计一个类装饰器来直接设置这个方法:

from dataclasses import dataclass

def _resolver_method(self, attr):
    """The resolution method of attributes that will replace __getattr__."""
    if attr.startswith("resolve_"):
        *_, actual_attr = attr.partition("resolve_")
    else:
        actual_attr = attr
    try:
        return self.__dict__[actual_attr]
    except KeyError as e:
        raise AttributeError from e

def with_resolver(cls):
    """Set the custom resolver method to a class."""
    cls.__getattr__ = _resolver_method
    return cls

@dataclass
@with_resolver
class Customer:
    customer_id: str
    name: str
    address: str 

两个版本都将符合以下行为:

>>> customer = Customer("1", "name", "address")
>>> customer.resolve_customer_id
'1'
>>> customer.resolve_name
'name' 

首先,我们将 resolve 方法作为一个独立的函数,它尊重原始__getattr__的外观特征(这就是为什么我甚至保留了self作为第一个参数的名称,目的是为了让该函数成为一个方法)。

代码的其余部分似乎相当简单。我们的 decorator 只将方法设置为它通过参数接收的类,然后我们将 decorator 应用于我们的类,而不必再使用继承。

这比前一个例子好多少?对于初学者,我们可以争辩说,decorator 的使用意味着我们使用的是组合(获取一个类,修改它,然后返回一个新的)而不是继承,因此我们的代码与一开始的基类耦合较少。

此外,我们可以说,在第一个示例中使用继承(通过 mixin 类)是相当虚构的。我们并没有使用继承来创建更专业化的类版本,只是为了利用__getattr__方法。这将有两个(互补的)原因:第一,继承不是重用代码的最佳方式。好的代码可以通过具有小的、内聚的抽象来重用,而不是创建层次结构。

第二,从前面的章节中记住,创建子类应该遵循专门化的思想,“是一种”关系。从概念的角度考虑,客户是否真的是BaseResolverMivin(顺便问一下,什么是?)。

为了进一步阐明第二点,假设您有这样一个层次结构:

class Connection: pass
class EncryptedConnection(Connection): pass 

在这种情况下,继承的使用可以说是正确的,毕竟,加密连接是一种更具体的连接。但更具体的BaseResolverMixin是什么?这是一个 mixin 类,因此它应该与其他类(使用多重继承)混合在层次结构中。在课堂上使用这种混合纯粹是出于实用目的,也是为了实现目的。不要误解我的意思,这是一本实用的书,所以你必须在你的专业经验中处理混合类,使用它们是很好的,但是如果我们能够避免这种纯粹的实现抽象,并用不损害我们的领域对象的东西(在本例中是Customer类)来代替它,那就更好了。

新设计还有另一个令人兴奋的功能,那就是可扩展性。我们已经了解了如何将装饰器参数化。想象一下,如果我们允许 decorator 设置任何解析器函数,而不仅仅是我们定义的函数,那么我们在设计中可以实现多大的灵活性。

干法原理与装饰

我们已经看到了装饰器如何允许我们将某些逻辑抽象为一个单独的组件。这样做的主要优点是,我们可以对不同的对象多次应用 decorator,以便重用代码。这遵循了不要重复自己的原则,因为我们只定义了一次特定的知识。

前面几节中实现的retry机制是一个很好的示例,它可以多次应用于重用代码。我们创建了一个装饰器并多次应用它,而不是让每个特定函数都包含自己的retry逻辑。一旦我们确保装饰器可以平等地使用方法和函数,这就有意义了。

定义如何表示事件的类装饰器也符合 DRY 原则,因为它为序列化事件的逻辑定义了一个特定的位置,而不需要复制分散在不同类中的代码。由于我们希望重用此装饰器并将其应用于许多类,因此它的开发(和复杂性)是值得的。

最后一句话是在尝试使用 decorator 以重用代码时要记住的重要一点,我们必须绝对确保我们实际上是在保存代码。

任何修饰符(特别是如果没有仔细设计的话)都会给代码添加另一个间接层次,从而增加复杂性。代码的读者可能希望遵循 decorator 的路径来完全理解函数的逻辑(尽管这些注意事项将在下一节中讨论),因此请记住,这种复杂性必须得到回报。如果不会有太多的重用,那么就不要选择装饰器,而选择更简单的选项(可能只是一个单独的函数或另一个小类就足够了)。

但是我们怎么知道什么是太多的重用呢?是否有规则来确定何时将现有代码重构为装饰器?Python 中没有特定于装饰器的东西,但是我们可以应用软件工程中的一般经验法则(GLASS 01),即在考虑创建一个类似于可重用组件的通用抽象之前,组件应该至少试用三次。同样的参考文献(GLASS 01)(我鼓励所有读者阅读软件工程的事实和谬误,因为这是一个很好的参考文献)也提出了这样一个观点,即创建可重用组件比创建简单组件要难三倍。

底线是,通过 decorators 重用代码是可以接受的,但只有在考虑以下因素时:

  • 不要从头开始创建装饰器。等到模式出现,装饰器的抽象变得清晰,然后重构。
  • 考虑到装饰器在实施之前必须多次应用(至少三次)。
  • 尽量减少装饰器中的代码。

由于我们已经从装饰者的角度重新审视了干式原理,我们仍然可以讨论应用于装饰者的关注点分离,如下一节所探讨的。

装饰器与关注点分离

上一个列表中的最后一点非常重要,它应该有自己的一部分。我们已经探讨了重用代码的思想,并注意到重用代码的一个关键要素是具有内聚组件。这意味着他们应该有最低限度的责任做一件事,只做一件事,并且把它做好。我们的组件越小,可重用性就越强,它们可以在不同的环境中应用得越多,而不会带来额外的行为,这些行为会导致耦合和依赖,从而使软件僵化。

为了向您展示这意味着什么,让我们重演我们在前面的示例中使用的一个装饰器。我们创建了一个 decorator,该 decorator 使用与以下类似的代码跟踪某些函数的执行:

def traced_function(function):
    @functools.wraps(function)
    def wrapped(*args, **kwargs):
        logger.info("started execution of %s", function.__qualname__)
        start_time = time.time()
        result = function(*args, **kwargs)
        logger.info(
            "function %s took %.2fs",
            function.__qualname__,
            time.time() - start_time
        )
        return result
    return wrapped 

现在,这个装潢师在工作时遇到了一个问题,它做的不止一件事。它记录了刚刚调用的特定函数,还记录了运行该函数所需的时间。每次我们使用这个装饰器,我们都要承担这两项责任,即使我们只想要其中一项。

这应该分解成更小的装饰师,每个人都有更具体和有限的责任:

def log_execution(function):
    @wraps(function)
    def wrapped(*args, **kwargs):
        logger.info("started execution of %s", function.__qualname__)
        return function(*kwargs, **kwargs)
    return wrapped
def measure_time(function):
    @wraps(function)
    def wrapped(*args, **kwargs):
        start_time = time.time()
        result = function(*args, **kwargs)
        logger.info(
            "function %s took %.2f",
            function.__qualname__,
            time.time() - start_time,
        )
        return result
    return wrapped 

请注意,只需将两者结合起来,就可以实现与我们之前相同的功能:

@measure_time
@log_execution
def operation():
    .... 

注意装饰器的应用顺序也很重要。

一个装饰师不得承担一项以上的责任。单一责任原则SRP同样适用于装修工。

最后,我们可以分析优秀的装饰师,了解他们在实践中的使用情况。下一节将通过分析 decorators 来总结我们在本章学到的内容。

优秀装修师分析

作为本章的结束语,让我们回顾一些好的装饰器示例,以及它们如何在 Python 本身以及流行的库中使用。这个想法是为了获得关于如何创建好的装饰器的指导方针。

在开始举例之前,让我们首先确定好的装饰师应该具备的特征:

  • 封装,或关注点分离:一个好的装饰者应该有效地将它所做的和它所装饰的区分开来。它不能是一个泄漏的抽象,这意味着 decorator 的客户机只能在黑盒模式下调用它,而不知道它实际上是如何实现其逻辑的。
  • 正交性:装饰者所做的应该是独立的,并且尽可能与装饰对象分离。
  • 可重用性:装饰符可以应用于多种类型,而不是只出现在一个函数的一个实例上,因为这意味着它可能只是一个函数。它必须足够通用。

Celery项目中可以找到一个很好的 decorator 示例,其中任务是通过将任务的 decorator 从应用程序应用到函数来定义的:

@app.task
def mytask():
   .... 

这是一个好的装饰器的原因之一是因为它非常擅长封装。库的用户只需要定义函数体,装饰器将自动将其转换为任务。@app.task装饰器当然包含了很多逻辑和代码,但这些都与mytask()的主体无关。它是对关注点的完全封装和分离,没有人需要查看装饰器所做的工作,因此它是一个正确的抽象,不会泄露任何细节。

decorator 的另一个常见用法是在 web 框架中(PyramidFlaskSanic,仅举几个例子),视图处理程序通过 decorator 注册到 URL:

@route("/", method=["GET"])
def view_handler(request):
 ... 

这类装饰师的考虑与以前相同;它们还提供了完全的封装,因为 web 框架的用户很少(如果有的话)需要知道@route装饰器在做什么。在本例中,我们知道装饰器正在做更多的事情,例如将这些函数注册到 URL 的映射器,并且它正在更改原始函数的签名,以便为我们提供一个更好的接口,该接口接收一个具有所有已设置信息的request对象。

前面的两个示例足以让我们注意到有关装饰器使用的其他一些内容。它们符合 API。这些框架库通过 decorator 向用户公开了它们的功能,事实证明 decorator 是定义干净编程接口的一种极好的方法。

这可能是我们考虑装饰师的最佳方式。很像在类装饰器的示例中告诉我们如何处理事件的属性,一个好的装饰器应该提供一个干净的接口,这样代码的用户就知道从装饰器期望得到什么,而不需要知道它是如何工作的,或者它的任何细节。

装饰器是 Python 中功能强大的工具,可以应用于许多事情,如类、方法、函数、生成器等等。我们已经演示了如何以不同的方式、出于不同的目的创建装饰师,并在此过程中得出了一些结论。

为函数创建装饰器时,请尝试使其签名与被装饰的原始函数匹配。与其使用泛型*args**kwargs,不如使签名与原始签名匹配,这样更易于阅读和维护,并且与原始函数更相似,因此该代码的读者会更熟悉它。

decorator 是重用代码和遵循 DRY 原则的非常有用的工具。然而,它们的有用性是有代价的,如果不明智地使用它们,其复杂性可能弊大于利。出于这个原因,我们强调当装饰器要应用多次(三次或更多次)时,应该使用装饰器。与干燥原则一样,我们接受分离关注点的思想,目标是使装饰者尽可能小。

decorator 的另一个很好的用途是创建更干净的接口,例如,通过将类的部分逻辑提取到 decorator 中来简化类的定义。从这个意义上讲,decorator 还通过向用户提供关于特定组件将要做什么的信息来帮助提高可读性,而不需要知道如何(封装)。

在下一章中,我们将了解 Python 描述符的另一个高级特性。特别是,我们将看到如何借助描述符,创建更好的装饰器,并解决本章中遇到的一些问题。

以下是您可以参考的信息列表:

教程来源于Github,感谢apachecn大佬的无私奉献,致敬!

技术教程推荐

Elasticsearch核心技术与实战 -〔阮一鸣〕

恋爱必修课 -〔李一帆〕

说透区块链 -〔自游〕

HarmonyOS快速入门与实战 -〔QCon+案例研习社〕

Kubernetes入门实战课 -〔罗剑锋〕

大厂设计进阶实战课 -〔小乔〕

快手 · 音视频技术入门课 -〔刘歧〕

云时代的JVM原理与实战 -〔康杨〕

程序员职业规划手册 -〔雪梅〕