Python 工匠:使用装饰器的技巧

论坛 期权论坛 期权     
Python开发者   2019-7-20 20:12   5578   0
(给Python开发者加星标,提升Python技能)
作者:piglei (本文来自作者投稿)
[h1]前言[/h1]装饰器(Decorator) 是 Python 里的一种特殊工具,它为我们提供了一种在函数外部修改函数的灵活能力。它有点像一顶画着独一无二
  1. @
复制代码
符号的神奇帽子,只要将它戴在函数头顶上,就能悄无声息的改变函数本身的行为。
你可能已经和装饰器打过不少交道了。在做面向对象编程时,我们就经常会用到
  1. @staticmethod
复制代码
  1. @classmethod
复制代码
两个内置装饰器。此外,如果你接触过 click 模块,就更不会对装饰器感到陌生。click 最为人所称道的参数定义接口
  1. @click.option(...)
复制代码
就是利用装饰器实现的。
除了用装饰器,我们也经常需要自己写一些装饰器。在这篇文章里,我将从
  1. 最佳实践
复制代码
  1. 常见错误
复制代码
两个方面,来与你分享有关装饰器的一些小知识。
[h1]最佳实践[/h1][h2]1. 尝试用类来实现装饰器[/h2]绝大多数装饰器都是基于函数和 闭包 实现的,但这并非制造装饰器的唯一方式。事实上,Python 对某个对象是否能通过装饰器(
  1. @decorator
复制代码
)形式使用只有一个要求:decorator 必须是一个“可被调用(callable)的对象。
    1. # 使用 callable 可以检测某个对象是否“可被调用”
    复制代码
    1. >>> def foo(): pass
    复制代码
    1. ...
    复制代码
    1. >>> type(foo)
    复制代码
    1. [/code]
    2. [*][code]>>> callable(foo)
    复制代码
    1. True
    复制代码
函数自然是“可被调用”的对象。但除了函数外,我们也可以让任何一个类(class)变得“可被调用”(callable)。办法很简单,只要自定义类的
  1. __call__
复制代码
魔法方法即可。
    1. class Foo:
    复制代码
    1.     def __call__(self):
    复制代码
    1.         print("Hello, __call___")
    复制代码

    1. foo = Foo()
    复制代码

    1. # OUTPUT: True
    复制代码
    1. print(callable(foo))
    复制代码
    1. # 调用 foo 实例
    复制代码
    1. # OUTPUT: Hello, __call__
    复制代码
    1. foo()
    复制代码
基于这个特性,我们可以很方便的使用类来实现装饰器。
下面这段代码,会定义一个名为
  1. @delay(duration)
复制代码
的装饰器,使用它装饰过的函数在每次执行前,都会等待额外的
  1. duration
复制代码
秒。同时,我们也希望为用户提供无需等待马上执行的
  1. eager_call
复制代码
接口。
    1. import time
    复制代码
    1. import functools
    复制代码


    1. class DelayFunc:
    复制代码
    1.     def __init__(self,  duration, func):
    复制代码
    1.         self.duration = duration
    复制代码
    1.         self.func = func
    复制代码

    1.     def __call__(self, *args, **kwargs):
    复制代码
    1.         print(f'Wait for {self.duration} seconds...')
    复制代码
    1.         time.sleep(self.duration)
    复制代码
    1.         return self.func(*args, **kwargs)
    复制代码

    1.     def eager_call(self, *args, **kwargs):
    复制代码
    1.         print('Call without delay')
    复制代码
    1.         return self.func(*args, **kwargs)
    复制代码


    1. def delay(duration):
    复制代码
    1.     """装饰器:推迟某个函数的执行。同时提供 .eager_call 方法立即执行
    复制代码
    1.     """
    复制代码
    1.     # 此处为了避免定义额外函数,直接使用 functools.partial 帮助构造
    复制代码
    1.     # DelayFunc 实例
    复制代码
    1.     return functools.partial(DelayFunc, duration)
    复制代码
如何使用装饰器的样例代码:
    1. @delay(duration=2)
    复制代码
    1. def add(a, b):
    复制代码
    1.     return a + b
    复制代码


    1. # 这次调用将会延迟 2 秒
    复制代码
    1. add(1, 2)
    复制代码
    1. # 这次调用将会立即执行
    复制代码
    1. add.eager_call(1, 2)
    复制代码
  1. @delay(duration)
