第十章 使用一等函数实现设计模式

是否符合模式并不是衡量好坏的标准。

                                                        ----Ralph Johnson, Coauthor of the Design Patterns classic

在软件工程中,设计模式是解决常见设计问题的通用方法。你不需要知道设计模式来学习本章。我将解释示例中使用的模式。

设计模式在编程中的使用由具有里程碑意义的书籍 Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1995) by Erich Gamma, Richard Helm, Ralph Johnson & John Vlissides—a.k.a. “the Gang of Four.”普及。这本书是一个包含 23 种模式的目录,由类的排列组成,以 C++ 中的代码为例,假设在其他面向对象的语言中也很有用。

尽管设计模式与语言无关,但这并不意味着每种模式都适用于每种语言。例如,第 17 章将表明在 Python 中模拟迭代器模式的做法是没有意义的,因为该模式嵌入在语言中并以生成器的形式进行使用——它不需要类来工作,并且比经典方式需要更少的代码。

设计模式的作者在他们的介绍中承认,实现的语言决定了哪些模式是可用的:

        编程语言的选择很重要,因为它会影响一个人的观点。我们的模式假设采用Smalltalk/C++ 级别的语言特性,而这种选择决定了哪些可以轻松实现,哪些不能轻松实现。如果我们假设过程式语言,我们可能会包含称为“继承”、“封装”和“多态”的设计模式。同样,我们的一些模式直接由不太常见的面向对象语言支持。例如,CLOS 具有多种方法,可减少对诸如访问者模式之类的需求。

在他 1996 年的演讲“Design Patterns in Dynamic Languages”中,Peter Norvig 指出原始设计模式书中的 23 种模式中有 16 种在动态语言中变得“不可见或更简单”(幻灯片 9)。他谈论的是 Lisp 和 Dylan 语言,但许多相关的动态特性也存在于 Python 中。特别是,在具有一等函数的语言的上下文中,Norvig 建议重新思考称为策略、命令、模板方法和访问者的经典模式。

本章的目标是展示在某些情况下如何通过使用更短且更易于阅读的代码,函数可以完成与类相同的工作。我们将使用函数作为对象重构 Strategy策略模式 的实现,删除大量样板代码。我们还将讨论简化命令模式的类似方法。

本章的新内容

我将本章移到第三部分的末尾,以便我可以在“装饰器增强策略模式”中应用注册装饰器,并在示例中使用类型提示。本章中使用的大多数类型提示并不复杂,它们确实有助于提高可读性。

案例研究:重构策略模式

策略模式是设计模式的一个很好的例子,如果您将函数用作一等对象,那么在 Python 中它可以更简单。在下一节中,我们使用设计模式中描述的“经典”结构来描述和实现策略模式。如果你熟悉经典模式,你可以跳到“面向函数的策略模式”,我们使用函数重构代码,显着减少行数。

经典策略模式

图 10-1 中的 UML 类图描绘了示例策略模式的类的排布。

策略模式在设计模式中总结如下:

定义一系列算法,封装每个算法,并使它们可以互换。策略让算法独立于使用它的客户而变化。 

策略模式在电子商务领域应用的一个明显例子是根据客户的属性或对订购商品的检查计算订单折扣。

考虑具有以下折扣规则的在线商店:

  • 拥有 1,000 或更多积分的客户每笔订单可享受全部 5% 的折扣。
  • 在同一订单中,单个商品数量达到20个及以上,享受10%折扣
  • 包含至少 10 件不同商品的订单可享受 全部7% 的折扣。

为简洁起见,我们假设一个订单只能享受一个折扣。

策略模式的 UML 类图如图 10-1 所示。其参与者是:

上下文:通过将某些计算委托给实现替代算法的可互换组件来提供服务。在电子商务示例中,上下文是一个order,它被配置为根据多种算法之一应用促销折扣。

策略:实现不同算法的组件通用的接口。在我们的示例中,此角色由名为 Promotion 的抽象类扮演。

具体策略:策略的具体子类之一。 FidelityPromo、BulkPromo 和 LargeOrderPromo 是实施的三个具体策略。

示例 10-1 中的代码遵循图 10-1 中的蓝图。如设计模式中所述,具体策略由上下文类的客户选择。在我们的示例中,在实例化order之前,系统会以某种方式选择促销折扣策略并将其传递给 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)

 请注意,在示例 10-1 中,我将 Promotion 编码为抽象基类 (ABC),以使用 @abstractmethod 装饰器并使模式更加明确。

示例 10-2 显示了用于演示和验证模块的操作的文档测试,该模块实现了前面描述的规则。

例 10-2。应用不同促销的 Order 类的示例用法。

    >>> joe = Customer('John Doe', 0)  1
    >>> ann = Customer('Ann Smith', 1100)
    >>> cart = (LineItem('banana', 4, Decimal('.5')),  2
    ...         LineItem('apple', 10, Decimal('1.5')),
    ...         LineItem('watermelon', 5, Decimal(5)))
    >>> Order(joe, cart, FidelityPromo())  3
    <Order total: 42.00 due: 42.00>
    >>> Order(ann, cart, FidelityPromo())  4
    <Order total: 42.00 due: 39.90>
    >>> banana_cart = (LineItem('banana', 30, Decimal('.5')),  5
    ...                LineItem('apple', 10, Decimal('1.5')))
    >>> Order(joe, banana_cart, BulkItemPromo())  6
    <Order total: 30.00 due: 28.50>
    >>> long_cart = tuple(LineItem(str(sku), 1, Decimal(1)) 7
    ...                  for sku in range(10))
    >>> Order(joe, long_cart, LargeOrderPromo())  8
    <Order total: 10.00 due: 9.30>
    >>> Order(joe, cart, LargeOrderPromo())
    <Order total: 42.00 due: 42.00>
  1. 两个客户:乔有 0 积分点,安有 1,100。
  2. 一个包含三个订单项的购物车。
  3. FidelityPromo 促销活动不给乔打折。
  4. 安获得 5% 的折扣,因为她有 1,000 以上积分。
  5. banana cart有 30 个单位的“香蕉”产品和 10 个苹果。
  6. 由于 BulkItemPromo,乔获得了 1.50 美元的香蕉折扣。
  7. long_order有10个不同的商品,每个商品加个为1美元
  8. 由于LargerOrderPromo,joe 获得了整个订单7% 的折扣。 

示例 10-1 运行良好,但通过将函数用作对象,可以在 Python 中用更少的代码实现相同的功能。下一节将展示如何操作。 

面向函数的策略模式

 示例 10-1 中的每个具体策略都是一个具有单个方法discount的类。此外,strategy实例没有状态(没有实例属性)。你可以说它们看起来很像普通的函数,你是对的。例 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:  # the Context
    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
    """5% discount for customers with 1000 or more fidelity points"""
    if order.customer.fidelity >= 1000:
        return order.total() * Decimal('0.05')
    return Decimal(0)


def bulk_item_promo(order: Order) -> Decimal:
    """10% discount for each LineItem with 20 or more units"""
    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:
    """7% discount for orders with 10 or more distinct items"""
    distinct_items = {item.product for item in order.cart}
    if len(distinct_items) >= 10:
        return order.total() * Decimal('0.07')
    return Decimal(0)
  1. 这个类型提示说:promotion 可能是 None,或者它可能是一个接收 Order 参数并返回一个 Decimal 的可调用对象。
  2. 要计算折扣,请调用 self.promotion 可调用对象,将 self 作为参数传递。原因见下文。
  3. 没有抽象类
  4. 每个策略都是一个函数。

WHY SELF.PROMOTION(SELF)

在 Order 类中,promotion 不是一个单独的方法。它是一个可调用的实例属性。因此,表达式的第一部分 self.promotion 检索可调用对象。要调用它,我们必须提供一个 Order 的实例,在这种情况下是 self。这就是 self 在该表达式中出现两次的原因。

“Methods Are Descriptors”将解释自动将方法绑定到实例的机制。它不适用于promotion,因为它不是一种方法。


