1. 程式人生 > >函數語言程式設計掃盲篇(轉)

函數語言程式設計掃盲篇(轉)

1. 概論

在過去的近十年的時間裡,面向物件程式設計大行其道。以至於在大學的教育裡,老師也只會教給我們兩種程式設計模型,面向過程和麵向物件。

孰不知,在面向物件產生之前,在面向物件思想產生之前,函數語言程式設計已經有了數十年的歷史。

那麼,接下來,就讓我們回顧這個古老又現代的程式設計模型,讓我們看看究竟是什麼魔力將這個概念,將這個古老的概念,在21世紀的今天再次拉入了我們的視野。

2. 什麼是函數語言程式設計

維基百科中,已經對函數語言程式設計有了很詳細的介紹。

那我們就來摘取一下Wiki上對Functional Programming的定義:

簡單地翻譯一下,也就是說函數語言程式設計是一種程式設計模型,他將計算機運算看做是數學中函式的計算,並且避免了狀態以及變數的概念

接下來,我們就來剖析下函數語言程式設計的一些特徵。

3. 從併發說開來

說來慚愧,我第一個真正接觸到函數語言程式設計,要追溯到兩年以前的《Erlang程式設計》,我們知道Erlang是一個支援高併發,有著強大容錯性的函數語言程式設計語言。

因為時間太久了,而且一直沒有過真正地應用,所以對Erlang也只是停留在一些感性認識上。在我眼裡,Erlang對高併發的支援體現在兩方面,第一,Erlang對輕量級程序的支援(請注意此處程序並不等於作業系統的程序,而只是Erlang內部的一個單位單元),第二,就是變數的不變性

4. 變數的不變性

在《Erlang程式設計》一書中,對變數的不變性是這樣說的,Erlang是目前唯一變數不變性的語言。具體的話我記不清了,我不知道是老爺子就是這麼寫的,還是譯者的問題。我在給這本書寫書評的時候吹毛求疵地說:

我對這句話有異議,切不說曾經的Lisp,再到如今的F#都對賦值操作另眼相看,低人一等。單說如今的Java和C#,提供的final和readonly一樣可以支援變數的不變性,而這個唯一未免顯得有點太孤傲了些。

讓我們先來看兩段程式,首先是我們常見的一種包含賦值的程式:

class Account: 
    def __init__(self,balance): 
        self.balance = balance 
    def desposit(self,amount): 
        self.balance = self.balance + amount 
        return self.balance 
    def despositTwice(self): 
        self.balance = self.balance * 2 
        return self.balance

if __name__ == '__main__': 
    account = Account(100) 
    print(account.desposit(10)) 
    print(account.despositTwice())

這段程式本身是沒有問題的,但是我們考慮這樣一種情況,現在有多個程序在同時跑這一個程式,那麼程式就會被先desposit 還是先 despositTwice所影響。

但是如果我們採用這樣的方式:

def makeAccount(balance): 
    global desposit 
    global despositTwice 
    def desposit(amount): 
        result = balance + amount 
        return result 
    def despositTwice(): 
        result = balance * 2 
        return result 
    def dispatch(method): 
        return eval(method) 
    return dispatch

if __name__ == '__main__': 
    handler = makeAccount(100) 
    print(handler('desposit')(10)) 
    print(handler('despositTwice')())

這時我們就會發現,無論多少個程序在跑,因為我們本身沒有賦值操作,所以都不會影響到我們的最終結果。

但是這樣也像大家看到的一樣,採用這樣的方式沒有辦法保持狀態。

這也就是我們在之前概念中看到的無狀態性。

5. 再看函數語言程式設計的崛起

既然已經看完了函數語言程式設計的基本特徵,那就讓我們來想想數十年後函數語言程式設計再次崛起的幕後原因。

一直以來,作為函數語言程式設計代表的Lisp,還是Haskell,更多地都是在大學中,在實驗室中應用,而很少真的應用到真實的生產環境。

先讓我們再來回顧一下偉大的摩爾定律:

1、積體電路晶片上所整合的電路的數目,每隔18個月就翻一番。

2、微處理器的效能每隔18個月提高一倍,而價格下降一半。

3、用一個美元所能買到的電腦效能,每隔18個月翻兩番。

