Python中使用装饰器的三个技巧

装饰器(Decorator) 是 Python 里的一种特殊工具,它为我们提供了一种在函数外部修改函数的灵活能力。它有点像一顶画着独一无二 @ 符号的神奇帽子,只要将它戴在函数头顶上,就能悄无声息的改变函数本身的行为。

你可能已经和装饰器打过不少交道了。在做面向对象编程时,我们就经常会用到 @staticmethod 和 @classmethod 两个内置装饰器。此外,如果你接触过 click 模块,就更不会对装饰器感到陌生。click 最为人所称道的参数定义接口 @click.option(...) 就是利用装饰器实现的。

除了用装饰器,我们也经常需要自己写一些装饰器。在这篇文章里,我将从 最佳实践 和 常见错误 两个方面,来与你分享有关装饰器的一些小知识。

1. 尝试用类来实现装饰器

绝大多数装饰器都是基于函数和 闭包 实现的,但这并非制造装饰器的唯一方式。事实上,Python 对某个对象是否能通过装饰器(@decorator)形式使用只有一个要求:decorator 必须是一个“可被调用(callable)的对象。

# 使用 callable 可以检测某个对象是否“可被调用”

>>> def foo(): pass

...

>>> type(foo)

<class'function'>

>>> callable(foo)

True

函数自然是“可被调用”的对象。但除了函数外,我们也可以让任何一个类(class)变得“可被调用”(callable)。办法很简单,只要自定义类的 __call__ 魔法方法即可。

classFoo:

    def __call__(self):

        print("Hello, __call___")

foo = Foo()

# OUTPUT: True

print(callable(foo))

# 调用 foo 实例

# OUTPUT: Hello, __call__

foo()

基于这个特性,我们可以很方便的使用类来实现装饰器。

下面这段代码,会定义一个名为@delay(duration) 的装饰器,使用它装饰过的函数在每次执行前,都会等待额外的 duration 秒。同时,我们也希望为用户提供无需等待马上执行的 eager_call 接口。

import time

import functools

classDelayFunc:

    def __init__(self,  duration, func):

        self.duration = duration

        self.func = func

def __call__(self, *args, **kwargs):

        print(f'Wait for {self.duration} seconds...')

        time.sleep(self.duration)

        returnself.func(*args, **kwargs)

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

        print('Call without delay')

        returnself.func(*args, **kwargs)

def delay(duration):

    """装饰器:推迟某个函数的执行。同时提供 .eager_call 方法立即执行

    """

    # 此处为了避免定义额外函数,直接使用 functools.partial 帮助构造

    # DelayFunc 实例

    returnfunctools.partial(DelayFunc, duration)

如何使用装饰器的样例代码:

@delay(duration=2)

def add(a, b):

    returna + b

# 这次调用将会延迟 2 秒

add(1, 2)

# 这次调用将会立即执行

add.eager_call(1, 2)

@delay(duration) 就是一个基于类来实现的装饰器。当然,如果你非常熟悉 Python 里的函数和闭包,上面的 delay 装饰器其实也完全可以只用函数来实现。所以,为什么我们要用类来做这件事呢?

与纯函数相比,我觉得使用类实现的装饰器在特定场景下有几个优势:

• 实现有状态的装饰器时,操作类属性比操作闭包内变量更符合直觉、不易出错

• 实现为函数扩充接口的装饰器时,使用类包装函数,比直接为函数对象追加属性更易于维护

• 更容易实现一个同时兼容装饰器与上下文管理器协议的对象(参考 unitest.mock.patch)

2. 使用 wrapt 模块编写更扁平的装饰器

在写装饰器的过程中,你有没有碰到过什么不爽的事情?不管你有没有,反正我有。我经常在写代码的时候,被下面两件事情搞得特别难受:

1. 实现带参数的装饰器时,层层嵌套的函数代码特别难写、难读

2. 因为函数和类方法的不同,为前者写的装饰器经常没法直接套用在后者上

比如,在下面的例子里,我实现了一个生成随机数并注入为函数参数的装饰器。

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 作为第一个参数追加后调用函数

            returnfunc(num, *args, **kwargs)

        returndecorated

    returnwrapper

@provide_number(1, 100)

