Haskell 函數式編程體驗

概述

本文經過完成一個簡單的練習題(Exercise),對照在命令式編程(Imperative Programming)語言和函數式編程(Functional Programming)語言中的多種實現,在函數式編程的思惟方式上給予你們一些直觀的感覺和體驗。python

前言

在學習和討論函數式編程的時候,我曾經被問到這麼一個問題:函數式編程有什麼特別優秀的地方?或者說有什麼事情是函數式編程能夠作到而在傳統命令式編程裏作不到或者很難作到的?記得這個問題當時回答得很敷衍,以致於如今都想不起來那時是怎麼說的。以後又通過了長時間的學習,練習和思考,到目前爲止仍是以爲這是一個很難回答的問題。在此我願意記錄一下如今的理解,相信這個不會是最終的答案,隨着學習的深刻和經驗的積累,甚至於未來會有比較大的變化。首先,流行的編程語言,無論是命令式的仍是函數式的都已經被證明具備非凡的表現能力,變幻無窮的軟件系統和應用就是證據。僅僅只有幾條規則的lambda演算被證實是圖靈完整的(https://en.wikipedia.org/wiki...),也就是說理論上僅僅基於lambda演算就能夠實現全部的智能和邏輯。有一門研究性質的編程語言叫Brain F*ck,驗證了只須要6個算符(或者能夠稱做編程原語)就能夠實現一門圖靈完整的編程語言,雖然它的Hello world簡直就是天書(https://en.wikipedia.org/wiki...)。所以我相信咱們不可能找到什麼東西是在命令式編程語言中不能實現的。區別僅在於一些問題域或是思惟方式與函數式編程的方式和風格特別地契合,從而演化出直接,簡明,高效的表述,顯得特別有表現力。但若是咱們試圖在函數式編程語言裏沿用命令式編程的思惟方式,就會遇到不少的困難,出來的結果也將會晦澀難懂,低效易錯,難於修改或擴展。程序員

練習題目

有一種植物,一年有兩個生長週期,春天高度加倍,夏天高度增長1米,秋冬季休眠。給出該植物在某個冬季的初始高度 i(米),請計算給出在 n 個生長週期後的高度。編碼實現函數 f(i, n)
這是一個來自某個在線解題(Online Judge)網站的題目。題目自己很是的簡單直接。使得咱們沒必要關注於精巧的數據結構和高深的算法,從而專一於不一樣的思惟方式。算法

Python 實現

def f(i, n):
    l = i
    for c in range(n):
        l = l + 1 if (c % 2) else l * 2
    return l

這裏咱們選擇了Python來做爲命令式語言的表明。Python的實現直接明瞭。你們能夠打開Python的交互式命令環境,鍵入並測試這段代碼。一些可能的測試結果以下:編程

>>> f (1, 3)
6
>>> f (2, 7)
46
>>> f (2, 2)
5
>>> f (2, 3)
10
>>> f (2, 4)
11

讓咱們簡單分析一下代碼:數組

  1. 建立一個局部變量 l 用於存放計算的中間和最終結果。其初始值就是給定的初始值。在計算過程當中,該變量的值會發生變化,始終存放當前的計算結果。在計算完成以後,其中保持的值就是最終的結果,被直接返回。
  2. 使用for循環配合range產生的n個元素的列表,分步驟地計算。
  3. 使用(exp) if (bool) else (exp)條件表達式根據生長週期的不一樣運用不一樣的算式。這裏的條件表達式至關於C/C++/Java中的三目條件運算符?:。也可使用條件語句(Condition statement),表述一樣簡潔明瞭。

Haskell 實現(1)

Haskell是一門基於Lambda演算的「純粹」的函數式編程語言。Haskell裏沒有語句(Statement)的概念,程序是由函數定義和表達式組成。函數和數值都是其中的一等公民(First Class Citizen),能夠被方便地表述(Represent),存儲(Store),傳遞(Pass/Transfer),使用(Use/Apply)和操縱(Manipulate)。一般的函數都是純粹的(Pure),無反作用的(Ineffective),簡單地說就是傳入相同的參數,只能返回相同的結果。
如下是Haskell的一個實現:安全

f :: Int -> Int -> Int
f i n = foldl (\acc c -> if (odd c) then acc + 1 else acc * 2) i (take n [0,1..])

你們能夠將以上代碼存成一個.hs文件(Haskell源代碼文件的擴展名),而後加載到ghci中測試。Ghci是Haskell的一個交互式命令環境,關於ghci安裝和使用不在本文的範圍內,請參見網上的其它資料。也能夠在ghci裏直接鍵入以上內容,可是不要忘記先鍵入一行「:{」,而後在結尾添加一行「:}」, :{和:}是ghci中用於鍵入多行代碼必須的標識。如下是在ghci中的函數定義和測試結果:數據結構

Prelude> :{
Prelude| f :: Int -> Int -> Int
Prelude| f i n = foldl (\acc c -> if (odd c) then acc + 1 else acc * 2) i (take n [0,1..])
Prelude| :}
Prelude> f 1 3
6
Prelude> f 2 7
46
Prelude> f 2 2
5
Prelude> f 2 3
10
Prelude> f 2 4
11

咱們來一步一步地解釋代碼:架構

  1. 第一句f :: Int -> Int -> Int是一個函數的類型申明。能夠理解爲申明瞭一個名爲f的函數,它依次接受兩個類型爲Int的參數,計算並返回一個Int型的值做爲結果。在Haskell的函數申明裏,最後的那一個->XXX能夠被理解爲函數的返回值的類型定義。
  2. Haskell裏沒有循環語句,咱們使用foldl函數實現可控的循環。熟悉Python/Java的程序員應該知道Python裏有reduce函數,Java(>=8)中有stream.reduce,跟這裏的foldl是相同的函數。foldl接受一個帶兩個參數的函數,一個初始的累積值,從一個列表的最左邊開始將當前的累積值和列表的一個元素做爲參數餵給該函數,而後將結果做爲新的累積值參加下一步計算。累積值的初始值就是傳入的初始累積值,當列表迭代完成時的累積值就是foldl的最終結果。例如 foldl (+) 0 [1,2,3]能夠將列表中的全部整數累加起來,而foldl (*) 1 [1,2,3]則計算出列表中全部整數的乘積。
  3. [0,1..]是一個無窮的序列,依次包含從0開始的全部整數。take函數從一個給定的列表中從頭取出n(take的第一個參數)個元素並以列表返回。能夠看到take n [0,1..]事實上構造了一個和Python中range(n)相同的序列。Haskell的惰性求值(Lazy evaluation)容許咱們方便的構造無窮序列並在適當的時候合理使用。
  4. (\acc c ->...)是一個lambda函數,跟Python/Java中的lambda函數相似,能夠理解爲在申明/定義的地方一次性使用的匿名函數。定義中‘\’標識了lammbda函數定義的開始,以後緊跟函數的參數,多個參數間以空格隔開,以後有一個‘->’,而後就是函數的實現。lambda函數跟一般的函數同樣,其實現都是一個表達式,在實現中能夠訪問全部的參數以及包含該lambda函數的表達式中定義和可訪問的名字。在這裏,咱們僅訪問該lambda函數本身的參數。
  5. Haskell中沒有相似if..then..else..的條件語句。這裏看到的是條件表達式,相似於上面Python代碼中的條件表達式和C/C++/Java中的?:運算符。在條件表達式中then和else子表達式都不可省略,並且必須具備相同的結果類型。在這裏,條件表達式實現了根據生長週期的不一樣而調用不一樣的算式。
  6. 那個odd函數是一個測試(Predict)函數,顧名思義,它接受一個整數,判斷其奇偶性,當參數是奇數時返回True,不然返回False。

總結一下,咱們使用foldl函數實現了可控循環,使用條件表達式實現了條件分發,odd函數判斷生長週期的屬性(奇偶),take函數配合無窮的天然數序列構造了一個有限的序列用於循環控制。這個解法雖然工做,可是顯得比較笨拙,特別是那個包含條件表達式的lambda函數。其根本緣由在於咱們一路秉承的命令式編程的思路,而後在函數式語言中尋找相應的結構或函數。那麼在函數式編程語言中咱們能夠怎樣更好地思考並解決這個問題呢?編程語言

Haskell 實現(2)

記得咱們提到過在函數式編程語言中函數是一等公民嗎?咱們能夠方便地表述,存儲,傳遞而且使用函數?下面咱們來構造一個函數序列來解決這個問題:函數式編程

f' :: Int -> Int -> Int
f' i n = foldl (flip ($)) i (take n $ cycle [(*2), (+1)])

foldl和take函數咱們以前已經瞭解了。 讓咱們來看看別的新出現的東西。

  1. 首先是函數名f',在Haskell裏單引號是合法的符號字符,能夠用於任何名字中,不過不能被用於名字的開頭。在Haskell代碼中咱們常用f',f''甚至是f'''來表示和f有些關係又不徹底同樣的定義/申明。
  2. Haskell裏全部的運算符其實都只是普通的函數。當咱們寫2 + 4時,事實上和函數調用(+) 2 4徹底等價。(+1)的意思能夠理解爲咱們使用(+)這個接受兩個參數的函數,而且傳入數值1,使其綁定到(+)的第二個參數上,從而生成一個新的函數,該函數接受一個參數,並在該參數上加上數值1做爲計算結果。這樣的函數咱們稱之爲部分應用(Partial Applied)函數,或者更加專業一點的中文說法:偏函數。在C/C++/Python/Java這種嚴格求值(Strict Evaluation)的編程語言裏,咱們很難作到調用一個函數,只傳遞給它部分的參數,使其「停」在這個中間狀態,等待其它參數的傳入(C++的模版庫裏有偏函數的實現,Python也有偏函數的庫,都費了老勁了,使用又不方便,寫出的代碼難於理解,限制還多)。而Haskell是非嚴格求值(Non-strict evaluation)的或者說咱們以前提到過,惰性求值,這使得咱們很容易綁定部分的參數到一個函數,從而造成一個新的,接受更少參數的函數。事實上函數類型上的Int -> Int -> Int,咱們說過能夠理解爲一個函數依次接受兩個Int型的參數並返回一個Int型的值做爲結果,「->」其實也是一個函數(運算符),它是右結合的, 那麼Int -> Int -> Int就等價於Int -> (Int -> Int),能夠看到這實際上是說該函數接受一個Int類型的參數並返回一個(Int -> Int)類型的函數。回到咱們的主題,這裏(*2)和(+1)均是這樣的偏函數,當傳入一個參數並求值時,分別將參數加倍和加一。
  3. cycle函數接受一個列表,把它經過循環重複的方式擴展爲一個無窮序列。所以cycle [(*2), (+1)]就是這樣一個無窮序列[(*2),(+1),(*2),(+1),(*2),(+1)......]
  4. $是一個二目算符,它接受一個函數f和一個參數a,把a做爲參數餵給函數f。f $ a等價於($) f a也等價於f a。因爲$是右結合的,並且優先級最低,咱們經常用它來減小括號的使用。這裏的(take n $ cycle [(*2), (+1)])就等價於(take n (cycle [(*2), (+1)]))
  5. 函數(flip ($))是用在foldl中的累積函數,咱們須要重點解釋一下。咱們已經知道($)是一個二目函數,也就是一個接受兩個參數的函數,它認定第一個參數是一個函數,而後把第二個參數做爲參數餵給該函數並將其計算結果做爲本身的計算結果。flip函數能夠簡單地理解爲改造一個多參數的函數,翻轉其第一個和第二個參數的順序。這樣(flip ($))就等價於($)的參數翻轉的版本,(flip ($))認定第二個參數是一個函數,把第一個參數做爲參數餵給該函數而獲得結果。咱們知道foldl的累積函數有兩個參數,第一個是累積值(開始時就是初始值),第二個是從後面的序列中取到的元素,在這裏序列中的元素是函數(*2)或者(+1),所以咱們須要累積函數將第一個參數做爲參數餵給第二個參數(函數)。提及來很繞口,不過請記得關於foldl的三條規則:A)foldl的結果,給定的累積函數的返回值和給定的初始值有相同的類型 B)累積函數的第一個參數和初始值類型相同 C)累積函數的第二個參數和給定的序列中的元素有相同的類型。

總結一下:咱們經過cycle [(*2), (+1)]構造了一個加倍和加一函數交替出現的無窮序列。而後用take函數依據給定的生長週期數目獲得一個有限的函數序列,該序列能夠看做是一個數據加工的動做序列。以後咱們使用foldl函數迭代該動做序列,依次將植物的高度做爲參數餵給序列中的動做並把結果做爲下次動做的參數。而將做爲累積值的植物高度餵給動做函數的任務則由函數(flip ($))來完成。這個解題思路就比較符合函數式編程的思惟方式和風格了。那麼還有沒有更加酷一點的方法呢?有的!至少我以爲下面的實現(3)就還要酷炫一點。

Haskell 實現(3)

在實現(2)中咱們表述(Represent)了偏函數,而且將偏函數存儲(Store)在列表中,以後將這些偏函數傳遞(Pass/Transfer)給高階函數(高階函數就是能處理函數的函數)foldl和(flip ($)),最後經過高階函數把所需的參數餵給這些偏函數,應用(Apply)了它們並最終求值(Evaluation)成功。不過彷佛少了同樣,說好的操縱(Manipulate)函數呢?別急,這就來:

f'' :: Int -> Int -> Int
f'' n = foldl (flip (.)) id $ take n $ cycle [(*2), (+1)]

這裏除了咱們的老朋友foldl,take,cycle以及偏函數(*2),(+1)以外,有這些新的內容:

  1. 你們也許注意到f''函數類型申明沒有發生變化,仍然是Int -> Int -> Int,但是它的實現爲何只有一個參數f'' n而不是像以前的f和f'那樣有兩個參數(f i n和f' i n)呢?這是函數的參數約簡在起做用。在數學上,對於兩個一元函數f和g,若是對全部可能的輸入參數a都有f a = g a,那麼咱們就說函數f等價於函數g,記爲f = g。在Haskell中這條規則徹底成立,無論何時若是有f i = g i,只要f和g中沒有包含i,咱們均可以安全地把參數i約去而獲得f = g。因此實際上f'' n = foldl ...是由f'' n i = (foldl ...) i經過兩邊約掉相同的i簡化得來的。那爲何參數i和n的順序變了呢?下面咱們立刻會說到。
  2. 這個解法的思路是咱們先使用動做偏函數序列以及給定的週期數目構造一個大的all-in-one的函數,而後把給定的初始高度傳給這個函數,其結果就是咱們須要的最終結果。所以咱們很天然地把先用到的週期數目n放到參數的第一個。那麼就要求f'' n構造並返回一個函數,該函數接受一個參數,就是初始高度,計算並返回最終結果。
  3. 這裏咱們兩次使用$來減小括號的使用,能夠把$都換成左括號‘(’,而後在表達式的末尾加上相應數量的右括號‘)’,獲得的表達式是等價的。你們能夠自行驗證。
  4. 這裏foldl用的累積函數是(flip (.)),咱們已經知道flip是作什麼的。而運算符(.)是組合函數的函數。在數學中函數的組合(或者叫複合函數)能夠定義爲,對於函數f和g,若是f能夠操做g的結果集,定義函數組合f.g,對於任何g定義域中的元素a,有(f.g) a = f (g a)。在Haskell裏這被徹底直接地表述爲f.g,對於任何a(能夠做爲g的參數)有(f.g) a = f (g a). 而咱們知道Haskell語法上中綴運算符能夠轉換爲函數調用,f.g等價於(.) f g。根據運算符(.)的定義,咱們能夠知道在把最終參數餵給組合函數並求值的時候,第二個函數會被先應用求值,而後結果再傳給第一個函數計算出結果。而foldl是從序列的左端開始摺疊,在咱們的動做序列中左端的動做是須要先作的,就是說咱們但願在組合函數中左端的(也就是第一個參數)函數先執行求值。這就是咱們用到flip的緣由了。
  5. 咱們以前說過foldl的累積初始值的類型和摺疊的結果類型要一致,這裏咱們要獲得一個函數,該函數接受植物的初始高度,計算並返回最終高度,它的類型應該是Int -> Int。那麼咱們就須要一個一樣類型的函數做爲foldl的累積初始值。注意到這個初始函數會被組合到最後的組合函數鏈的尾端(也就是最內層)。若是咱們爲這個函數命名h的話,最後的組合函數鏈看起來應該像這個樣子.... (+1).(*2).(+1).(*2).h。而咱們並不但願這個函數會對結果有任何影響或改變,很天然地咱們認定這個函數應該直接返回傳給它的參數而不作任何更改,該函數的定義應該是h=\x->x。在Haskell裏,預約義的id正是這麼一個函數。因而咱們看到id被做爲foldl的累積初始值。

能夠在ghci中鍵入並驗證這個實現,注意參數n和i的順序不一樣於以前的f和f',除非你使用(flip f'')。

Prelude> :{
Prelude| f'' :: Int -> Int -> Int
Prelude| f'' n = foldl (flip (.)) id $ take n $ cycle [(*2), (+1)]
Prelude| :}
Prelude> f'' 3 1
6
Prelude> f'' 7 2
46
Prelude> f'' 2 2
5
Prelude> f'' 3 2
10
Prelude> f'' 4 2
11

總結一下:咱們如方法(2)中同樣構建了一個由偏函數(*2)和(+1)交替出現的動做序列。而後使用foldl和組合函數(.)將序列中的前n個元素組合(函數操縱Manipulate的一種情形)成爲一個大的函數。最後該函數接受到做爲參數的植物的初始高度,求值計算出結果,也就是最後的植物高度。
或者咱們能夠這樣理解:咱們在方法(3)中構建了一套邏輯/理論架構,在給定生長週期數目的時候,該理論架構發生塌縮,生成一個Int -> Int的函數,咱們能夠理解爲規模較小的理論架構。而後當傳入植物的初始高度時,這個較小的理論架構再次發生塌縮,從而計算/獲得最終的結果,也就是咱們想要獲得的植物的最終高度。這個理解完美地契合了函數式建模/編程的一種世界觀,那就是編程就是設計和建模一個理論架構,而後當接收到外來刺激的時候,該理論架構就會發生塌縮,從而造成/產生在該刺激條件下的你們所期待的完美結果。是的,完美!

相關文章
相關標籤/搜索