Python設計模式知多少

自動化程式碼美學發表於2021-05-21

設計模式

設計模式是前輩們經過相當長的一段時間的試驗和錯誤總結出來的最佳實踐。我找到的資料列舉了以下這些設計模式:工廠模式、抽象工廠模式、單例模式、建造者模式、原型模式、介面卡模式、橋接模式、過濾器模式、組合模式、裝飾器模式、外觀模式、享元模式、代理模式、責任鏈模式、命令模式、直譯器模式、迭代器模式、中介者模式、備忘錄模式、觀察者模式、狀態模式、空物件模式、策略模式、模板模式、訪問者模式、MVC模式、業務代表模式、組合實體模式、資料訪問物件模式、前端控制器模式、攔截過濾器模式、服務定位器模式、傳輸物件模式,共33種

這些設計模式在純物件導向程式語言中使用最多。Python擁有一等函式,既不需要使用某些設計模式,也減少了某些設計模式樣板程式碼。本文將使用一等函式實現策略模式和命令模式,研究Python程式碼是如何簡化的。

策略模式

策略模式概述:“定義一系列演算法,把它們一一封裝起來,並且使它們可以相互替換。本模式使得演算法可以獨立於使用它的客戶而變化。”

經典實現

示例,根據客戶的屬性或訂單中的商品計算折扣,規則如下:

  • 有1000或以上積分的客戶,每個訂單享5%折扣。
  • 同一訂單中,單個商品的數量達到20個或以上,享10%折扣。
  • 訂單中的不同商品達到10個或以上,享7%折扣。

這很適合用策略模式來做,UML類圖設計如下:

image-20210419085600095

  • 上下文,整合演算法的類,圖中Order會根據不同的演算法計算折扣。
  • 策略,實現不同演算法的元件的共同介面,圖中Promotion是個抽象類。
  • 具體策略,策略的具體子類,圖中FidelityPromo、BulkItemPromo、LargeOrderPromo分別對應上面3條計算折扣規則。

程式碼實現如下:

from abc import ABC, abstractmethod
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:  # the Context

    def __init__(self, customer, cart, promotion=None):
        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

    def due(self):
        if self.promotion is None:
            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):  # the Strategy: an Abstract Base Class

    @abstractmethod
    def discount(self, order):
        """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):
        return order.total() * .05 if order.customer.fidelity >= 1000 else 0


class BulkItemPromo(Promotion):  # second Concrete Strategy
    """10% discount for each LineItem with 20 or more units"""

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


class LargeOrderPromo(Promotion):  # third Concrete Strategy
    """7% discount for orders with 10 or more distinct items"""

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

實現策略模式的關鍵程式碼是Promotion類,它是一個抽象基類,通過繼承abc.ABC來定義。

測試下這段程式碼:

>>> joe = Customer('John Doe', 0)  # 顧客joe積分0
>>> ann = Customer('Ann Smith', 1100)  # 顧客ann積分1100

# 測試第一條折扣規則
>>> cart = [LineItem('banana', 4, .5),  # 3類商品
...         LineItem('apple', 10, 1.5),
...         LineItem('watermellon', 5, 5.0)]
>>> Order(joe, cart, FidelityPromo())
<Order total: 42.00 due: 42.00>
>>> Order(ann, cart, FidelityPromo())  # 積分折扣
<Order total: 42.00 due: 39.90>

# 測試第二條折扣規則
>>> banana_cart = [LineItem('banana', 30, .5),  # 商品數量超過20
...                LineItem('apple', 10, 1.5)]
>>> Order(joe, banana_cart, BulkItemPromo())  # 數量折扣
<Order total: 30.00 due: 28.50>

# 測試第三條折扣規則
>>> long_order = [LineItem(str(item_code), 1, 1.0) # 10類不同商品
...               for item_code in range(10)]
>>> Order(joe, long_order, LargeOrderPromo())  # 種類折扣
<Order total: 10.00 due: 9.30>
>>> Order(joe, cart, LargeOrderPromo())
<Order total: 42.00 due: 42.00>

函式實現

現在開始使用Python函式改寫程式碼。觀察上文程式碼可以發現每個具體策略是一個類,類裡面只有一個方法:discount(),並且沒有屬性。看起來就像是普通的函式。改造如下:

image-20210421132250401

最關鍵的是,刪除了抽象類。測試一下,函式拿來即用的美妙體驗:

>>> 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)]
>>> Order(joe, cart, fidelity_promo)  # 直接傳函式名
<Order total: 42.00 due: 42.00>
>>> Order(ann, cart, fidelity_promo)
<Order total: 42.00 due: 39.90>

>>> banana_cart = [LineItem('banana', 30, .5),
...                LineItem('apple', 10, 1.5)]
>>> Order(joe, banana_cart, bulk_item_promo)  # 直接傳函式名
<Order total: 30.00 due: 28.50>

>>> long_order = [LineItem(str(item_code), 1, 1.0)
...               for item_code in range(10)]
>>> Order(joe, long_order, large_order_promo)  # 直接傳函式名
<Order total: 10.00 due: 9.30>
>>> Order(joe, cart, large_order_promo)
<Order total: 42.00 due: 42.00>

函式的意義體現在:

image-20210421133340833

可以得出結論:普通函式比只有一個方法的類使用起來更簡單

選擇最佳策略

繼續看另外一個問題,從具體策略中選擇最佳策略,本文示例就是要選擇優惠最多的折扣,程式碼實現如下:

promos = [fidelity_promo, bulk_item_promo, large_order_promo]

def best_promo(order):
    """Select best discount available
    """
    return max(promo(order) for promo in promos)

promos列表包含了三個具體策略。best_promo()函式先使用生成器表示式計算每個策略的折扣,再使用max()函式返回最大折扣。

測試一下:

>>> Order(joe, long_order, best_promo)
<Order total: 10.00 due: 9.30>
>>> Order(joe, banana_cart, best_promo)
<Order total: 30.00 due: 28.50>
>>> Order(ann, cart, best_promo)
<Order total: 42.00 due: 39.90>

沒有問題。但是存在一個隱藏缺陷:如果想要新增新的促銷策略,那麼要定義相應函式並新增到promos列表中。

新增新策略

接下來針對這個缺陷進行優化。

方法一

藉助globals()函式自動找到其他可用的*_promo函式:

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

def best_promo(order):
    """Select best discount available
    """
    return max(promo(order) for promo in promos)

globals()返回一個字典,表示當前的全域性符號表。這個符號表始終針對當前模組。對函式或方法來說,是指定義它們的模組,而不是呼叫它們的模組。

方法二

通過函式內省自動查詢promotions模組中的所有函式作為策略函式(要求promotions模組中只能包含策略函式,不能包含其他函式):

promos = [func for name, func in
                inspect.getmembers(promotions, inspect.isfunction)]

def best_promo(order):
    """Select best discount available
    """
    return max(promo(order) for promo in promos)

inspect.getmembers()的第一個引數是目標模組(promotions模組),第二個引數是判斷條件(只查詢模組中的函式)。

方法三

裝飾器,這個方法更優雅,在下篇文章講到裝飾器時,再給出程式碼實現。

命令模式

命令模式的目的是解耦呼叫操作的物件(呼叫者)和提供實現的物件(接收者)。

示例,選單驅動文字編輯器,呼叫者是選單,接收者是被編輯的文件。

UML類圖設計如下:

image-20210514090207203

命令模式的做法是在呼叫者和接收者之間放一個Command物件,讓它實現只有一個execute()方法的介面,呼叫接收者中的方法執行具體命令。這樣呼叫者Menu不需要了解接收者Document的介面。並且可以新增Command子類擴充套件多個不同的接收者。

使用一等函式對命令模式的優化思路:不為呼叫者提供一個Command物件,而是給它一個函式,呼叫者不用調command.execute(),直接調command()即可。這和策略模式是類似的,把實現單方法介面的類的例項替換成可呼叫物件

注意,圖中的MacroCommand是巨集命令,可能儲存一系列命令,它的execute()方法會在各個命令上呼叫相同的方法,在使用一等函式函式時,可以實現成定義了__call__方法的類:

class MacroCommand:
    "一個執行一組命令的命令"
    
    def __init__(self, commands):
        self.commands = list(commands)
        
    def __call__(self):
        for command in self.commands:
            command()

畢竟,__call__使得每個Python可呼叫物件都實現了單方法介面。

小結

本文簡單列舉了33種設計模式,從兩個經典的設計模式,策略模式和命令模式入手,介紹設計模式在Python中是如何實現的,藉助函式是一等物件的這一特性,大大簡化了程式碼。在此基礎上,還能更Pythonic一點,那就是用函式裝飾器和閉包。

參考資料:

《流暢的Python》

https://www.runoob.com/design-pattern/design-pattern-tutorial.html

https://blog.csdn.net/xldmx/article/details/112337759

https://github.com/fluentpython/example-code/tree/master/06-dp-1class-func

相關文章