一如摩爾的預測,整個資訊產業就這樣飛速地向前發展著,但是在近年,我們卻可以發現摩爾定律逐漸地失效了,晶片上元件的尺寸是不可能無限地縮小的,這就意味著晶片上所能整合的電子元件的數量一定會在某個時刻達到一個極限。那麼當技術達到這個極限時,我們又該如何適應日益增長的計算需求,電子元件廠商給出了答案,就是多核。

核並行程式設計就這樣被推到了前線,而指令式程式設計天生的缺陷卻使並行程式設計模型變得非常複雜,無論是訊號量,還是鎖的概念,都使程式設計師不堪其重。

就這樣,函數語言程式設計終於在數十年後,終於走出實驗室,來到了真實的生產環境中,無論是冷門的Haskell,Erlang,還是Scala,F#,都是函數語言程式設計成功的典型。

6. 函數語言程式設計的第一型

我們知道,物件是面向物件的第一型,那麼函數語言程式設計也是一樣,函式是函數語言程式設計的第一型。

我們在函數語言程式設計中努力用函式來表達所有的概念,完成所有的操作。

在面向物件程式設計中,我們把物件傳來傳去,那在函數語言程式設計中,我們要做的是把函式傳來傳去,而這個,說成術語,我們把他叫做高階函式

那我們就來看一個高階函式的應用,熟悉js的同學應該對下面的程式碼很熟悉,讓哦我們來寫一個在電子電路中常用的濾波器的示例程式碼。

def Filt(arr,func): 
    result = [] 
    for item in arr: 
        result.append(func(item)) 
    return result

def MyFilter(ele): 
    if ele < 0 : 
        return 0 
    return ele

if __name__ == '__main__': 
    arr = [-5,3,5,11,-45,32] 
    print('%s' % (Filt(arr,MyFilter)))

哦,之前忘記了說,什麼叫做高階函式,我們給出定義:

數學電腦科學中,高階函式是至少滿足下列一個條件的函式:

  • 接受一個或多個函式作為輸入
  • 輸出一個函式

那麼,毫無疑問上面的濾波器,就是高階函式的一種應用。

在函數語言程式設計中,函式是基本單位,是第一型,他幾乎被用作一切,包括最簡單的計算,甚至連變數都被計算所取代。在函數語言程式設計中,變數只是一個名稱,而不是一個儲存單元,這是函數語言程式設計與傳統的指令式程式設計最典型的不同之處。

讓我們看看,變數只是一個名稱,在上面的程式碼中,我們可以這樣重寫主函式:

if __name__ == '__main__': 
    arr = [-5,3,5,11,-45,32] 
    func = MyFilter 
    print('%s' % (Filt(arr,func)))

當然,我們還可以把程式更精簡一些,利用函數語言程式設計中的利器,map,filter和reduce :

if __name__ == '__main__': 
    arr = [-5,3,5,11,-45,32] 
    print('%s' % (map(lambda x : 0 if x<0 else x ,arr)))

這樣看上去是不是更賞心悅目呢?

這樣我們就看到了,函式是我們程式設計的基本單位。

7. 函數語言程式設計的數學本質

忘了是誰說過:一切問題,歸根結底到最後都是數學問題。

程式設計從來都不是難事兒,無非是細心,加上一些函式類庫的熟悉程度,加上經驗的堆積,而真正困難的,是如何把一個實際問題,轉換成一個數學模型。這也是為什麼微軟,Google之類的公司重視演算法,這也是為什麼數學建模大賽在大學計算機系如此被看重的原因。

先假設我們已經憑藉我們良好的數學思維和邏輯思維建立好了數學模型,那麼接下來要做的是如何把數學語言來表達成計算機能看懂的程式語言。

這裡我們再看在第四節中,我們提到的賦值模型,同一個函式,同一個引數,卻會在不同的場景下計算出不同的結果,這是在數學函式中完全不可能出現的情況,f(x) = y ,那麼這個函式無論在什麼場景下,都會得到同樣的結果,這個我們稱之為函式的確定性。

這也是賦值模型與數學模型的不相容之處。而函數語言程式設計取消了賦值模型,則使數學模型與程式設計模型完美地達成了統一

8. 函數語言程式設計的抽象本質

相信每個程式設計師都對抽象這個概念不陌生。

