尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python 工匠:使用装饰器的技巧——从类实现、wrapt 到常见陷阱的完整指南

Python 工匠:使用装饰器的技巧——从类实现、wrapt 到常见陷阱的完整指南 技术博客教程文档【免费下载链接】one-python-craftsman来自一位 Pythonista 的编程经验分享内容涵盖编码技巧、最佳实践与思维模式等方面。项目地址https://gitcode.com/gh_mirrors/on/one-python-craftsman点击查看免费下载装饰器Decorator是 Python 中一种在函数外部灵活修改函数行为的高阶工具它像一个印着独一无二符号的神奇帽子戴到函数头上就能悄无声息地改变函数本身的行为。本文是《Python 工匠》系列第 8 篇围绕装饰器的最佳实践与常见错误两大主线完整讲解基于类的装饰器实现、wrapt模块的扁平化写法以及functools.wraps、nonlocal等关键细节。读完本文你将能够写出更可维护、对类方法通用、且不破坏函数签名信息的装饰器并准确区分装饰器与装饰器模式这两个截然不同的概念。装饰器一顶神奇的帽子在做面向对象编程时我们经常会用到staticmethod和classmethod两个内置装饰器如果你接触过 click 模块就更不会对它感到陌生——click 最为人称道的参数定义接口click.option(...)就是利用装饰器实现的。在深入技巧之前有必要先看清装饰器语法背后的本质。下面这段使用装饰器的代码log_time cache_result def foo(): pass基本完全等同于def foo(): pass foo log_time(cache_result(foo))装饰器本身只是一颗语法糖它在 Python 中并没有提供某种无法替代的功能。同时需要注意叠加多个装饰器时的执行顺序装饰器的应用是自下而上的上面例子中cache_result会先包装foo随后log_time再包装上一步的结果而调用时执行顺序恰好相反log_time的包裹逻辑先执行然后才进入cache_result。如果装饰器本身还接收参数如delay(duration2)其真实展开形式则多了一层函数调用等价于foo delay(duration2)(foo)——delay(duration2)先返回一个真正用于装饰的函数再由后者完成对foo的包装。最佳实践一尝试用类来实现装饰器绝大多数装饰器都是基于函数和闭包实现的但这并非制造装饰器的唯一方式。事实上Python 对某个对象能否通过decorator形式使用只有一个要求decorator 必须是一个可被调用callable的对象。函数自然是可被调用的 def foo(): pass ... type(foo) class function callable(foo) True但除了函数外我们也可以让任何一个类实例变得可被调用。办法很简单只要自定义类的__call__魔法方法即可class Foo: def __call__(self): print(Hello, __call__) foo Foo() # OUTPUT: True print(callable(foo)) # 调用 foo 实例 # OUTPUT: Hello, __call__ foo()基于任何 callable 对象都可作为装饰器这一特性我们可以很方便地用类来实现装饰器。下面这段代码定义了一个名为delay(duration)的装饰器使用它装饰过的函数在每次执行前都会等待额外的duration秒同时它还为用户提供了无需等待、马上执行的eager_call接口import time import functools class DelayFunc: def __init__(self, duration, func): self.duration duration self.func func def __call__(self, *args, **kwargs): print(fWait for {self.duration} seconds...) time.sleep(self.duration) return self.func(*args, **kwargs) def eager_call(self, *args, **kwargs): print(Call without delay) return self.func(*args, **kwargs) def delay(duration): 装饰器推迟某个函数的执行。同时提供 .eager_call 方法立即执行 # 此处为了避免定义额外函数直接使用 functools.partial 帮助构造 # DelayFunc 实例 return functools.partial(DelayFunc, duration)使用方式如下delay(duration2) def add(a, b): return a b # 这次调用将会延迟 2 秒 add(1, 2) # 这次调用将会立即执行 add.eager_call(1, 2)这里的delay(duration)就是一个基于类实现的装饰器。细心的读者会发现delay(duration)的返回值并不是普通的函数而是functools.partial构造出的部分函数对象——它绑定了DelayFunc类和duration参数剩下的func参数留给语法去填充最终得到的就是一个完整的DelayFunc实例。因为该实例实现了__call__所以它天然是 callable 的完全满足装饰器的要求。关于用functools.partial构造新函数的更多思路可以参考系列文章 《Python 工匠让函数返回结果的技巧》。当然如果你非常熟悉 Python 里的函数和闭包上面的delay装饰器其实完全可以用纯函数实现。那么为什么要用类来做这件事呢与纯函数相比在特定场景下使用类实现装饰器有以下几个优势实现有状态的装饰器时操作类属性比操作闭包内变量更符合直觉、不易出错。例如标准库中的functools.lru_cache就是典型的有状态缓存装饰器其缓存命中率、缓存内容都需要在多次调用间保持状态用类属性来管理这类状态远比操作闭包变量直观。实现为函数扩充接口的装饰器时使用类包装函数比直接为函数对象追加属性更易于维护。上面DelayFunc.eager_call就是一个现成的例子——把附加方法挂在类上而不是给函数对象动态挂属性。更容易实现一个同时兼容装饰器与上下文管理器协议的对象可以参考标准库unittest.mock.patch它既是装饰器也能作为with语句的上下文管理器使用。最佳实践二使用 wrapt 模块编写更扁平的装饰器写装饰器时有两件事特别令人难受实现带参数的装饰器时层层嵌套的函数代码特别难写、难读因为函数和类方法的工作机制不同为前者写的装饰器经常没法直接套用在后者上。比如下面这个装饰器它随机生成一个整数并注入为函数参数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) # 输出 1-100 的随机整数 # OUTPUT: 72 print_random_number()provide_number功能看上去不错但它有着前面提到的两个问题嵌套层级深、无法在类方法上使用。如果直接用它去装饰类方法会出现下面的情况class Foo: 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二者在工作机制上有细微不同Python 的实例方法本质上是一个描述符通过实例.方法访问时会由描述符协议自动把实例self绑定并填充到参数列表首位因此*args里的第一个元素其实是self。要在这种场景下正确注入num装饰器必须聪明地跳过藏在*args里的self才能把num作为真正的第一个参数。这时wrapt 模块就派上用场了。wrapt 是一个专门帮助你编写装饰器的工具库利用它可以非常方便地改造provide_number同时解决嵌套层级深和无法通用两个问题import random 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 provide_number(1, 100) def print_random_number(num): print(num) class Foo: provide_number(1, 100) def print_random_number(self, num): print(num) # 普通函数正常注入随机数 print_random_number() # 类方法也能正确注入随机数而不会把 self 打印出来 # OUTPUT: 48 Foo().print_random_number()使用 wrapt 模块编写的装饰器相比原来拥有这些优势嵌套层级少使用wrapt.decorator可以将两层嵌套减少为一层只需写一个wrapper函数更简单处理位置与关键字参数时可以忽略类实例等特殊情况wrapped(*args, **kwargs)一步完成调用更灵活针对instance值进行条件判断后更容易让装饰器变得通用例如可以区分普通方法、类方法、静态方法与普通函数的调用场景附带好处wrapt 装饰器默认会保留被装饰函数的元信息不必手动处理functools.wraps的复制工作。常见错误一装饰器并不是装饰器模式设计模式在计算机世界里是一个鼎鼎大名的词。写 Python 时我们极少谈起设计模式虽然 Python 也支持面向对象但它的鸭子类型设计以及出色的动态特性决定了大部分设计模式对 Python 程序员来说并不是必需品。不过装饰器模式Decorator Pattern是个例外。因为 Python 的装饰器和装饰器模式有着一模一样的名字经常有人把它们当成一回事认为使用装饰器就是在实践装饰器模式。但事实上它们是两个完全不同的东西。装饰器模式是一个完全基于面向对象衍生出的编程手法它拥有几个关键组成一个统一的接口定义、若干个遵循该接口的类、类与类之间一层一层的包装最终由它们共同形成一种装饰的效果。它本质上是 OOP 世界里组合优于继承思想的一种落地手法。而 Python 里的装饰器和面向对象没有任何直接联系它完全可以只是发生在函数和函数间的把戏。事实上装饰器并没有提供某种无法替代的功能它仅仅就是一颗语法糖。前面已经演示过一段使用装饰器的代码log_time cache_result def foo(): pass基本完全等同于def foo(): pass foo log_time(cache_result(foo))装饰器最大的功劳在于让我们在某些特定场景下可以写出更符合直觉、易于阅读的代码。它只是一颗糖并不是某个面向对象领域的复杂编程模式。Python 社区把装饰器模式的思想继续发扬光大才有了functools.lru_cache、unittest.mock.patch这类既能以装饰器语法使用、内部又蕴含丰富对象协作逻辑的标准库设施——但用语法糖与实践 OOP 模式仍是两件不同的事。常见错误二记得用 functools.wraps() 装饰内层函数下面是一个简单的装饰器专门用来打印函数调用耗时import time import random def timer(wrapped): 装饰器记录并打印函数耗时 def decorated(*args, **kwargs): st time.time() ret wrapped(*args, **kwargs) print(execution take: {} seconds.format(time.time() - st)) return ret return decorated timer def random_sleep(): 随机睡眠一小会 time.sleep(random.random())注示例中random_sleep用到了random.random()运行前需要import random。timer装饰器虽然没有错误但是使用它装饰函数后函数的原始签名就会被破坏——你再也无法正确拿到random_sleep函数的名称和文档内容了所有签名都会变成内层函数decorated的值print(random_sleep.__name__) # 输出 decorated print(random_sleep.__doc__) # 输出 None这虽然只是个小问题但在某些时候也可能导致难以察觉的 bug例如依赖__name__做日志定位、依赖__doc__生成 API 文档、或借助inspect.signature()做参数校验的框架都会因此拿到错误的信息。幸运的是标准库functools为它提供了解决方案只需要在定义装饰器时用functools.wraps再装饰一下内层decorated函数即可import functools def timer(wrapped): # 将 wrapper 函数的真实签名赋值到 decorated 上 functools.wraps(wrapped) def decorated(*args, **kwargs): st time.time() ret wrapped(*args, **kwargs) print(execution take: {} seconds.format(time.time() - st)) return ret return decorated这样处理后timer装饰器就不会影响它所装饰的函数了print(random_sleep.__name__) # 输出 random_sleep print(random_sleep.__doc__) # 输出 随机睡眠一小会functools.wraps之所以能修复签名是因为它在背后把被包装函数的__module__、__name__、__qualname__、__annotations__、__doc__等元信息复制到包装函数上并同步更新包装函数的__dict__。这也是为什么它成为编写函数式装饰器时几乎必备的一行代码。如果你选择用类实现装饰器同样需要注意在类中手动复制这些属性或者直接借助 wrapt 这类工具自动完成。常见错误三修改外层变量时记得使用 nonlocal装饰器是对函数对象的高级应用。在编写装饰器的过程中你会经常碰到内层函数需要修改外层函数变量的情况就像下面这个统计调用次数的装饰器一样import functools def counter(func): 装饰器记录并打印调用次数 count 0 functools.wraps(func) def decorated(*args, **kwargs): # 次数累加 count 1 print(fCount: {count}) return func(*args, **kwargs) return decorated 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当作一个局部变量在当前作用域内查找最终没有找到任何定义于是抛出UnboundLocalError。为了解决这个问题需要通过nonlocal关键字告诉解释器count 变量并不属于当前的 local 作用域去外面找找吧def decorated(*args, **kwargs): nonlocal count count 1 # ... 已省略 ...加上这一行之后之前的错误就得到了解决count会正确地在外层counter函数的作用域中被累加。nonlocal是 Python 3 引入的关键字其设计历史可以追溯至 PEP-3104。在它出现之前Python 2 时代的内层函数只能通过把状态塞进列表或字典等可变容器如count [0]然后写count[0] 1来绕开这一限制。理解nonlocal本质上就是理解 Python 的变量作用域查找规则局部 → 闭包外层 → 全局 → 内建这正是写有状态装饰器时最容易踩的坑之一。总结在这篇文章里我们从最佳实践与常见错误两个角度分享了有关装饰器的一些技巧与小知识。要点总结如下一切 callable 的对象都可以被用来实现装饰器函数、__call__实例、functools.partial对象皆可混合使用函数与类可以更好地实现装饰器——类更适合实现有状态、需扩充接口或同时支持上下文管理器协议的装饰器wrapt 模块很有用它可以帮助我们用更简单的代码写出复杂的、对函数和类方法通用的装饰器装饰器只是语法糖它不是装饰器模式二者一个是语言层面的语法设施一个是面向对象的设计手法装饰器会改变函数的原始签名你需要functools.wraps来保留__name__、__doc__等元信息在内层函数修改外层函数的变量时需要使用nonlocal关键字否则会触发UnboundLocalError。装饰器是 Python 里小细节成就好代码的典型代表语法上它只占用一行但其背后涉及 callable 协议、闭包与作用域、描述符与类方法绑定等一整套语言机制。掌握这些机制才能写出既优雅又不易出错的装饰器。系列其他文章本文所属的《Python 工匠》系列完整文章列表见仓库 README.md其中第 8 篇即本文 使用装饰器的技巧。系列相关文章推荐阅读Python 工匠编写地道循环的两个建议上一篇Python 工匠让函数返回结果的技巧functools.partial 详解Python 工匠一个关于模块的小故事下一篇赞分享技术博客教程文档【免费下载链接】one-python-craftsman来自一位 Pythonista 的编程经验分享内容涵盖编码技巧、最佳实践与思维模式等方面。项目地址https://gitcode.com/gh_mirrors/on/one-python-craftsman点击查看免费下载相关推荐告别装饰器陷阱wrapt如何彻底解决Python函数包装难题告别装饰器陷阱wrapt如何彻底解决Python函数包装难题 为什么你的Python装饰器从一开始就错了 你是否曾遇到过这些困惑精心编写的装饰器在类方法上开发工具告别装饰器陷阱wrapt如何彻底解决Python函数包装难题告别装饰器陷阱wrapt如何彻底解决Python函数包装难题 你是否曾为Python装饰器的实现而头疼当你用 functools.wraps 包装函数后是开发工具Python 工匠编写条件分支代码的技巧if/else 最佳实践、常见技巧与陷阱全解析Python 工匠编写条件分支代码的技巧if/else 最佳实践、常见技巧与陷阱全解析 导读 现实世界的代码从来都不是一条笔直的高速公路而更像是由无数岔技术博客教程文档创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表