函數式編程中的常用技巧


在Closure、Haskell、Python、Ruby這些語言越來越流行的今天,我們撇開其在數學純度性上的不同,單從它們都擁有一類函數特性來講,討論函數式編程也顯得很有意義。

一類函數為函數式編程打下了基礎,雖然這並不能表示可以完整發揮函數式編程的優勢,但是如果能掌握一些基礎的函數式編程技巧,那么仍將對並行編程、聲明性編程以及測試等方面提供新的思路。

很多開發者都有聽過函數式編程,但更多是抱怨它太難,太碾壓智商。的確,函數式編程中很多的概念理解起來都有一定的難度,最著名的莫過於單子,但是通過一定的學習和實踐會發現,函數式編程能讓你站在一個更高的角度思考問題,並在某種層面上提升效率甚至是性能。我們都知道飛機比汽車難開,但是開飛機卻明顯比開汽車快,高學習成本的東西解決的大部分是高回報的需求,這不敢說是定論,但從實踐來看這句話基本也正確。

概述

wikipedia上對於函數式編程的解釋是這樣的:

In computer science, functional programming is a programming paradigm—a style of building the structure and elements of computer programs—that treats computation as the evaluation of mathematical functions and avoids changing-state and mutable data.

翻譯過來是這樣的:

在計算機科學中,函數式編程是一種編程范式,一種構建計算機結構和元素的風格,它將計算看作是對數學函數的求值,並避免改變狀態以及可變數據。

關鍵的其實就兩點:不可變數據以及函數求值(表達式求值)。由這兩點引申出了一些重要的方面。

不變性

FP中並沒有變量的概念,東西一旦創建后就不能再變化,所以在FP中經常使用“值”這一術語而非“變量”。

不變性對程序並行化有着深遠的影響,因為一切不可變意味着可以就地並行,不涉及競態,也就沒有了鎖的概念。

不變性還對測試有了新的啟發,函數的輸入和輸出不改變任何狀態,於是我們可以隨時使用REPL工具來測試函數,測試通過即可使用,不用擔心行為的異常,不變性保證了該函數在任何地方都能以同樣的方式工作。事實上,在函數式編程實踐中,“編寫函數、使用REPL工具測試,使用”三步曲有着強大的生產力。

不變性還對重構有了新的意義,因為它使得對函數的執行有了數學意義,於是乎重構本身成了對函數的化簡。FP使代碼的分析變的容易,從而使重構的過程也變的輕松了許多。

聲明性風格

FP程序代碼是一個描述期望結果的表達式,所以可以很輕松、安全的將這些表達式組合起來,在隱藏執行細節的同時隱藏復雜性。可組合性是FP程序的基本能力之一,所以要求每個組合子都有良好的語義,這和聲明式風格不謀而合。

我們經常寫SQL,它就是一種聲明性的語言,聲明性只提出what to do而不解決how to do的問題,例如下面:

SELECT id, amount
FROM orders
WHERE create_date > '2015-11-21'
ORDER BY create_date DESC

省去了具體的數據庫查詢細節,我們只需要告訴數據庫要orders表里創建日期大於11月21號的數據,並只要id和amout兩個字段,然后按創建日期降序。這是一種典型的聲明性風格。

是的,我同意靠嘴是解決不了任何問題的,what to do提出來后總得有地方或有人實現具體的細節,也就是說總是需要有how to do的部分來支持。但是換個思路,假如你每天都在寫foreach語句來遍歷某個集合數據,難道你沒有想過你此時正在重復的how to do嗎?就不能將某種通用的“思想”提取出來復用嗎?假如你可以提取,那么你會發現,這個提取出來的詞語(或函數名)已經是一種what to do層面的思想了。

再比如,對於一個整型數據集合,我們要通過C#遍歷並拿到所有的偶數,典型的命令式編程會這么做:

// csharp
var result = new List<int>();
foreach(var item in sourceList) {
    if(item % 2 == 0) {
        result.Add(item);
    }
}
return result;