示例 10-3 中的代码比示例 10-1 短。使用新的 Order 也更简单一些,如示例 10-4 doctests 所示。

    >>> joe = Customer('John Doe', 0)  1
    >>> ann = Customer('Ann Smith', 1100)
    >>> cart = [LineItem('banana', 4, Decimal('.5')),
    ...         LineItem('apple', 10, Decimal('1.5')),
    ...         LineItem('watermelon', 5, Decimal(5))]
    >>> Order(joe, cart, fidelity_promo)  2
    <Order total: 42.00 due: 42.00>
    >>> Order(ann, cart, fidelity_promo)
    <Order total: 42.00 due: 39.90>
    >>> banana_cart = [LineItem('banana', 30, Decimal('.5')),
    ...                LineItem('apple', 10, Decimal('1.5'))]
    >>> Order(joe, banana_cart, bulk_item_promo)  3
    <Order total: 30.00 due: 28.50>
    >>> long_cart = [LineItem(str(item_code), 1, Decimal(1))
    ...               for item_code in range(10)]
    >>> Order(joe, long_cart, large_order_promo)
    <Order total: 10.00 due: 9.30>
    >>> Order(joe, cart, large_order_promo)
    <Order total: 42.00 due: 42.00>
  1. 与示例 10-1 相同的测试装置。
  2.  要将折扣策略应用于order,只需将promotion函数作为参数传递。
  3. 此处和下一个测试中使用了不同的promotion函数。

请注意示例 10-4 中的标注:无需为每个新订单实例化一个新的promotion对象:函数可以拿来就用。

 有趣的是,作者在设计模式中建议:“策略对象通常是很好的享元。”该工作的另一部分中对享元的定义指出:“享元是一个共享对象,可以同时在多个上下文中使用。推荐使用享元以降低创建新的具体策略对象的成本,因为在我们的示例中,对每个新上下文(在每个​​新的 Order 实例中)一遍又一遍地应用相同的策略。因此,为了克服 Strategy 模式的一个缺点——它的运行时成本——作者建议应用另一种模式。同时,您的代码的行数和维护成本正在增长。

一个棘手的用例,具有保存内部状态的复杂的具体策略,可能需要组合策略和享元设计模式的所有部分。但往往具体的策略没有内部状态;他们只处理上下文中的数据。如果是这种情况,那么一定要使用以前使用的普通的函数,而不是使用在另一个类中声明的单方法接口的单方法类。函数比用户定义的类的实例更轻量级,并且不需要享元,因为每个策略函数在每个 Python 进程加载模块时只创建一次。普通函数也是“可以同时在多个上下文中使用的共享对象”。

现在我们已经用函数实现了策略模式,其他的可能性就出现了。假设您要创建一个“元策略”,为给定的order选择最佳可用折扣。以下部分中,我们将研究使用各种将函数和模块作为对象的方法来实现此要求的其他重构。

选择最佳策略:简单方法

给定示例 10-4 中的测试中的客户和购物车相同,我们现在在示例 10-5 中添加三个额外的测试。

例 10-5。 best_promo 函数应用所有折扣并返回最大折扣

 >>> Order(joe, long_cart, best_promo)  1
    <Order total: 10.00 due: 9.30>
 >>> Order(joe, banana_cart, best_promo)  2
    <Order total: 30.00 due: 28.50>
 >>> Order(ann, cart, best_promo)  3
    <Order total: 42.00 due: 39.90>
  1. best_promo 为客户 joe 选择了 large_order_promo。
  2. 在这里,joe从bulk_item_promo 获得了订购大量香蕉的折扣。
  3. 使用简单的购物车结账时,best_promo 为忠实客户 ann 提供了 fidelity_promo 的折扣。

best_promo 的实现非常简单。请参见示例 10-6。

例 10-6。 best_promo 查找迭代函数列表的最大折扣

promos = [fidelity_promo, bulk_item_promo, large_order_promo]  1


