什麼是函數語言程式設計

ysjxjf發表於2014-06-28

我們已經在本教程中學了有一段時間了,然而我們還沒有真正考慮過函數語言程式設計(functional programming)。目前文中講述的一系列特點——豐富的資料型別、模式匹配、型別推斷、巢狀函式——這些你只能想像存在於某種“超級C”語言中。有了這些神奇特點之後,就能讓你的程式碼更精煉、更容易閱讀並且錯誤更少,但但實際上它們和函數語言程式設計的關係卻不是很大。事實上我要說的觀點是函式式語言如此神奇並非是因為其函數語言程式設計,而是因為我們長時間侷限於類C語言之中,而同時程式設計的界限卻在不斷地繼續擴充套件。所以當我們在第N次重複寫struct { int type; union { ... } }的時候,ML和Haskell程式設計師已經可以對資料型別使用安全變體和模式匹配了。當我們還在小心翼翼地free每個我們所malloc的東西時,自從80年代就已經有能比手工編碼更好的垃圾收集器了。

好吧,說完這些之後,我就可以告訴你什麼是函數語言程式設計了。

最基本的定義為(不過不是很讓人明白的):在函式式語言中,函式是第一等公民。

光說不練,難以令人理解,所以先看一個例子:

# let double x = x * 2 in List.map double [ 1; 2; 3 ];; - : int list = [2; 4; 6]

在這個例子中,我首先定義了一個叫做double巢狀函式,輸入一個引數x並返回x * 2map對給定的列表([1; 2; 3])中的每個元素呼叫double,產生了這個結果:一個新的列表,其中每個數字都變成了原來的兩倍。

我們已經知道map是一個高階函式(higher-order function,簡稱HOF)。高階函式就是說這個函式可以用另一個函式作為它的一個引數,聽起來很深奧的說法。

目前來說這個概念還很簡單。如果你熟悉C/C++,就會覺得它看上去好像就是傳遞一個函式指標。Java則有種叫做匿名類的討厭東西,類似於一種遲鈍的、冗長的閉包。如果你瞭解Perl,那麼你可能已經知道並正在使用Perl的閉包以及Perl的map函式,它就是我們正在討論的東西。實際上Perl也是一個很好的函式式語言。

閉包(Closure)指攜帶著它們被定義所處的“環境”的函式。特別地說,一個閉包可以引用在它定義時可用的那些變數。下面我們來泛化前面的函式,使得函式可以輸入任何整數列表並將每個元素乘以一個任意值n

let multiply n list = let f x = n * x in List.map f list ;;

因此:

# multiply 2 [1; 2; 3];; - : int list = [2; 4; 6] # multiply 5 [1; 2; 3];; - : int list = [5; 10; 15]

multiply中需要注意的重點是巢狀函式f。這是一個閉包。看一下f是如何使用n的值的,它並未實際作為一個明確的傳遞給ff而是直接從它的環境中獲取了n的值——它是multiply函式的引數,因此在其中是可用的。

聽起來可能很直觀,不過讓我們再仔細看一下對對映的呼叫:List.map f list

map是定義在List模組中的,和當前的程式碼並不在一起。換句話說,我們將f傳遞到一個“很久很久以前,在一個遙遠的星系中”定義的模組。就我們所知而言,程式碼可以將f傳遞到其他模組,或者將f的引用儲存在某處等待以後呼叫。無論怎麼做,閉包都會確保f一定可以訪問其來源的環境,並獲取n

下面是從lablgtk中擷取的實際的例子。它其實是一個類的方法(我們還沒討論過類和方法,現在只要將其認為是一個函式定義就行了)。

class html_skel obj = object (self) ... ... method save_to_channel chan = let receiver_fn content = output_string chan content; true in save obj receiver_fn

首先,你要知道在這個方法最後呼叫的save函式,其第二個引數是一個函式(receiver_fn)。它再重複呼叫receiver_fn,將來自部件的文字片斷儲存起來。

現在看一下recevier_fn的定義。這個函式正是一個閉包因為它儲存了一個從其環境中引入的chan的引用。

部分函式的應用以及柯里化

讓我們定義一個將兩數相加的加法函式:

let plus a b = a + b ;;

下面給在教室後面睡覺的傢伙們一些問題:

  1. plus是什麼?
  2. plus 2 3是什麼?
  3. plus 2是什麼?

問題 1 很簡單。plus是一個函式,它接受兩個整數引數並返回一個整數。型別是這麼寫的:

plus : int -> int -> int

問題 2 就更簡單了。plus 2 3是一個數字,整數5。型別是這麼寫的:

5 : int