這對很多人來說都很輕松,因為就是在按照計算機的思維一步一步的指揮。那么聲明性的風格呢?

// csharp
return sourceList.Where(item => item %2 == 0);
// or LINQ style
return from item in sourceList where item % 2 == 0 select item;

甚至更進一步,假設我們有聲明性原語,可以做到更好:

// csharp
// if we already defined an atom function like below:
public bool NumberIsEven(int number) {
    return number % 2 == 0;
}

// then we can re-use it directly.
return sourceList.Where(NumberIsEven);

說句題外話,我有個數據庫背景很深的C#工程師同事,第一次見到LINQ時一臉不屑的說:C#的LINQ就是抄SQL的。其實我並沒有告訴它C#的LINQ借鑒的是FP的高階函數以及monad,只是和SQL長的比較像而已。當然我並不排除這可能是為了避免新的學習成本所以選用了和SQL相近的關鍵字,但是LINQ的啟蒙卻真的不是SQL。

我更沒有說GC、閉包、高階函數等先進的東西並不是.NET抄Java或者誰抄誰,大家都是從50多年前的LISP以及LISP系的Scheme來抄。我似乎聽到了apple指着ms說:你抄我的圖形界面技術…

類型

在FP中,每個表達式都有對應的類型,這確保了表達式組合的正確性。表達式的類型可以是某種基元類型,可以是復合類型,當然,也可以是支持泛型類型的,例如F#、ML、Haskell。類型也為編譯時檢查提供了基礎,同時,也讓屌炸天的類型推斷有了根據。

F#的類型推斷要比C#強太多了,一方面是受益於ML及OCamel的影響,一方面是在CLR層面上泛型的良好設計。很多人並不知道F#的歷史可以追溯到.NET第一個版本的發布(2002年),而當時F#作為一個研究項目,對泛型的需求很大,遺憾的是.NET第一版並沒有從CLR層面支持泛型。所以,F#團隊參與設計了.NET的泛型設計並加入到.NET 2.0開始的后續版本,這也同時讓所有.NET語言獲益。

那么我們以不同的視角審視一下泛型。何為泛型?泛型是一種代碼重用的技術,它使用類型占位符來將真正的類型延遲到運行時再決定,類似一種類型模板,當需要的時候會插入真實的類型。我們換一個角度,將泛型理解為一種包裝而非模板,它打包了某種具體的類型,使用類似F#的簽名表達會是這樣:'T -> M<'T>,轉變這種思維很重要,尤其是在編寫F#的計算表達式(即Monad)時,經常會使用包裝類這個術語。在C#中也可以看到類似的方面,例如int?其實是指Nullable<T>int類型的包裝。

表達式求值

由於整個程序就是一個大的表達式,計算機在不斷的求值這個表達式的同時也就意味着我們的程序正在運行。那么很有挑戰的一方面就是,程序該如何組織?

FP中沒有語句的概念,就連常用的綁定值操作也是一個表達式而非語句。那么這一切如何實現呢?假設我們有下面這段C#代碼:

// csharp
var a = 11;
var b = a + 9;

我們有兩個賦值語句(並且有先后依賴),如何用表達式的方式來重寫?

// csharp
// we build this helper function for next use.
public int Eval(int binding, Action<int> continues) {
    contineues(binding);
}

// then, below sample is totally one expresion.
Eval(11, a => {
    //now a is binding to 11
    Eval(a + 9, b => {
        // now, b is binding to a + 9, 
        // which is evaluate to 11 + 9
    }); 
});

這里使用了函數閉包,我們會在接下來的柯里化部分繼續談到。通過使用continues(延續)技術以及閉包,我們成功的將賦值語句變了函數式的表達式,這也是F#中let的基本工作方式。

高階函數

一類函數特性使得高階函數成為可能。何為高階函數?高階函數(higher-order function)就是指能函數自身能夠接受函數,並可以返回函數的一種函數。我們來看下面兩個例子:

// C#
var filteredData = Products.Where(p => p.Price > 10.0);
// javascript
var timer = setInterval(1000, function () {
    console.log("hello world.");
});

C#中的Where接受了一個匿名函數(Lambda表達式),所以它是一個高階函數,javascript的SetInterval函數接受一個匿名的回調函數,因而也是高階的。

我們用一個更加有表現力的例子來說明高階函數可以提供的強大能力:

// fsharp
let addBy value = fun n -> n + value
let add10 = addBy 10
let add20 = addBy 20

let result11 = add10 1
let result21 = add20 1

addBy函數接受一個值value,並返回一個匿名函數,該匿名函數對參數n和閉包值value相加后返回結果。也就是說,addBy函數通過傳入的參數,返回了一個經過定制的函數。

高階函數使函數定制變的容易,它可以隱藏具體的執行細節,將可定制的部分(或行為)抽象出來並傳給某個高階函數使用。

是的,這聽起來很像是OO設計模式中的模板方法,在FP中並沒有模板方法的概念,使用高階函數就可以達到目的了。

在下節的柯里化部分將會看到,這種定制函數的能力內建在很多FP語言中,Haskell、F#中都有提供。

在FP中最常用的就是mapfilterfold了,我們通過檢查在F#中它們的簽名就可以推測它們的用途:

map:    ('a -> 'b) -> 'a list -> 'b list
filter: ('a -> bool) -> 'a list -> 'a list
fold:   ('a -> 'b -> 'a) -> 'a -> 'b list -> 'a

map通過對列表中的每個元素執行參數函數,得到相應的結果,是一種映射。C#對應的操作為Select
filter通過對列表中的每個元素執行參數函數,將結果為true的元素返回,是一種過濾。C#對應的操作為Where
fold相對復雜一些,我們可以理解為一種帶累加器的化簡函數。C#對應的操作為Aggregate

之前我們提到過,泛型本身可以看做是某種類型的包裝,所以如果我們面對一個'T list,那么我們可以說這是一個'T類型的包裝,注意此處並沒有說它是個范型列表。於是乎,我們對map有了一種更加高層次的理解,我們可以嘗試一種新的簽名:('a -> 'b) -> M<'a> -> M<'b>,這就是說,map將拆開包裝,對包裝內類型進行轉換產生某種新的類型,然后再以同樣的包裝將其重新打包。

map也叫普通投影,請記住這個簽名,我們在最后的延續一節將提出一個新的術語叫平展投影,到時候還會來對比map

如果我們對兩個甚至是三個包裝類型的值進行投影呢?我們會猜想它的簽名可能是這樣:

  • lift2: ('a -> 'b -> 'c) -> M<'a> -> M<'b> -> M<'c>
  • lift3: ('a -> 'b -> 'c -> 'd) -> M<'a> -> M<'b> -> M<'c> -> M<'d>

其實這便是FP中為人們廣泛熟知的“提升”,它甚至可以稱作是一種函數式設計模式。提升允許將一個對值進行處理的函數轉換為一個在不同設置中完成相同任務的函數。

柯里化和部分函數應用

在計算機科學中,柯里化(Currying)是把接受多個參數的函數變換成接受一個單一參數(最初函數的第一個參數)的函數,並且返回接受余下的參數且返回結果的新函數的技術。

這段定義有些拗口,我們借助前面的一個例子,並通過javascript來解釋一下:

// javascript
function addBy(value) {
    return function(n) {
        return n + value;
    };
}

var add10 = addBy(10);
var result11 = add10(1);

javascript版本完全是F#版本的復刻,如果我們想換個方式來使用它呢?

var result11 = addBy(10, 1);

這明顯是不可以的(並不是說不能調用,而是說結果並非所期望的),因為addBy函數只接收一個參數。但是柯里化要求我們函數只能接受一個參數,該如何處理呢?

var result11 = addBy(10)(1);
//             ~~~~~~~~~    return an anonymous fn(anonymousFn, e.g)

如此就可以了,addBy(10)將被正常調用沒有問題,返回的匿名函數又立即被調用anonymousFn(1),結果正是我們所期望的。

假如javascript在調用函數時可以像Ruby和F#那樣省略括號呢?我們會得到addBy 10 1,這和真實的多參數函數調用就更像了。在addBy函數內部,返回匿名函數時帶出了value的值,這是一個典型的閉包應用。在addBy調用后,value值將在外部作用域中不可見,而在返回的匿名函數內部,value值仍然是可以采集到的。

閉包(Closure)是詞法閉包(Lexical Closure)或函數閉包(function closures)的簡稱,可參見wikipedia上的詳細解釋。

如此看來,是不是所有的多參數函數都能被柯里化呢?我們假想一個這樣的例子:

function fakeAddFn(n1) {
    return function(n2) {
        return function(n3) {
            return function(n4) {
                return n1 + n2 + n3 + n4;
            };
        };
    };
}

var result = fakeAddFn(1)(2)(3)(4);
//           ~~~~~~~~~~~~           now is function(n2)
//                       ~~~        now is function(n3)
//                          ~~~     now is function(n4)
//                             ~~~  return n1 + n2 + n3 + n4

但是這樣又顯得非常麻煩並且經常會出現智商不夠用的情況,如果語言能夠內建支持currying,那么情況將樂觀許多,例如F#可以這樣做:

let fakeAddFn n1 n2 n3 n4 = n1 + n2 + n3 + n4

編譯器將自動進行柯里化,完全展開形式如下:

let fakeAddFn n1 = fun n2 -> fun n3 -> fun n4 -> n1 + n2 + n3 + n4

並且F#調用函數時可以省略括號,所以對fakeAddFn的調用看上去就像是對多參數函數的調用:let result = fakeAddFn 1 2 3 4。到這里你也許會問,currying到底有什么用呢?答案是:部分函數應用。

由於編譯器自動進行currying,所以每一個函數本身是可以部分調用的,舉個例子,F#中的+運算符其實是一個函數,定義如下:

let (+) a b = a + b

利用前面的知識我們知道它的完全形式是這樣:

let (+) a = fun b -> a + b

所以我們自然可以編寫一個表達式只給+運算符一個參數,這樣返回的結果是另一個接受一個參數的函數,之后,再傳入剩余一個參數。

let add10partial = (+) 10
let result = add10partial 1

同時,由於add10partial函數的簽名是int -> int,所以可以直接用於List.map函數,如下:

let add10partial = (+) 10
let result = someIntList |> List.map add10partial

// upon expression equals below 
// let result = List.map add10partial someIntList

// or, more magic, make List.map partially:
let mapper = (+) 10 |> List.map
let sameResult = someIntList |> mapper

|>運算符本身也是一個函數,簡單的定義就是let (|>) p f = f p,這種類似管道的表達式為FP提供了更高級的表達。

我們知道FP是以Alonzo Church的lambda演算為理論基礎的,lambda演算的函數都是接受一個參數,后來Haskell Curry提出的currying概念為lambda演算補充了表示多參數函數的能力。

遞歸及優化

由於FP沒有可變狀態的概念,所以當我們以OO的思維來思考時會覺得無從下手,在這個時候,遞歸就是強有力的武器。

其實並不是說現代的FP語言沒有可變狀態,其實幾乎所有的FP語言都做了一定程度的妥協,諸如F#構建在.NET平台之上,那么在與BCL提供的類庫互操作時避免不了要涉及狀態的改變,而且如果全部使用遞歸的方式來處理可變狀態,在性能上也是一個嚴峻的考驗。所以F#其實提供了可變操作,但是需要明確的使用mutable關鍵字來聲明或者使用引用單元格

以一個典型的例子為開始,我們實現一個Factorial階乘函數,如果以命令式的方式來實現是這樣的:

// csharp
public int Factorial(int n) {
    var result = 1;
    for(int index = 2; index <= n; index++) {
        result = result * index;
    }
    return result;
}

這是典型的how to do,我們開始嘗試用遞歸並且盡可能的用表達式來解決問題:

// csharp
public int Factorial(int n) {
    return n <= 1
        ? 1
        : n * Factorial(n - 1);
}

這段代碼是可以正常工作的,但是如果n的值為10,000呢?會棧溢出。此時便出現了本節要解決的第二個問題:遞歸優化。

那么這段遞歸代碼為什么會溢出?我們展開它的調用過程:

n               (n-1)       ...      3         2       1  // state
--------------------------------------------------------
n*f(n-1) -> (n-1)*f(n-2) -> ... -> 3*f(2) -> 2*f(1) -> 1  // stack in
                                                       |  
n*r      <-  (n-1)*(r-1) <- ... <-   3*2  <-   2*1  <- 1  // stack out

簡單來說,因為當n大於1時,每次遞歸都卡在了n * _上,必須等后面的結果返回后,當前的函數調用棧才能返回,久而久之就會爆棧。那可以做點什么呢?如果我們在遞歸調用的時候不需要做任何工作(例如不去乘以n),那么就可以從當前的調用棧直接跳到下一次的調用棧上去。這稱為尾遞歸優化。

我們考慮,當前調用時的n,如果以某種形式直接帶到下一次的遞歸調用中,那么是不是就達到了目的?沒錯,這就是累加器技術,來嘗試一下:

private int FactorialHelper(acc, n) {
    return n <= 1
        ? acc
        : FactorialHelper(acc * n, n - 1);
}

public int Factorial(int n) { return FactorialHelper(1, n); }

C#畢竟沒有F#那么方便的內嵌函數支持,所以我們聲明了一個Helper函數用來達到目的,對應的F#實現如下:

let factorial n =
    let rec helper acc n' =
        if n' <= 1 then acc
        else helper (acc * n') (n' - 1)
    helper 1 n

下面的示意表達了我們想達到的效果:

init        f(1, n)             // stack in
                |               // stack pop, jump to next
n           f(n, n-1)           // stack in
                |               // stack pop, jump to next
n-1         f(n*(n-1), n-2)     // stack in
                |               // stack pop, jump to next
...         ...                 // stack in
                |               // stack pop, jump to next
3           f((k-2), 2)         // stack in
                |               // stack pop, jump to next
2           f((k-1), 1)         // stack in
                |               // stack pop, jump to next
1           k                   // return result

可以看到,調用展開成尾遞歸的形式,從而避免了棧溢出。尾遞歸是一項基本的遞歸優化技術,其中關鍵的就是對累加器的使用。幾乎所有的遞歸函數都可以優化成尾遞歸的形式,所以掌握這項技能對編寫FP程序是有重要的意義的。

假如我們遇到的是一個非常龐大的列表需要處理,例如找到最大數或者列表求和,那么尾遞歸技術也將會讓我們避免在深度的遍歷時發生棧溢出的情形。

在前面我們說過fold是一種自帶累加器的化簡函數,那么列表求和以及最大數查找是不是可以直接用fold來實現呢?我們來嘗試一下。

// fsharp
let sum l = l |> List.fold (+) 0
let times l = l |> List.fold (*) 1

let max l = 
    let compare s e = if s > e then s else e
    l |> List.fold compare 0

可以看到,fold抽取了遍歷並化簡的核心步驟,僅將需要自定義的部分以參數的形式開放出來。這也是高階函數組合的威力。

還有一個和fold很類型的術語叫reduce,它和fold的唯一區別在於,fold的累加器需要一個初始值需要指定,而reduce的初始累加器使用列表的第一個元素的值。

記憶化

我們知道大多數的FP函數是沒有副作用的,這意味着以相同的參數調用同一函數將會返回相同的結果,那么如果有一個函數會被調用很多次,為什么不把對應參數的求值結果緩存起來,當參數匹配時直接返回緩存結果呢?這個過程就是記憶化,也是FP編程中常用的技巧。

我們以一個簡單的加法函數為例:

let add (a, b) = a + b

注意這里我們使用了非currying化的參數,它是一個元組。接下來我們嘗試使用記憶化來緩存結果:

let memoizedAdd = 
    let cache = new Dictionary<_, _>()
    fun p ->
        match cache.TryGetValue(p) with
        | true, result -> result
        | _ ->
            let result = add p
            cache.Add(p, result)
            result

借助一個字典,將已經求值的結果緩存起來,下次以同樣的參數調用時就可以直接從字典中檢索出值,避免了重新計算。

我們甚至可以設計一個通用的記憶化函數,用於將任意函數記憶化:

let memorize f =
    let cache = new Dictionary<_, _>()
    fun p ->
        match cache.TryGetValue(p) with
        | true, result -> result
        | _ ->
            let result = f p
            cache.Add(p, result)
            result

那么前面的memorizedAdd函數可以寫為let memorizedAdd = memorize add。這也是一個高階函數應用的好例子。

惰性求值

Haskell是一種純函數語言,它不允許存在任何的副作用,並且在Haskell中,當表達式不必立即求值時是不會主動求值的,換句話說,是延遲計算的。而在大多數主流語言中,計算策略卻是即時計算的(eager evaluation),這在某種極端情況下會不經意的浪費計算資源。有沒有什么方法能夠模擬類似Haskell中的延遲計算?

假如我們需要將表達式n % 2 == 0 ? "right" : "wrong"綁定到標識(即變量名)isEven上,例如var isEven = n % 2 == 0 ? "right" : "wrong";,那么整個表達式是立即求值的,但是isEven可能在某種狀況下不會被使用,有沒有什么辦法能在我們確定需要isEven時再計算表達式的值呢?

假如我們將isEven綁定到某種結構上,這個結構知道如何求值,並且是按需求值的,那么我們的目的就達到了。

// csharp
var isEven = new Lazy<string> (() => n % 2 == 0 ? "right" : "wrong");
// fsharp
let isEven = lazy (if n % 2 = 0 then "right" else "wrong")

當使用isEven時,C#可以直接使用isEven.Value來即時求值並返回結果,而F#的使用方式也是一樣的isEven.Value

還有一種更加通用的方式來實現惰性求值,就是通過函數,函數表達了某種可以得到值的方式,但是需要調用才能得到,這和惰性求值的思想不謀而合。我們可以改寫上面的例子:

// csharp
var isEven = (Func<string>)(() => n % 2 == 0 ? "right" : "wrong");
// fsharp
let isEven = fun () -> if n % 2 = 0 then "right" else "wrong"

這樣,在需要使用isEven的值時就是一個簡單的函數調用,C#和F#都是isEven()

延續

如果你之前使用過jQuery,那么在某種程度上已經接觸過延續的概念了。
通過jQuery發起ajax調用其實就是一種延續:

$.get('http://test.com/data.json', function(data) {
    // processing.
});

ajax調用成功后會調用匿名回調函數,而此函數表達了我們希望ajax調用成功后繼續執行的行為,這就是延續。

現在,我們回顧一下,在概述-表達式求值一節,我們為了將兩個C#賦值語句改寫成表達式的方式,新增了一個Eval函數:

// csharp
public int Eval(int binding, Action<int> continues) {
    contineues(binding);
}

它也是一種延續,指定了在binding求值后繼續執行延續的行為,我們將它稍做修改:

// csharp
public TOutput binding<TEvalValue, TOutput>(
    TEvalValue evaluation, 
    Func<TEvalValue, TOutput> continues) {
    
    return continues(evaluation());
}
// fsharp
let binding v cont = cont v
// binding: 'a -> cont:('a -> 'b) -> 'b

於是我們可以模擬let的工作方式:

// fsharp
binding 11 (fun a -> printfn "%d" a)

那么延續這種技術在實踐中有什么用途呢?你可以說它就是個回調函數,這沒有問題。深層次的理解在於,它延后了某種行為且該行為對上下文有依賴。

我們考慮這樣一個場景,假設我們有一顆樹需要遍歷並求和,例如:

// fsharp
type NumberTree =
    | Leaf of int
    | Node of NumberTree * NumberTree

let rec sumTree tree =
    match tree with
    | Leaf(n)           -> n
    | Node(left, right) -> sumTree(left) + sumTree(right)

那么問題來了,我們顯然可以發現當樹的層級太深時sumTree函數會發生棧溢出,我們也自然而然的想到了使用尾遞歸來優化,但是當我們在嘗試做優化時會發現,然並卵。這就是一個無法使用尾遞歸的場景。

核心的訴求在於,我們希望進行尾遞歸調用(sumTree(left)),但在尾遞歸調用完成之后,還有需要執行的代碼(sumTree(right))。延續為我們提供了一種手段,在函數調用結束后自動調用指定的行為(函數),於是當前正在編寫的函數便僅包含一次遞歸調用了。我們仍然可以將它看作是一種累加器技術,區別在於,之前是累加值,而延續是累加行為。

我們嘗試為sumTree遞歸函數加上延續功能:

// fsharp
let rec sumTree tree continues =
    match tree with
    | Leaf(n) -> continues n
    | Node(left, right) ->
        sumTree left (fun leftSum -> 
            sumTree right (fun rightSum -> 
                continues(leftSum + rightSum)))

此時,sumTree的簽名從NumberTree -> int變成了NumberTree -> (int -> 'a) -> 'aNode(left, right)分支現在變成了單個函數的調用,所以它是尾遞歸優化的,每次計算時都會將結束后需要繼續執行的行為以函數的方式指定,直到整個遞歸完成。

使用時,可以以延續的方式來調用sumTree函數,也可以像往常一樣從返回值獲取結果:

// fsharp
// continues way:
sumTree sampleTree (fun result -> printfn "result: %d" result)

// normal way:
let result = sumTree sampleTree (fun r -> r)

我們甚至可以從延續的思想逐漸推導出類似bind的函數,我們將它與map的簽名對比:

// bind
('a -> M<'b>) -> M<'a> -> M<'b>
// map
('a -> 'b)    -> M<'a> -> M<'b>

在高階函數一節我們說過,map叫普通投影,而新的bind叫做平展投影,它是一種外層匹配模式,在C#中對應的操作是SelectMany,在F#中就是bind,是一種通用函數。

在前面我們定義了一個binding函數,我們稍微調整一下參數順序,並把它和bind對比:

// binding:
('a -> 'b)    -> 'a -> 'b
// map:
('a -> 'b)    -> M<'a> -> M<'b>
// bind:
('a -> M<'b>) -> M<'a> -> M<'b>

也就是說,如果我們為'a加上某種包裝,然后在bind里再做一些轉換,那么我們就可以推導出bind函數。

C#的LINQ里SelectMany對應的就是from語句,比如下面:

var result = from a in l1
             from b in l2
             from c in l3
             select { a, b }

這將轉換成一系統嵌套的SelectMany調用,而select將轉換為某種類似於Return<T>()的操作。對於F#來說,類似的代碼可以用計算表達式(或者更加具體的序列表達式):

let result = seq {
    let! a = l1
    let! b = l2
    let! c = l3
    return (a, b)
}

到這里,似乎差不多該結束了,我們不打算繼續深究bind,因為再往下走就到了monad了。事實上,大家已經看到了monad,F#的序列表達式以及C#中LINQ的一部分操作,就是monad


希望本文講述的一些淺顯的函數式編程概念可以在實踐中對你有所幫助。最重要的是通過對思維的訓練,可以從更加抽象的角度思考問題,提取問題最核心的部分以復用,將可變部分提出,從而使問題可組合,並且獲得更好的表達性。

有關monad,推薦大家看看Erik Meijer大大在Channel9上的課程Functional Programming Fundamentals,它同時也是Rx庫的作者之一,以及LINQ的作者。

(完)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM