Decorator

December 30, 2021

你打算設計一個點餐程式,目前主餐有炸雞、漢堡,而套餐組合可以有優惠,如果使用繼承的方式來達到這個目的,例如:

class FriedChicken {
    double price() {
        return 49.0;
    }

    String toString() {
        return "炸雞";
    }
}

class FriedChickenHamburger extends FriedChicken {
    double price() {
        return super.price() + 30.0;
    }

    String toString() {
        return "%s | %s".formatted(super.toString(), "漢堡");
    }
}

組合優於繼承

經常有人說不要濫用繼承,因為繼承具有較高的約束性,特別是在只能單一繼承的語言,這個設計為例,繼承父類別之後,只是取得父類別的 price 執行結果進一步處理,另一方面,如果漢堡也想要搭配附餐一,目前的 ComboA 顯然無法給漢堡重用,還得為漢堡建立有附餐一的子類別。

可以的話,在使用繼承之前先想想有沒有替代方案,例如改為組合的方式:

interface Meal {
    double price();
}

class FriedChicken implements Meal {
    public double price() {
        return 49.0;
    }
    String toString() {
        return "不黑心炸雞";
    }
}

class Hamburger implements Meal {
    public double price() {
        return 99.0;
    }
    public String toString() {
        return "美味蟹堡";
    }
}

abstract class Combo implements Meal {
    protected Meal meal;
    Combo(Meal meal) {
        this.meal = meal;
    }
}

class ComboA extends Combo {
    ComboA(Meal meal) {
        super(meal);
    }
    public double price() {
        return meal.price() + 30.0;
    }
    public String toString() {
        return "A 套餐:%s | %s | %s".formatted(meal.toString(), "可樂", "薯條");
    }
}

你可以如上設計 ComboBComboC 等,後續怎麼搭都可以了:

 meal1 = new ComboA(new FriedChicken());
var meal2 = new ComboA(new Hamburger());
var meal3 = new ComboB(new FriedChicken());
out.printf("%s: $%f", meal1.toString(), meal1.price());
out.printf("%s: $%f", meal2.toString(), meal2.price());
out.printf("%s: $%f", meal3.toString(), meal3.price());

這樣的設計是組合優於繼承(composite over inheritance)的實現,這邊的組合與 Gof 的 Composite 模式沒有關係,只是英文正好都使用 composite,在 Gof 中稱這個模式為 Decorator。

java.io 的 Decorator

java.io 套件中,有些輸入輸出的功能修飾,就是採用 Decorator 來實現,例如:

var reader = new BufferedReader(new FileReader("Main.java"));

FileReader 沒有緩衝區處理的功能,可以由 BufferedReader 提供,BufferedReader 沒有改變 FileReader 的功能,是在既有 FileReader 的操作成果上再做加工,而 BufferedReader 也不只可以用於 FileReader,只要是 Reader 的子類別,都可以套用 BufferedReader,例如讀取標準輸入:

var reader = new BufferedReader(new InputStreamReader(System.in));

InputStreamReader 修飾了 System.inBufferedReader 修飾了 InputStreamReader,也就是說,功能可以視需求而堆疊。

Python 的標註

雖然 Gof 示範設計模式時,是基於物件導向典範,使用 C++ 實作範例,不過,那只是模式最後因應典範或語言的實現外觀,你不應該將焦點放在模式的實現外觀,應該多去思考模式構成過程中,是基於什麼樣的本質而成形。

Decorator 本質上就是可以指定一組演算,將該演算封裝,傳回另一組演算。

在物件導向中,物件就是封裝一組演算,建立一個物件封裝某個物件,就是封裝了一組演算,作為封裝者的物件,基於被封裝物件提供另一組演算。

物件導向的優點之一是,可以將一組演算與資料封裝在一起,然而,在一些不需要演算與資料封裝在一起的場合,函式更為適合,函式單純用來封裝演算,有的語言中,函式可以當成值傳遞,如果一個函式可以接受函式,傳回另一個函式,有人稱這種「接受函式傳回函式」的函式為高階函式(high order function)。

如果高階函式接受的函式,被封裝在傳回的函式中,也就有了 Decorator 的概念了。

來用一開始的點餐程式作為範例好了,假設你設計了點餐程式,目前主餐有炸雞,價格為 49 元:

def friedchicken():
    return 49.0

print(friedchicken())  # 49.0

之後在程式中其他幾個地方都呼叫了 friedchicken 函式,若現在打算推套餐該怎麼做?修改 friedchicken 函式?另外增加一個 friedchicken_hamburger函式?也許你的主餐不只有炸雞,還有漢堡、義大利麵等各式主餐呢!套餐也會有各式各樣?

Python 的函式是一級值,函式可以接受函式並傳回函式,可以這麼撰寫:

def combo_a(meal):
    return lambda: meal() + 30

def friedchicken():
    return 49.0

combo_a = combo_a(friedchicken)
print(combo_a())    # 顯示 79.0

這就實現了 Decorator 的概念,如果現在,炸雞不能單點了,直接推 A 套餐,Python 還可以這麼寫:

def combo_a(meal):
    return lambda: meal() + 30

@combo_a
def friedchicken():
    return 49.0

print(friedchicken())    # 顯示 79.0

@ 可以接上函式,對於底下的程式碼,若 decorator 是個函式:

@decorator
def func():
    ...

執行時結果相當於:

func = decorator(func)

這也就是為什麼,Python 中這種 @decorator 的語法,會被稱為 decorator,必要時也可以堆疊:

@decorator2
@decorator1
def func():
    ...

這也就是為什麼之前一直在說,不要過於著墨模式最後的實現形式,因為模式的實現會因語言而異,重點在於思考,才能在各種語言、需求下靈活變化,只要你有思考過,長得像或不像 Gof 設計模式或某文件說的 xx 模式都無所謂。

例如 Scala 可以這麼實現 Decorator 模式:

val meal1 = new FriedChicken with ComboA
val meal2 = new FriedChicken with ComboB
val meal3 = new FriedChicken with ComboA with ComboC

記得,你要觀察、思考,就這邊而言,就是觀察、思考,你需要的是不是指定一組演算,將該演算封裝,傳回另一組演算呢?