所謂命令式編程,是以命令為主的,給機器提供一條又一條的命令序列讓其原封不動的執行。程序執行的效率取決於執行命令的數量。因此才會出現大O表示法等等表示時間空間復雜度的符號。
而函數式語言並不是通常意義上理解的“通過函數的變換進行編程”。注意到純的函數式語言中是沒有變量的(沒有可以改變的東西,所有的東西在定義以后就都是不變的),那么這樣的東西有什么好處呢?就比如,如果所有的東西都是不變的,那么我們又怎么進行編程呢?
實際上,我們在函數式編程中進行構建的是實體與實體之間的關系。在這種意義上,lisp雖然不是純粹的函數式編程,但是也算是函數式編程一員。使用這種定義,大多數提供了原生的list支持的腳本語言也可以算混合了函數式語言的功能,但是這不是函數式語言的精髓。知其然,還要知其所以然。我們既然已經有了精確自然的命令式編程,又為什么還需要函數式編程呢?我們舉個小例子。
int fab(int n) {
return n == 1 || n == 2 ? 1 : fab(n - 1) + fab(n - 2);
}
這是用C語言寫的求斐波那契數列的第N項的程序,相應的Haskell代碼是這樣的:
fab :: (Num a) => a -> a
fab n = if n == 1 || n == 2 then 1 else fab(n - 1) + fab(n - 2)
看上去差不多對不對?但是這兩個程序在執行的效率方面有着天差地別的差距。為什么呢?C語言是標准的命令式編程語言。因此對於你寫下的每一行語句,C程序會原封不動地機械地去執行。如果想效率提高,你必須自己去分析程序,去人工地減少程序中執行的語句的數量。具體到這個C程序,我們注意到在每次函數調用時,都會產生兩個新的函數調用。這時,實際產生的函數調用的數目是指數級別的!比方說,我們寫fab(5),實際的執行結果是:
fab(5)
fab(4)
fab(3)
fab(2)
fab(1)
fab(2)
fab(3)
fab(2)
fab(1)
我們看到,fab(3)被求值了兩遍。為了計算fab(5),我們實際執行了8次函數調用。
那么函數式語言呢?我們說過,函數式語言里面是沒有變量的。換句話說,所有的東西都是不變的。因此在執行fab(5)的時候,過程是這樣的:
fab(5)
fab(4)
fab(3)
fab(2)
fab(1)
fab(3)
總共只有五次應用。注意我說的是應用而不是調用。因為函數式語言里的函數本意並不是命令式語言里面的“調用”或者“執行子程序”的語義,而是“函數與函數之間的關系”的意思。比如fab函數中出現的兩次fab的應用,實際上說明要計算fab函數,必須先計算后續的兩個fab函數。這並不存在調用的過程。因為所有的計算都是靜態的。haskell可以認為所有的fab都是已知的。因此實際上所有遇到的fab函數,haskell只是實際地計算一次,然后就緩存了結果。
本質上,這代表了我們提供給函數式語言的程序其實並不是一行一行的“命令”,而只是對數據變換的說明。這樣函數式語言可以深入這些說明中,尋找這些說明中冗余的共性,從而進行優化。這就是函數式語言並不需要精心設計就會比命令式語言高效的秘密。命令式語言當然也可以進行這種優化,但是因為命令式語言是有邊界效應的。而且大部分情況下都是利用邊界效應進行計算,因此很難推廣這種優化,只有少數幾種窺孔優化能取得效果。
放到這個例子上,因為本質上我們兩次的fab應用是重疊的。haskell發現了這個特點,於是將兩次fab的結果緩存下來(注意,能緩存結果的必要條件是這個函數返回的值是不會變的!而這是函數式語言主要的特性)。如果后續的計算需要用到這兩次fab的結果,就不需要再次重復計算,而只是直接提取結果就可以了。這就是上面幾乎完全一樣的兩個程序效率相差如此之大的主要原因。
函數式語言有這樣的優勢,那么函數式語言有沒有缺陷呢?當然是有的。函數式語言不如命令式語言那么純粹。和機器一一對應,這在某些情形下會導致更差的效率和更低的開發效率。對計算模型不斷深入的了解會縮短這兩者之間的差距。然而,一定要注意命令式語言是植根於馮·諾依曼體系的,一旦新的體系產生了革命性的改變。那么命令式語言就不會再適用,而只能通過模擬的方法進行執行,到那個時候,函數式語言和命令式語言的地位就會完全顛倒過來,當然這並不是我們目前需要考慮的問題,但是在現在稍微了解一點函數式語言編程的思想是十分重要的。