def print_random_number(num):

    print(num)

# 输出 1-100 的随机整数

# OUTPUT: 72

print_random_number()

@provide_number 装饰器功能看上去很不错,但它有着我在前面提到的两个问题:嵌套层级深、无法在类方法上使用。如果直接用它去装饰类方法,会出现下面的情况:

classFoo:

    @provide_number(1, 100)

    def print_random_number(self, num):

        print(num)

# OUTPUT: <__main__.Foo object at 0x104047278>

Foo().print_random_number()

Foo 类实例中的 print_random_number 方法将会输出类实例 self ,而不是我们期望的随机数 num。

之所以会出现这个结果,是因为类方法(method)和函数(function)二者在工作机制上有着细微不同。如果要修复这个问题,provider_number 装饰器在修改类方法的位置参数时,必须聪明的跳过藏在 *args 里面的类实例 self 变量,才能正确的将 num 作为第一个参数注入。

这时,就应该是 wrapt 模块闪亮登场的时候了。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

        returnwrapped(*args, **kwargs)

    returnwrapper

<... 应用装饰器部分代码省略 ...>

# OUTPUT: 48

Foo().print_random_number()

使用 wrapt 模块编写的装饰器,相比原来拥有下面这些优势:

• 嵌套层级少:使用 @wrapt.decorator 可以将两层嵌套减少为一层;

• 更简单:处理位置与关键字参数时,可以忽略类实例等特殊情况;

• 更灵活:针对 instance 值进行条件判断后,更容易让装饰器变得通用。

常见错误

1. “装饰器”并不是“装饰器模式”

“设计模式”是一个在计算机世界里鼎鼎大名的词。假如你是一名 Java 程序员,而你一点设计模式都不懂,那么我打赌你找工作的面试过程一定会度过的相当艰难。

但写 Python 时,我们极少谈起“设计模式”。虽然 Python 也是一门支持面向对象的编程语言,但它的 鸭子类型设计以及出色的动态特性决定了,大部分设计模式对我们来说并不是必需品。所以,很多 Python 程序员在工作很长一段时间后,可能并没有真正应用过几种设计模式。

不过 “装饰器模式(Decorator Pattern)” 是个例外。因为 Python 的“装饰器”和“装饰器模式”有着一模一样的名字,我不止一次听到有人把它们俩当成一回事,认为使用“装饰器”就是在实践“装饰器模式”。但事实上,它们是两个完全不同的东西。

“装饰器模式”是一个完全基于“面向对象”衍生出的编程手法。它拥有几个关键组成:一个统一的接口定义、若干个遵循该接口的类、类与类之间一层一层的包装。最终由它们共同形成一种“装饰”的效果。

而 Python 里的“装饰器”和“面向对象”没有任何直接联系,它完全可以只是发生在函数和函数间的把戏。事实上,“装饰器”并没有提供某种无法替代的功能,它仅仅就是一颗“语法糖”而已。下面这段使用了装饰器的代码:

@log_time

@cache_result

def foo(): pass

基本完全等同于下面这样:

def foo(): pass

foo = log_time(cache_result(foo))

装饰器最大的功劳,在于让我们在某些特定场景时,可以写出更符合直觉、易于阅读的代码。它只是一颗“糖”,并不是某个面向对象领域的复杂编程模式。

Hint: 在 Python 官网上有一个 实现了装饰器模式的例子,你可以读读这个例子来更好的了解它。

2. 记得用 functools.wraps() 装饰内层函数

下面是一个简单的装饰器,专门用来打印函数调用耗时:

import time

def timer(wrapped):

    """装饰器:记录并打印函数耗时"""

    def decorated(*args, **kwargs):

        st = time.time()

        ret = wrapped(*args, **kwargs)

        print('execution take: {} seconds'.format(time.time() - st))

        returnret

    returndecorated

@timer

def random_sleep():

    """随机睡眠一小会"""

    time.sleep(random.random())

timer 装饰器虽然没有错误,但是使用它装饰函数后,函数的原始签名就会被破坏。也就是说你再也没办法正确拿到 random_sleep 函数的名称、文档内容了,所有签名都会变成内层函数 decorated 的值:

print(random_sleep.__name__)

# 输出 'decorated'

