第 5 章:使用 Compose 開發

Functional 飼養

這就是 compose

var compose = function(f, g) {
  return function(x) {
    return f(g(x));
  };
};

fg 都是 function,x 則是通過它們之間「管道」的值。

Compose 感覺起來就像在飼養 function。你就是 function 的飼養員,選擇兩個有你喜歡特色的 function 並將它們組合,產生一個新的 function。使用起來如下:

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!"

組合兩個 function 並回傳一個新的 function 是很容易理解的:組合某種類型(在本例中為 function)的兩個元素應該產生一個該類型的新元素。你將兩個樂高積木組合起來並不會得到林肯積木。所以這是有跡可循的,我們會在適當的時候探討這方面的一些底層理論。

composer 的定義中,g 會在 f 之前執行,而建立一個由右至左的資料流。這麼做的可讀性遠高於巢狀的 function 呼叫。若不用 composer,那麼會像以下這樣:

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

程式由右而左執行,而不是由內而外,我認為這可以稱之為「左傾(left direction)」。讓我們看看一個順序重要的例子:

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 會將列表反轉,head 則會取得列表的第一個元素。結果就得到了一個效率不高的 last function。這個組合中 function 的執行順序是顯而易見的。雖然我們可以定義一個由左而右的版本,但是由右而左更能反映出數學上的含義。沒錯,compose 的概念直接來自於數學課本。事實上,現在是時候看看所有 compose 都有的一個特性了。

// 結合律(associativity)
var associative = compose(f, compose(g, h)) == compose(compose(f, g), h);
// true

Compose 有結合律的特性,意指不管你將哪兩個分為一組都不重要。所以,如果我們想將字串轉為大寫,可以這樣寫:

compose(toUpperCase, compose(head, reverse));

// 或
compose(compose(toUpperCase, head), reverse);

因為呼叫 compose 時的分組方式不重要,所以結果都會是相同的。因此,這也讓我們可以撰寫一個參數數量可變的 compose,用法如下:

// 在前面的例子中我們寫了兩個 compose,不過因為 compose 符合結合律,我們可以讓 compose 接受多個 function,並讓它自己決定如何分組。
var lastUpper = compose(toUpperCase, head, reverse);

lastUpper(['jumpkick', 'roundhouse', 'uppercut']);
//=> 'UPPERCUT'


var loudLastUpper = compose(exclaim, toUpperCase, head, reverse);

loudLastUpper(['jumpkick', 'roundhouse', 'uppercut']);
//=> 'UPPERCUT!'

運用結合律的屬性能夠為我們帶來強大的靈活性,及當結果相同時的所帶來的安心感。稍微複雜一點,參數數量可變的 compose 都已經包含在本書的提供的 library 中,你也可以在像是 lodashunderscoreramda 的 library 中也可以找到它們。

結合率的一大好處是任何一個 function 的分組都可以被拆開,然後再以他們自己的 compose 方式封裝在一起。讓我們來重構前面的例子:

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);

// 更多變種⋯

這沒有標準答案-我們只是以自己喜歡的方式玩樂高積木而已。一般來說,分組的最佳方式就是讓它可重用,像是 lastangry。如果熟悉的 Fowler 的《Refactoring》這本書的話,你可能會知道這個過程稱之為「extract method(抽出方法)」⋯只不過不需要擔心object的所有狀態。

Pointfree