def best_promo(order: Order) -> Decimal:  2
    """Compute the best discount available"""
    return max(promo(order) for promo in promos)  3
  1. promos:作为函数实现的策略列表。
  2. best_promo 将 Order实例作为参数,其他 *_promo 函数也是如此。
  3. 使用生成器表达式,我们将 promos 中的每个函数应用于Order,并返回计算出的最大折扣。

示例 10-6 很简单:promos 是一个函数列表。一旦你习惯了函数是一等对象的观念,自然而然地,构建包含函数的数据结构通常是有意义的。

尽管示例 10-6 有效且易于阅读,但仍有一些重复可能会导致一个微妙的错误:要添加新的promo促销策略,我们需要对函数进行编码并记住需要将其添加到promos列表中,否则新的promo将在作为参数显式传递给 Order 时起作用,但不会被 best_promotion 考虑。

请继续阅读此问题的几种解决方案。

在模块中找出全部策略

Python 中的模块也是一等对象,标准库提供了几个函数来处理它们。 Python 文档中对内置全局变量的描述如下:

 globals():

        返回表示当前全局符号表的字典。这始终是当前模块的字典(在函数或方法中,这是定义它的模块,而不是调用它的模块)。

示例 10-7 是一种使用globals()帮助 best_promo 自动找到其他可用 *_promo 函数的复杂的方法。

例 10-7。 promos 列表是通过对模块全局命名空间的内省构建的

from decimal import Decimal
from strategy import Order
from strategy import (
    fidelity_promo, bulk_item_promo, large_order_promo  1
)

promos = [promo for name, promo in globals().items()  2
                if name.endswith('_promo') and        3
                   name != 'best_promo'               4
]


def best_promo(order: Order) -> Decimal:              5
    """Compute the best discount available"""
    return max(promo(order) for promo in promos)
  1.  导入promo函数,使其在全局命名空间中可用。
  2. 迭代 globals() 返回的 dict 中的每个item。
  3. 仅选择名称以 _promo 后缀结尾的值,并且...
  4. 过滤出 best_promo 本身,以避免在调用 best_promo 时无限递归。
  5. best_promo 没有变化。

收集可用的促销策略的另一种方法是创建一个模块并将所有策略函数放在那里,除了 best_promo。

在示例 10-8 中,唯一显着的变化是策略函数列表是通过对名为 Promotions 的单独模块的内省构建的。请注意,示例 10-8 依赖于导入 Promotions 模块和 inspect,后者提供高级内省功能。

例 10-8。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:
    """Compute the best discount available"""
    return max(promo(order) for promo in promos)

函数inspect.getmembers 返回一个对象的属性——在本例中是promotions 模块——可以选择通过谓词(布尔函数)进行过滤。我们使用inspect.isfunction 只从模块中获取函数。

示例 10-8 的工作与函数的名称无关;重要的是促销模块只包含计算给定订单折扣的函数。当然,这是代码的隐含假设。如果有人要在促销模块中创建具有不同签名的函数,则 best_promo 在尝试将其应用于订单时会中断。

我们可以添加更严格的测试来过滤函数,例如通过检查它们的参数。示例 10-8 的重点不是提供完整的解决方案,而是强调模块自省的一种可能用途。

动态收集促销折扣函数的一个更明确的替代方法是使用简单的装饰器。这是接下来的内容。

装饰器增强策略模式

回想一下,示例 10-6 的主要问题是函数名称在其定义中的重复,然后在 best_promo 函数使用的促销列表中又重复一次,以确定适用的最高折扣。

这个重复性是有问题的,因为有人可能会添加一个新的促销策略功能而忘记手动将其添加到促销列表中,在这种情况下,best_promo 将默默地忽略新策略,在系统中引入一个微妙的错误。示例 10-9 使用“注册装饰器”中介绍的技术解决了这个问题。

例 10-9。promos列表由promo装饰器填写

Promotion = Callable[[Order], Decimal]

promos: list[Promotion] = []  1


def promotion(promo: Promotion) -> Promotion:  2
    promos.append(promo)
    return promo