复制代码
就是一个基于类来实现的装饰器。当然,如果你非常熟悉 Python 里的函数和闭包,上面的
  1. delay
复制代码
装饰器其实也完全可以只用函数来实现。所以,为什么我们要用类来做这件事呢?
与纯函数相比,我觉得使用类实现的装饰器在特定场景下有几个优势:
  • 实现有状态的装饰器时,操作类属性比操作闭包内变量更符合直觉、不易出错
  • 实现为函数扩充接口的装饰器时,使用类包装函数,比直接为函数对象追加属性更易于维护
  • 更容易实现一个同时兼容装饰器与上下文管理器协议的对象(参考 unitest.mock.patch)
[h2]2. 使用 wrapt 模块编写更扁平的装饰器[/h2]在写装饰器的过程中,你有没有碰到过什么不爽的事情?不管你有没有,反正我有。我经常在写代码的时候,被下面两件事情搞得特别难受:
  • 实现带参数的装饰器时,层层嵌套的函数代码特别难写、难读
  • 因为函数和类方法的不同,为前者写的装饰器经常没法直接套用在后者上
比如,在下面的例子里,我实现了一个生成随机数并注入为函数参数的装饰器。
    1. import random
    复制代码


    1. def provide_number(min_num, max_num):
    复制代码
    1.     """装饰器:随机生成一个在 [min_num, max_num] 范围的整数,追加为函数的第一个位置参数
    复制代码
    1.     """
    复制代码
    1.     def wrapper(func):
    复制代码
    1.         def decorated(*args, **kwargs):
    复制代码
    1.             num = random.randint(min_num, max_num)
    复制代码
    1.             # 将 num 作为第一个参数追加后调用函数
    复制代码
    1.             return func(num, *args, **kwargs)
    复制代码
    1.         return decorated
    复制代码
    1.     return wrapper
    复制代码



    1. @provide_number(1, 100)
    复制代码
    1. def print_random_number(num):
    复制代码
  • 复制代码

    1. # 输出 1-100 的随机整数
    复制代码
    1. # OUTPUT: 72
    复制代码
    1. print_random_number()
    复制代码
  1. @provide_number
复制代码
装饰器功能看上去很不错,但它有着我在前面提到的两个问题:嵌套层级深、无法在类方法上使用。如果直接用它去装饰类方法,会出现下面的情况:
    1. class Foo:
    复制代码
    1.     @provide_number(1, 100)
    复制代码
    1.     def print_random_number(self, num):
    复制代码
    1.         print(num)
    复制代码

    1. # OUTPUT:
    复制代码
    1. Foo().print_random_number()
    复制代码
  1. Foo
复制代码
类实例中的
  1. print_random_number
复制代码
方法将会输出类实例
  1. self
复制代码
,而不是我们期望的随机数
  1. num
复制代码

之所以会出现这个结果,是因为类方法(method)和函数(function)二者在工作机制上有着细微不同。如果要修复这个问题,
  1. provider_number
复制代码
装饰器在修改类方法的位置参数时,必须聪明的跳过藏在
  1. *args
复制代码
里面的类实例
  1. self
复制代码
变量,才能正确的将
  1. num
复制代码
作为第一个参数注入。
这时,就应该是 wrapt 模块闪亮登场的时候了。
  1. wrapt
复制代码
模块是一个专门帮助你编写装饰器的工具库。利用它,我们可以非常方便的改造
  1. provide_number
复制代码
装饰器,完美解决“嵌套层级深”和“无法通用”两个问题,
    1. import wrapt
    复制代码

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

    1. [/code]
    2. [*]
    3. [*][code]# OUTPUT: 48
    复制代码
    1. Foo().print_random_number()
    复制代码
使用
  1. wrapt
复制代码
模块编写的装饰器,相比原来拥有下面这些优势:
  • 嵌套层级少:使用
    1. @wrapt.decorator
    复制代码
    可以将两层嵌套减少为一层
  • 更简单:处理位置与关键字参数时,可以忽略类实例等特殊情况
  • 更灵活:针对
    1. instance
    复制代码
    值进行条件判断后,更容易让装饰器变得通用
