[TOC]
# 第 5 章: 代碼組合(compose)
## 函數(shù)飼養(yǎng)
這就是 `組合`(compose,以下將稱之為組合):
```js
var compose = function(f,g) {
return function(x) {
return f(g(x));
};
};
```
`f` 和 `g` 都是函數(shù),`x` 是在它們之間通過“管道”傳輸?shù)闹怠?
`組合`看起來像是在飼養(yǎng)函數(shù)。你就是飼養(yǎng)員,選擇兩個有特點又遭你喜歡的函數(shù),讓它們結(jié)合,產(chǎn)下一個嶄新的函數(shù)。組合的用法如下:
```js
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!"
```
兩個函數(shù)組合之后返回了一個新函數(shù)是完全講得通的:組合某種類型(本例中是函數(shù))的兩個元素本就該生成一個該類型的新元素。把兩個樂高積木組合起來絕不可能得到一個林肯積木。所以這是有道理的,我們將在適當(dāng)?shù)臅r候探討這方面的一些底層理論。
在 `compose` 的定義中,`g` 將先于 `f` 執(zhí)行,因此就創(chuàng)建了一個從右到左的數(shù)據(jù)流。這樣做的可讀性遠(yuǎn)遠(yuǎn)高于嵌套一大堆的函數(shù)調(diào)用,如果不用組合,`shout` 函數(shù)將會是這樣的:
```js
var shout = function(x){
return exclaim(toUpperCase(x));
};
```
讓代碼從右向左運行,而不是由內(nèi)而外運行,我覺得可以稱之為“左傾”(吁——)。我們來看一個順序很重要的例子:
```js
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'
```
`reverse` 反轉(zhuǎn)列表,`head` 取列表中的第一個元素;所以結(jié)果就是得到了一個 `last` 函數(shù)(譯者注:即取列表的最后一個元素),雖然它性能不高。這個組合中函數(shù)的執(zhí)行順序應(yīng)該是顯而易見的。盡管我們可以定義一個從左向右的版本,但是從右向左執(zhí)行更加能夠反映數(shù)學(xué)上的含義——是的,組合的概念直接來自于數(shù)學(xué)課本。實際上,現(xiàn)在是時候去看看所有的組合都有的一個特性了。
```js
// 結(jié)合律(associativity)
var associative = compose(f, compose(g, h)) == compose(compose(f, g), h);
// true
```
這個特性就是結(jié)合律,符合結(jié)合律意味著不管你是把 `g` 和 `h` 分到一組,還是把 `f` 和 `g` 分到一組都不重要。所以,如果我們想把字符串變?yōu)榇髮?,可以這么寫:
```js
compose(toUpperCase, compose(head, reverse));
// 或者
compose(compose(toUpperCase, head), reverse);
```
因為如何為 `compose` 的調(diào)用分組不重要,所以結(jié)果都是一樣的。這也讓我們有能力寫一個可變的組合(variadic compose),用法如下:
```js
// 前面的例子中我們必須要寫兩個組合才行,但既然組合是符合結(jié)合律的,我們就可以只寫一個,
// 而且想傳給它多少個函數(shù)就傳給它多少個,然后讓它自己決定如何分組。
var lastUpper = compose(toUpperCase, head, reverse);
lastUpper(['jumpkick', 'roundhouse', 'uppercut']);
//=> 'UPPERCUT'
var loudLastUpper = compose(exclaim, toUpperCase, head, reverse)
loudLastUpper(['jumpkick', 'roundhouse', 'uppercut']);
//=> 'UPPERCUT!'
```
運用結(jié)合律能為我們帶來強大的靈活性,還有對執(zhí)行結(jié)果不會出現(xiàn)意外的那種平和心態(tài)。至于稍微復(fù)雜些的可變組合,也都包含在本書的 `support` 庫里了,而且你也可以在類似 [lodash][lodash-website]、[underscore][underscore-website] 以及 [ramda][ramda-website] 這樣的類庫中找到它們的常規(guī)定義。
結(jié)合律的一大好處是任何一個函數(shù)分組都可以被拆開來,然后再以它們自己的組合方式打包在一起。讓我們來重構(gòu)重構(gòu)前面的例子:
```js
var loudLastUpper = compose(exclaim, toUpperCase, head, reverse);
// 或
var last = compose(head, reverse);
var loudLastUpper = compose(exclaim, toUpperCase, last);
// 或
var last = compose(head, reverse);
var angry = compose(exclaim, toUpperCase);
var loudLastUpper = compose(angry, last);
// 更多變種...
```
關(guān)于如何組合,并沒有標(biāo)準(zhǔn)的答案——我們只是以自己喜歡的方式搭樂高積木罷了。通常來說,最佳實踐是讓組合可重用,就像 `last` 和 `angry` 那樣。如果熟悉 Fowler 的《[重構(gòu)][refactoring-book]》一書的話,你可能會認(rèn)識到這個過程叫做 “[extract method][extract-method-refactor]”——只不過不需要關(guān)心對象的狀態(tài)。
## pointfree
pointfree 模式指的是,永遠(yuǎn)不必說出你的數(shù)據(jù)??瓤葘Σ黄穑ㄗg者注:此處原文是“Pointfree style means never having to say your data”,源自 1970 年的電影 *Love Story* 里的一句著名臺詞“Love means never having to say you're sorry”。緊接著作者又說了一句“Excuse me”,大概是一種幽默)。它的意思是說,函數(shù)無須提及將要操作的數(shù)據(jù)是什么樣的。一等公民的函數(shù)、柯里化(curry)以及組合協(xié)作起來非常有助于實現(xiàn)這種模式。
```js
// 非 pointfree,因為提到了數(shù)據(jù):word
var snakeCase = function (word) {
return word.toLowerCase().replace(/\s+/ig, '_');
};
// pointfree
var snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);
```
看到 `replace` 是如何被局部調(diào)用的了么?這里所做的事情就是通過管道把數(shù)據(jù)在接受單個參數(shù)的函數(shù)間傳遞。利用 curry,我們能夠做到讓每個函數(shù)都先接收數(shù)據(jù),然后操作數(shù)據(jù),最后再把數(shù)據(jù)傳遞到下一個函數(shù)那里去。另外注意在 pointfree 版本中,不需要 `word` 參數(shù)就能構(gòu)造函數(shù);而在非 pointfree 的版本中,必須要有 `word` 才能進行進行一切操作。
我們再來看一個例子。
```js
// 非 pointfree,因為提到了數(shù)據(jù):name
var initials = function (name) {
return name.split(' ').map(compose(toUpperCase, head)).join('. ');
};
// pointfree
var initials = compose(join('. '), map(compose(toUpperCase, head)), split(' '));
initials("hunter stockton thompson");
// 'H. S. T'
```
另外,pointfree 模式能夠幫助我們減少不必要的命名,讓代碼保持簡潔和通用。對函數(shù)式代碼來說,pointfree 是非常好的石蕊試驗,因為它能告訴我們一個函數(shù)是否是接受輸入返回輸出的小函數(shù)。比如,while 循環(huán)是不能組合的。不過你也要警惕,pointfree 就像是一把雙刃劍,有時候也能混淆視聽。并非所有的函數(shù)式代碼都是 pointfree 的,不過這沒關(guān)系??梢允褂盟臅r候就使用,不能使用的時候就用普通函數(shù)。
## debug
組合的一個常見錯誤是,在沒有局部調(diào)用之前,就組合類似 `map` 這樣接受兩個參數(shù)的函數(shù)。
```js
// 錯誤做法:我們傳給了 `angry` 一個數(shù)組,根本不知道最后傳給 `map` 的是什么東西。
var latin = compose(map, angry, reverse);
latin(["frog", "eyes"]);
// error
// 正確做法:每個函數(shù)都接受一個實際參數(shù)。
var latin = compose(map(angry), reverse);
latin(["frog", "eyes"]);
// ["EYES!", "FROG!"])
```
如果在 debug 組合的時候遇到了困難,那么可以使用下面這個實用的,但是不純的 `trace` 函數(shù)來追蹤代碼的執(zhí)行情況。
```js
var trace = curry(function(tag, x){
console.log(tag, x);
return x;
});
var dasherize = compose(join('-'), toLower, split(' '), replace(/\s{2,}/ig, ' '));
dasherize('The world is a vampire');
// TypeError: Cannot read property 'apply' of undefined
```
這里報錯了,來 `trace` 下:
```js
var dasherize = compose(join('-'), toLower, trace("after split"), split(' '), replace(/\s{2,}/ig, ' '));
// after split [ 'The', 'world', 'is', 'a', 'vampire' ]
```
??!`toLower` 的參數(shù)是一個數(shù)組,所以需要先用 `map` 調(diào)用一下它。
```js
var dasherize = compose(join('-'), map(toLower), split(' '), replace(/\s{2,}/ig, ' '));
dasherize('The world is a vampire');
// 'the-world-is-a-vampire'
```
`trace` 函數(shù)允許我們在某個特定的點觀察數(shù)據(jù)以便 debug。像 haskell 和 purescript 之類的語言出于開發(fā)的方便,也都提供了類似的函數(shù)。
組合將成為我們構(gòu)造程序的工具,而且幸運的是,它背后是有一個強大的理論做支撐的。讓我們來研究研究這個理論。
## 范疇學(xué)
范疇學(xué)(category theory)是數(shù)學(xué)中的一個抽象分支,能夠形式化諸如集合論(set theory)、類型論(type theory)、群論(group theory)以及邏輯學(xué)(logic)等數(shù)學(xué)分支中的一些概念。范疇學(xué)主要處理對象(object)、態(tài)射(morphism)和變化式(transformation),而這些概念跟編程的聯(lián)系非常緊密。下圖是一些相同的概念分別在不同理論下的形式:

抱歉,我沒有任何要嚇唬你的意思。我并不假設(shè)你對這些概念都了如指掌,我只是想讓你明白這里面有多少重復(fù)的內(nèi)容,讓你知道為何范疇學(xué)要統(tǒng)一這些概念。
在范疇學(xué)中,有一個概念叫做...范疇。有著以下這些組件(component)的搜集(collection)就構(gòu)成了一個范疇:
* 對象的搜集
* 態(tài)射的搜集
* 態(tài)射的組合
* identity 這個獨特的態(tài)射
范疇學(xué)抽象到足以模擬任何事物,不過目前我們最關(guān)心的還是類型和函數(shù),所以讓我們把范疇學(xué)運用到它們身上看看。
**對象的搜集**
對象就是數(shù)據(jù)類型,例如 `String`、`Boolean`、`Number` 和 `Object` 等等。通常我們把數(shù)據(jù)類型視作所有可能的值的一個集合(set)。像 `Boolean` 就可以看作是 `[true, false]` 的集合,`Number` 可以是所有實數(shù)的一個集合。把類型當(dāng)作集合對待是有好處的,因為我們可以利用集合論(set theory)處理類型。
**態(tài)射的搜集**
態(tài)射是標(biāo)準(zhǔn)的、普通的純函數(shù)。
**態(tài)射的組合**
你可能猜到了,這就是本章介紹的新玩意兒——`組合`。我們已經(jīng)討論過 `compose` 函數(shù)是符合結(jié)合律的,這并非巧合,結(jié)合律是在范疇學(xué)中對任何組合都適用的一個特性。
這張圖展示了什么是組合:


這里有一個具體的例子:
```js
var g = function(x){ return x.length; };
var f = function(x){ return x === 4; };
var isFourLetterWord = compose(f, g);
```
**identity 這個獨特的態(tài)射**
讓我們介紹一個名為 `id` 的實用函數(shù)。這個函數(shù)接受隨便什么輸入然后原封不動地返回它:
```js
var id = function(x){ return x; };
```
你可能會問“這到底哪里有用了?”。別急,我們會在隨后的章節(jié)中拓展這個函數(shù)的,暫時先把它當(dāng)作一個可以替代給定值的函數(shù)——一個假裝自己是普通數(shù)據(jù)的函數(shù)。
`id` 函數(shù)跟組合一起使用簡直完美。下面這個特性對所有的一元函數(shù)(unary function)(一元函數(shù):只接受一個參數(shù)的函數(shù)) `f` 都成立:
```js
// identity
compose(id, f) == compose(f, id) == f;
// true
```
嘿,這就是實數(shù)的單位元(identity property)嘛!如果這還不夠清楚直白,別著急,慢慢理解它的無用性。很快我們就會到處使用 `id` 了,不過暫時我們還是把當(dāng)作一個替代給定值的函數(shù)。這對寫 pointfree 的代碼非常有用。
好了,以上就是類型和函數(shù)的范疇。不過如果你是第一次聽說這些概念,我估計你還是有些迷糊,不知道范疇到底是什么,為什么有用。沒關(guān)系,本書全書都在借助這些知識編寫示例代碼。至于現(xiàn)在,就在本章,本行文字中,你至少可以認(rèn)為它向我們提供了有關(guān)組合的知識——比如結(jié)合律和單位律。
除了類型和函數(shù),還有什么范疇呢?還有很多,比如我們可以定義一個有向圖(directed graph),以節(jié)點為對象,以邊為態(tài)射,以路徑連接為組合。還可以定義一個實數(shù)類型(Number),以所有的實數(shù)對象,以 `>=` 為態(tài)射(實際上任何偏序(partial order)或全序(total order)都可以成為一個范疇)。范疇的總數(shù)是無限的,但是要達到本書的目的,我們只需要關(guān)心上面定義的范疇就好了。至此我們已經(jīng)大致瀏覽了一些表面的東西,必須要繼續(xù)后面的內(nèi)容了。
## 總結(jié)
組合像一系列管道那樣把不同的函數(shù)聯(lián)系在一起,數(shù)據(jù)就可以也必須在其中流動——畢竟純函數(shù)就是輸入對輸出,所以打破這個鏈條就是不尊重輸出,就會讓我們的應(yīng)用一無是處。
我們認(rèn)為組合是高于其他所有原則的設(shè)計原則,這是因為組合讓我們的代碼簡單而富有可讀性。另外范疇學(xué)將在應(yīng)用架構(gòu)、模擬副作用和保證正確性方面扮演重要角色。
現(xiàn)在我們已經(jīng)有足夠的知識去進行一些實際的練習(xí)了,讓我們來編寫一個示例應(yīng)用。
[第 6 章: 示例應(yīng)用](ch6.md)
## 練習(xí)
```js
require('../../support');
var _ = require('ramda');
var accounting = require('accounting');
// 示例數(shù)據(jù)
var CARS = [
{name: "Ferrari FF", horsepower: 660, dollar_value: 700000, in_stock: true},
{name: "Spyker C12 Zagato", horsepower: 650, dollar_value: 648000, in_stock: false},
{name: "Jaguar XKR-S", horsepower: 550, dollar_value: 132000, in_stock: false},
{name: "Audi R8", horsepower: 525, dollar_value: 114200, in_stock: false},
{name: "Aston Martin One-77", horsepower: 750, dollar_value: 1850000, in_stock: true},
{name: "Pagani Huayra", horsepower: 700, dollar_value: 1300000, in_stock: false}
];
// 練習(xí) 1:
// ============
// 使用 _.compose() 重寫下面這個函數(shù)。提示:_.prop() 是 curry 函數(shù)
var isLastInStock = function(cars) {
var last_car = _.last(cars);
return _.prop('in_stock', last_car);
};
// 練習(xí) 2:
// ============
// 使用 _.compose()、_.prop() 和 _.head() 獲取第一個 car 的 name
var nameOfFirstCar = undefined;
// 練習(xí) 3:
// ============
// 使用幫助函數(shù) _average 重構(gòu) averageDollarValue 使之成為一個組合
var _average = function(xs) { return reduce(add, 0, xs) / xs.length; }; // <- 無須改動
var averageDollarValue = function(cars) {
var dollar_values = map(function(c) { return c.dollar_value; }, cars);
return _average(dollar_values);
};
// 練習(xí) 4:
// ============
// 使用 compose 寫一個 sanitizeNames() 函數(shù),返回一個下劃線連接的小寫字符串:例如:sanitizeNames(["Hello World"]) //=> ["hello_world"]。
var _underscore = replace(/\W+/g, '_'); //<-- 無須改動,并在 sanitizeNames 中使用它
var sanitizeNames = undefined;
// 彩蛋 1:
// ============
// 使用 compose 重構(gòu) availablePrices
var availablePrices = function(cars) {
var available_cars = _.filter(_.prop('in_stock'), cars);
return available_cars.map(function(x){
return accounting.formatMoney(x.dollar_value);
}).join(', ');
};
// 彩蛋 2:
// ============
// 重構(gòu)使之成為 pointfree 函數(shù)。提示:可以使用 _.flip()
var fastestCar = function(cars) {
var sorted = _.sortBy(function(car){ return car.horsepower }, cars);
var fastest = _.last(sorted);
return fastest.name + ' is the fastest';
};
```
[lodash-website]: https://lodash.com/
[underscore-website]: http://underscorejs.org/
[ramda-website]: http://ramdajs.com/
[refactoring-book]: http://martinfowler.com/books/refactoring.html
[extract-method-refactor]: http://refactoring.com/catalog/extractMethod.html
