1. 程式人生 > >scala與函數語言程式設計——面向物件模式在函數語言程式設計下的實現

scala與函數語言程式設計——面向物件模式在函數語言程式設計下的實現

用函式組合實現設計模式

  設計模式是面向物件下的產物,但其中蘊藏的程式設計理念仍然是通用的。對於面向物件的程式設計熟手而言,在程式設計時幾乎離不開常用的設計模式。在剛開始使用函數語言程式設計的時候,還會不自覺地想使用策略、裝飾器等模式,但卻不知在函數語言程式設計的世界裡,有些模式早已被函式的組合替代了。
  設計模式中的核心思想就是遵循封裝原則將一段可變的行為提取出來成為另一個物件,並基於多型特性、使用組合優於繼承的原則將不同的實現插入。這樣的例子有策略模式、狀態模式、裝飾器模式、命令模式等。而函數語言程式設計的關鍵在於函式的組合,而策略、命令等介面大多情況下都是隻有一個方法的函式式介面,是可以直接用一個函式物件來替代的。

實現策略模式

  策略模式是將一段演算法或邏輯提取到一為介面,使用時動態拼裝不同的介面實現。而所謂的策略介面其實就是一個函式式介面:只定義了一個方法的介面。函式式介面可以直接用一個具體的函式例項來替代。如下面的java程式碼基於策略模式實現了一個可以去除不同型別數字的收集器:

class IntCollector {
    public List<Integer> filter(List<Integer> list, IntFilter filter) {
        list.stream().filter(i -> filter.shouldFilter(i)).collect(...);
    }
}
interface IntFilter {
    boolean
shouldFilter(Integer i); } class EvenFilter implements IntFilter { public boolean shouldFilter(Integer i){ return i % 2 != 0; } } class ModByNFilter implements IntFilter { private int i; ModByNFilter(int i) { this.i = i; } public boolean shouldFilter(Integer i){ return
i % n == 0; } } IntCollector c = new IntCollector(); c.filter(Arrays.asList(1,2,3,4,5,6), new EvenFilter()) //return 1,3,5 c.filter(Arrays.asList(1,2,3,4,5,6), new ModByNFilter(3)) //return 1,2,4,5

  而通過函式組合,則可以用更少的程式碼簡潔地實現“提取演算法並替換”的這個目標。首先把collect這個方法中的IntFilter介面型別替換為函式型別:

object IntCollector {
    def filter(list: List[Int], f:Int=>Boolean): List[Int] = 
        list.filter(i => f(i)) //也可以簡寫為list.filter(f)
}

  然後,再將策略的例項,即EvenFilter的實現,直接使用Lambda表示式產生一個匿名函式,並傳入collect方法中:

//EvenFilter的實現:i => i%2 == 0的型別是函式Int=>Boolean
IntCollector.filter(List(1,2,3,4,5,6), i => i%2 == 0) //return 1,3,5

  對於像ModByNFilter這樣,構建時需要額外入參作為演算法執行狀態的策略,可以通過一個高階函式接收入參,並返回一個函式作為ModByNFilter的具體實現:

//用高階函式替代ModByNFilter的建構函式,產生一個函式Int=>Boolean
def modByNFilter(n: Int): Int=>Boolean = i => i % n == 0
IntCollector.filter(List(1,2,3,4,5,6), modByNFilter(3)) //return 1,2,4,5

  可見,用函式組合的方法實現策略模式可以在實現完整功能的前提下減少大量程式碼,主要包括函式式介面的申明(interface IntFilter),以及簡化了含狀態的策略構造過程,並且可以通過匿名函式來進一步減少程式碼量。

實現命令模式

  命令模式的主要表現形式是將一段待執行的行為構造出來但暫不執行,將其傳遞給呼叫者在需要的時候呼叫,在必要的情況下呼叫者還能快取這些命令物件,以便重放甚至撤消。如下面一段java程式碼實現了一個收銀的例子,由Client建立購買/退貨的命令Purchase/Cancel,並傳遞給PurchaseInvoker,執行後每個Purchase會依次修改Client中Cash的狀態:

class Client {
    private CommandInvoker invoker;
    private Cash cash;
    ...
    public void purchase(int amount) {
        invoker.add(new Purchase(amount, cash));
    }
    public void cancel(int amount) {
        invoker.add(new Cancel(amount, cash));
    }
    public void refresh() {
        invoker.invokeAll();
    }
}
interface Command {
    void execute();
}
class Purchase implements Command {
    private Cash cash;
    private int amount;
    public void execute() {cash.minus(amount);}
}
class Cancel implements Command {
    private Cash cash;
    private int amount;
    public void execute() {cash.plus(amount);}
}
class CommandInvoker {
    private List<Command> commands;
    ...
    public void invokeAll() {
        for (Command c: commands)
            c.execute();
        commands.clear();
    }
}
Client c = create a client with cash 100
c.purchase(30);
c.cancel(10);
c.purchase(20); //cash = 100
c.refresh(); //cash = 60

  有了上面策略模式的經驗,很容易產生將Command作為函式的直覺。再結合之前ModByN的例子,就可以通過高階函式將Purchase和Cancel這兩個帶有狀態的命令物件構造出來。整體的感覺和策略模式非常接近:

class Client {
    ...
    //Unit相當於java中的void,表示忽略返回值,返回Unit常常表示含有副作用
    def purchase(amount:Int):Unit = invoker.add(makePruchase(amount))
    def cancel(amount:Int):Unit = invoker.add(makeCancel(amount))

    //通過高階函式返回帶有副作用的函式:()=>Unit
    def makePruchase(amount: Int):()=>Unit = () => cash.minus(amount)
    def makeCancel(amount: Int):()=>Unit = () => cash.plus(amount)
}
class CommandInvoker {
    //Command已經改為()=>Unit,因此commands也要改為List[()=>Unit],是一個函式的列表
    var commands: List[() => Unit]
    //refreshAll含有副作用
    def refreshAll():Unit = commands.foreach(c => c()) //執行每個c,c是一個函式
}

  命令模式和策略一樣,也從一個函式式介面改為一個普通函式,而原先用到Command型別的地方都替換為函式型別。唯一不同的是,IntFilter介面的函式型別是Int=>Boolean,而命令介面則變為了()=>Unit。使用函式組合的方法也節約了大量程式碼,從原來一頁紙變為短短几行。程式設計效率的提升十分明顯。

實現裝飾器模式

  裝飾器模式的作用是提供多種附加功能並按需組合,從而應對多變的使用場景。具體的操作物件以及裝飾物件都共享同一個介面,裝飾物件會呼叫被裝飾的物件,並在處理過程中增加自身的額外邏輯。如下面一段java程式碼展示了一個奶茶製作流程:

class TeaDrink {
    private List<String> liquids; //可供選擇:Water,Milk,GreenTea, RedTea
    private List<String> additives; //可供選擇:珍珠,波霸,椰果,仙草
    private int sugar; //從0-10表示甜度
}
interface TeaDrinkMaker {
    TeaDrink make();
}
class BasicMaker implements TeaDrinkMaker {
    public TeaDrink make() {
        TeaDrink drink = new TeaDrink();
        drink.liquids.add("water");
        drink.sugar = 8;
    }
}
class LiquidAdder implements TeaDrinkMaker {
    private TeaDrinkMaker maker;
    private String liquid;

    LiquidAdder(TeaDrinkMaker maker, String liquid){...}

    public TeaDrink make() {
        TeaDrink drink = maker.make();
        drink.liquids.add(liquid);
        drink.sugar += 2;
    }
}
class AdditiveAdder implements TeaDrinkMaker {
    private TeaDrinkMaker maker;
    private String additive;

    LiquidAdder(TeaDrinkMaker maker, List<String> additives){...}

    public TeaDrink make() {
        TeaDrink drink = maker.make();
        drink.additives.addAll(additives);
    }
}
class NoneSugarMaker implements TeaDrinkMaker {
    private TeaDrinkMaker maker;

    LiquidAdder(TeaDrinkMaker maker){...}

    public TeaDrink make() {
        TeaDrink drink = maker.make();
        drink.sugar = 0;
    }
}
class HalfSugarMaker implements TeaDrinkMaker {
    private TeaDrinkMaker maker;

    LiquidAdder(TeaDrinkMaker maker){...}

    public TeaDrink make() {
        TeaDrink drink = maker.make();
        drink.sugar /= 2;
    }
}
//含Water,RedTea;波霸,仙草;糖度=(8+2)/2
TeaDrink drink = new HalfSugarMaker(new AdditiveAdder(new LiquidAdder(new BasicMaker(), "RedTea"), Arrays.asList("波霸","仙草"))).make();
//含Water,GreenTea;none;糖度=0+2
TeaDrink drink = new LiquidAdder(new NoneSugarMaker(new BasicMaker()), "GreenTea")).make();

  如果用函式組合來實現奶茶的製作過程,則首先也需要將TeaDrinkMaker這個函式式介面轉為函式型別,並將各類裝飾類的建構函式替換為函式生成器(指產生函式的高階函式):

