您可以实际使用的 Python 装饰器的深入教程

深入了解 Python 的内部结构 — 简介 Python 和许多其他语言可以做的神奇事情之一就是装饰函数。装饰器可以修改函数的输入、输出以及函数本身的行为。 …

您可以实际使用的 Python 装饰器的深入教程

深入了解 Python 的内部结构

Introduction

使用 Python 和许多其他语言可以做的神奇事情之一就是装饰函数。装饰器可以修改函数的输入、输出以及函数本身的行为。最好的部分是您只需一行代码就可以完成所有这些操作,并且根本不需要修改函数语法!

要了解装饰器的工作原理以及如何为自己创建装饰器,您需要了解一些重要的 Python 概念。

因此,在我们开始编写装饰器之前,我们将深入学习一些 Python 内部结构,例如作用域和闭包。如果您熟悉这些概念,请跳过它们并转到第 5 部分,所有乐趣从这里开始!

Functions Are Objects

您会喜欢 Python 的众多优点之一是它能够将任何事物表示为对象,函数也不例外。对于第一次阅读本文的人来说,将函数作为参数传递给另一个函数可能看起来很奇怪,但这样做是完全合法的:

作为对象,函数是完全一样的:

  • strings
  • integers and floats
  • Pandas DataFrames
  • lists, tuples, dictionaries
  • os、datatime、numpy 等模块

您可以将函数分配给新变量并使用它来调用函数:

>>> new_func = my_func
>>> new_func()
Printing the function's argument

现在这个变量还包含函数的属性:

您还可以将每个函数存储在其他对象中,例如列表和字典,并调用它们:

Scope

考虑一下 Bob 和 Job 之间的对话:

  • 鲍勃:“乔恩,你昨天怎么没来上课?”
  • 乔恩:“我得了流感……”

不是最好的故事,但是当鲍勃问乔恩昨天缺席的原因时,我们知道他指的是站在他旁边的乔恩,而不是另一个国家的随机乔恩。作为人类不难注意到这一点,但是编程语言使用称为范围的东西来告诉我们在程序中引用的名称。

在 Python 中,名称可以是变量、函数、模块名称等。

考虑这两个变量:

>>> a = 24
>>> b = 42
>>> print(a)
24

在这里,print 可以毫不费力地告诉我们,我们指的是我们刚刚定义的 a。现在考虑一下:

>>> def foo():
... a = 100
... print(a)

如果我们运行 foo,你认为会发生什么?它会打印 24 还是 100?

>>> foo()
100

Python如何区分我们在函数开头定义的a?这就是范围变得有趣的地方,因为我们引入了不同的范围层:

上图显示了这个小脚本的作用域:

全局范围是脚本/程序的整体范围。与 a 和 b 具有相同缩进级别的变量、函数、模块将在全局范围内。例如, foo 函数在全局范围内,但它的变量 a 在 foo 的本地范围内。

在一个全局范围内,可以有许多本地范围。例如,for 循环和列表解析中的每个临时变量,上下文管理器的返回值将在其代码块内是本地的,无法从全局范围访问。

在全球范围之外还有更大的范围:

内置范围包含您使用 Python、pip 或 conda 安装的所有模块和包。

现在,让我们探讨另一个案例。在我们的 foo 函数中,我们想要修改 global a 的值。我们希望它是一个字符串,但是如果我们在 foo 中写入 a = ‘some text’,Python 将创建一个新变量而不更改全局 a。

Python 为我们提供了一个关键字,让我们可以指定我们引用的是全局范围内的名称:

编写 global 将让我们在全局范围内修改名称的值。

顺便说一句,坏消息,我在上图中遗漏了一级范围。在全局和本地之间,有一个级别我们没有涉及:

当我们有嵌套函数时,非局部作用域就会发挥作用:

在嵌套函数 outer 中,我们首先创建一个名为 my_var 的变量并将其分配给字符串 Python。然后我们决定创建一个新的内部函数,并希望为 my_var 分配一个新值——Data Science 并打印它。但是如果我们运行它,我们会看到 my_var 仍然分配给“Python”。我们不能使用 global 关键字,因为 my_var 不在全局范围内。

对于这种情况,您可以使用 nonlocal 关键字来访问外部函数(非本地)范围内的所有名称,但不能访问全局:

总之,作用域告诉 Python 解释器在我们的程序中查找名称的位置。单个脚本/程序中可以有四个级别的范围:

  • 内置:使用 Python、pip 和 conda 安装的所有包名
  • 全局:通用范围,所有在脚本中没有缩进的名字
  • Local:包含代码块中的局部变量,例如函数、循环、列表推导等。
  • 非本地:在嵌套函数的情况下,全局和本地之间的额外范围

Closures

