函數語言程式設計中的常用技巧

Ninja_Lu發表於2015-11-23

在Clojure、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, Func<int, int> continues) {
    return contineues(binding);
}

// then, below sample is totally one expression.
Eval(11, a => 
    //now a is binding to 11
    Eval(a + 9, b => 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(function () {
    console.log("hello world.");
}, 1000);

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#對應的操作為Selectfilter通過對列表中的每個元素執行引數函式,將結果為true的元素返回,是一種過濾。C#對應的操作為Wherefold相對複雜一些,我們可以理解為一種帶累加器的化簡函式。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, Func<int, int> continues) {
    return 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的作者。

(完)

相關文章