在面向物件程式設計中,我們說,類是現實事物的一種抽象表示。那麼抽象的最大作用在我看來就在於抽象事物的重用性,一個事物越具體,那麼他的可重用性就越低,因此,我們再打造可重用性程式碼,類,類庫時,其實在做的本質工作就在於提高程式碼的抽象性。而再往大了說開來,程式設計師做的工作,就是把一系列過程抽象開來,反映成一個通用過程,然後用程式碼表示出來。

在面向物件中,我們把事物抽象。而在函數語言程式設計中,我們則是在將函式方法抽象,第六節的濾波器已經讓我們知道,函式一樣是可重用,可置換的抽象單位。

那麼我們說函數語言程式設計的抽象本質則是將函式也作為一個抽象單位,而反映成程式碼形式,則是高階函式

9.狀態到底怎麼辦

我們說了一大堆函數語言程式設計的特點,但是我們忽略了,這些都是在理想的層面,我們回頭想想第四節的變數不變性,確實,我們說,函數語言程式設計是無狀態的,可是在我們現實情況中,狀態不可能一直保持不變,而狀態必然需要改變,傳遞,那麼我們在函數語言程式設計中的則是將其儲存在函式的引數中,作為函式的附屬品來傳遞。

ps:在Erlang中,程序之間的互動傳遞變數是靠“信箱”的收發信件來實現,其實我們想一想,從本質而言,也是將變數作為一個附屬品來傳遞麼!

我們來看個例子,我們在這裡舉一個求x的n次方的例子,我們用傳統的指令式程式設計來寫一下:

def expr(x,n): 
    result = 1 
    for i in range(1,n+1): 
        result = result * x 
    return result

if __name__ == '__main__': 
    print(expr(2,5))

這裡,我們一直在對result變數賦值,但是我們知道,在函數語言程式設計中的變數是具有不變性的,那麼我們為了保持result的狀態,就需要將result作為函式引數來傳遞以保持狀態:

def expr(num,n): 
    if n==0: 
        return 1 
    return num*expr(num,n-1)

if __name__ == '__main__': 
    print(expr(2,5))

呦,這不是遞迴麼!

10. 函數語言程式設計和遞迴

遞迴是函數語言程式設計的一個重要的概念,迴圈可以沒有,但是遞迴對於函數語言程式設計卻是不可或缺的。

在這裡,我得承認,我確實不知道我該怎麼解釋遞迴為什麼對函數語言程式設計那麼重要。我能想到的只是遞迴充分地發揮了函式的威力,也解決了函數語言程式設計無狀態的問題。(如果大家有其他的意見,請賜教)

遞迴其實就是將大問題無限地分解,直到問題足夠小。

而遞迴與迴圈在程式設計模型和思維模型上最大的區別則在於:

迴圈是在描述我們該如何地去解決問題。

遞迴是在描述這個問題的定義。

那麼就讓我們以斐波那契數列為例來看下這兩種程式設計模型。

先說我們最常見的遞迴模型,這裡,我不採用動態規劃來做臨時狀態的快取,只是說這種思路:

def Fib(a): 
    if a==0 or a==1: 
        return 1 
    else: 
        return Fib(a-2)+Fib(a-1)

遞迴是在描述什麼是斐波那契數列,這個數列的定義就是一個數等於他的前兩項的和,並且已知Fib(0)和Fib(1)等於1。而程式則是用計算機語言來把這個定義重新描述了一次。

那接下來,我們看下迴圈模型:

def Fib(n): 
    a=1 
    b=1 
    n = n - 1 
    while n>0: 
        temp=a 
        a=a+b 
        b=temp 
        n = n-1 
    return b

這裡則是在描述我們該如何求解斐波那契數列,應該先怎麼樣再怎麼樣。

而我們明顯可以看到,遞迴相比於迴圈,具有著更加良好的可讀性。

但是,我們也不能忽略,遞迴而產生的StackOverflow,而賦值模型呢?我們懂的,函數語言程式設計不能賦值,那麼怎麼辦?

11.  尾遞迴,偽遞迴

我們之前說到了遞迴和迴圈各自的問題,那怎麼來解決這個問題,函數語言程式設計為我們丟擲了答案,尾遞迴。

什麼是尾遞迴,用最通俗的話說:就是在最後一部單純地去呼叫遞迴函式,這裡我們要注意“單純”這個字眼。

那麼我們說下尾遞迴的原理,其實尾遞迴就是不要保持當前遞迴函式的狀態,而把需要保持的東西全部用引數給傳到下一個函式裡,這樣就可以自動清空本次呼叫的棧空間。這樣的話,佔用的棧空間就是常數階的了。