但問題 3 怎麼回答呢?貌似plus 2是一個錯誤。然而,實際上,並非如此。如果我們在OCaml的頂層輸入這個表示式,那麼它會告訴我們:

# plus 2;; - : int -> int = 

這不是一個錯誤。它告訴我們plus 2實際上是一個函式,它接受一個int並返回一個int。這是怎樣的一個函式呢?首先我們給這個函式起個名字叫做(f),然後再進行一些實驗,給它一些整數看看會發生什麼:

# let f = plus 2;; val f : int -> int =  # f 10;; - : int = 12 # f 15;; - : int = 17 # f 99;; - : int = 101

在工程中,我們有說plus 2就是將2加上另一個數的函式。

回到原來的定義,讓我們“填入”第一個引數(a)2,則獲得了:

let plus 2 b = (* 這不是真正的OCaml程式碼! *) 2 + b ;;

我希望你可以瞭解為何plus 2就是一個函式。

看一下這些表示式的型別也許就能夠一窺函式型別中用到的奇怪的->箭頭記號的原理:

plus : int -> int -> int plus 2 : int -> int plus 2 3 : int

這個過程稱之為currying(柯里化,也可能叫做uncurrying反柯里化,我至今也搞不清哪個是哪個)。它之所以如此命名是為了紀念Haskell Curry,他對lambda運算元做出了一些相關的重要貢獻。由於我要儘可能迴避在OCaml背後的數學原理——因為這十分冗長也不切主題——所以這個問題上我就不深入了。如果你對此感興趣,你可以透過Google搜尋來查詢更多關於柯里化的更多資訊。

還記得我們前面的doublemultiply函式嗎?multiply的定義是這樣的:

let multiply n list = let f x = n * x in List.map f list ;;

現在我們來定義doubletriple,這些函式很好寫,如下:

let double = multiply 2;; let triple = multiply 3;;

它們其實都是函式,看:

# double [1; 2; 3];; - : int list = [2; 4; 6] # triple [1; 2; 3];; - : int list = [3; 6; 9]

你也可以像這樣直接使用部分應用(無需中間的f函式)

# let multiply n = List.map (( * ) n);; val multiply : int -> int list -> int list =  # let double = multiply 2;; val double : int list -> int list =  # let triple = multiply 3;; val triple : int list -> int list =  # double [1; 2; 3];; - : int list = [2; 4; 6] # triple [1; 2; 3];; - : int list = [3; 6; 9]

在上面的例子中,(( * ) n)( * )(乘法)函式的部分應用。注意空格是必須的,這樣OCaml才不會認為(*是一個註釋的開始。

你可以將中綴運算子放入括號中來定義函式。下面是定義的和前面的plus函式完全一樣:

# let plus = (+);; val plus : int -> int -> int =  # plus 2 3;; - : int = 5

下面還有一些有趣的柯理化:

# List.map (plus 2) [1; 2; 3];; - : int list = [3; 4; 5] # let list_of_functions = List.map plus [1; 2; 3];; val list_of_functions : (int -> int) list = [; ; ]

函數語言程式設計的優勢是什麼?

函數語言程式設計,和其他的優秀程式設計技術一樣,它也是你的錦囊中用於解決某幾類問題的有效工具。對於設計回撥函式——它用多種使用者,從GUI到事件驅動的迴圈——十分方便。對於表達泛型演算法也十分好用。List.map就是一個泛型演算法,用於將函式應用於任何型別的列表。類似的,你還可以定義處理樹的泛型函式。某幾種型別的數字問題也可以利用函數語言程式設計更快地解決(例如,用數字計算數學函式派生出來的東西)。

純粹的和不純粹的函數語言程式設計

一個純粹的函式是指沒有任何副作用的函式。副作用其實是指函式在其內部儲存某種隱藏的狀態。C中,strlen是一個純函式的很好的例子。如果你對同樣的字串多次呼叫strlen,它總是返回同樣的長度值。strlen的輸出(長度)僅由其輸入(字串)決定,不依賴於任何其他東西。但不幸的是,C中很多函式都是不純的。例如,malloc——如果你用同樣的數字對其進行呼叫,它肯定都不會返回同樣的指標給你。malloc當然還依賴於很多隱藏的內部狀態(堆上分配的物件、使用的分配方法、從作業系統抓取頁,等等)。

ML派生的語言,如OCaml,是“近乎純粹的”。它們允許透過像引用和陣列這類的東西產生一些副作用,但是大部分你寫出來的程式碼都會是純函式式的因為它們鼓勵這種思考方式。Haskell——另一種函式式語言——則是完全純函式式的。因為寫不純的函式有時候更加有用和有效,所以OCaml更加實用的。

使用純函式還有一些理論上的好處。其中一個好處是,如果某個函式是純粹的,那麼如果使用同樣的引數對其呼叫多次的話,編譯器就只需要呼叫該函式一次。在C中有一個很好的例子:

  1. for (i = 0; i 
  2.   {   
  3.     // 做一些不影響s的事情   
  4.   }  

如果就這樣編譯了,那麼這個迴圈是O(n2)因為每次都要呼叫strlen (s)然後strlen又需要迭代整個s。如果編譯器足夠聰明可以推斷出strlen是一個純函式同時s又沒有在迴圈中更新過,那麼它就可以刪除冗餘的strlen的呼叫,就能使函式變為O(n)複雜度。那麼編譯器真的能這麼做嗎?在strlen的情況下,可以,而在其他情況下,可能就不行了。

集中於寫短小的純函式可以讓你使用一種自地向上的方式來構建可複用的程式碼,同時邊繼續邊測試每個小函式。而當前時尚的方式是使用一種自頂向下的方式來仔細設計你的程式,不過在作者的經歷中,這往往會導致專案失敗。

嚴格性和惰性

C派生的和ML派生的語言都是嚴格的。Haskell和Miranda則是非嚴格的,或者說是惰性的。OCaml預設是嚴格的,不過當必須時也可以進行惰性風格的程式設計。

在嚴格語言中,給函式傳遞引數的時候都是先計算好的,然後把結果傳遞給函式。例如,在嚴格語言中,下面的呼叫肯定會導致“被零除”的錯誤:

give_me_a_three (1/0);; 
如果你用一些常見的語言程式設計的話,這就是其工作的方式,如果它能以任何方式執行起來的話,你一定十分驚訝。 在一個惰性語言中,就會有一些其他奇怪的事情發生。傳遞給函式的引數只有當函式實際用到它們的時候才會進行計算。還記得前面give_me_a_three函式會拋棄它的引數,總是返回3麼?在惰性語言中,上面的呼叫並不會失敗,因為give_me_a_three從不會看它第一個引數,所以第一個引數並不會被計算,所以被零除並不會發生。 惰性語言還能讓你做一些很古怪的事情,比如定義一個無限長的列表。前提是你不會真的去迭代整個列表,這就沒問題(換句話說,比如你只要獲取前10個元素)。 OCaml是一個嚴格語言,不過它有一個Lazy模組可以讓你寫一些惰性表示式。下面有一個例子。首先我們給1/0建立一個惰性表示式:
# let lazy_expr = lazy (1/0);; val lazy_expr : int lazy_t = 
注意這種惰性表示式的型別是int lazy_t。 
因為give_me_a_three接受'a(任何型別)作為引數,所以我們可以將惰性表示式傳遞給該函式:
# give_me_a_three lazy_expr;; - : int = 3
如果要計算一個惰性表示式,必須使用Lazy.force函式:
# Lazy.force lazy_expr;; Exception: Division_by_zero.

裝箱型別和拆箱型別

當討論函式式語言的時候,有一個術語你會經常聽到,這就是“裝箱”。當我第一次聽到這個術語的時候,我也很困惑,其實如果你以前用過C/C++或者Java,那麼對你來說裝箱型別和拆箱型別之間的區別是相當簡單的(在Perl中,一切東西都是被裝箱過的)。 
如何去思考一個被裝箱的物件呢,在C中這種物件就是使用malloc在堆上分配的(或者在C++中等同於new),同時/或者透過一個指標來引用的物件。看一下這個C程式的例子:
  1. #include    
  2.   
  3. void  
  4. printit (int *ptr)   
  5. {   
  6.   printf ("the number is %d\n", *ptr);   
  7. }   
  8.   
  9. void  
  10. main ()   
  11. {   
  12.   int a = 3;   
  13.   int *p = &a;   
  14.   
  15.   printit (p);   
  16. }  
變數a是分配在棧上的,所以肯定是被拆箱過的。 
函式printit接受一個裝箱過的整數並將其列印出來。 
下面的影像是了一組拆箱(上部)和裝箱(下部)整數之間的對比:  boxedarray.png 
拆箱了的整數要比裝箱了的快很多。另外,因為單獨分配更少,所以對於拆箱的物件的型別垃圾收集更加快速也更加簡單。 
在C/C++中,你應該對構造以上兩種型別的陣列是沒有問題的。在Java中,有兩種型別,int是拆箱型的,而Integer是裝箱型的,因此相對不如前者有效。在OCaml中,所有的基本型別都是拆箱型的。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/66634/viewspace-1198171/,如需轉載,請註明出處,否則將追究法律責任。

相關文章