[h1]常见错误[/h1][h2]1. “装饰器”并不是“装饰器模式”[/h2]“设计模式”是一个在计算机世界里鼎鼎大名的词。假如你是一名 Java 程序员,而你一点设计模式都不懂,那么我打赌你找工作的面试过程一定会度过的相当艰难。
但写 Python 时,我们极少谈起“设计模式”。虽然 Python 也是一门支持面向对象的编程语言,但它的 鸭子类型 设计以及出色的动态特性决定了,大部分设计模式对我们来说并不是必需品。所以,很多 Python 程序员在工作很长一段时间后,可能并没有真正应用过几种设计模式。
不过 “装饰器模式(Decorator Pattern)” 是个例外。因为 Python 的“装饰器”和“装饰器模式”有着一模一样的名字,我不止一次听到有人把它们俩当成一回事,认为使用“装饰器”就是在实践“装饰器模式”。但事实上,它们是两个完全不同的东西。
“装饰器模式”是一个完全基于“面向对象”衍生出的编程手法。它拥有几个关键组成:一个统一的接口定义、若干个遵循该接口的类、类与类之间一层一层的包装。最终由它们共同形成一种“装饰”的效果。
而 Python 里的“装饰器”和“面向对象”没有任何直接联系,它完全可以只是发生在函数和函数间的把戏。事实上,“装饰器”并没有提供某种无法替代的功能,它仅仅就是一颗“语法糖”而已。下面这段使用了装饰器的代码:
    1. @log_time
    复制代码
    1. @cache_result
    复制代码
    1. def foo(): pass
    复制代码
基本完全等同于下面这样:
    1. def foo(): pass
    复制代码

    1. foo = log_time(cache_result(foo))
    复制代码
装饰器最大的功劳,在于让我们在某些特定场景时,可以写出更符合直觉、易于阅读的代码。它只是一颗“糖”,并不是某个面向对象领域的复杂编程模式。
Hint: 在 Python 官网上有一个 实现了装饰器模式的例子,你可以读读这个例子来更好的了解它。
[h2]2. 记得用 functools.wraps() 装饰内层函数[/h2]下面是一个简单的装饰器,专门用来打印函数调用耗时:
    1. import time
    复制代码


    1. def timer(wrapped):
    复制代码
    1.     """装饰器:记录并打印函数耗时"""
    复制代码
    1.     def decorated(*args, **kwargs):
    复制代码
    1.         st = time.time()
    复制代码
    1.         ret = wrapped(*args, **kwargs)
    复制代码
    1.         print('execution take: {} seconds'.format(time.time() - st))
    复制代码
    1.         return ret
    复制代码
    1.     return decorated
    复制代码


    1. @timer
    复制代码
    1. def random_sleep():
    复制代码
    1.     """随机睡眠一小会"""
    复制代码
    1.     time.sleep(random.random())
    复制代码
  1. timer
复制代码
装饰器虽然没有错误,但是使用它装饰函数后,函数的原始签名就会被破坏。也就是说你再也没办法正确拿到
  1. random_sleep
复制代码
函数的名称、文档内容了,所有签名都会变成内层函数
  1. decorated
复制代码
的值:
    1. print(random_sleep.__name__)
    复制代码
    1. # 输出 'decorated'
    复制代码
    1. print(random_sleep.__doc__)
    复制代码
    1. # 输出 None
    复制代码
这虽然只是个小问题,但在某些时候也可能会导致难以察觉的 bug。幸运的是,标准库
  1. functools
复制代码
为它提供了解决方案,你只需要在定义装饰器时,用另外一个装饰器再装饰一下内层
  1. decorated
复制代码
函数就行。
听上去有点绕,但其实就是新增一行代码而已:
    1. def timer(wrapped):
    复制代码
    1.     # 将 wrapper 函数的真实签名赋值到 decorated 上
    复制代码
    1.     @functools.wraps(wrapped)
    复制代码
    1.     def decorated(*args, **kwargs):
    复制代码
    1.         #  已省略
    复制代码
    1.     return decorated
    复制代码
这样处理后,
  1. timer
复制代码
装饰器就不会影响它所装饰的函数了。
    1. print(random_sleep.__name__)
    复制代码
    1. # 输出 'random_sleep'
    复制代码
    1. print(random_sleep.__doc__)
    复制代码
    1. # 输出 '随机睡眠一小会'
    复制代码
