策略模式是常见的设计模式之一。本文探讨Python中关于策略模式的应用,以电商购物车的不同打折策略的实现为例,并示范怎么逐步优化代码的实现


需求描述

假如一个网店制定了下述折扣规则: 1、 有 1000 或以上积分的顾客,每个订单享 5% 折扣。 2、 同一订单中,单个商品的数量达到 20 个或以上,享 10% 折扣。 3、 订单中的不同商品达到 10 个或以上,享 7% 折扣。

简单起见,我们假定一个订单一次只能享用一个折扣。

需求分析

不同的打折策略对应着设计模式的策略模式,以策略模式的思路来解决问题即可。

用策略类实现策略模式

典型的策略实现是以策略类为基础,不同的策略创建不同的类。 解决方案分析如下:

上下文

  把一些计算委托给实现不同算法的可互换组件,它提供服务。在这个电商示例中,上下文是 Order,它会根据不同的算法计算促销折扣。

策略

  实现不同算法的组件共同的接口。在这个示例中,名为 Promotion 的抽象类扮演这个角色。

具体策略

  “策略”的具体子类。fidelityPromo、BulkPromo 和 LargeOrderPromo 是这里实现的三个具体策略。

按照《设计模式:可复用面向对象软件的基础》一书的说明,具体策略由上下文类的客户选择。在这个示例 中,实例化订单之前,系统会以某种方式选择一种促销折扣策略,然后把它传给 Order 构造方法。 具体怎么选择策略,不在这个模式的职责范围内。

实现 Order 类,支持插入式折扣策略。

实现代码如下:

from abc import ABC, abstractclassmethod
from collections import namedtuple

Customer = namedtuple('Customer', 'name fidelity')


class LineItem:
    '''购物车条目类 每个条目有属性:产品 价格 数量'''

    def __init__(self, product, quantity, price):
        self.product = product
        self.quantity = quantity
        self.price = price

    def total(self):
        '''购物车一个条目的总价'''
        return self.price * self.quantity


class Order:
    '''订单类 客户 购物车 折扣 计算总价和应付款 '''

    def __init__(self, customer, cart, promotion):
        self.customer = customer
        self.cart = list(cart)
        self.promotion = promotion

    def total(self):
        '''打折前的总价'''
        # if not hasattr(self, '__total'): # 为什么要加这句判断?
        #     self.__total = sum(item.total() for item in self.cart)
        # return self.__total
        return sum(item.total() for item in self.cart)

    def due(self):
        '''打折后的应付款'''
        if not self.promotion:
            discount = 0
        else:
            discount = self.promotion.discount(self)
        return self.total() - discount

    def __repr__(self):
        fmt = '<Order total: {:.2f} due: {:.2f}>'
        return fmt.format(self.total(), self.due())
        
        
class Promotion(ABC):
    '''在 Python 3.4 中,声明抽象基类最简单的方式是子类化 abc.ABC。'''

    @abstractclassmethod
    def discount(self, order):
        pass


class FidelityPromo(Promotion):

    def discount(self, order):
        return order.total() * .05 if order.customer.fidelity >= 1000 else 0



class BulkItemPromo(Promotion):

    def discount(self, order):
        discount = 0
        for item in order.cart:
            if item.quantity >= 20:
                discount += item.total() * .1
        return discount


class LargeOrderPromo(Promotion):

    def discount(self, order):
        distinct_items = {item.product for item in order.cart}
        if len(distinct_items) >= 10:
            return order.total() * .07
        return 0

测试用例:

joe = Customer('John Doe', 0)
ann = Customer('Ann Smith', 1100)
cart = [LineItem('banana', 4, .5), 
        LineItem('apple', 10, 1.5), 
        LineItem('watermellon', 5, 5.0)]
print(Order(joe, cart, FidelityPromo()))
print(Order(ann, cart, FidelityPromo()))
banana_cart = [LineItem('banana', 30, .5), 
                LineItem('apple', 10, 1.5)]
print(Order(joe, banana_cart, BulkItemPromo()))
long_order = [LineItem(str(item_code), 1, 1.0) for item_code in range(10)]
print(Order(joe, long_order, LargeOrderPromo()))
print(Order(joe, cart, LargeOrderPromo()))

用函数实现策略模式

利用 Python 中作为对象的函数,可以使用更少的代码实现相同的功能。 每个具体策略都是一个类,而且都只定义了一个方法,即 discount。此外,策略实例没有状态(没有实例属性)。你可能会说,它们看起来像是普通的函数——的确如此。可以把具体策略换成简单的函数,去掉 Promotion 抽象类。用函数分别实现不同的促销策略,并把促销函数作为参数传入即可。

以下是重构之后的实现:

from collections import namedtuple

Customer = namedtuple('Customer', 'name fidelity')


class LineItem:
    '''购物车条目类 每个条目有属性:产品 价格 数量'''

    def __init__(self, product, quantity, price):
        self.product = product
        self.quantity = quantity
        self.price = price

    def total(self):
        '''购物车一个条目的总价'''
        return self.price * self.quantity


class Order:
    '''订单类 客户 购物车 折扣 计算总价和应付款 '''

    def __init__(self, customer, cart, promotion):
        self.customer = customer
        self.cart = list(cart)
        self.promotion = promotion

    def total(self):
        '''打折前的总价'''
        # if not hasattr(self, '__total'): # 为什么要加这句判断?
        #     self.__total = sum(item.total() for item in self.cart)
        # return self.__total
        return sum(item.total() for item in self.cart)

    def due(self):
        '''打折后的应付款'''
        if not self.promotion:
            discount = 0
        else:
            discount = self.promotion(self)
        return self.total() - discount

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



def fidelity_promo(order):
    return order.total() * .05 if order.customer.fidelity >= 1000 else 0

def bulk_item_promo(order):
    discount = 0
    for item in order.cart:
        if item.quantity >= 20:
            discount += item.total() * .1
    return discount

def large_order_promo(order):
    distinct_items = {item.product for item in order.cart}
    if len(distinct_items) >= 10:
        return order.total() * .07
    return 0

不难看出,用函数实现策略后,去掉了抽象类和策略类,代码更精简,也更加易读, 新的 Order 类使用起来更简单。

测试用例:

joe = Customer('John Doe', 0)
ann = Customer('Ann Smith', 1100)
cart = [LineItem('banana', 4, .5), 
        LineItem('apple', 10, 1.5), 
        LineItem('watermellon', 5, 5.0)]
print(Order(joe, cart, fidelity_promo))
print(Order(ann, cart, fidelity_promo))
banana_cart = [LineItem('banana', 30, .5), 
                LineItem('apple', 10, 1.5)]
print(Order(joe, banana_cart, bulk_item_promo))
long_order = [LineItem(str(item_code), 1, 1.0) for item_code in range(10)]
print(Order(joe, long_order, large_order_promo))
print(Order(joe, cart, large_order_promo))

具体策略一般没有内部状态,只是处理上下文中的数据。此时,适合使用普通的函数,别去编写只有一个方法的类,再去实现另一个类声明的单函数接口。函数比用户定义的类的实例轻量,因为各个策略函数在 Python 编译模块时只会创建一次。普通的函数也是“可共享的对象,可以同时在多个上下文中使用”。(模式是死的,人是活的!)

需求升级与实现

新的需求,帮用户自动选择最优惠的打折方式。用户买单的时候自动判断好了最划算的打折并返回应付款。

解决方案:

迭代一个函数列表,并找出折扣额度最大的,实现代码如下:

promos = [fidelity_promo, bulk_item_promo, large_order_promo]

def best_promo(order):
    return max(promo(order) for promo in promos) # 生成器表达式 简洁有力!

测试用例:

joe = Customer('John Doe', 0)
ann = Customer('Ann Smith', 1100)
cart = [LineItem('banana', 4, .5), 
        LineItem('apple', 10, 1.5), 
        LineItem('watermellon', 5, 5.0)]
print(Order(joe, cart, best_promo))
print(Order(ann, cart, best_promo))
banana_cart = [LineItem('banana', 30, .5), 
                LineItem('apple', 10, 1.5)]
print(Order(joe, banana_cart, best_promo))
long_order = [LineItem(str(item_code), 1, 1.0) for item_code in range(10)]
print(Order(joe, long_order, best_promo))
print(Order(joe, cart, best_promo))

以上函数列表的方式虽然简洁易读,但有容易被忽略的缺陷,新添加的促销策略,要添加到promos列表中,否则出问题。

解决方案是利用内置函数globals(): globals()返回一个字典,表示当前的全局符号表。这个符号表始终针对当前模块(对函数或方法来说,是指定义它们的模块,而不是调用它们的模块)。

借助globals 函数帮助 best_promo 自动找到其他可用的 *_promo 函数,实现如下:

promos = [globals()[name] for name in globals()
            if name.endswith('_promo')
            and name != 'best_promo']

def best_promo(order):
    return max(promo(order) for promo in promos)

测试用例:

joe = Customer('John Doe', 0)
ann = Customer('Ann Smith', 1100)
cart = [LineItem('banana', 4, .5), 
        LineItem('apple', 10, 1.5), 
        LineItem('watermellon', 5, 5.0)]
print(Order(joe, cart, best_promo))
print(Order(ann, cart, best_promo))
banana_cart = [LineItem('banana', 30, .5), 
                LineItem('apple', 10, 1.5)]
print(Order(joe, banana_cart, best_promo))
long_order = [LineItem(str(item_code), 1, 1.0) for item_code in range(10)]
print(Order(joe, long_order, best_promo))
print(Order(joe, cart, best_promo))

以上的改进已经挺好的了,但其中有个小限制是要求策略函数都以_promo结果。 一种思路是既然要收集所有可用的促销函数,就可以在单独的模块中保存策略函数, 把best_promo排除在外。 要导入 promotions 模块,得用到高阶内省函数的 inspect 模块,inspect.getmembers 函数用于获取对象(这里是 promotions 模块)的属性,第二个参数是可选的判断条件(一个布尔值函数)。我们使用的是 inspect.isfunction,只获取模块中的函数。

实现如下:

(忽略把策略函数放到单独模块promotion的过程)

import promotion
import inspect

promos = [func for _, func in inspect.getmembers(promotion, inspect.isfunction)]

def best_promo(order):
    return max(promo(order) for promo in promos)

测试用例:

joe = Customer('John Doe', 0)
ann = Customer('Ann Smith', 1100)
cart = [LineItem('banana', 4, .5), 
        LineItem('apple', 10, 1.5), 
        LineItem('watermellon', 5, 5.0)]
print(Order(joe, cart, best_promo))
print(Order(ann, cart, best_promo))
banana_cart = [LineItem('banana', 30, .5), 
                LineItem('apple', 10, 1.5)]
print(Order(joe, banana_cart, best_promo))
long_order = [LineItem(str(item_code), 1, 1.0) for item_code in range(10)]
print(Order(joe, long_order, best_promo))
print(Order(joe, cart, best_promo))

修改后,经测试可以是行得通的,把策略函数的实现放到单独的模块,逻辑清晰,方便扩展, 定义函数也更加灵活自在,不必拘泥于函数名。到此可能会觉得已经改进得差不多了。但其实是有问题的。 promotions 模块只能包含计算订单折扣的函数!这是对代码的隐性假设。如果有人在 promotions 模块中使用不同的签名定义函数,那么 best_promo 函数尝试将其应用到订单上时会出错。(实际上以_promo结尾标记策略函数也有同样的问题。) 解决方案思路之一是可以添加更为严格的测试,审查传给实例的参数,进一步过滤函数。

但有更简单更优雅地方式来解决这个问题: 使用简单的装饰器来实现动态收集促销折扣函数!

装饰器的一个关键特性是,它们在被装饰的函数定义之后立即运行。 这通常是在导入时(即 Python 加载模块时)而不是运行时。

promos 列表中的值使用 promotion 装饰器填充,代码重构如下:

promos = []

def promotion(promo_func):
    promos.append(promo_func)
    return promo_func


@promotion
def fidelity_promo(order):
    return order.total() * .05 if order.customer.fidelity >= 1000 else 0

@promotion
def bulk_item_promo(order):
    discount = 0
    for item in order.cart:
        if item.quantity >= 20:
            discount += item.total() * .1
    return discount

@promotion
def large_order_promo(order):
    distinct_items = {item.product for item in order.cart}
    if len(distinct_items) >= 10:
        return order.total() * .07
    return 0


def best_promo(order):
    return max(promo(order) for promo in promos)

测试用例:

joe = Customer('John Doe', 0)
ann = Customer('Ann Smith', 1100)
cart = [LineItem('banana', 4, .5), 
        LineItem('apple', 10, 1.5), 
        LineItem('watermellon', 5, 5.0)]
print(Order(joe, cart, best_promo))
print(Order(ann, cart, best_promo))
banana_cart = [LineItem('banana', 30, .5), 
                LineItem('apple', 10, 1.5)]
print(Order(joe, banana_cart, best_promo))
long_order = [LineItem(str(item_code), 1, 1.0) for item_code in range(10)]
print(Order(joe, long_order, best_promo))
print(Order(joe, cart, best_promo))

到此,得到了一个相对完美的解决方案。优点如下:

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

本文内容参考整理自《流畅的Python》相关的内容,做了少许修改。示例代码的逐步优化很棒👍,涉及不少Python的知识点的巧妙应用,精彩!