天天看點

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 %  != ;
    }
}
class ModByNFilter implements IntFilter {
    private int i;
    ModByNFilter(int i) { 
        this.i = i;
    }
    public boolean shouldFilter(Integer i){
        return i % n == ;
    }
}
IntCollector c = new IntCollector();
c.filter(Arrays.asList(,,,,,), new EvenFilter()) //return 1,3,5
c.filter(Arrays.asList(,,,,,), new ModByNFilter()) //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(,,,,,), i => i% == ) //return 1,3,5
           

  對于像ModByNFilter這樣,建構時需要額外入參作為算法運作狀态的政策,可以通過一個高階函數接收入參,并傳回一個函數作為ModByNFilter的具體實作:

//用高階函數替代ModByNFilter的構造函數,産生一個函數Int=>Boolean
def modByNFilter(n: Int): Int=>Boolean = i => i % n == 
IntCollector.filter(List(,,,,,), modByNFilter()) //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 
c.purchase();
c.cancel();
c.purchase(); //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 = ;
    }
}
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 += ;
    }
}
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 = ;
    }
}
class HalfSugarMaker implements TeaDrinkMaker {
    private TeaDrinkMaker maker;

    LiquidAdder(TeaDrinkMaker maker){...}

    public TeaDrink make() {
        TeaDrink drink = maker.make();
        drink.sugar /= ;
    }
}
//含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, )
//接受一個函數,傳回一個同樣類型的函數
def liquidAdder(maker:()=>TeaDrink, liquid:String):()=>TeaDrink = () => {
    val drink = maker()
    drink.copy(liquids=drink.liquids::liquid, sugar = drink.sugar+)
}
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=)
}
def halfSugar(maker:()=>TeaDrink) =  () => {
    val drink = maker()
    drink.copy(sugar=drink.sugar/)
}
//先擷取産生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相關知識,請期待後續文章

  

繼續閱讀