0%

流畅的 Python 第 2 版(10):使用一等函数实现设计模式

在软件工程中,设计模式指解决常见设计问题的一般性方案。编程设计模式的使用通过 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides(​四人组​)合著的《设计模式》一书普及开来。虽然设计模式与语言无关,但这并不意味着每一个模式都能在每一门语言中使用,例如迭代器模式就深植 Python 语言之中。

本章的目标是展示在某些情况下,函数可以起到类的作用,而且写出的代码可读性更高且更简洁。

重构策略模式

如果合理利用作为一等对象的函数,则某些设计模式可以简化,而策略模式就是一个很好的例子。如下 UML 类图指出了策略模式对类的编排:

  • 上下文:提供一个服务,把一些计算委托给实现不同算法的可互换组件,即这里的 Order 类
  • 策略:实现不同算法的组件共同的接口,即这里的 Promotion 类
  • 策略的具体子类:FidelityPromo、BulkPromo 和 LargeOrderPromo 是这里实现的 3 个具体策略

具体策略由上下文类的客户选择。如下是一个实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
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)
  • Promotion 定义为了抽象基类,这么做是为了使用 @abstractmethod 装饰器,明确表明所用的模式

虽然这个例子完全可用,但是在 Python 中,如果把函数当作对象使用,则实现同样的功能所需的代码更少。

使用函数实现策略模式

上面的例子中,每个具体策略都是一个类,而且都只定义了一个方法,即 discount。此外,策略实例没有状态(没有实例属性)​,因此可以把具体策略换成了简单的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
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

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)
return self.total() - discount

def __repr__(self):
return f'<Order total: {self.total():.2f} due: {self.due():.2f}>'

def fidelity_promo(order: Order) -> Decimal:
"""为积分为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 的类型提示,含义是 promotion 既可以是 None,也可以是接收一个 Order 参数并返回一个 Decimal 值的可调用对象
  • 为了把折扣策略应用到 Order 实例上,只需把促销函数作为参数传入即可

值得注意的是,​《设计模式》一书指出:​策略对象通常是很好的享元(flyweight)。该书的另一部分对 享元 下了定义:​享元是可共享的对象,可以同时在多个上下文中使用

  • 共享是推荐的做法,这样在每个新的上下文(这里是 Order 实例)中使用相同的策略时则不必不断新建具体策略对象,从而减少消耗。
  • 在复杂的情况下,当需要用具体策略维护内部状态时,可能要把策略和享元模式结合起来
  • 但是,具体策略一般没有内部状态,只负责处理上下文中的数据。此时,一定要使用普通函数,而不是编写只有一个方法的类,再去实现另一个类声明的单函数接口。而且普通函数本身就是可共享的对象,无需额外的享元机制

至此,我们使用函数实现了策略模式,由此也出现了其他可能性。假设我们想创建一个 元策略​,为 Order 选择最佳折扣。

选择最佳策略的简单方式

如下实现了一个简单的最佳策略选择:

1
2
3
4
5
6
promos = [fidelity_promo, bulk_item_promo, large_order_promo]


def best_promo(order: Order) -> Decimal:
"""选择可用的最佳折扣"""
return max(promo(order) for promo in promos)
  • promos 是函数列表。习惯函数是一等对象后,自然而然就会构建那种数据结构来存储函数

虽然这个代码简单,但是如果想添加新的促销策略,那么不仅要定义相应的函数,还要记得把它添加到 promos 列表中。为了解决这个问题,有几种解决方案。

找出一个模块中的全部策略

在 Python 中,模块也是一等对象,而且标准库提供了几个处理模块的函数。globals() 返回一个字典,表示当前的全局符号表。这个符号表始终针对当前模块(对函数或方法来说,是指定义它们的模块,而不是调用它们的模块)​。

如下代码内省模块的全局命名空间,构建 promos 列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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)

这种方式还是要记得 import 对应的 xxx_promo 函数。另一种解决方案在一个单独的模块中保存所有的策略函数(best_promo除外)​:

1
2
3
4
5
6
7
8
9
10
11
12
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 函数判断属性是否为函数

当然这种方案并不完善,如果有人在 promotions 模块中使用不同的签名来定义函数,那么在 best_promo 函数尝试将其应用到订单上时会出错。

使用装饰器改进策略模式

动态收集促销折扣函数的一种更为显式的方案是使用简单的装饰器。定义中出现了函数的名称,best_promo 用来判断哪个折扣幅度最大的 promos 列表中也有函数的名称,这种重复是个问题,因为新增策略函数后可能会忘记把它添加到 promos 列表中,导致不易察觉的 bug。

如下代码使用装饰器解决这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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)
  • Promotion 是注册装饰器,在把 promo 函数添加到 promos 列表中之后,它会原封不动地返回 promo 函数

使用这种方案有多个优点:

  • 促销策略函数无须使用特殊的名称
  • @promotion 装饰器突出了被装饰的函数的作用,还便于临时禁用某个促销策略:把装饰器注释掉即可
  • 促销折扣策略可以在其他模块中定义,在系统中的任何地方都行,只要使用了 @promotion 装饰器

命令模式

命令设计模式也可以通过把函数作为参数传递而简化。如下展示了一般的命令模式的 UML 类图:

  • 各个命令可以有不同的接收者,即实现操作的对象
  • 命令模式的目的是解耦调用操作的对象(调用者)和提供实现的对象(接收者)​
  • 这个模式的做法是,在二者之间放一个 Command 对象,让它实现只有一个方法(execute)的接口,调用接收者中的方法执行所需的操作
  • 这样,调用者无须了解接收者的接口,而且不同的接收者可以适应不同的 Command 子类

命令模式是回调机制的面向对象替代品。可以不为调用者提供 Command 实例,而是给它一个函数。此时,调用者不调用 command.execute(),而是直接调用 command()。如下是一个简答的 Command 类,它的实例就是可调用对象:

1
2
3
4
5
6
7
8
9
class MacroCommand:
"""一个执行一组命令的命令"""

def __init__(self, commands):
self.commands = list(commands)

def __call__(self):
for command in self.commands:
command()

复杂的命令模式(例如支持撤销操作)可能需要的不仅仅是简单的回调函数。即便如此,也可以考虑使用 Python 提供的几个替代品:

  • 类似于 MacroCommand 那样的可调用实例,可以保存任何所需的状态,除了 __call__,还可以提供其他方法
  • 可以使用闭包在调用之间保存函数的内部状态

很多情况下,在 Python 中使用函数或可调用对象实现回调更自然。有时,设计模式或 API 要求组件实现单方法接口,而该方法有一个很宽泛的名称,例如 executerundo_it​。在 Python 中,这些模式或 API 通常可以使用作为一等对象的函数实现,从而减少样板代码。