case class TeaDrink(liquids:List[String], additives:List[String], sugar: Int)
//返回一個產生TeaDrink的函式
def basicMaker:()=>TeaDrink = () => TeaDrink(List("Water"), List.empty, 8)
//接受一個函式,返回一個同樣型別的函式
def liquidAdder(maker:()=>TeaDrink, liquid:String):()=>TeaDrink = () => {
    val drink = maker()
    drink.copy(liquids=drink.liquids::liquid, sugar = drink.sugar+2)
}
def additiveAdder(maker:()=>TeaDrink, addtives:List[String]) = () => {
    val drink = maker()
    drink.copy(additives=addtives++drink.liquids)
}
def noneSugar(maker:()=>TeaDrink) =  () => {
    val drink = maker()
    drink.copy(sugar=0)
}
def halfSugar(maker:()=>TeaDrink) =  () => {
    val drink = maker()
    drink.copy(sugar=drink.sugar/2)
}
//先獲取產生TeaDrink的函式,再()呼叫獲取結果
val drink = halfSugar(additiveAdder(liquidAdder(basicMaker, "RedTea"),List("波霸","仙草"))()
val drink = nonSugar(liquidAdder(basicMaker, "GreenTea"))()

  在實現裝飾器的過程中,所用到的技巧是將介面改為函式,對非裝飾功能直接返回此型別的函式,對裝飾功能的構造則接受一個函式作為引數,返回一個新的函式作為輸出。

用函式組合實現依賴注入

  在任何模式的程式設計過程中,總是會用到依賴注入的原則將模組之間解耦,從而便於模組之間的組合複用與單獨測試。在面向物件的語言中,依賴注入體現為通過建構函式或set方法將依賴模組設定到呼叫模組中,並通過框架來簡化簡化模組的組裝。如下面的@Autowire會由框架來代為處理,自動將各個元件注入到所需要的模組中:

class TeaDomainService {
    @Autowire private TeaRepository repo;
    public Tea businessOp1(String param) {
        //use repository to complete operation
    }
}
class OtherDomainService {
    @Autowire private TeaRepository repo;
    public Tea businessOp2(Tea tea) {
        //use repository & tea to complete operation
    }
}
class TeaAppService {
    @Autowire private TeaDomainService service1;
    @Autowire private OtherDomainService service2;
    public String perform(String param) {
        Tea t = service1.businessOp1(param);
        String s = service2.businessOp2(t);
        return s;
    }
}

  也可以手工組裝:

//In the Context:
TeaRepository repo = new HibernateTeaRepository();
TeaDomainService service1 = new TeaDomainService();
service1.setRepository(repo);
OtherDomainService service2 = new OtherDomainService();
service2.setRepository(repo);
TeaAppService appService = new TeaAppService();
appService.setService1(service1);
appService.setService2(service2);
//Out of the Context
TeaAppService appService = Context.getBean(TeaAppService.class);
String result = appService.perform("param");

  然而在函數語言程式設計中,我們可以通過語言本身的特性,不依賴任何外部的框架,只通過函式組合來做到依賴注入。
  首先,讓我們迴歸函式是一等公民的角度,擺脫在類中定義函式、並且讓函式使用類的狀態這種思維方式。函式是可以獨立存在的!

trait TeaDomainService {
    def businessOp1(String param):Tea = {
        //如何拿到repository?
    }
}

  那麼如何獲取依賴的repository呢?答案是,返回一個函式。這個函式的引數是依賴的repository,把相應的repository傳給這個函式,就能得到相應的結果。

trait TeaDomainService {
    def businessOp1(String param):TeaRepository=>Tea = repository => {
        repository.xxx //與原來的程式碼一致
    }
}

val repository = new TeaRepository
//businessOp1("param")得到一個函式:TeaRepository=>Tea,而不是一個具體的Tea
val tea = businessOp1("param")(repository)

  至此,我們能處理1個模組需要注入的情況了,那麼如何處理TeaAppService中需要2個模組的情況呢?一個簡單的想法就是把多個模組打包到一個物件中去:

trait Context {
    val repository: TeaRepository
    val service1: TeaDomainService 
    val service2: OtherDomainService 
}

trait OtherDomainService {
    def businessOp2(Tea tea):Context=>Tea = context => {
        context.repository.xxx //將原來的repository改為context.repository
    }
}

trait TeaAppService {
    def perform(String param):Context=>String = context => {
        Tea t = context.service1.businessOp1(param)(context) //businessOp1(param)返回一個函式
        String s = context.service2.businessOp2(t)(context) //businessOp2(tea)也返回一個函式
        s
    }
}

//與Spring一樣,需要一個Context的例項
object AppContext extends Context {
    val repository: TeaRepository = new HibernateTeaRepository
    val service1: TeaDomainService = new xxx
    val service2: OtherDomainService = new xxx
}
val s = perform("param")(AppContext)

  至此,已經相對完整地處理了多個模組的注入問題,程式已經可以正常執行。但TeaAppService.perform裡的程式碼顯得很囉嗦,不如DomainService中的簡潔。這是因為perform方法本身需要注入,所使用的service物件上的方法也需要注入,因此不得不反覆將Context應用到service返回的函式上來獲取結果。
  為了處理這一情況,可以使用高階的Reader[Context,X]型別來替代我們現在使用的context=>X函式型別。Reader型別是範疇論中的一種單子型別(Monad),什麼是Monad會在後續文章中介紹,目前只要知道它支援一個操作叫flatMap,簽名類似這樣:

trait Reader[Context,A] {
    def flatMap[B](f: A=>Reader[Context,B]):Reader[Context,B]
    def map[B](f:A=>B):Reader[Context,B]
    def apply(context:Context):A
}

  如果用Reader型別來實現依賴注入,那麼在遇到巢狀注入的時候程式碼就會好看很多:

trait OtherDomainService {
    def businessOp2(tea:Tea):Reader[Context,Tea] = Reader { context => 
        context.repository.xxx //與原來的程式碼一致,只是要返回Reader物件
    }
}

trait TeaAppService {
    def perform(String param):Reader[Context,String] = {
        for { //下面的語法和原來的基本一致
            t <- context.service1.businessOp1(param)
            s <- context.service2.businessOp2(t)
        } yield s
    }
}

object AppContext extends Context {
    ...
}
val s = perform("param").apply(AppContext) //perform("param")返回一個Reader,再呼叫apply獲得結果

  上面的for{}是什麼意思?其實這是scala的語法糖,scala會將for和yield語句替換為flatMap和map操作。如果我們還原for語法糖就能看清Reader其中的原理:

trait TeaAppService {
    def perform(String param):Reader[Context,String] = {
        val tReader:Reader[Context,Tea] = context.service1.businessOp1(param)
        val sReader:Reader[Context,String] = tReader.flatMap(
            //businessOp2(t)返回一個Reader[Context,String],正好滿足flatMap的要求
            t => context.service2.businessOp2(t)) 
        sReader
    }
}

  先是通過businessOp1獲得了一個Reader[Context,Tea],然後Reader可以支援flatMap這個高階函式操作。我們可以將t => businessOp2(t)傳遞給它,是因為businessOp2返回Reader[Context,String],因此t => businessOp2(t)的型別是Tea=>Reader[Context,String],滿足flatMap的要求,因而tReader.flatMap返回的結果就是Reader[Context,String]

總結

  個人認為傳統面向物件中的一些原則其實是放之四海而皆準的,比如單一職責、開閉原則以及優先使用組合原則(即組合優於繼承,但在函數語言程式設計裡沒有繼承)等。在函數語言程式設計中也一樣要以這些原則為指導,否則程式碼依然會陷入混亂。但兩者的實現方式有所不同,主要區別在於將面向物件中原本的函式式介面直接替換為函式型別本身,將函式式介面物件的構造方法或工廠方法替換為輸出新函式的高階函式,因而大大減少了程式碼量,不僅增加了可讀性,也提高了編碼效率。
  同樣,依賴注入也是所有程式語言都要遵循的原則。在面向物件中主要通過物件的set方法和框架來組裝物件,而在函數語言程式設計中可以不依賴框架,通過返回一個以依賴元件為形參的函式來實現。獲得了這個函式之後再將依賴的元件以實參傳入即能得到相應的結果。最後,還可以使用Reader等Monad型別中提供的複雜函式組合方法來簡化在巢狀注入情況下的程式碼。

如何進一步學習?

  若想進一步瞭解設計模式是如何在函數語言程式設計下實現的,可以參考Scala與Clojure函數語言程式設計模式一書。
  若想進一步瞭解scala的for語法,可以參考Scala官網文件
  若想進一步瞭解Monad相關知識,請期待後續文章