Pointfree 模式指的是永遠不必說出你的資料。呃抱歉(譯註:此處原文是「Pointfree style means never having to say your data」,源自 1970 年的電影 Love Story 裡的一句著名台詞「Love means never having to say you're sorry」。緊接著作者又說了一句「Excuse me」,大概是一種幽默)。意思是指,function 不必提及要操作的資料是什麼樣的。First Class Function、curry 及 compose 協作起來非常有助於建立這種模式。

// 非 pointfree,因為我們提到資料:word
var snakeCase = function(word) {
  return word.toLowerCase().replace(/\s+/ig, '_');
};

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

看到 replace 是如何被部分呼叫了嗎?這裡做的事情就是將資料傳遞至每個接收單一參數的 function。Curry 讓每個 function 都先接收資料,再操作資料,最後再將資料傳遞至下一個 function。另外要注意在 pointfree 的版本中,我們不需資料來建構 function,而在非 pointfree 的版本中,我們必須先擁有 word 才能進行其他操作。

讓我們看看另一個例子。

// 非 pointfree,因為我們提到資料: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 幫助我們移除不必要的命名,讓程式碼保持簡潔和通用。對 functional 的程式碼來說,pointfree 是個很好的試金石,因為它能告訴我們一個 function 是否為接受輸入回傳輸出的小 function。像是 compose 無法用於 while 迴圈上。不過請注意,pointfree 就像一把雙刃劍,有時會混淆視聽。並不是所有的 functional 程式碼都為 pointfree,不過這沒關係。可以使用他的時候就使用,不能使用的時候就用普通的 function。

Debug

Compose 常見的錯誤就是,在沒有第一次部分呼叫前,就 compose 像是 map 接受兩個參數的 function。

// 不正確-我們傳遞 array 給 angry,但不知道部分呼叫的 map 接收到什麼。
var latin = compose(map, angry, reverse);

latin(['frog', 'eyes']);
// error


// 正確-每個 function 都預期接受一個參數。
var latin = compose(map(angry), reverse);

latin(['frog', 'eyes']);
// ['EYES!', 'FROG!'])

如果你在 debug compose 時遇到了問題,我們可以使用下面這個實用,但 impure 的 trace function 追蹤執行情況。

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 看看

var dasherize = compose(join('-'), toLower, trace('after split'), split(' '), replace(/\s{2,}/ig, ' '));
// after split [ 'The', 'world', 'is', 'a', 'vampire' ]

啊!因為 toLower 執行於 array,我們需要透過 map 呼叫它。

var dasherize = compose(join('-'), map(toLower), split(' '), replace(/\s{2,}/ig, ' '));

dasherize('The world is a vampire');

// 'the-world-is-a-vampire'

trace function 讓我們在某個特定的點觀察資料,以便進行 debug。像是 haskell 與 purescript 的語言為了方便開發,也都提供了相似的 function。

Compose 會成為我們建構程式的工具,且幸運的是,他背後有個強大的理論做支撐。讓我們來研究一下這個理論。

範疇論

範疇論(category theory)是個數學的抽象分支,能夠形式化集合論(set theory)、類型論(type theory)、群論(group theory)及邏輯學(logic)等數學分支的一些概念。範疇學主要處理 object、態射(morphism)及轉化(transformation),而這些概念跟程式設計的關係非常密切。下圖是一些同樣概念分別在不同理論下的形式:

抱歉,我沒有任何要嚇你的意思。我不假設你對這些概念瞭若指掌,我的重點只想讓你知道這裡有多少重複的內容,讓你知道為何範疇學要統一這些概念。

在範疇學中,有一個概念稱之為⋯範疇。有以下 component 的 collection 就構成一個範疇:

  • object 的 collection
  • 態射的 collection
  • 態射的組合
  • 一個名為 identity 獨特的態射

範疇學抽象到可以模擬任何事物,不過我們目前最關心的還是類型及 function,所以讓我們將範疇學運用到它們身上看看。

Object 的 collection Object 就是資料類型。例如:StringBooleanNumberObject 等等。通常我們把資料類型作為所有可能值的一個集合(set)。像是 Boolean 就是 [true, false] 的集合,Number 可以是所有實數的集合。把類型當作集合是有好處的,因為我們可以利用集合論處理類型。

態射的 collection 態射會是標準的 pure function。

態射的組合 你可以已經猜到了,這就是本章所介紹的新玩具-compose。我們已經討論過 compose function 是符合結合律,這並不是巧合,結合律是範疇學中對任何組合都適用的一個特性。

下圖展示了何為組合:

下方的程式碼是個具體的例子:

var g = function(x) {
  return x.length;
};
var f = function(x) {
  return x === 4;
};
var isFourLetterWord = compose(f, g);

一個名為 identity 獨特的態射 讓我們來介紹一個名為 id 的實用 function。這個 function 只是接受隨便的輸入然後原封不動的還給你。如下:

var id = function(x) {
  return x;
};

你可能想問「這到底哪裡有用了?」。我們會在之後的幾個章節擴增這個 function,暫時將它當作一個可以替代給定值的 function-一個假裝自己是資料的 function。

id 與 compose 簡直是天作之合。下面這個特性對所有的 unary function(一元 function:只接受一個參數的 function)f 都成立:

// identity
compose(id, f) == compose(f, id) == f;
// true

嘿,這不就是實數的單一律(identity property)阿!如過這還不夠清楚明瞭,就慢慢理解它的無用性吧。我們很快會到處使用 id,但現在我們暫時將它當作是個替代給定值得 function 。這對於撰寫 pointfree 的程式碼相當有用。

好了,以上就是類型和 function 的範疇。如果這是你第一次聽說這些概念,我猜測你現在還有些不瞭解,不懂範疇為何及為何有用。沒關係,本書都會借助這些知識。至於現在,本章的本行中,你至少可以認為它向我們提供了有關 compose 的知識-例如結合律與單一律。

除了這些,還有哪些範疇呢?當然,我們可以定義一個向量圖,以結點為 object,邊為態射,以路徑連接為組合。我們可以定義一個實數為 object,>= 為態射(事實上任何偏序(partial order)及全序(total order)都可成為一個範疇)。範疇的總數是無上限的,但是要達到本書的目的,我們只需關心上方所定義的範疇即可。到目前我們已經瀏覽了一些表面的東西,接著必須進入下一章節了。

總結

Compose 將我們的的 function 連結在一起,就像一條管線一樣。資料也會在我們的應用程式中流動-畢竟 pure function 就是輸入對輸出,所以打破這個鏈結就是不遵重輸出,會讓我們的應用程式一無是處。

我們認為 compose 是高於其他原則的設計模式,這是因為 compose 讓我們的程式簡單而可讀。範疇學會在應用程式架構、模擬副作用及保證正確性方面扮演重要的角色。

現在我們已經有足夠的知識去進行一些實際的練習了,讓我們來撰寫一個範例應用程式。

第 6 章:範例應用程式

練習

var _ = require('ramda');
var accounting = require('accounting');

// 範例資料
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,
}];

// 練習 1:
// ============
// 使用 _.compose() 重寫以下的 function。提示: _.prop() 是 curry function。
var isLastInStock = function(cars) {
  var last_car = _.last(cars);
  return _.prop('in_stock', last_car);
};

// 練習 2:
// ============
// 使用 _.compose()、 _.prop() 及 _.head() 來取得第一筆 car 的 name。
var nameOfFirstCar = undefined;


// 練習 3:
// ============
// 使用 helper function _average 來重構 averageDollarValue 使之為 compose function。
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);
};


// 練習 4:
// ============
// 使用 compose 撰寫一個 function:sanitizeNames(),回傳一個 car 的 name 為全小寫及底線連接的列表:例如:sanitizeNames([{name: 'Ferrari FF', horsepower: 660, dollar_value: 700000, in_stock: true}]) //=> ['ferrari_ff']。

var _underscore = _.replace(/\W+/g, '_'); //<-- leave this alone and use to sanitize

var sanitizeNames = undefined;


// 加分題 1:
// ============
// 使用 compose 重構 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:
// ============
// 重構使它 pointfree。提示: 你可以使用 _.flip()。

var fastestCar = function(cars) {
  var sorted = _.sortBy(function(car) {
    return car.horsepower;
  }, cars);
  var fastest = _.last(sorted);
  return fastest.name + ' is the fastest';
};

results matching ""

    No results matching ""