理解函數式編程

相信你們平時或多或少聽過很多關於「函數式編程」 (FP)相關的詞語,有些Geek常常吹捧函數式的優勢或者特性好比:純函數無反作用、不變的數據、高階函數、流計算模式、尾遞歸、柯里化等等,再加上目前的函數式理論愈來愈多的應用於工程中,OCaml,clojure, scala等FP語言日漸火爆。本編文章,筆者準備帶領你們深刻理解函數式編程的相關理論概念。javascript

定義

首先引用維基百科對函數式編程的解釋:在計算機科學裏,函數式編程是一種編程範式,它將計算描述爲表達式求值並避免了狀態和數據改變。函數式編程裏面的「函數」指的是數學函數,數學函數和咱們平時工做中遇到的編程函數有什麼區別呢?java

編程函數和數學函數

從上圖不難發現:數學函數的特色是對於每個自變量,存在惟一的因變量與之對應。而編程函數的特色是參數和返回值都不是必須的,函數可能依賴外界或者影響外界。那麼編程函數可否轉換成數學函數,或者說咱們的編程函數可否變成「純函數」?python

如何轉換?

對於任何一個編程函數,須要知足下面3個條件,便可轉換成純數學函數。編程

  • 每一個函數必須包含輸入參數(做爲自變量)
  • 每一個函數必須有返回值(做爲因變量)
  • 不管什麼時候,給定參數調用函數時,返回值必須一致。

命令式和函數式的區別

以快排爲例,過程式的版本,能夠發現重視過程:app

void Solution::quickSort(vecotr<int> &nums, int left, int right)
{
    int i = left, j = right;
    int pviot = nums[(i + j) >> 1];
    while (i <= j)
    {
        while (nums[i] < pviot)
            i ++;
        while (nums[j] > pviot)
            j --;
        if (i <= j) 
        {
            swap(nums[i], nums[j]);
            i ++;
            j --;
        }
    }
    if (left < j)
        quickSort(nums, left, j);
    if (right > i)
        quickSort(nums, i, right);
}

Haskell的快排實現,能夠發現更加註重結果:編程語言

quickSort  :: (Ord a) => [a] -> [a]

-- If input list is empty
quickSort [] = []

-- List isn't empty
quickSort (x : xs) = 
    let smallerSorted = quickSort (filter (<= x) xs)
        biggerSorted = quickSort (filter (> x) xs)
    in smallerSorted ++ [x] ++ biggerSorted

全部的命令式語言都被設計來高效地使用馮諾依曼體系結構的計算機。實際上,最初的命令式語言的目的就是取代彙編語言,對機器指令進行進一步抽象。所以,命令式語言帶有強烈的硬件結構特徵。命令式語言的核心特性有:模擬存儲單元的變量、基於傳輸操做的賦值語句,以及迭代形式的循環運算。命令式語言的基礎是語句(特別是賦值),它們經過修改存儲器的值而產生反作用(side effect)的方式去影響後續的計算。
函數式語言設計的基礎是Lambda表達式,函數式程序設計把程序的輸出定義爲其輸入的一個數學函數,在這裏沒有內部狀態,也沒有反作用。函數式語言進行計算的主要是將函數做用與給定參數之上。函數式語言沒有命令式語言所必需的那種變量,能夠沒有賦值語句,也能夠沒有循環。一個程序就是函數定義和函數應用的說明;一個程序的執行就是對函數應用的求值。ide

高階函數

高階函數實際上就是函數的函數,它是全部函數式語言的性質。函數式語言中,函數做爲第一等公民,這也意味着你像定義或調用變量同樣去定義或調用函數。能夠在任何地方定義,在函數內或函數外,能夠將函數做爲參數或者返回值。在數學和計算機科學中,高階函數是至少知足下列一個條件的函數:模塊化

  • 接受一個或多個函數做爲輸入
  • 輸出一個函數

在數學中它們也叫作算子(運算符)或泛函。微積分中的導數就是常見的例子,由於它映射一個函數到另外一個函數。函數式編程

以Haskell裏面的Map和Filter爲例子。函數

