javascript 函數式編程基礎

一、引言

函數式編程的歷史已經很悠久了,但是最近幾年卻頻繁的出現在大衆的視野,很多不支持函數式編程的語言也在積極加入閉包,匿名函數等非常典型的函數式編程特性。大量的前端框架也標榜自己使用了函數式編程的特性,好像一旦跟函數式編程沾邊,就很高大上一樣,而且還有一些專門針對函數式編程的框架和庫,比如:RxJS、cycleJS、ramdaJS、lodashJS、underscoreJS 等。函數式編程變得越來越流行,掌握這種編程範式對書寫高質量和易於維護的代碼都大有好處,所以我們有必要掌握它。

二、什麼是函數式編程

維基百科定義:函數式編程(英語:functional programming),又稱泛函編程,是一種編程範式,它將電腦運算視爲數學上的函數計算,並且避免使用程序狀態以及易變對象。

三、純函數(函數式編程的基石,無副作用的函數)

在初中數學裏,函數 f 的定義是:對於輸入 x 產生一個唯一輸出 y=f(x)。這便是純函數。它符合兩個條件:1. 此函數在相同的輸入值時,總是產生相同的輸出。函數的輸出和當前運行環境的上下文狀態無關。2. 此函數運行過程不影響運行環境,也就是無副作用(如觸發事件、發起 http 請求、打印 / log 等)。簡單來說,也就是當一個函數的輸出不受外部環境影響,同時也不影響外部環境時,該函數就是純函數,也就是它只關注邏輯運算和數學運算,同一個輸入總得到同一個輸出。javascript 內置函數有不少純函數,也有不少非純函數。

純函數:Array.prototype.sliceArray.prototype.mapString.prototype.toUpperCase

非純函數:Math.randomDate.nowArray.ptototype.splice

這裏我們以 slice 和 splice 方法舉例:

var xs = [1,2,3,4,5];
// 純的
xs.slice(0,3);
//=[1,2,3]
xs.slice(0,3);
//=[1,2,3]
xs.slice(0,3);
//=[1,2,3]

// 不純的
xs.splice(0,3);
//=[1,2,3]
xs.splice(0,3);
//=[4,5]
xs.splice(0,3);
//=[]

我們看到調用數組的 slice 方法每次返回的結果完全相同,同時 xs 不會被改變,而調用 splice 方法每次返回值都不一樣,同時 xs 變得面目全非。這就是我們強調使用純函數的原因,因爲純函數相對於非純函數來說,在可緩存性、可移植性、可測試性以及並行計算方面都有着巨大的優勢。這裏我們以可緩存性舉例:

var squareNumber  = memoize(function(x){ return x*x; });
squareNumber(4);
//=16
squareNumber(4); // 從緩存中讀取輸入值爲 4 的結果
//=16

那我們如何把一個非純函數變純呢?比如下面這個函數:

var minimum = 21;
var checkAge = function(age) {
  return age >= minimum;
};

這個函數的返回值依賴於可變變量 minimum 的值,它依賴於系統狀態。在大型系統中,這種對於外部狀態的依賴是造成系統複雜性大大提高的主要原因。

var checkAge = function(age) {
  var minimum = 21;
  return age >= minimum;
};

通過改造,我們把 checkAge 變成了一個純函數,它不依賴於系統狀態,但是 minimum 是通過硬編碼的方式定義的,這限制了函數的擴展性,我們可以在後面的柯里化中看到如何優雅的使用函數式解決這個問題。所以把一個函數變純的基本手段是不要依賴系統狀態。

四、函數柯里化

curry 的概念很簡單:將一個低階函數轉換爲高階函數的過程就叫柯里化。用一個形象的比喻就是:比如對於加法操作:var add = (x, y) => x + y,我們可以這樣柯里化:

//es5寫法
var add = function(x) {
  return function(y) {
    return x + y;
  };
};

//es6寫法
var add = x =(y => x + y);

//試試看
var increment = add(1);
var addTen = add(10);

increment(2);  // 3

addTen(2);  // 12

對於加法這種極其簡單的函數來說,柯里化並沒有什麼用。還記得上面的 checkAge 函數嗎?我們可以這樣柯里化它:

var checkage = min =(age => age > min);
var checkage18 = checkage(18);
checkage18(20);
// =>true

這表明函數柯里化是一種 “預加載” 函數的能力,通過傳遞一到兩個參數調用函數,就能得到一個記住了這些參數的新函數。從某種意義上來講,這是一種對參數的緩存,是一種非常高效的編寫函數的方法:

var curry = require('lodash').curry;

//柯里化兩個純函數
var match = curry((what, str) => str.match(what));
var filter = curry((f, ary) => ary.filter(f));

//判斷字符串裏有沒有空格
var hasSpaces = match(/\s+/g);

hasSpaces("hello world");  // [ ' ' ]
hasSpaces("spaceless");  // null

var findSpaces = filter(hasSpaces);

findSpaces(["tori_spelling""tori amos"]);  // ["tori amos"]

五、函數組合

假設我們需要對一個字符串做一些列操作,如下,爲了方便舉例,我們只對一個字符串做兩種操作,我們定義了一個新函數 shout,先調用 toUpperCase,然後把返回值傳給 exclaim 函數,這樣做有什麼不好呢?不優雅,如果做得事情一多,嵌套的函數會非常深,而且代碼是由內往外執行,不直觀,我們希望代碼從右往左執行,這個時候我們就得使用組合。

var toUpperCase = function(x) { return x.toUpperCase(); };
var exclaim = function(x) { return x + '!'; };