[h2]3. 修改外层变量时记得使用 nonlocal[/h2]装饰器是对函数对象的一个高级应用。在编写装饰器的过程中,你会经常碰到内层函数需要修改外层函数变量的情况。就像下面这个装饰器一样:
    1. import functools
    复制代码

    1. def counter(func):
    复制代码
    1.     """装饰器:记录并打印调用次数"""
    复制代码
    1.     count = 0
    复制代码
    1.     @functools.wraps(func)
    复制代码
    1.     def decorated(*args, **kwargs):
    复制代码
    1.         # 次数累加
    复制代码
    1.         count += 1
    复制代码
    1.         print(f"Count: {count}")
    复制代码
    1.         return func(*args, **kwargs)
    复制代码
    1.     return decorated
    复制代码

    1. @counter
    复制代码
    1. def foo():
    复制代码
    1.     pass
    复制代码

    1. foo()
    复制代码
为了统计函数调用次数,我们需要在
  1. decorated
复制代码
函数内部修改外层函数定义的
  1. count
复制代码
变量的值。但是,上面这段代码是有问题的,在执行它时解释器会报错:
    1. Traceback (most recent call last):
    复制代码
    1.   File "counter.py", line 22, in
    复制代码
    1.     foo()
    复制代码
    1.   File "counter.py", line 11, in decorated
    复制代码
    1.     count += 1
    复制代码
    1. UnboundLocalError: local variable 'count' referenced before assignment
    复制代码
这个错误是由
  1. counter
复制代码
  1. decorated
复制代码
函数互相嵌套的作用域引起的。
当解释器执行到
  1. count+=1
复制代码
时,并不知道
  1. count
复制代码
是一个在外层作用域定义的变量,它把
  1. count
复制代码
当做一个局部变量,并在当前作用域内查找。最终却没有找到有关
  1. count
复制代码
变量的任何定义,然后抛出错误。
为了解决这个问题,我们需要通过
  1. nonlocal
复制代码
关键字告诉解释器:“count 变量并不属于当前的 local 作用域,去外面找找吧”,之前的错误就可以得到解决。
    1. def decorated(*args, **kwargs):
    复制代码
    1.     nonlocal count
    复制代码
    1.     count += 1
    复制代码
    1.     #
    复制代码
Hint:如果要了解更多有关 nonlocal 关键字的历史,可以查阅 PEP-3104
[h1]总结[/h1]在这篇文章里,我与你分享了有关装饰器的一些技巧与小知识。
一些要点总结:
  • 一切 callable 的对象都可以被用来实现装饰器
  • 混合使用函数与类,可以更好的实现装饰器
  • wrapt 模块很有用,用它可以帮助我们用更简单的代码写出复杂装饰器
  • “装饰器”只是语法糖,它不是“装饰器模式”
  • 装饰器会改变函数的原始签名,你需要
    1. functools.wraps
    复制代码
  • 在内层函数修改外层函数的变量时,需要使用
    1. nonlocal
    复制代码
    关键字
看完文章的你,有没有什么想吐槽的?请留言或者在 项目 Github Issues 告诉我吧。
[h1]附录[/h1]
  • 题图来源: Photo by Clem Onojeghuo on Unsplash
  • 更多系列文章地址:https://github.com/piglei/one-python-craftsman

【本文作者】


piglei,Python 语言爱好者,热爱一切有趣的东西。


推荐阅读
(点击标题可跳转阅读)
[/url]
[url=http://mp.weixin.qq.com/s?__biz=MzA4MjEyNTA5Mw==&mid=2652568538&idx=1&sn=787d25b7a302b71049dfcebd993257d2&chksm=8464d390b3135a86cf1b9125c7bd7d4469cd88029633bd54b4debeb00147554c28a6eed92cb9&scene=21#wechat_redirect]Python 工匠:使用数字与字符串的技巧

Python 工匠:编写地道循环的两个建议
Python 工匠:编写条件分支代码的技巧


觉得本文对你有帮助?请分享给更多人
关注「Python开发者」加星标,提升Python技能

好文章,我在看
分享到 :
0 人收藏
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

积分:835
帖子:167
精华:0
期权论坛 期权论坛
发布
内容

下载期权论坛手机APP