在我解释装饰器是如何工作的之前,我们还需要谈谈闭包。让我们从一个例子开始:

我们在 foo 中创建一个嵌套函数 bar 并返回它。 bar 尝试打印 x 的值:

当我们编写 var = foo() 时,我们将 bar 函数分配给 var。现在 var 可以用来调用 bar。当我们调用它时,它会打印出 42。

>>> var()
42

但是等一下,var 是怎么知道 x 的呢? x 是在 foo 的范围内定义的,而不是在 bar 的范围内。你会认为 x 不能在 foo 的范围之外访问。这就是闭包的用武之地。

闭包是函数的内置内存,包含函数需要运行的所有非本地名称(在元组中)!

因此,当 foo 返回 bar 时,它附加了所有非局部变量 bar 需要在 foo 的范围之外运行。您可以使用 .__closure__ 属性访问函数的闭包:

一旦您以元组的形式访问函数的闭包,它将包含称为单元格的元素,其值具有单个非本地参数的值。根据函数的需要,闭包内可以有尽可能多的单元格:

在上面的示例中,变量 x、y、z 是子变量的非局部变量,因此它们被添加到函数的闭包中。任何其他名称,例如 value 和 outside 都不在闭包中,因为它们不在 nonlocal 范围内。

现在,考虑这个更棘手的例子:

var = 'dummy'

def parent(arg):
    
    def child():
        print(arg)
    
    return child
    
func = parent(var)

我们创建一个父函数,它接受一个参数和一个嵌套函数 child,它打印传递给 parent 的任何值。我们用 var (‘dummy’) 调用 parent 并将结果分配给 func。如果我们称之为:

>>> func()
dummy

正如预期的那样,它打印出“dummy”。现在让我们删除 var 并再次调用 func:

>>> # Delete 'var'
>>> del var
>>> # call func again
>>> func()
dummy

它仍然打印出“虚拟”。为什么?

你猜对了,它被添加到了闭包中!所以,当一个来自范围外层的值被添加到闭包中时,即使我们删除了原始值,它也会保持不变!

>>> func.__closure__[0].cell_contents‘dummy’

如果我们不删除 var 并更改其值,则闭包仍将包含其旧值:

var = 'dummy'

def parent(arg):
    
    def child():
        print(arg)
    
    return child

# Call it as is
my_func = parent(var)
my_func()

# Call after changing var
var = 'new_dummy'
my_func()

--------------

dummy
dummy

当我们在下一节讨论装饰器时,这个概念将很重要。

让我们复习一些概念以确保您理解:

  • 闭包是嵌套函数的内部存储器,它包含存储在元组中的所有非局部变量。
  • 一旦一个值存储在闭包中,它就可以被访问,但如果原始值被删除或修改,则不能被覆盖
  • 嵌套函数是在另一个函数中定义的函数,并遵循以下一般模式:
>>> def parent(arg):

... def child():
... print(arg)
...
... return child

Finally, Decorators

装饰器是修改另一个函数的函数。它们可以改变函数的输入、输出甚至行为。

让我们从一个非常简单的装饰器开始:

def add_one(func):
    
    def wrapper(a):
        return func(a + 1)
    return wrapper

现在,我们创建一个函数来平方传入的任何参数,并用 add_one 装饰它。 add_one 将 1 添加到传递函数的参数:

>>> @add_one
... def square(a):
...     return a ** 2

>>> square(5)
36

要将函数用作装饰器,只需将 @ 符号后跟装饰函数的名称放在函数定义的正上方。当我们将 5 传递给修饰的 square 函数时,它没有返回 25,而是生成 36,因为 add_one 接受 square 的参数,即 5,并将其加一并将其插入回 square:

>>> square(10)
121

现在,让我们仔细看看 add_one。

首先,让我们从 add_one 开始,它只返回传递给它的任何函数:

>>> def square(a):
...     return a ** 2
... 
>>> def add_one(func):
...     return func

>>> new_square = add_one(square)
>>> new_square(5)
25

对于我们的装饰器返回一个修改后的函数,定义一个嵌套函数来返回通常是有帮助的:

def add_one(func):
    # Define a new function to modify
    def child(a):
        # Return the result by calling `func` with `a`
        return func(a)
    # Return the nested function
    return child
    
>>> new_square = add_one(square)
>>> new_square(5)
25

我们的装饰器仍然什么都不做。在 add_one 中,我们定义了一个嵌套的子函数。 child 只接受一个参数并调用传递给 add_one 的任何函数。然后, add_one 返回孩子。

在这种嵌套子函数的情况下,我们假设传递给 add_one 的 func 采用与 child 完全相同数量的参数。

现在,我们可以让所有的魔法发生在子函数中。我们不想简单地调用 func,而是想通过将 1 添加到参数来修改其参数:

def add_one(func):
    # Define a new function to modify
    def child(a):
        # Add 1 to `a` and then, call
        return func(a + 1)
    # Return the nested function
    return child

注意 func(a + 1)?它正在调用传递给 add_one 的任何参数,并将 1 添加到参数中。这一次,我们将覆盖 square,而不是创建一个新变量来存储孩子:

>>> square = add_one(square)
>>> square(5)
36

现在,当我们通过 5 时,它返回 36 而不是 25。

即使我们覆盖它,它如何使用平方函数?好在我们学会了闭包,因为旧方块现在在子闭包内:

>>> square.__closure__[0].cell_contents
<function __main__.square(a)>

至此,我们的 add_one 函数就可以用作装饰器了。我们可以将 @add_one 放在 square 定义的正上方,然后看看奇迹发生了:

@add_one
def square(num):
    return num ** 2

>>> square(7) # returns 64
64

装饰器的真实示例

如果我不向您展示如何创建计时器装饰器,我认为这将是一种耻辱:

import time


def timer(func):
    """
    A decorator to calculate how long a function runs.
    
    Parameters
    ----------
    func: callable
      The function being decorated.
      
    Returns
    -------
    func: callable
      The decorated function.
    """
    def wrapper(*args, **kwargs):
        # Start the timer
        start = time.time()
        # Call the `func`
        result = func(*args, **kwargs)
        # End the timer
        end = time.time()
        
        print(f"{func.__name__} took {round(end - start, 4)} "
                "seconds to run!")
        return result
    return wrapper

这一次,请注意我们如何使用 *args 和 **kwargs。当我们不知道函数中位置和关键字参数的确切数量时使用它们,这在这种情况下是完美的,因为我们可以在任何类型的函数上使用计时器。

现在,您可以在任何函数上使用此装饰器来确定它运行多长时间。没有重复的代码!

@timer
def sleep(n):
    """
    Sleep for n seconds
    
    Parameters
    ----------
    n: int or float
      The number of seconds to wait
    """
    time.sleep(n)


>>> sleep(5)
sleep took 5.0006 seconds to run!

下一个非常有用的装饰器将是缓存装饰器。缓存装饰器非常适合计算量大的函数,您可以使用相同的参数多次调用这些函数。在闭包中缓存每个函数调用的结果将让我们在使用已知值调用装饰函数时立即返回结果:

def cache(func):
    """
    A decorator to cache/memoize func's restults
    
    Parameters
    ----------
    func: callable
      The function being decorated
    
    Returns
      func: callable
        The decorated function
    """
    # Create a dictionary to store results
    cache = {}  # this will be stored in closure because it is nonlocal
    
    def wrapper(*args, **kwargs):
        # Unpack args and kwargs intp a tuple to be used as dict keys
        keys = (tuple(args) + tuple(kwargs.keys()))
        # If not seen before
        if keys not in cache:
            # Store them in cache
            cache[keys] = func(*args, **kwargs)
        # Else return the recorded result
        return cache[keys]
    
    return wrapper

在主缓存函数中,我们希望创建一个字典,将元组中的所有参数存储为键及其结果。缓存字典看起来像这样:

cache = {
(arg1, arg2, arg3): func(arg1, arg2, arg3)
}

我们可以使用参数元组作为键,因为元组是不可变的对象。

现在,让我们看看如果我们用缓存和计时器装饰我们的睡眠函数会发生什么:

@timer
@cache
def sleep(n):
    """
    Sleep for n seconds
    
    Parameters
    ----------
    n: int or float
      The number of seconds to wait
    """
    time.sleep(n)

首先,让我们尝试睡眠 10 秒:

>>> sleep(10)
sleep took 10.0001 seconds to run!

不出所料,运行了 10 秒。现在,如果我们再次以 10 作为参数运行 sleep,你认为会发生什么:

>>> sleep(10)
sleep took 0.0 seconds to run!

花了0秒!我们的缓存装饰器有效!

接受争论的装饰器

到目前为止,我们对装饰器的了解非常扎实。然而,当你让装饰器接受参数时,装饰器的真正威力就来了。

考虑这个装饰器,它检查函数的结果是否为 str 类型:

def is_str(func):
    """
    A decorator to check if `func`'s result is a string
    """
    def wrapper(*args, **kwargs):
        # Call func
        result = func(*args, **kwargs)
        return type(result) == str
    return wrapper

我们在一个虚拟函数上调用它来检查它是否有效:

>>> @is_str
... def foo(arg):
...     return arg

>>> foo(4)
False

>>> foo("Python")
True

这是工作。但是,如果我们有办法检查函数的返回类型是否为任何数据类型,那不是很酷吗?在这里,检查一下:

def is_type(dtype):
    """
    Defines a decorator and returns it.
    """
    def decorator(func):
        """
        A decorator to check if func's result is of type `dtype`
        """
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            return type(result) == dtype
        return wrapper
    return decorator

@is_type(dict)
def foo(arg):
    return arg

>>> foo({1: 'Python'})
True

@is_type(int)
def square(num):
    return num ** 2

>>> square(12)
True

使用这种类型的装饰器,您可以为所有函数编写数据类型检查。让我们从头开始构建它。

首先,让我们创建一个简单的装饰器来调用传递给它的任何函数:

def decorator(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result
    return wrapper

我们如何调整这段代码,使其也接受自定义数据类型并检查 func 的结果?我们不能添加额外的参数装饰器,因为装饰器应该只接受一个函数作为参数。

我们定义了一个更大的父函数,它返回一个装饰器来解决这个问题。这样,我们可以将任何参数传递给父函数,而父函数又可以在装饰器中使用:

请注意我们如何将装饰器包装在更大的父函数中?它接受一个数据类型作为参数,将它传递给我们的装饰器,然后返回它。在包装器中,我们编写了 type(result) == dtype ,它计算数据类型是否匹配为 True 或 False。现在您可以使用此函数对任何函数执行类型检查:

@is_type(tuple)
def return_tuple(arg):
    return arg

>>> return_tuple((1, 2, 3, 4))
True

>>> return_tuple('Hello')
False

保留装饰函数的元数据

到目前为止,我们从未检查过一件事——装饰函数是否以所有方式保留?例如,让我们回到我们用定时器装饰的 sleep 函数:

让我们调用它并检查它的元数据:

# Call the function for an example
>>> sleep(3)
sleep took 3.0005 seconds to run!

# Checking metadata
# Extracting the doc string
>>> sleep.__doc__
None
# Get the default arguments
>>> sleep.__defaults__
None
# Get the name
>>> sleep.__name__
'wrapper'

我们检查函数的三个元数据属性。前两个返回 None ,但他们应该产生一些东西。我的意思是,sleep 有一个很长的文档字符串和一个等于 5 的默认参数。它们去了哪里?当我们调用 __name__ 并获得函数名的包装器时,我们得到了答案。

如果我们检查计时器的定义:

def timer(func):
    """A decorator to calculate how long a function runs.
    """
    def wrapper(*args, **kwargs):
        # Start the timer
        start = time.time()
        # Call the `func`
        result = func(*args, **kwargs)
        # End the timer
        end = time.time()
        
        print(f"{func.__name__} took {round(end - start, 4)} seconds to run!")
        return result
    return wrapper

我们可以看到我们实际上并没有返回传递的函数,而是在包装器中返回它。显然 wrapper 没有文档字符串或任何默认参数,这就是我们在上面得到 None 的原因。

为了解决这个问题,Python 为我们提供了一个来自 functools 模块的有用函数:

from functools import wraps


def timer(func):
    """A decorator to calculate how long a function runs.
    """
    @wraps(func)
    def wrapper(*args, **kwargs):
        # Start the timer
        start = time.time()
        # Call the `func`
        result = func(*args, **kwargs)
        # End the timer
        end = time.time()
        
        print(f"{func.__name__} took {round(end - start, 4)} seconds to run!")
        return result
    return wrapper

在 wrapper 函数上使用 wraps 可以让我们保留所有附加到 func 的元数据。请注意我们如何将 func 传递给函数定义之上的 wraps。

如果我们使用这个修改后的计时器版本,我们将看到它按预期工作:

@timer
def sleep(n=5):
    """
    A function to sleep for n seconds.
    
    Parameters
    ----------
    n: int
      Number of seconds to sleep.
    """
    time.sleep(n)

>>> sleep.__name__
'sleep'

>>> sleep.__doc__.strip()
'A function to sleep for n seconds.\n    \n    '
'Parameters\n    ----------\n    n: int\n      '
'Number of seconds to sleep.'

使用 wraps(func) 是编写装饰器的好习惯,所以将它添加到我们今天定义的所有装饰器中!

Conclusion

阅读这篇文章后,您对创建装饰器有了深入的了解。更重要的是,您知道它们是如何工作的以及它们的工作方式。

最后一点,我建议每当您重复代码在您的函数上执行类似任务时使用装饰器。装饰器可以是使您的代码干燥的另一个步骤(不要重复自己)。

感谢您的阅读!

阅读我的更多故事:

文章出处登录后可见!

已经登录?立即刷新

共计人评分,平均

到目前为止还没有投票!成为第一位评论此文章。

(0)
心中带点小风骚的头像心中带点小风骚普通用户
上一篇 2022年5月12日
下一篇 2022年5月12日

相关推荐