def best_promo(order: Order) -> Decimal:
    """Compute the best discount available"""
    return max(promo(order) for promo in promos)  3


@promotion  4
def fidelity(order: Order) -> Decimal:
    """5% discount for customers with 1000 or more fidelity points"""
    if order.customer.fidelity >= 1000:
        return order.total() * Decimal('0.05')
    return Decimal(0)


@promotion
def bulk_item(order: Order) -> Decimal:
    """10% discount for each LineItem with 20 or more units"""
    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:
    """7% discount for orders with 10 or more distinct items"""
    distinct_items = {item.product for item in order.cart}
    if len(distinct_items) >= 10:
        return order.total() * Decimal('0.07')
    return Decimal(0)
  1. promos 列表是一个全局模块,开始是一个空列表。
  2. promotion是一个注册装饰器:它在将promo函数附加到promos列表后返回不变的promo函数。
  3. best_promo 不需要更改,因为它依赖于promos列表。
  4. 任何由@promotion 修饰的函数都会被添加到promos。

与之前介绍的其他解决方案相比,此解决方案有几个优点:

  • promo策略函数不必使用特殊名称——不需要 _promo 后缀。
  • @promotion 装饰器突出了装饰函数的目的,还可以轻松的暂时禁用促销策略:只需注释掉装饰器即可。
  • 促销折扣策略可以在系统的任何地方的其他模块中定义,只要@promotion 装饰器应用于它们即可。

在下一节中,我们将讨论命令模式——另一种设计模式,当普通函数可以执行时,它有时会通过单方法类实现。

命令模式

命令是另一种设计模式,可以通过使用作为参数传递的函数来简化。图 10-2 显示了命令模式中类的编排。

命令模式的目标是将调用操作的对象(调用者)与实现它的提供者对象(接收者)解耦。在 Design Patterns 的示例中,每个调用者都是图形应用程序中的一个菜单项,而接收者是正在编辑的文档或应用程序本身。

其思想是在两者之间放置一个 Command 对象,实现一个接口,该接口具有单个方法 execute,该方法调用 Receiver 中的某些方法来执行所需的操作。这样Invoker就不需要知道Receiver的接口,不同的Receiver可以通过不同的Command子类适配。Invoker 配置了一个具体的command,并调用其 execute 方法对其进行操作。请注意,在图 10-2 中,MacroCommand 可能存储一系列命令;它的 execute() 方法在存储的每个命令中调用相同的方法。

引用 Gamma 等人的话说,“命令是回调的面向对象的替代品。”问题是:我们需要一个面向对象的回调来替代吗?有时是的,但并非总是如此。

我们可以简单地给它一个函数,而不是给 Invoker 一个 Command 实例。 Invoker 可以只调用 command(),而不是调用 command.execute()。MacroCommand 可以通过一个实现 __call__ 的类来实现。 MacroCommand 的实例将是可调用的,每个实例都包含一个函数列表以供将来调用,如示例 10-10 中实现的那样。

例 10-10。 MacroCommand 的每个实例都有一个内部命令列表

class MacroCommand:
    """A command that executes a list of commands"""

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

    def __call__(self):
        for command in self.commands:  2
            command()
  1. 从命令参数构建列表可确保它是可迭代的,并在每个 MacroCommand 实例中保留命令引用的本地副本。
  2. 当 MacroCommand 的实例被调用时,self.commands 中的每个命令都会被依次调用。

命令模式的更高级用途——例如支持撤消——可能需要的不仅仅是一个简单的回调函数。即便如此,Python 也提供了一些值得考虑的替代方案:

  • 示例 10-10 中的 MacroCommand 等可调用实例可以保持任何必要的状态,并提供除 __call__ 之外的额外方法。
  • 可使用闭包用于在调用之间保持函数的内部状态。

这结束了我们对具有一流功能的命令模式的重新思考。在高层次上,这里的方法类似于我们应用于 Strategy 的方法:用可调用对象替换实现单方法接口的参与者类的实例。毕竟,每个 Python 可调用对象都实现了一个单方法接口,该方法名为 __call__。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值