流畅的python--第十章 使用一等函数实现设计 模式
经典的策略模式
示例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): # 上下文
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): # 策略:抽象基类
@abstractmethod
def discount(self, order: Order) -> Decimal:
"""返回折扣金额(正值)"""
class FidelityPromo(Promotion): # 第一个具体策略
"""为积分为1000或以上的顾客提供5%折扣"""
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): # 第二个具体策略
"""单个商品的数量为20个或以上时提供10%折扣"""
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): # 第三个具体策略
"""订单中不同商品的数量达到10个或以上时提供7%折扣"""
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)
注意,示例 10-1 把 Promotion
定义为了抽象基类,这么做是为了使用
@abstractmethod
装饰器,明确表明所用的模式。
示例10-2 使用不同促销折扣的Order类示例
示例 10-1 完全可用,但是在 Python 中,如果把函数当作对象使用,则
实现同样的功能所需的代码更少。
使用函数实现策略模式
在示例 10-1 中,每个具体策略都是一个类,而且都只定义了一个方
法,即 discount
。此外,策略实例没有状态(没有实例属性)。你可
能会说,它们看起来像是普通函数——的确如此。示例 10-3 是对示例
10-1 的重构,把具体策略换成了简单的函数,而且去掉了抽象类
Promo
。Order
类也要修改,但改动不大。
示例10-3 Order
类和使用函数实现的折扣策略
from collections.abc import Sequence
from dataclasses import dataclass
from decimal import Decimal
from typing import Optional, Callable, NamedTuple
class Customer(NamedTuple):
name: str
fidelity: int
class LineItem(NamedTuple):
product: str
quantity: int
price: Decimal
def total(self):
return self.price * self.quantity
@dataclass(frozen=True)
class Order: # 上下文
customer: Customer
cart: Sequence[LineItem]
promotion: Optional[Callable[['Order'], Decimal]] = None # 1
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(self) # 2
return self.total() - discount
def __repr__(self):
return f'<Order total: {self.total():.2f} due: {self.due():.2f}>' # 3
def fidelity_promo(order: Order) -> Decimal: # 4
"""为积分为1000或以上的顾客提供5%折扣"""
if order.customer.fidelity >= 1000:
return order.total() * Decimal('0.05')
return Decimal(0)
def bulk_item_promo(order: Order) -> Decimal:
"""单个商品的数量为20个或以上时提供10%折扣"""
discount = Decimal(0)
for item in order.cart:
if item.quantity >= 20:
discount += item.total() * Decimal('0.1')
return discount
def large_order_promo(order: Order) -> Decimal:
"""订单中不同商品的数量达到10个或以上时提供7%折扣"""
distinct_items = {item.product for item in order.cart}
if len(distinct_items) >= 10:
return order.total() * Decimal('0.07')
return Decimal(0)
❶ 这个类型提示的意思是,promotion
既可以是 None
,也可以是接收
一个 Order
参数并返回一个 Decimal
值的可调用对象。
❷ 调用可调用对象 self.promotion
,传入 self
,计算折扣。
❸ 没有抽象类。
❹ 各个策略都是函数。
🚩 为什么写成self.promotion(self)? 在
Order
类中,promotion
不是方法,而是一个实例属性,只不
过它的值是可调用对象。因此,作为表达式的第一部分,self.promotion
的作用是获取可调用对象。为了调用得到的
可调用对象,必须提供一个Order
实例,即表达式中的self
。因此,表达式中出现了两个self
。
示例10-4 以函数实现促销折扣的Order
类使用示例。
注意示例 10-4 中的标号:没必要在新建订单时实例化新的促销对象,
函数拿来即用。
选择最佳策略的简单方式
示例10-5 best_promo
函数计算所有折扣,返回幅度最大的那一个
- 1
best_promo
为顾客joe
选择larger_order_promo
。 - 2 订购大量香蕉时,
joe
使用bulk_item_promo
提供的折扣。 - 3 在一个简单的购物车中,
best_promo
为忠实顾客ann
提供fidelity_promo
优惠的折扣。 - 4 promos 列出以函数实现的各个策略。
- 5 与其他几个 *_promo 函数一样,best_promo 函数的参数是一个Order 实例。
- 6 使用生成器表达式把 order 传给 promos 列表中的各个函数,返回折扣幅度最大的那个函数。
🚩 虽然示例 10-5 可行,而且易于理解,但是存在一些重复,可能导致不
易察觉的bug
:如果想添加新的促销策略,那么不仅要定义相应的函
数,还要记得把它添加到promos
列表中;否则,只能把新促销函数作
为参数显式传给Order
,因为best_promo
不知道新函数的存在。
找出一个模块中的全部策略
在 Python
中,模块也是一等对象,而且标准库提供了几个处理模块的
函数。Python
文档对内置函数 globals
的描述如下。
globals()
返回一个字典,表示当前的全局符号表。这个符号表始终针对当前模块(对函数或方法来说,是指定义它们的模块,而不是调用它们的模块)
示例10-7 内省模块的全局命名空间,构建promos
列表
from decimal import Decimal
# from strategy import Order
# from strategy import (
# fidelity_promo, bulk_item_promo, large_order_promo #❶
# )
promos = [promo for name, promo in globals().items() #❷
if name.endswith('_promo') and #❸
name != 'best_promo' #❹
]
def best_promo(order: Order) -> Decimal: #❺
"""选择可用的最佳折扣"""
return max(promo(order) for promo in promos)
❶ 导入促销函数,以便其在全局命名空间中可用。
❷ 迭代 globals()
返回的字典中的各项。
❸ 只选择以 _promo
结尾的值。
❹ 过滤掉 best_promo
自身,防止调用 best_promo
时出现无限递
归。
❺ best_promo
没有变化。
收集所有可用促销的另一种方法是,在一个单独的模块中保存所有的策
略函数(best_promo
除外)。
示例10-8 内省单独的promotions
模块,构建promos
列表
from decimal import Decimal
import inspect
from strategy import Order
import promotions
promos = [func for _, func in inspect.getmembers(promotions,
inspect.isfunction)]
def best_promo(order: Order) -> Decimal:
"""选择可用的最佳折扣"""
return max(promo(order) for promo in promos)
inspect.getmembers
函数用于获取对象(这里是 promotions
模块)
的属性,第二个参数是可选的判断条件(一个布尔值函数)。这里使用
的判断条件是 inspect.isfunction
,只获取模块中的函数。
不管怎么命名策略函数,示例 10-8 都行之有效。唯一的要求
是,promotions
模块只能包含计算订单折扣的函数。当然,这是对代
码的隐性假设。如果有人在 promotions
模块中使用不同的签名来定义
函数,那么在 best_promo
函数尝试将其应用到订单上时会出错。
使用装饰器改进策略模式
回顾一下,示例 10-6 的主要问题是,定义中出现了函数的名
称,best_promo
用来判断哪个折扣幅度最大的 promos
列表中也有函
数的名称。这种重复是个问题,因为新增策略函数后可能会忘记把它添
加到 promos
列表中,导致 best_promo
悄无声息忽略新策略,为系统
引入不易察觉的 bug
。示例 10-9 使用 9.4 节介绍的技术解决了这个问题。
示例10-9 promos
列表中的值使用promotion
装饰器填充
Promotion = Callable[[Order], Decimal]
promos: list[Promotion] = [] #❶
def promotion(promo: Promotion) -> Promotion: #❷
promos.append(promo)
return promo
def best_promo(order: Order) -> Decimal:
"""选择可用的最佳折扣"""
return max(promo(order) for promo in promos) #❸
@promotion #❹
def fidelity(order: Order) -> Decimal:
"""为积分为1000或以上的顾客提供5%折扣"""
if order.customer.fidelity >= 1000:
return order.total() * Decimal('0.05')
return Decimal(0)
@promotion
def bulk_item(order: Order) -> Decimal:
"""单个商品的数量为20个或以上时提供10%折扣"""
discount = Decimal(0)
for item in order.cart:
if item.quantity >= 20:
discount += item.total() * Decimal('0.1')
return discount
@promotion
def large_order(order: Order) -> Decimal:
"""订单中不同商品的数量达到10个或以上时提供7%折扣"""
distinct_items = {item.product for item in order.cart}
if len(distinct_items) >= 10:
return order.total() * Decimal('0.07')
return Decimal(0)
❶ promos
列表位于模块全局命名空间中,起初是空的。
❷ Promotion
是注册装饰器,在把 promo
函数添加到 promos
列表中
之后,它会原封不动地返回 promo
函数。
❸ 无须修改 best_promos
,因为它依赖于 promos
列表。
❹ 被 @promotion
装饰的函数都会添加到 promos
列表中。
与前几种方案相比,这种方案有以下几个优点。
- 促销策略函数无须使用特殊的名称(不用以
_promo
结尾)。 @promotion
装饰器突出了被装饰的函数的作用,还便于临时禁用某个促销策略:把装饰器注释掉即可。- 促销折扣策略可以在其他模块中定义,在系统中的任何地方都行,只要使用了
@promotion
装饰器。
示例10-10 MacroCommand
的各个实例都在内部存储着命令列表
class MacroCommand:
"""一个执行一组命令的命令"""
def __init__(self, commands):
self.commands = list(commands) #❶
def __call__(self):
for command in self.commands: #❷
command()
❶ 根据 commands
参数构建一个列表,这样能确保参数是可迭代对象,
还能在各个 MacroCommand
实例中保存各个命令引用的副本。
❷ 调用 MacroCommand
实例时,self.commands
中的各个命令依序执行。
复杂的命令模式(例如支持撤销操作)可能需要的不仅仅是简单的回调
函数。即便如此,也可以考虑使用 Python
提供的几个替代品。
- 像示例 10-10 中
MacroCommand
那样的可调用实例,可以保存任何
所需的状态,除了__call__
,还可以提供其他方法。 - 可以使用闭包在调用之间保存函数的内部状态。
使用一等函数对命令模式的重新审视到此结束。站在一定高度上看,这
里采用的方式与策略模式所用的方式类似:把实现单方法接口的类的实
例替换成可调用对象。毕竟,每个 Python
可调用对象都实现了单方法
接口,这个方法就是 __call__
。