print(random_sleep.__doc__)

# 输出 None

这虽然只是个小问题,但在某些时候也可能会导致难以察觉的 bug。幸运的是,标准库 functools 为它提供了解决方案,你只需要在定义装饰器时,用另外一个装饰器再装饰一下内层 decorated 函数就行。

听上去有点绕,但其实就是新增一行代码而已:

def timer(wrapped):

    # 将 wrapper 函数的真实签名赋值到 decorated 上

    @functools.wraps(wrapped)

    def decorated(*args, **kwargs):

        # <...> 已省略

    returndecorated

这样处理后,timer 装饰器就不会影响它所装饰的函数了。

print(random_sleep.__name__)

# 输出 'random_sleep'

print(random_sleep.__doc__)

# 输出 '随机睡眠一小会'

3. 修改外层变量时记得使用 nonlocal

装饰器是对函数对象的一个高级应用。在编写装饰器的过程中,你会经常碰到内层函数需要修改外层函数变量的情况。就像下面这个装饰器一样:

import functools

def counter(func):

    """装饰器:记录并打印调用次数"""

    count= 0

    @functools.wraps(func)

    def decorated(*args, **kwargs):

        # 次数累加

        count+= 1

        print(f"Count: {count}")

        returnfunc(*args, **kwargs)

    returndecorated

@counter

def foo():

    pass

foo()

为了统计函数调用次数,我们需要在 decorated 函数内部修改外层函数定义的 count 变量的值。但是,上面这段代码是有问题的,在执行它时解释器会报错:

Traceback (most recent call last):

  File "counter.py", line 22, in <module>

    foo()

  File "counter.py", line 11, in decorated

    count+= 1

UnboundLocalError: local variable 'count'referenced before assignment

这个错误是由 counter 与 decorated 函数互相嵌套的作用域引起的。

当解释器执行到 count += 1 时,并不知道 count 是一个在外层作用域定义的变量,它把 count 当做一个局部变量,并在当前作用域内查找。最终却没有找到有关 count 变量的任何定义,然后抛出错误。

为了解决这个问题,我们需要通过 nonlocal 关键字告诉解释器:“count 变量并不属于当前的 local 作用域,去外面找找吧”,之前的错误就可以得到解决。

def decorated(*args, **kwargs):

    nonlocal count

    count+= 1

    # <... 已省略 ...>

Hint:如果要了解更多有关 nonlocal 关键字的历史,可以查阅 PEP-3104

总结

在这篇文章里分享了有关装饰器的一些技巧与小知识。

一些要点总结:

• 一切 callable 的对象都可以被用来实现装饰器

• 混合使用函数与类,可以更好的实现装饰器

• wrapt 模块很有用,用它可以帮助我们用更简单的代码写出复杂装饰器

• “装饰器”只是语法糖,它不是“装饰器模式”

• 装饰器会改变函数的原始签名,你需要 functools.wraps

• 在内层函数修改外层函数的变量时,需要使用 nonlocal 关键字

本文转自:https://www.py.cn/jishu/gaoji/10315.html

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,921评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,635评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,393评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,836评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,833评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,685评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,043评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,694评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,671评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,670评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,779评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,424评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,027评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,984评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,214评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,108评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,517评论 2 343

推荐阅读更多精彩内容

  • Python史上最全开发总结(转自静熙老师哈哈哈) 两本不错的书: 《Python参考手册》:对Python各个标...
    春风在抱阅读 754评论 1 4
  • 写在前面的话 代码中的# > 表示的是输出结果 输入 使用input()函数 用法 注意input函数输出的均是字...
    FlyingLittlePG阅读 2,730评论 0 8
  • Python语言特性 1 Python的函数参数传递 看两个如下例子,分析运行结果: 代码一: a = 1 def...
    伊森H阅读 3,048评论 0 15
  • 1.1==,is的使用 ·is是比较两个引用是否指向了同一个对象(引用比较)。 ·==是比较两个对象是否相等。 1...
    TENG书阅读 724评论 0 0
  • 每个人都有的内裤主要功能是用来遮羞,但是到了冬天它没法为我们防风御寒,咋办?我们想到的一个办法就是把内裤改造一下,...
    chen_000阅读 1,360评论 0 3