流畅的 Python 第二版(GPT 重译)(五)(2)https://developer.aliyun.com/article/1484581
提示
使用 ABCs 或typing.Protocol
与@singledispatch
允许您的代码支持现有或未来的类,这些类是这些 ABCs 的实际或虚拟子类,或者实现了这些协议。ABCs 的使用和虚拟子类的概念是第十三章的主题。
singledispatch
机制的一个显著特点是,您可以在系统中的任何模块中注册专门的函数。如果以后添加了一个具有新用户定义类型的模块,您可以轻松提供一个新的自定义函数来处理该类型。您可以为您没有编写且无法更改的类编写自定义函数。
singledispatch
是标准库中经过深思熟虑的添加,它提供的功能比我在这里描述的要多。PEP 443—单分派通用函数是一个很好的参考,但它没有提到后来添加的类型提示的使用。functools
模块文档已经改进,并在其singledispatch
条目中提供了更多最新的覆盖范例。
注意
@singledispatch
并非旨在将 Java 风格的方法重载引入 Python。一个具有许多重载方法变体的单个类比具有一长串if/elif/elif/elif
块的单个函数更好。但这两种解决方案都有缺陷,因为它们在单个代码单元(类或函数)中集中了太多责任。@singledispatch
的优势在于支持模块化扩展:每个模块可以为其支持的每种类型注册一个专门的函数。在实际用例中,您不会像示例 9-20 中那样将所有通用函数的实现放在同一个模块中。
我们已经看到一些接受参数的装饰器,例如@lru_cache()
和htmlize.register(float)
,由@singledispatch
在示例 9-20 中创建。下一节将展示如何构建接受参数的装饰器。
参数化装饰器
在源代码中解析装饰器时,Python 将装饰的函数作为第一个参数传递给装饰器函数。那么如何使装饰器接受其他参数呢?答案是:创建一个接受这些参数并返回装饰器的装饰器工厂,然后将其应用于要装饰的函数。令人困惑?当然。让我们从基于我们看到的最简单的装饰器register
的示例开始:示例 9-21。
示例 9-21. 来自示例 9-2 的简化 registration.py 模块,这里为方便起见重复显示
registry = [] def register(func): print(f'running register({func})') registry.append(func) return func @register def f1(): print('running f1()') print('running main()') print('registry ->', registry) f1()
一个参数化注册装饰器
为了方便启用或禁用register
执行的函数注册,我们将使其接受一个可选的active
参数,如果为False
,则跳过注册被装饰的函数。示例 9-22 展示了如何。从概念上讲,新的register
函数不是一个装饰器,而是一个装饰器工厂。当调用时,它返回将应用于目标函数的实际装饰器。
示例 9-22. 要接受参数,新的register
装饰器必须被调用为一个函数
registry = set() # ① def register(active=True): # ② def decorate(func): # ③ print('running register' f'(active={active})->decorate({func})') if active: # ④ registry.add(func) else: registry.discard(func) # ⑤ return func # ⑥ return decorate # ⑦ @register(active=False) # ⑧ def f1(): print('running f1()') @register() # ⑨ def f2(): print('running f2()') def f3(): print('running f3()')
①
registry
现在是一个set
,因此添加和移除函数更快。
②
register
接受一个可选的关键字参数。
③
decorate
内部函数是实际的装饰器;注意它如何将一个函数作为参数。
④
仅在active
参数(从闭包中检索)为True
时注册func
。
⑤
如果not active
并且func in registry
,则移除它。
⑥
因为decorate
是一个装饰器,所以它必须返回一个函数。
⑦
register
是我们的装饰器工厂,因此它返回decorate
。
⑧
必须将@register
工厂作为一个函数调用,带上所需的参数。
⑨
如果没有传递参数,则必须仍然调用register
作为一个函数—@register()
—即,返回实际装饰器decorate
。
主要点是register()
返回decorate
,然后应用于被装饰的函数。
示例 9-22 中的代码位于registration_param.py模块中。如果我们导入它,我们会得到这个:
>>> import registration_param running register(active=False)->decorate(<function f1 at 0x10063c1e0>) running register(active=True)->decorate(<function f2 at 0x10063c268>) >>> registration_param.registry [<function f2 at 0x10063c268>]
注意只有f2
函数出现在registry
中;f1
没有出现,因为active=False
被传递给register
装饰器工厂,所以应用于f1
的decorate
没有将其添加到registry
中。
如果我们不使用@
语法,而是将register
作为一个常规函数使用,装饰一个函数f
所需的语法将是register()(f)
来将f
添加到registry
中,或者register(active=False)(f)
来不添加它(或移除它)。查看示例 9-23 了解如何向registry
添加和移除函数的演示。
示例 9-23. 使用示例 9-22 中列出的 registration_param 模块
>>> from registration_param import * running register(active=False)->decorate(<function f1 at 0x10073c1e0>) running register(active=True)->decorate(<function f2 at 0x10073c268>) >>> registry # ① {<function f2 at 0x10073c268>} >>> register()(f3) # ② running register(active=True)->decorate(<function f3 at 0x10073c158>) <function f3 at 0x10073c158> >>> registry # ③ {<function f3 at 0x10073c158>, <function f2 at 0x10073c268>} >>> register(active=False)(f2) # ④ running register(active=False)->decorate(<function f2 at 0x10073c268>) <function f2 at 0x10073c268> >>> registry # ⑤ {<function f3 at 0x10073c158>}
①
当模块被导入时,f2
在registry
中。
②
register()
表达式返回decorate
,然后应用于f3
。
③
前一行将f3
添加到registry
中。
④
这个调用从registry
中移除了f2
。
⑤
确认只有f3
保留在registry
中。
参数化装饰器的工作方式相当复杂,我们刚刚讨论的比大多数都要简单。参数化装饰器通常会替换被装饰的函数,它们的构建需要另一层嵌套。现在我们将探讨这样一个函数金字塔的架构。
带参数的时钟装饰器
在本节中,我们将重新访问clock
装饰器,添加一个功能:用户可以传递一个格式字符串来控制时钟函数报告的输出。参见示例 9-24。
注意
为简单起见,示例 9-24 基于初始clock
实现示例 9-14,而不是使用@functools.wraps
改进的实现示例 9-16,后者添加了另一个函数层。
示例 9-24. 模块 clockdeco_param.py:带参数时钟装饰器
import time DEFAULT_FMT = '[{elapsed:0.8f}s] {name}({args}) -> {result}' def clock(fmt=DEFAULT_FMT): # ① def decorate(func): # ② def clocked(*_args): # ③ t0 = time.perf_counter() _result = func(*_args) # ④ elapsed = time.perf_counter() - t0 name = func.__name__ args = ', '.join(repr(arg) for arg in _args) # ⑤ result = repr(_result) # ⑥ print(fmt.format(**locals())) # ⑦ return _result # ⑧ return clocked # ⑨ return decorate # ⑩ if __name__ == '__main__': @clock() ⑪ def snooze(seconds): time.sleep(seconds) for i in range(3): snooze(.123)
①
clock
是我们的带参数装饰器工厂。
②
decorate
是实际的装饰器。
③
clocked
包装了被装饰的函数。
④
_result
是被装饰函数的实际结果。
⑤
_args
保存了clocked
的实际参数,而args
是用于显示的str
。
⑥
result
是_result
的str
表示,用于显示。
⑦
在这里使用**locals()
允许引用clocked
的任何局部变量在fmt
中。¹⁰
⑧
clocked
将替换被装饰的函数,因此它应该返回该函数返回的任何内容。
⑨
decorate
返回clocked
。
⑩
clock
返回decorate
。
⑪
在这个自测中,clock()
被无参数调用,因此应用的装饰器将使用默认格式str
。
如果你在 shell 中运行示例 9-24,你会得到这个结果:
$ python3 clockdeco_param.py [0.12412500s] snooze(0.123) -> None [0.12411904s] snooze(0.123) -> None [0.12410498s] snooze(0.123) -> None
为了练习新功能,让我们看一下示例 9-25 和 9-26,它们是使用clockdeco_param
的另外两个模块以及它们生成的输出。
示例 9-25. clockdeco_param_demo1.py
import time from clockdeco_param import clock @clock('{name}: {elapsed}s') def snooze(seconds): time.sleep(seconds) for i in range(3): snooze(.123)
示例 9-25 的输出:
$ python3 clockdeco_param_demo1.py snooze: 0.12414693832397461s snooze: 0.1241159439086914s snooze: 0.12412118911743164s
示例 9-26. clockdeco_param_demo2.py
import time from clockdeco_param import clock @clock('{name}({args}) dt={elapsed:0.3f}s') def snooze(seconds): time.sleep(seconds) for i in range(3): snooze(.123)
示例 9-26 的输出:
$ python3 clockdeco_param_demo2.py snooze(0.123) dt=0.124s snooze(0.123) dt=0.124s snooze(0.123) dt=0.124s
注意
第一版的技术审阅员 Lennart Regebro 认为装饰器最好编写为实现__call__
的类,而不是像本章示例中的函数那样。我同意这种方法对于复杂的装饰器更好,但为了解释这种语言特性的基本思想,函数更容易理解。参见“进一步阅读”,特别是 Graham Dumpleton 的博客和wrapt
模块,用于构建装饰器的工业级技术。
下一节展示了 Regebro 和 Dumpleton 推荐风格的示例。
基于类的时钟装饰器
最后一个例子,示例 9-27 列出了一个作为类实现的带参数clock
装饰器的实现,其中使用了__call__
。对比示例 9-24 和示例 9-27。你更喜欢哪一个?
示例 9-27. 模块 clockdeco_cls.py:作为类实现的带参数时钟装饰器
import time DEFAULT_FMT = '[{elapsed:0.8f}s] {name}({args}) -> {result}' class clock: # ① def __init__(self, fmt=DEFAULT_FMT): # ② self.fmt = fmt def __call__(self, func): # ③ def clocked(*_args): t0 = time.perf_counter() _result = func(*_args) # ④ elapsed = time.perf_counter() - t0 name = func.__name__ args = ', '.join(repr(arg) for arg in _args) result = repr(_result) print(self.fmt.format(**locals())) return _result return clocked
①
与clock
外部函数不同,clock
类是我们的带参数装饰器工厂。我用小写字母c
命名它,以明确表明这个实现是示例 9-24 中的一个可替换项。
②
在clock(my_format)
中传入的参数被分配给了这里的fmt
参数。类构造函数返回一个clock
的实例,其中my_format
存储在self.fmt
中。
③
__call__
使clock
实例可调用。当调用时,实例将用clocked
替换被装饰的函数。
④
clocked
包装了被装饰的函数。
我们的函数装饰器探索到此结束。我们将在第二十四章中看到类装饰器。
章节总结
我们在本章涵盖了一些困难的领域。我尽力使旅程尽可能顺利,但我们确实进入了元编程的领域。
我们从一个没有内部函数的简单@register
装饰器开始,最后完成了一个涉及两个嵌套函数级别的参数化@clock()
。
虽然注册装饰器在本质上很简单,但在 Python 框架中有真正的应用。我们将在第十章中将注册思想应用于策略设计模式的一个实现。
理解装饰器实际工作原理需要涵盖导入时间和运行时之间的差异,然后深入研究变量作用域、闭包和新的nonlocal
声明。掌握闭包和nonlocal
不仅有助于构建装饰器,还有助于为 GUI 或异步 I/O 编写事件导向的程序,并在有意义时采用函数式风格。
参数化装饰器几乎总是涉及至少两个嵌套函数,如果您想使用@functools.wraps
来生成提供更好支持更高级技术的装饰器,则可能涉及更多嵌套函数。其中一种技术是堆叠装饰器,我们在示例 9-18 中看到了。对于更复杂的装饰器,基于类的实现可能更易于阅读和维护。
作为标准库中参数化装饰器的示例,我们访问了functools
模块中强大的@cache
和@singledispatch
。
进一步阅读
Brett Slatkin 的Effective Python第 2 版(Addison-Wesley)的第 26 条建议了函数装饰器的最佳实践,并建议始终使用functools.wraps
——我们在示例 9-16 中看到的。¹¹
Graham Dumpleton 在他的一系列深入的博客文章中介绍了实现行为良好的装饰器的技术,从“你实现的 Python 装饰器是错误的”开始。他在这方面的深厚专业知识也很好地包含在他编写的wrapt
模块中,该模块简化了装饰器和动态函数包装器的实现,支持内省,并在进一步装饰、应用于方法以及用作属性描述符时表现正确。第 III 部分的第二十三章是关于描述符的。
《Python Cookbook》第 3 版(O’Reilly)的第九章“元编程”,作者是 David Beazley 和 Brian K. Jones,包含了从基本装饰器到非常复杂的装饰器的几个示例,其中包括一个可以作为常规装饰器或装饰器工厂调用的示例,例如,@clock
或@clock()
。这在该食谱书中是“食谱 9.6. 定义一个带有可选参数的装饰器”。
Michele Simionato 编写了一个旨在“简化普通程序员对装饰器的使用,并通过展示各种非平凡示例来普及装饰器”的软件包。它在 PyPI 上作为decorator 软件包提供。
当装饰器在 Python 中仍然是一个新功能时创建的,Python 装饰器库维基页面有数十个示例。由于该页面多年前开始,一些显示的技术已经过时,但该页面仍然是一个极好的灵感来源。
“Python 中的闭包”是 Fredrik Lundh 的一篇简短博客文章,解释了闭包的术语。
PEP 3104—访问外部作用域中的名称 描述了引入 nonlocal
声明以允许重新绑定既不是本地的也不是全局的名称。它还包括了如何在其他动态语言(Perl、Ruby、JavaScript 等)中解决这个问题的优秀概述,以及 Python 可用的设计选项的利弊。
在更理论层面上,PEP 227—静态嵌套作用域 记录了在 Python 2.1 中引入词法作用域作为一个选项,并在 Python 2.2 中作为标准的过程,解释了在 Python 中实现闭包的原因和设计选择。
PEP 443 提供了单分派通用函数的理由和详细描述。Guido van Rossum 在 2005 年 3 月的一篇博客文章 “Python 中的五分钟多方法” 通过使用装饰器实现了通用函数(又称多方法)。他的代码支持多分派(即基于多个位置参数的分派)。Guido 的多方法代码很有趣,但这只是一个教学示例。要了解现代、适用于生产的多分派通用函数的实现,请查看 Martijn Faassen 的 Reg—作者是面向模型驱动和 REST 专业的 Morepath web 框架的作者。
¹ 这是 1995 年的设计模式一书,由所谓的四人帮(Gamma 等,Addison-Wesley)撰写。
² 如果你在前一句中将“函数”替换为“类”,你就得到了类装饰器的简要描述。类装饰器在 第二十四章 中有介绍。
³ 感谢技术审阅者 Leonardo Rochael 提出这个总结。
⁴ Python 没有程序全局作用域,只有模块全局作用域。
⁵ 为了澄清,这不是一个打字错误:memoization 是一个与“memorization”模糊相关的计算机科学术语,但并不相同。
⁶ 不幸的是,当 Mypy 0.770 看到多个同名函数时会报错。
⁷ 尽管在 “数值塔的崩塌” 中有警告,number
ABCs 并没有被弃用,你可以在 Python 3 代码中找到它们。
⁸ 也许有一天你也能用单个无参数的 @htmlize.register
和使用 Union
类型提示来表达这个,但当我尝试时,Python 报错,提示 Union
不是一个类。因此,尽管 @singledispatch
支持 PEP 484 的语法,但语义还没有实现。
⁹ 例如,NumPy 实现了几种面向机器的整数和浮点数类型。
¹⁰ 技术审阅者 Miroslav Šedivý 指出:“这也意味着代码检查工具会抱怨未使用的变量,因为它们倾向于忽略对 locals()
的使用。” 是的,这是静态检查工具如何阻止我和无数程序员最初被 Python 吸引的动态特性的又一个例子。为了让代码检查工具满意,我可以在调用中两次拼写每个本地变量:fmt.format(elapsed=elapsed, name=name, args=args, result=result)
。我宁愿不这样做。如果你使用静态检查工具,非常重要的是要知道何时忽略它们。
¹¹ 我想尽可能简化代码,所以我并没有在所有示例中遵循 Slatkin 的优秀建议。
第十章:具有一等函数的设计模式
符合模式并不是好坏的衡量标准。
拉尔夫·约翰逊,设计模式经典著作的合著者¹
在软件工程中,设计模式是解决常见设计问题的通用配方。你不需要了解设计模式来阅读本章。我将解释示例中使用的模式。
编程中设计模式的使用被设计模式:可复用面向对象软件的元素(Addison-Wesley)一书所推广,作者是 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides,也被称为“四人组”。这本书是一个包含 23 种模式的目录,其中有用 C++代码示例的类排列,但也被认为在其他面向对象语言中也很有用。
尽管设计模式是与语言无关的,但这并不意味着每种模式都适用于每种语言。例如,第十七章将展示在 Python 中模拟Iterator模式的配方是没有意义的,因为该模式已嵌入在语言中,并以生成器的形式准备好使用,不需要类来工作,并且比经典配方需要更少的代码。
设计模式的作者在介绍中承认,实现语言决定了哪些模式是相关的:
编程语言的选择很重要,因为它影响一个人的观点。我们的模式假设具有 Smalltalk/C++级别的语言特性,这种选择决定了什么可以轻松实现,什么不能。如果我们假设过程式语言,我们可能会包括称为“继承”、“封装”和“多态性”的设计模式。同样,一些我们的模式直接受到不太常见的面向对象语言的支持。例如,CLOS 具有多方法,这减少了像 Visitor 这样的模式的需求²
在他 1996 年的演讲中,“动态语言中的设计模式”,Peter Norvig 指出原始设计模式书中的 23 种模式中有 16 种在动态语言中变得“不可见或更简单”(幻灯片 9)。他谈到的是 Lisp 和 Dylan 语言,但许多相关的动态特性也存在于 Python 中。特别是在具有一等函数的语言环境中,Norvig 建议重新思考被称为 Strategy、Command、Template Method 和 Visitor 的经典模式。
本章的目标是展示如何——在某些情况下——函数可以像类一样完成工作,代码更易读且更简洁。我们将使用函数作为对象重构 Strategy 的实现,消除大量样板代码。我们还将讨论简化 Command 模式的类似方法。
本章的新内容
我将这一章移到第三部分的末尾,这样我就可以在“装饰增强的 Strategy 模式”中应用注册装饰器,并在示例中使用类型提示。本章中使用的大多数类型提示并不复杂,它们确实有助于可读性。
案例研究:重构 Strategy
Strategy 是一个很好的设计模式示例,在 Python 中,如果你利用函数作为一等对象,它可能会更简单。在接下来的部分中,我们使用设计模式中描述的“经典”结构来描述和实现 Strategy。如果你熟悉经典模式,可以直接跳到“面向函数的 Strategy”,我们将使用函数重构代码,显著减少行数。
经典 Strategy
图 10-1 中的 UML 类图描述了展示 Strategy 模式的类排列。
图 10-1. 使用策略设计模式实现订单折扣处理的 UML 类图。
设计模式 中对策略模式的总结如下:
定义一组算法家族,封装每个算法,并使它们可以互换。策略让算法独立于使用它的客户端变化。
在电子商务领域中应用策略的一个明显例子是根据客户属性或订购商品的检查计算订单折扣。
考虑一个在线商店,具有以下折扣规则:
- 拥有 1,000 或更多忠诚积分的顾客每个订单可以获得全局 5% 的折扣。
- 每个订单中有 20 个或更多单位的行项目都会获得 10% 的折扣。
- 至少有 10 个不同商品的订单可以获得 7% 的全局折扣。
为简洁起见,假设订单只能应用一个折扣。
策略模式的 UML 类图在 图 10-1 中描述。参与者有:
上下文
通过将一些计算委托给实现替代算法的可互换组件来提供服务。在电子商务示例中,上下文是一个 Order
,它被配置为根据几种算法之一应用促销折扣。
策略
实现不同算法的组件之间的公共接口。在我们的例子中,这个角色由一个名为 Promotion
的抽象类扮演。
具体策略
Strategy 的具体子类之一。FidelityPromo
、BulkPromo
和 LargeOrderPromo
是实现的三个具体策略。
示例 10-1 中的代码遵循 图 10-1 中的蓝图。如 设计模式 中所述,具体策略由上下文类的客户端选择。在我们的例子中,在实例化订单之前,系统会以某种方式选择促销折扣策略并将其传递给 Order
构造函数。策略的选择超出了模式的范围。
示例 10-1. 实现具有可插入折扣策略的 Order
类
from abc import ABC, abstractmethod from collections.abc import Sequence from decimal import Decimal from typing import NamedTuple, Optional class Customer(NamedTuple): name: str fidelity: int class LineItem(NamedTuple): product: str quantity: int price: Decimal def total(self) -> Decimal: return self.price * self.quantity class Order(NamedTuple): # the Context customer: Customer cart: Sequence[LineItem] promotion: Optional['Promotion'] = None def total(self) -> Decimal: totals = (item.total() for item in self.cart) return sum(totals, start=Decimal(0)) def due(self) -> Decimal: if self.promotion is None: discount = Decimal(0) else: discount = self.promotion.discount(self) return self.total() - discount def __repr__(self): return f'<Order total: {self.total():.2f} due: {self.due():.2f}>' class Promotion(ABC): # the Strategy: an abstract base class @abstractmethod def discount(self, order: Order) -> Decimal: """Return discount as a positive dollar amount""" class FidelityPromo(Promotion): # first Concrete Strategy """5% discount for customers with 1000 or more fidelity points""" def discount(self, order: Order) -> Decimal: rate = Decimal('0.05') if order.customer.fidelity >= 1000: return order.total() * rate return Decimal(0) class BulkItemPromo(Promotion): # second Concrete Strategy """10% discount for each LineItem with 20 or more units""" def discount(self, order: Order) -> Decimal: discount = Decimal(0) for item in order.cart: if item.quantity >= 20: discount += item.total() * Decimal('0.1') return discount class LargeOrderPromo(Promotion): # third Concrete Strategy """7% discount for orders with 10 or more distinct items""" def discount(self, order: Order) -> Decimal: distinct_items = {item.product for item in order.cart} if len(distinct_items) >= 10: return order.total() * Decimal('0.07') return Decimal(0)
流畅的 Python 第二版(GPT 重译)(五)(4)https://developer.aliyun.com/article/1484584