var shout = function(x){
  return exclaim(toUpperCase(x));
};

shout("send in the clowns");
//="SEND IN THE CLOWNS!"

使用組合,我們可以這樣定義我們的 shout 函數:

//定義compose
var compose = (...args) =x => args.reduceRight((value, item) => item(value), x);

var toUpperCase = function(x) { return x.toUpperCase(); };
var exclaim = function(x) { return x + '!'; };

var shout = compose(exclaim, toUpperCase);

shout("send in the clowns");
//="SEND IN THE CLOWNS!"

代碼從右往左執行,非常清晰明瞭,一目瞭然。我們定義的 compose 像 N 面膠一樣,可以將任意多個純函數結合到一起。這種靈活的組合可以讓我們像拼積木一樣來組合函數式的代碼:

var head = function(x) { return x[0]; };
var reverse = reduce(function(acc, x){ return [x].concat(acc); }[]);
var last = compose(head, reverse);

last(['jumpkick''roundhouse''uppercut']);
//='uppercut'

六、聲明式和命令式代碼

命令式代碼:命令 “機器” 如何去做事情 (how),這樣不管你想要的是什麼(what),它都會按照你的命令實現。聲明式代碼:告訴“機器” 你想要的是什麼 (what),讓機器想出如何去做(how)。與命令式不同,聲明式意味着我們要寫表達式,而不是一步一步的指示。以 SQL 爲例,它就沒有“先做這個,再做那個” 的命令,有的只是一個指明我們想要從數據庫取什麼數據的表達式。至於如何取數據則是由它自己決定的。以後數據庫升級也好,SQL 引擎優化也好,根本不需要更改查詢語句。這是因爲,有多種方式解析一個表達式並得到相同的結果。這裏爲了方便理解,我們來看一個例子:

// 命令式
var makes = [];
for (var i = 0; i < cars.length; i++) {
  makes.push(cars[i].make);
}

// 聲明式
var makes = cars.map(function(car){ return car.make; });

命令式的循環要求你必須先實例化一個數組,而且執行完這個實例化語句之後,解釋器才繼續執行後面的代碼。然後再直接迭代 cars 列表,手動增加計數器,就像你開了一輛零部件全部暴露在外的汽車一樣。這不是優雅的程序員應該做的。聲明式的寫法是一個表達式,如何進行計數器迭代,返回的數組如何收集,這些細節都隱藏了起來。它指明的是做什麼,而不是怎麼做。除了更加清晰和簡潔之外,map 函數還可以進一步獨立優化,甚至用解釋器內置的速度極快的 map 函數,這麼一來我們主要的業務代碼就無須改動了。函數式編程的一個明顯的好處就是這種聲明式的代碼,對於無副作用的純函數,我們完全可以不考慮函數內部是如何實現的,專注於編寫業務代碼。優化代碼時,目光只需要集中在這些穩定堅固的函數內部即可。相反,不純的不函數式的代碼會產生副作用或者依賴外部系統環境,使用它們的時候總是要考慮這些不乾淨的副作用。在複雜的系統中,這對於程序員的心智來說是極大的負擔。

七、Point Free

pointfree 模式指的是,永遠不必說出你的數據。它的意思是說,函數無須提及將要操作的數據是什麼樣的。一等公民的函數、柯里化(curry)以及組合協作起來非常有助於實現這種模式。

// 非 pointfree,因爲提到了數據:word
var snakeCase = function (word) {
  return word.toLowerCase().replace(/\s+/ig, '_');
};

// pointfree
var snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);

這種風格能夠幫助我們減少不必要的命名,讓代碼保持簡潔和通用。當然,爲了在一些函數中寫出 Point Free 的風格,在代碼的其它地方必然是不那麼 Point Free 的,這個地方需要自己取捨。

八、示例應用

擁有了以上的知識,我們是時候該寫一個示例應用了。這裏我們使用了 ramda ,沒有用 lodash 或者其他類庫。ramda 提供了 compose、curry 等很多函數。我們的應用將做四件事:1. 根據特定搜索關鍵字構造 url
2. 向 flickr 發送 api 請求
3. 把返回的 json 轉爲 html 圖片
4. 把圖片放到屏幕上上面提到了兩個不純的動作,即從 flickr 的 api 獲取數據和在屏幕上放置圖片這兩件事。我們先來定義這兩個動作,這樣就能隔離它們了。這裏我們只是簡單包裝了一下 jQuery 的 getJSON 函數,把它變爲一個 curry 函數,還有就是把參數位置也調換了下,我們把它們放在 Impure 命名空間下以用來隔離,這樣我們就知道它們都是危險函數。運用函數柯里化和函數組合的技巧,我們就可以創建一個函數式的實際應用了:預覽地址:demo[1] 看看,多麼美妙的聲明式規範啊,只說做什麼,不說怎麼做。現在我們可以把每一行代碼都視作一個等式,變量名所代表的屬性就是等式的含義。

九、總結

我們已經見識到如何在一個小而不失真實的應用中運用新技能了,但是異常處理以及代碼分支呢?如何讓整個應用都是函數式的,而不僅僅是把破壞性的函數放到命名空間下?如何讓應用更安全更富有表現力?我會在下一篇文章中介紹函數式編程的更加高階一些的知識,例如 Functor、Monad、Applicative 等概念。

參考資料

[1]

預覽地址:demo: https://code.h5jun.com/vixe/1/edit?html,js,output

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/32DwoU2OtAcYPpTQAo6npA