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

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