-- 好比咱們有一組List,[1,2,3,4,5],我須要將他們都平方
map (\x->x^2) [1,2,3,4,5]

-- 找到大於3的全部數
filter (>3) [1,2,3,4,5]

遞歸、尾調用和尾遞歸

因爲變量不可變,純函數編程語言裏面沒法實現循環,這是由於for循環使用可變的狀態做爲計數器,而while循環或者do-while循環須要可變的狀態做爲跳出循環的條件。所以函數式語言裏面只能用遞歸來解決迭代問題,這使得函數式編程嚴重依賴遞歸。以階乘函數的實現爲例子:

factorial :: Int -> Int
factorial 0 = 1
factorial n = n * factorial (n - 1)

這個時候的程序調用內部的計算表現形式爲線性擴張(先擴張,後收縮):

回顧下函數調用的過程:

  • 1,調用開始前,調用方(或函數自己)會往棧上壓相關的數據,參數,返回地址,局部變量等。
  • 2,執行函數
  • 3,清理棧上相關的數據,返回

在函數 A 執行的時候,若是在第二步中,它又調用了另外一個函數 B,B 又調用 C.... 棧就會不斷地增加不斷地裝入數據,當這個調用鏈很深的時候,棧很容易就滿 了,這就是通常遞歸函數所容易面臨的大問題。稍有不慎,就會有爆棧的危險(好比經典的斐波那契數列,樹形擴張)。

尾調用:指某個函數的最後一步是調用另外一個函數。

尾遞歸:函數尾部調用自身。大部分函數式編程語言好比Scheme、Haskell裏面要求實現尾遞歸優化,編譯器會在編譯期間會將尾遞歸優化爲循環。

將上述普通遞歸函數用尾遞歸的方式重寫:

factorial :: Int -> Int
factorial n = factiter 1 1 n

factiter :: Int -> Int -> Int -> Int
factiter product counter maxCount
  | counter > maxCount = product
  | otherwise = factiter (* counter product) (+ counter 1) maxCount

這個時候的程序調用內部的計算表現形式如圖,內存消耗從O(n)到O(1):

偏函數應用(Partial application)與柯里化(currying)

偏函數解決這樣的問題:若是咱們有函數是多個參數的,咱們但願能固定其中某幾個參數的值。以Python爲例子:

from functools import partial

def foo (a, b, c):

  return a + b + c

foo21 = partial (foo, b=21)

foo21(a = 1, c = 3) # => 25

函數式語言的currying特性來自於lambda calculus,lambda calculus只支持單參函數,但它能夠返回一個函數來接受第二個參數。
關於柯里化,咱們能夠這麼理解:柯里化就是一個函數在參數沒給全時返回另外一個函數,返回的函數的參數正好是餘下的參數。好比:你制定了x和y, 如2的3次方,就返回8, 若是你只制定x爲2,y沒指定, 那麼就返回一個函數:2的y次方, 這個函數只有一個參數:y。

它的 2 大特性:

  • 匿名函數
  • 每一個函數只有1個參數

以Javascript爲例子,一個函數接受2個參數,返回它們的和:

function add (a, b) {
  return a + b;
}

add(3, 4); returns 7

採用柯里化後,變成一個函數接受1個參數,返回一個接受另一個參數而且返回它們和的的函數:

function add (a) {
  return function (b) {
    return a + b;
  }
}

// 調用
add(3)(4);

var add3 = add(3);

add3(4);

流計算模式

這個概念來自於SICP裏面的第3章,能夠理解爲unix裏面的pipline,使用它可讓代碼具備申明式的語義化、模塊化,更加富有表現力。
以javascript爲例,設計好的風格的代碼表現以下:

getAsyncStockData()
  .filter(quote => quote.price > 30)
  .map(quote => quote.price)
  .forEach(price => console.log(`Prices higher than $30: ${price}`));

實用建議

  • 函數中不使用全局變量和IO,有入參和返回值
  • 使用map and reduce對列表進行操做,不使用循環迭代
  • 聲明式,而不是命令式
  • 不改變原始數據
相關文章
相關標籤/搜索