函數(shù)式編程與設(shè)計模式(其他 GoF 設(shè)計模式)

在序章中,我們提到了策略模式、模板模式、觀察者模式、責(zé)任鏈模式和工廠模式可以使用 Java 8 中的 Lambda 和方法引用來簡化書寫。這些都是在《設(shè)計模式:可復(fù)用面向?qū)ο筌浖A(chǔ)(Design Patterns: Elements of Reusable Object-Oriented Software)》一書中最早提出的 23 個設(shè)計模式之中。因為該書的作者為四位,所以人稱“四人組”即 GoF(Gang of Four)。接下來,我們將討論其他的 GoF 設(shè)計模式中的幾個也可以使用函數(shù)式編程簡化或者優(yōu)化的設(shè)計模式。

首先我們會詳細(xì)介紹三個 GoF 設(shè)計模式:我們先看裝飾器模式,然后將討論訪問者模式,最后會介紹命令模式。其中裝飾器模式在 Java 8 和 Scala 中均可以簡化,但 Scala 中書寫較為簡潔;訪問者模式則是在 Java 和 Scala 語言中針對擴展問題提供相應(yīng)方便擴展的實現(xiàn),可以作為訪問者模式的補充知識;命令模式則也是可以使用 Scala 和 Java 8 進行簡化。
在文章末尾我們將對在函數(shù)式編程得到較多運用的現(xiàn)在,可以有新的實現(xiàn)的 GoF 設(shè)計模式們做一個整體的總結(jié)。
1 裝飾器模式
1.1 裝飾器模式簡介
裝飾器模式(Decorator Pattern),也稱為裝飾模式,其定義為:
動態(tài)地給一個對象添加一些額外的職責(zé)。就增加功能來說,裝飾模式相比生成子類更為靈活。
裝飾器模式中主要有四個角色:
Component 抽象構(gòu)件:Component 是一個接口或者是抽象類,就是定義我們最核心的對象,也就是最原始的對象。
ConcreteComponent 具體構(gòu)件:ConcreteComponent 是最核心、最原始、最基本的接口或抽象類的實現(xiàn),你要裝飾的就是它。
Decorator 裝飾角色:一般是一個抽象類,并維持一個指向 Component 對象的指針(在它的屬性里必然有一個 private 變量指向 Component 抽象構(gòu)件)。
ConcreteDecorator 具體裝飾角色:具體的裝飾類,向組件添加職責(zé)。你要把你最核心的、最原始的、最基本的東西裝飾成其他東西。
對應(yīng)的 Java 代碼如下:
裝飾模式降低了系統(tǒng)的耦合度,可以動態(tài)增加或刪除對象的職責(zé),并使得需要裝飾的具體構(gòu)件類和具體裝飾類可以獨立變化,以便增加新的具體構(gòu)件類和具體裝飾類。在軟件開發(fā)中,裝飾模式應(yīng)用較為廣泛,例如在 Java IO 中的輸入流和輸出流的設(shè)計、javax.swing 包中一些圖形界面構(gòu)件功能的增強等地方都運用了裝飾模式。
裝飾模式的主要優(yōu)點如下:
對于擴展一個對象的功能,裝飾模式比繼承更加靈活性,不會導(dǎo)致類的個數(shù)急劇增加。
可以通過一種動態(tài)的方式來擴展一個對象的功能,通過配置文件可以在運行時選擇不同的具體裝飾類,從而實現(xiàn)不同的行為。
可以對一個對象進行多次裝飾,通過使用不同的具體裝飾類以及這些裝飾類的排列組合,可以創(chuàng)造出很多不同行為的組合,得到功能更為強大的對象。
具體構(gòu)件類與具體裝飾類可以獨立變化,用戶可以根據(jù)需要增加新的具體構(gòu)件類和具體裝飾類,原有類庫代碼無須改變,符合“開閉原則”。
裝飾模式的主要缺點如下:
使用裝飾模式進行系統(tǒng)設(shè)計時將產(chǎn)生很多小對象,這些對象的區(qū)別在于它們之間相互連接的方式有所不同,而不是它們的類或者屬性值有所不同,大量小對象的產(chǎn)生勢必會占用更多的系統(tǒng)資源,在一定程序上影響程序的性能。
裝飾模式提供了一種比繼承更加靈活機動的解決方案,但同時也意味著比繼承更加易于出錯,排錯也很困難,對于多次裝飾的對象,調(diào)試時尋找錯誤可能需要逐級排查,較為繁瑣。
1.2 傳統(tǒng)的 Java 實現(xiàn)示例:日志計算器
讓我們考慮這樣一個裝飾模式的應(yīng)用場景:
我們有一個處理四則運算計算器,該計算器有四個操作:add()
、substract()
、multiply()
和 divide()
。我們將使用裝飾器模式來給這個基礎(chǔ)的計算器添加日志功能,它可以將我們的計算過程展示在控制臺。
傳統(tǒng)的 Java 實現(xiàn):
可以看出重復(fù)代碼還是挺多的。
1.3 使用函數(shù)式編程簡化日志計算器的實現(xiàn)
我們可以考慮使用 Lambda 和方法引用,將四個四則運算方法直接作為方法引用參數(shù),傳遞給日志記錄的方法;通過這種方式,實現(xiàn)裝飾器模式,代碼如下:
使用 Scala 實現(xiàn)的代碼將更加簡潔,主要體現(xiàn)在 Java 中的 BiFunction<Integer, Integer, Integer>
可以直接簡寫為 Scala 的 (Int, Int) => Int
,以及 Java 的 apply
也可以在 Scala 調(diào)用時省略掉:
2 訪問者模式
下面我們還是先回顧一下訪問者模式,然后結(jié)合案例具體介紹 Scala 和 Java 中方便擴展的實現(xiàn)方法。
2.1 訪問者模式簡介
訪問者模式(Visitor Pattern)是一個相對簡單的模式,其定義如下:
封裝一些作用于某種數(shù)據(jù)結(jié)構(gòu)中的各元素的操作,它可以在不改變數(shù)據(jù)結(jié)構(gòu)的前提下定義作用于這些元素的新的操作。
訪問者模式具有以下幾個角色:
Visitor 抽象訪問者:抽象類或者接口,聲明訪問者可以訪問哪些元素,具體到程序中就是visit方法的參數(shù)定義哪些對象是可以被訪問的。
ConcreteVisitor 具體訪問者:它影響訪問者訪問到一個類后該怎么干,要做什么事情。
Element 抽象元素:接口或者抽象類,聲明接受哪一類訪問者訪問,程序上是通過accept方法中的參數(shù)來定義的。
ConcreteElement 具體元素:實現(xiàn) accept 方法,通常是 visitor.visit(this),基本上都形成了一種模式了。
ObjectStruture 結(jié)構(gòu)對象:元素產(chǎn)生者,一般容納在多個不同類、不同接口的容器,如List、Set、Map等,在項目中,一般很少抽象出這個角色。
對應(yīng)的 Java 代碼如下:
由于訪問者模式的使用條件較為苛刻,本身結(jié)構(gòu)也較為復(fù)雜,因此在實際應(yīng)用中使用頻率不是特別高。當(dāng)系統(tǒng)中存在一個較為復(fù)雜的對象結(jié)構(gòu),且不同訪問者對其所采取的操作也不相同時,可以考慮使用訪問者模式進行設(shè)計。在XML文檔解析、編譯器的設(shè)計、復(fù)雜集合對象的處理等領(lǐng)域訪問者模式得到了一定的應(yīng)用。
訪問者模式的主要優(yōu)點如下:
增加新的訪問操作很方便。使用訪問者模式,增加新的訪問操作就意味著增加一個新的具體訪問者類,實現(xiàn)簡單,無須修改源代碼,符合“開閉原則”。
將有關(guān)元素對象的訪問行為集中到一個訪問者對象中,而不是分散在一個個的元素類中。類的職責(zé)更加清晰,有利于對象結(jié)構(gòu)中元素對象的復(fù)用,相同的對象結(jié)構(gòu)可以供多個不同的訪問者訪問。
讓用戶能夠在不修改現(xiàn)有元素類層次結(jié)構(gòu)的情況下,定義作用于該層次結(jié)構(gòu)的操作。
訪問者模式的主要缺點如下:
增加新的元素類很困難。在訪問者模式中,每增加一個新的元素類都意味著要在抽象訪問者角色中增加一個新的抽象操作,并在每一個具體訪問者類中增加相應(yīng)的具體操作,這違背了“開閉原則”的要求。
破壞封裝。訪問者模式要求訪問者對象訪問并調(diào)用每一個元素對象的操作,這意味著元素對象有時候必須暴露一些自己的內(nèi)部操作和內(nèi)部狀態(tài),否則無法供訪問者訪問。
2.2 Scala 中的擴展訪問者模式示例:可擴展的幾何形狀
上面可以看出,訪問者模式面對的缺點有一點就是:增加新的元素類和新的操作很困難。如果是直接去修改原有的類,就違反了“開閉原則”。
開閉原則(Open-Closed Principle, OCP):一個軟件實體應(yīng)當(dāng)對擴展開放,對修改關(guān)閉。即軟件實體應(yīng)盡量在不修改原有代碼的情況下進行擴展。
這一點在 Scala 中,通過特質(zhì)(trait)可以較好的解決。
下面我們舉的示例將先定義兩個形狀:圓形和矩形,以及計算它們周長的操作。然后將展示如何為既有的周長計算操作添加新的可支持的形狀,以及如何為既有的形狀添加新的操作。最后將這兩種類型的擴展合并起來。
首先,最初的 Shape 特質(zhì)和兩個實現(xiàn)的 Scala 代碼如下:
為了擴展求面積的操作,我們可以這樣使用特質(zhì)來擴展:創(chuàng)建頂層的特質(zhì) AreaShapes 擴展于 PerimeterShapes。內(nèi)部創(chuàng)建了一個新的 Shape 特質(zhì),并讓它擴展了 PerimeterShapes 中那個老的 Shape 特質(zhì)。
然后再擴展老的 Circle 和 Rectangle 類,將它們混入新的 Shape 特質(zhì),該特質(zhì)擁有新的 area()
方法
然后假如我們要添加新的圖形——正方形 Square 類的話,也可以按照類似的邏輯擴展:
這樣,我們就可以為 Shape 添加新的實現(xiàn)和新的操作,而且我們所采用的是一種類型安全的方式。
2.3 Java 中的擴展訪問者模式示例
針對上面的例子,我們也可以很容易得到對應(yīng)的 Java 代碼:
本來希望可以使用 Java 16 正式引入的 record 新特性來簡化書寫的,但是發(fā)現(xiàn) record 無法繼承,只能還是使用 class 的語法啦。
需要注意的一個細(xì)節(jié)是,Java 的接口是可以 extends 多個接口的,是多繼承的,和類不太一樣。
通過這種方式去使用訪問者模式,我們就可以保證訪問者模式在不違反開閉原則的情況下進行擴展了。
3 命令模式
3.1 命令模式簡介
命令模式(Command Pattern)是一個高內(nèi)聚的模式,其定義為:
將一個請求封裝成一個對象,從而讓你使用不同的請求把客戶端參數(shù)化,對請求排隊或者記錄請求日志,可以提供命令的撤銷和恢復(fù)功能。
命令模式有三個角色:
Receive 接收者角色:該角色就是干活的角色,命令傳遞到這里是應(yīng)該被執(zhí)行的。
Command 命令角色:需要執(zhí)行的所有命令都在這里聲明。
Invoker 調(diào)用者角色:接收到命令,并執(zhí)行命令。
對應(yīng) Java 代碼如下:
命令模式是一種使用頻率非常高的設(shè)計模式,它可以將請求發(fā)送者與接收者解耦,請求發(fā)送者通過命令對象來間接引用請求接收者,使得系統(tǒng)具有更好的靈活性和可擴展性。在基于 GUI 的軟件開發(fā),無論是在電腦桌面應(yīng)用還是在移動應(yīng)用中,命令模式都得到了廣泛的應(yīng)用。
命令模式的主要優(yōu)點如下:
降低系統(tǒng)的耦合度。由于請求者與接收者之間不存在直接引用,因此請求者與接收者之間實現(xiàn)完全解耦,相同的請求者可以對應(yīng)不同的接收者,同樣,相同的接收者也可以供不同的請求者使用,兩者之間具有良好的獨立性。
新的命令可以很容易地加入到系統(tǒng)中。由于增加新的具體命令類不會影響到其他類,因此增加新的具體命令類很容易,無須修改原有系統(tǒng)源代碼,甚至客戶類代碼,滿足“開閉原則”的要求。
可以比較容易地設(shè)計一個命令隊列或宏命令(組合命令)。
為請求的撤銷(Undo)和恢復(fù)(Redo)操作提供了一種設(shè)計和實現(xiàn)方案。
命令模式的主要缺點如下:
使用命令模式可能會導(dǎo)致某些系統(tǒng)有過多的具體命令類。因為針對每一個對請求接收者的調(diào)用操作都需要設(shè)計一個具體命令類,因此在某些系統(tǒng)中可能需要提供大量的具體命令類,這將影響命令模式的使用。
3.2 傳統(tǒng)的 Java 實現(xiàn)示例:現(xiàn)金出納機
我們將先使用傳統(tǒng)的 Java 面向?qū)ο蠓绞綄崿F(xiàn)一個簡單的現(xiàn)金出納機。這個出納機的功能非常簡單:它只處理整額的美元,同時包含了一定總量的現(xiàn)金,并且只允許將現(xiàn)金增加到出納機。我們將保存一份事務(wù)日志,以方便對操作進行重放。
代碼如下:
3.3 使用函數(shù)式編程簡化現(xiàn)金出納機的實現(xiàn)
在 Java 中,我們可以考慮使用 Lambda 函數(shù)式編程省略掉中間的 Command 抽象類的聲明。CashRegister 類依然保持不變,Command 抽象類、Purchase 實現(xiàn)類和 PurchaseInvoker 調(diào)用類則都被簡略成了方法。具體代碼如下:
這里因為 Java 并沒有自帶表示 void -> void 方法的類型,我們使用 Runnable
代表(你用 Callable
之類的滿足 @FunctionalInterface 注解條件的單個 void -> void 抽象方法的接口也可以啦,只是不管哪種方式,可讀性上都會比較迷惑。也可以使用 Supplier<Void>
的方式,但這樣我們需要多加一行 return null;
的代碼)。
對應(yīng)到 Scala 中,我們就沒有這么多閱讀語義上糾結(jié)的問題了,代碼如下:
其中 purchase()
就代表了對 purchase 這個 () => Unit
的方法引用執(zhí)行 purchase.apply()
。而 () => Unit
也就表達(dá)了 Java 中的 void -> void
的語義。
4 總結(jié)
經(jīng)過以上三個示例,以及之前序章中的五個示例,我們可以大概觀察到一些特點。本質(zhì)上,這些簡化其實就是通過方法引用本身的抽象,來代替了許多設(shè)計模式中聲明的抽象類/接口。從而我們省去了聲明新的類型的煩瑣工作。
這里我們使用函數(shù)式編程的方式簡化設(shè)計模式有可能帶來的問題是:代碼運行時的語義上可能相對模糊了一些,因為無法再通過類名來判斷設(shè)計模式了。但是我們?nèi)匀豢梢酝ㄟ^變量名稱等方式保證程序員閱讀時的可讀性。
經(jīng)過這么兩篇文章的介紹,我們可以了解到一些傳統(tǒng) GoF 設(shè)計模式在函數(shù)式編程中的新實踐。接下來,我將給大家介紹依賴注入在 Scala 中的 Cake 模式實現(xiàn)以及一些函數(shù)式編程獨有的設(shè)計模式,敬請期待。