在看尾遞迴程式碼之前,我們還是先來明確一下遞迴的分類,我們將遞迴分成“樹形遞迴”和“尾遞迴”,什麼是樹形遞迴,就是把計算過程逐一展開,最後形成的是一棵樹狀的結構,比如之前的斐波那契數列的遞迴解法。

那麼我們來看下斐波那契尾遞迴的寫法:

def Fib(a,b,n): 
    if n==0: 
        return b 
    else: 
        return Fib(b,a+b,n-1)

這裡看上去有些難以理解,我們來解釋一下:傳入的a和b分別是前兩個數,那麼每次我都推進一位,那麼b就變成了第一個數,而a+b就變成的第二個數。

這就是尾遞迴。其實我們想一想,這不是在描述問題,而是在尋找一種問題的解決方案,和上面的迴圈有什麼區別呢?我們來做一個從尾遞迴到迴圈的轉換把!

最後返回b是把,那我就先聲明瞭,b=0

要傳入a是把,我也聲明瞭,a=1

要計算到n==0是把,還是迴圈while n!=0

每一次都要做一個那樣的計算是吧,我用臨時變數交換一下。temp=b ; b=a+b;a=temp。

那麼按照這個思路一步步轉換下去,是不是就是我們在上面寫的那段迴圈程式碼呢?

那麼這個尾遞迴,其實本質上就是個“偽遞迴”,您說呢?

既然我們可以優化,對於大多數的函數語言程式設計語言的編譯器來說,他們對尾遞迴同樣提供了優化,使尾遞迴可以優化成迴圈迭代的形式,使其不會造成堆疊溢位的情況。

12. 惰性求值與並行

第一次接觸到惰性求值這個概念應該是在Haskell語言中,看一個最簡單的惰性求值,我覺得也是最經典的例子:

在Haskell裡,有個repeat關鍵字,他的作用是返回一個無限長的List,那麼我們來看下:

take 10 (repeat 1)  

就是這句程式碼,如果沒有了惰性求值,我想這個程序一定會死在那裡,可是結果卻是很正常,返回了長度為10的List,List裡的值都是1。這就是惰性求值的典型案例。

我們看這樣一段簡單的程式碼:

def getResult(): 
    a = getA()   //Take a long time 
    b = getB()   //Take a long time 
    c = a + b

這段程式碼本身很簡單,在命令式程式設計中,編譯器(或直譯器)會做的就是逐一解釋程式碼,按順序求出a和b的值,然後再求出c。

可是我們從並行的角度考慮,求a的值是不是可以和求b的值並行呢?也就是說,直到執行到a+b的時候我們編譯器才意識到a和b直到現在才需要,那麼我們雙核處理器就自然去發揮去最大的功效去計算了呢!

這才是惰性求值的最大威力。

當然,惰性求值有著這樣的優點也必然有著缺點,我記得我看過一個例子是最經典的:

def Test(): 
    print('Please enter a number:') 
    a = raw_input()

可是這段程式碼如果惰性求值的話,第一句話就不見得會在第二句話之前執行了。

13. 函數語言程式設計總覽

我們看完了函數語言程式設計的特點,我們想想函數語言程式設計的應用場合。

1. 數學推理

2. 並行程式

那麼我們總體地說,其實函數語言程式設計最適合地還是解決區域性性的數學小問題,要讓函數語言程式設計來做CRUD,來做我們傳統的邏輯性很強的Web程式設計,就有些免為其難了。

就像如果要用Scala完全取代今天的Java的工作,我想恐怕效果會很糟糕。而讓Scala來負責底層服務的編寫,恐怕再合適不過了。

而在一種語言中融入多種語言正規化,最典型的C#。在C# 3.0中引入Lambda表示式,在C# 4.0中引入宣告式程式設計,我們某些人在嘲笑C#越來越臃腫的同時,卻忽略了,這樣的語法糖,帶給我們的不僅僅是程式碼書寫上的遍歷,更重要的是程式設計思維的一種進步。

好吧,那就讓我們忘記那些C#中Lambda背後的實現機制,在C#中,還是在那些更純粹地支援函數語言程式設計的語言中,盡情地去體驗函數語言程式設計帶給我們的快樂把!