[TOC]
# 第 3 章:純函數的好處
## 再次強調“純”
首先,我們要厘清純函數的概念。
> 純函數是這樣一種函數,即相同的輸入,永遠會得到相同的輸出,而且沒有任何可觀察的副作用。
比如 `slice` 和 `splice`,這兩個函數的作用并無二致——但是注意,它們各自的方式卻大不同,但不管怎么說作用還是一樣的。我們說 `slice` 符合*純*函數的定義是因為對相同的輸入它保證能返回相同的輸出。而 `splice` 卻會嚼爛調用它的那個數組,然后再吐出來;這就會產生可觀察到的副作用,即這個數組永久地改變了。
```js
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);
//=> []
```
在函數式編程中,我們討厭這種會*改變*數據的笨函數。我們追求的是那種可靠的,每次都能返回同樣結果的函數,而不是像 `splice` 這樣每次調用后都把數據弄得一團糟的函數,這不是我們想要的。
來看看另一個例子。
```js
// 不純的
var minimum = 21;
var checkAge = function(age) {
return age >= minimum;
};
// 純的
var checkAge = function(age) {
var minimum = 21;
return age >= minimum;
};
```
在不純的版本中,`checkAge` 的結果將取決于 `minimum` 這個可變變量的值。換句話說,它取決于系統狀態(system state);這一點令人沮喪,因為它引入了外部的環境,從而增加了認知負荷(cognitive load)。
這個例子可能還不是那么明顯,但這種依賴狀態是影響系統復雜度的罪魁禍首(http://www.curtclifton.net/storage/papers/MoseleyMarks06a.pdf )。輸入值之外的因素能夠左右 `checkAge` 的返回值,不僅讓它變得不純,而且導致每次我們思考整個軟件的時候都痛苦不堪。
另一方面,使用純函數的形式,函數就能做到自給自足。我們也可以讓 `minimum` 成為一個不可變(immutable)對象,這樣就能保留純粹性,因為狀態不會有變化。要實現這個效果,必須得創建一個對象,然后調用 `Object.freeze` 方法:
```js
var immutableState = Object.freeze({
minimum: 21
});
```
## 副作用可能包括...
讓我們來仔細研究一下“副作用”以便加深理解。那么,我們在*純函數*定義中提到的萬分邪惡的*副作用*到底是什么?“作用”我們可以理解為一切除結果計算之外發生的事情。
“作用”本身并沒什么壞處,而且在本書后面的章節你隨處可見它的身影。“副作用”的關鍵部分在于“副”。就像一潭死水中的“水”本身并不是幼蟲的培養器,“死”才是生成蟲群的原因。同理,副作用中的“副”是滋生 bug 的溫床。
> *副作用*是在計算結果的過程中,系統狀態的一種變化,或者與外部世界進行的*可觀察的交互*。
副作用可能包含,但不限于:
* 更改文件系統
* 往數據庫插入記錄
* 發送一個 http 請求
* 可變數據
* 打印/log
* 獲取用戶輸入
* DOM 查詢
* 訪問系統狀態
這個列表還可以繼續寫下去。概括來講,只要是跟函數外部環境發生的交互就都是副作用——這一點可能會讓你懷疑無副作用編程的可行性。函數式編程的哲學就是假定副作用是造成不正當行為的主要原因。
這并不是說,要禁止使用一切副作用,而是說,要讓它們在可控的范圍內發生。后面講到 functor 和 monad 的時候我們會學習如何控制它們,目前還是盡量遠離這些陰險的函數為好。
副作用讓一個函數變得不*純*是有道理的:從定義上來說,純函數必須要能夠根據相同的輸入返回相同的輸出;如果函數需要跟外部事物打交道,那么就無法保證這一點了。
我們來仔細了解下為何要堅持這種「相同輸入得到相同輸出」原則。注意,我們要復習一些八年級數學知識了。
## 八年級數學
根據 mathisfun.com:
> 函數是不同數值之間的特殊關系:每一個輸入值返回且只返回一個輸出值。
換句話說,函數只是兩種數值之間的關系:輸入和輸出。盡管每個輸入都只會有一個輸出,但不同的輸入卻可以有相同的輸出。下圖展示了一個合法的從 `x` 到 `y` 的函數關系;
(http://www.mathsisfun.com/sets/function.html)
相反,下面這張圖表展示的就*不是*一種函數關系,因為輸入值 `5` 指向了多個輸出:
(http://www.mathsisfun.com/sets/function.html)
函數可以描述為一個集合,這個集合里的內容是 (輸入, 輸出) 對:`[(1,2), (3,6), (5,10)]`(看起來這個函數是把輸入值加倍)。
或者一張表:
<table>
<tr>
<th style="background-color:blue;color:#FFF">輸入</th>
<th style="background-color:blue;color:#FFF">輸出</th>
</tr>
<tr> <td>1</td> <td>2</td></tr>
<tr> <td>2</td> <td>4</td> </tr>
<tr> <td>3</td> <td>6</td> </tr>
</table>
甚至一個以 `x` 為輸入 `y` 為輸出的函數曲線圖:

如果輸入直接指明了輸出,那么就沒有必要再實現具體的細節了。因為函數僅僅只是輸入到輸出的映射而已,所以簡單地寫一個對象就能“運行”它,使用 `[]` 代替 `()` 即可。
```js
var toLowerCase = {"A":"a", "B": "b", "C": "c", "D": "d", "E": "e", "D": "d"};
toLowerCase["C"];
//=> "c"
var isPrime = {1:false, 2: true, 3: true, 4: false, 5: true, 6:false};
isPrime[3];
//=> true
```
當然了,實際情況中你可能需要進行一些計算而不是手動指定各項值;不過上例倒是表明了另外一種思考函數的方式。(你可能會想“要是函數有多個參數呢?”。的確,這種情況表明了以數學方式思考問題的一點點不便。暫時我們可以把它們打包放到數組里,或者把 `arguments` 對象看成是輸入。等學習 `curry` 的概念之后,你就知道如何直接為函數在數學上的定義建模了。)
戲劇性的是:純函數*就是*數學上的函數,而且是函數式編程的全部。使用這些純函數編程能夠帶來大量的好處,讓我們來看一下為何要不遺余力地保留函數的純粹性的原因。
## 追求“純”的理由
### 可緩存性(Cacheable)
首先,純函數總能夠根據輸入來做緩存。實現緩存的一種典型方式是 memoize 技術:
```js
var squareNumber = memoize(function(x){ return x*x; });
squareNumber(4);
//=> 16
squareNumber(4); // 從緩存中讀取輸入值為 4 的結果
//=> 16
squareNumber(5);
//=> 25
squareNumber(5); // 從緩存中讀取輸入值為 5 的結果
//=> 25
```
下面的代碼是一個簡單的實現,盡管它不太健壯。
```js
var memoize = function(f) {
var cache = {};
return function() {
var arg_str = JSON.stringify(arguments);
cache[arg_str] = cache[arg_str] || f.apply(f, arguments);
return cache[arg_str];
};
};
```
值得注意的一點是,可以通過延遲執行的方式把不純的函數轉換為純函數:
```js
var pureHttpCall = memoize(function(url, params){
return function() { return $.getJSON(url, params); }
});
```
這里有趣的地方在于我們并沒有真正發送 http 請求——只是返回了一個函數,當調用它的時候才會發請求。這個函數之所以有資格成為純函數,是因為它總是會根據相同的輸入返回相同的輸出:給定了 `url` 和 `params` 之后,它就只會返回同一個發送 http 請求的函數。
我們的 `memoize` 函數工作起來沒有任何問題,雖然它緩存的并不是 http 請求所返回的結果,而是生成的函數。
現在來看這種方式意義不大,不過很快我們就會學習一些技巧來發掘它的用處。重點是我們可以緩存任意一個函數,不管它們看起來多么具有破壞性。
### 可移植性/自文檔化(Portable / Self-Documenting)
純函數是完全自給自足的,它需要的所有東西都能輕易獲得。仔細思考思考這一點...這種自給自足的好處是什么呢?首先,純函數的依賴很明確,因此更易于觀察和理解——沒有偷偷摸摸的小動作。
```js
// 不純的
var signUp = function(attrs) {
var user = saveUser(attrs);
welcomeUser(user);
};
var saveUser = function(attrs) {
var user = Db.save(attrs);
...
};
var welcomeUser = function(user) {
Email(user, ...);
...
};
// 純的
var signUp = function(Db, Email, attrs) {
return function() {
var user = saveUser(Db, attrs);
welcomeUser(Email, user);
};
};
var saveUser = function(Db, attrs) {
...
};
var welcomeUser = function(Email, user) {
...
};
```
這個例子表明,純函數對于其依賴必須要誠實,這樣我們就能知道它的目的。僅從純函數版本的 `signUp` 的簽名就可以看出,它將要用到 `Db`、`Email` 和 `attrs`,這在最小程度上給了我們足夠多的信息。
后面我們會學習如何不通過這種僅僅是延遲執行的方式來讓一個函數變純,不過這里的重點應該很清楚,那就是相比不純的函數,純函數能夠提供多得多的信息;前者天知道它們暗地里都干了些什么。
其次,通過強迫“注入”依賴,或者把它們當作參數傳遞,我們的應用也更加靈活;因為數據庫或者郵件客戶端等等都參數化了(別擔心,我們有辦法讓這種方式不那么單調乏味)。如果要使用另一個 `Db`,只需把它傳給函數就行了。如果想在一個新應用中使用這個可靠的函數,盡管把新的 `Db` 和 `Email` 傳遞過去就好了,非常簡單。
在 JavaScript 的設定中,可移植性可以意味著把函數序列化(serializing)并通過 socket 發送。也可以意味著代碼能夠在 web workers 中運行。總之,可移植性是一個非常強大的特性。
命令式編程中“典型”的方法和過程都深深地根植于它們所在的環境中,通過狀態、依賴和有效作用(available effects)達成;純函數與此相反,它與環境無關,只要我們愿意,可以在任何地方運行它。
你上一次把某個類方法拷貝到新的應用中是什么時候?我最喜歡的名言之一是 Erlang 語言的作者 Joe Armstrong 說的這句話:“面向對象語言的問題是,它們永遠都要隨身攜帶那些隱式的環境。你只需要一個香蕉,但卻得到一個拿著香蕉的大猩猩...以及整個叢林”。
### 可測試性(Testable)
第三點,純函數讓測試更加容易。我們不需要偽造一個“真實的”支付網關,或者每一次測試之前都要配置、之后都要斷言狀態(assert the state)。只需簡單地給函數一個輸入,然后斷言輸出就好了。
事實上,我們發現函數式編程的社區正在開創一些新的測試工具,能夠幫助我們自動生成輸入并斷言輸出。這超出了本書范圍,但是我強烈推薦你去試試 *Quickcheck*——一個為函數式環境量身定制的測試工具。
### 合理性(Reasonable)
很多人相信使用純函數最大的好處是*引用透明性*(referential transparency)。如果一段代碼可以替換成它執行所得的結果,而且是在不改變整個程序行為的前提下替換的,那么我們就說這段代碼是引用透明的。
由于純函數總是能夠根據相同的輸入返回相同的輸出,所以它們就能夠保證總是返回同一個結果,這也就保證了引用透明性。我們來看一個例子。
```js
var Immutable = require('immutable');
var decrementHP = function(player) {
return player.set("hp", player.hp-1);
};
var isSameTeam = function(player1, player2) {
return player1.team === player2.team;
};
var punch = function(player, target) {
if(isSameTeam(player, target)) {
return target;
} else {
return decrementHP(target);
}
};
var jobe = Immutable.Map({name:"Jobe", hp:20, team: "red"});
var michael = Immutable.Map({name:"Michael", hp:20, team: "green"});
punch(jobe, michael);
//=> Immutable.Map({name:"Michael", hp:19, team: "green"})
```
`decrementHP`、`isSameTeam` 和 `punch` 都是純函數,所以是引用透明的。我們可以使用一種叫做“等式推導”(equational reasoning)的技術來分析代碼。所謂“等式推導”就是“一對一”替換,有點像在不考慮程序性執行的怪異行為(quirks of programmatic evaluation)的情況下,手動執行相關代碼。我們借助引用透明性來剖析一下這段代碼。
首先內聯 `isSameTeam` 函數:
```js
var punch = function(player, target) {
if(player.team === target.team) {
return target;
} else {
return decrementHP(target);
}
};
```
因為是不可變數據,我們可以直接把 `team` 替換為實際值:
```js
var punch = function(player, target) {
if("red" === "green") {
return target;
} else {
return decrementHP(target);
}
};
```
`if` 語句執行結果為 `false`,所以可以把整個 `if` 語句都刪掉:
```js
var punch = function(player, target) {
return decrementHP(target);
};
```
如果再內聯 `decrementHP`,我們會發現這種情況下,`punch` 變成了一個讓 `hp` 的值減 1 的調用:
```js
var punch = function(player, target) {
return target.set("hp", target.hp-1);
};
```
總之,等式推導帶來的分析代碼的能力對重構和理解代碼非常重要。事實上,我們重構海鷗程序使用的正是這項技術:利用加和乘的特性。對這些技術的使用將會貫穿本書,真的。
### 并行代碼
最后一點,也是決定性的一點:我們可以并行運行任意純函數。因為純函數根本不需要訪問共享的內存,而且根據其定義,純函數也不會因副作用而進入競爭態(race condition)。
并行代碼在服務端 js 環境以及使用了 web worker 的瀏覽器那里是非常容易實現的,因為它們使用了線程(thread)。不過出于對非純函數復雜度的考慮,當前主流觀點還是避免使用這種并行。
## 總結
我們已經了解什么是純函數了,也看到作為函數式程序員的我們,為何深信純函數是不同凡響的。從這開始,我們將盡力以純函數式的方式書寫所有的函數。為此我們將需要一些額外的工具來達成目標,同時也盡量把非純函數從純函數代碼中分離。
如果手頭沒有一些工具,那么純函數程序寫起來就有點費力。我們不得不玩雜耍似的通過到處傳遞參數來操作數據,而且還被禁止使用狀態,更別說“作用”了。沒有人愿意這樣自虐。所以讓我們來學習一個叫 curry 的新工具。
[第 4 章: 柯里化(curry)](ch4.md)