這是完結篇了,前兩篇文章在這裡:
JavaScript函數語言程式設計(二)
在第二篇文章裡,我們介紹了 Maybe、Either、IO 等幾種常見的 Functor,或許很多看完第二篇文章的人都會有疑惑:
『這些東西有什麼卵用?』
事實上,如果只是為了學習編寫函式式、副作用小的程式碼的話,看完第一篇文章就足夠了。第二篇文章和這裡的第三篇著重於的是一些函式式理論的實踐,是的,這些很難(但並非不可能)應用到實際的生產中,因為很多輪子都已經造好了並且很好用了。比如現在在前端大規模使用的 Promise 這種非同步呼叫規範,其實就是一種 Monad(等下會講到);現在日趨成熟的 Redux 作為一種 FLUX 的變種實現,核心理念也是狀態機和函數語言程式設計。
一、Monad
關於 Monad 的介紹和教程在網路上已經層出不窮了,很多文章都寫得比我下面的更好,所以我在這裡只是用一種更簡單易懂的方式介紹 Monad,當然簡單易懂帶來的壞處就是不嚴謹,所以見諒/w\
如果你對 Promise 這種規範有了解的話,應該記得 Promise 裡一個很驚豔的特性:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
doSomething() .then(result => { // 你可以return一個Promise鏈! return fetch('url').then(result => parseBody(result)); }) .then(result => { // 這裡的result是上面那個Promise的終值 }) doSomething() .then(result => { // 也可以直接return一個具體的值! return 123; }) .then(result => { // result === 123 }) |
對於 Promise 的一個回撥函式來說,它既可以直接返回一個值,也可以返回一個新的 Promise,但對於他們後續的回撥函式來說,這二者都是等價的,這就很巧妙地解決了 nodejs 裡被詬病已久的巢狀地獄。
事實上,Promise 就是一種 Monad,是的,可能你天天要寫一大堆 Promise,可直到現在才知道天天用的這個東西竟然是個聽起來很高大上的函式式概念。
下面我們來實際實現一個 Monad,如果你不想看的話,只要記住 『Promise 就是一種 Monad』 這句話然後直接跳過這一章就好了。
我們來寫一個函式 cat,這個函式的作用和 Linux 命令列下的 cat 一樣,讀取一個檔案,然後打出這個檔案的內容,這裡 IO 的實現請參考上一篇文章:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import fs from 'fs'; import _ from 'lodash'; var map = _.curry((f, x) => x.map(f)); var compose = _.flowRight; var readFile = function(filename) { return new IO(_ => fs.readFileSync(filename, 'utf-8')); }; var print = function(x) { return new IO(_ => { console.log(x); return x; }); } var cat = compose(map(print), readFile); cat("file") //=> IO(IO("file的內容")) |
由於這裡涉及到兩個 IO:讀取檔案和列印,所以最後結果就是我們得到了兩層 IO,想要執行它,只能呼叫:
1 2 |
cat("file").__value().__value(); //=> 讀取檔案並列印到控制檯 |
很尷尬對吧,如果我們涉及到 100 個 IO 操作,那麼難道要連續寫 100 個 __value() 嗎?
當然不能這樣不優雅,我們來實現一個 join 方法,它的作用就是剝開一層 Functor,把裡面的東西暴露給我們:
1 2 3 4 5 6 7 8 9 10 |
var join = x => x.join(); IO.prototype.join = function() { return this.__value ? IO.of(null) : this.__value(); } // 試試看 var foo = IO.of(IO.of('123')); foo.join(); //=> IO('123') |
有了 join 方法之後,就稍微優雅那麼一點兒了:
1 2 3 |
var cat = compose(join, map(print), readFile); cat("file").__value(); //=> 讀取檔案並列印到控制檯 |
join 方法可以把 Functor 拍平(flatten),我們一般把具有這種能力的 Functor 稱之為 Monad。
這裡只是非常簡單地移除了一層 Functor 的包裝,但作為優雅的程式設計師,我們不可能總是在 map 之後手動呼叫 join 來剝離多餘的包裝,否則程式碼會長得像這樣:
1 |
var doSomething = compose(join, map(f), join, map(g), join, map(h)); |
所以我們需要一個叫 chain 的方法來實現我們期望的鏈式呼叫,它會在呼叫 map 之後自動呼叫 join 來去除多餘的包裝,這也是 Monad 的一大特性:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
var chain = _.curry((f, functor) => functor.chain(f)); IO.prototype.chain = function(f) { return this.map(f).join(); } // 現在可以這樣呼叫了 var doSomething = compose(chain(f), chain(g), chain(h)); // 當然,也可以這樣 someMonad.chain(f).chain(g).chain(h) // 寫成這樣是不是很熟悉呢? readFile('file') .chain(x => new IO(_ => { console.log(x); return x; })) .chain(x => new IO(_ => { // 對x做一些事情,然後返回 })) |
哈哈,你可能看出來了,chain 不就類似 Promise 中的 then 嗎?是的,它們行為上確實是一致的(then 會稍微多一些邏輯,它會記錄巢狀的層數以及區別 Promise 和普通返回值),Promise 也確實是一種函式式的思想。
(我本來想在下面用 Promise 為例寫一些例子,但估計能看到這裡的人應該都能熟練地寫各種 Promise 鏈了,所以就不寫了0w0)
總之就是,Monad 讓我們避開了巢狀地獄,可以輕鬆地進行深度巢狀的函數語言程式設計,比如IO和其它非同步任務。
二、函數語言程式設計的應用
好了,關於函數語言程式設計的一些基礎理論的介紹就到此為止了,如果想了解更多的話其實建議去學習 Haskell 或者 Lisp 這樣比較正統的函式式語言。下面我們來回答一個問題:函數語言程式設計在實際應用中到底有啥用咧?
1、React
React 現在已經隨處可見了,要問它為什麼流行,可能有人會說它『效能好』、『酷炫』、『第三方元件豐富』、『新穎』等等,但這些都不是最關鍵的,最關鍵是 React 給前端開發帶來了全新的理念:函式式和狀態機。
我們來看看 React 怎麼寫一個『純元件』吧:
1 2 3 |
var Text = props => ( <div style={props.style}>{props.text}</div> ) |
咦這不就是純函式嗎?對於任意的 text 輸入,都會產生唯一的固定輸出,只不過這個輸出是一個 virtual DOM 的元素罷了。配合狀態機,就大大簡化了前端開發的複雜度:
1 |
state => virtual DOM => 真實 DOM |
在 Redux 中更是可以把核心邏輯抽象成一個純函式 reducer:
1 |
reducer(currentState, action) => newState |
關於 React+Redux(或者其它FLUX架構)就不在這裡介紹太多了,有興趣的可以參考相關的教程。
2、Rxjs
Rxjs 從誕生以來一直都不溫不火,但它函式響應式程式設計(Functional Reactive Programming,FRP)的理念非常先進,雖然或許對於大部分應用環境來說,外部輸入事件並不是太頻繁,並不需要引入一個如此龐大的 FRP 體系,但我們也可以瞭解一下它有哪些優秀的特性。
在 Rxjs 中,所有的外部輸入(使用者輸入、網路請求等等)都被視作一種 『事件流』:
1 |
--- 使用者點選了按鈕 --> 網路請求成功 --> 使用者鍵盤輸入 --> 某個定時事件發生 --> ...... |
舉個最簡單的例子,下面這段程式碼會監聽點選事件,每 2 次點選事件產生一次事件響應:
1 2 3 4 |
var clicks = Rx.Observable .fromEvent(document, 'click') .bufferCount(2) .subscribe(x => console.log(x)); // 列印出前2次點選事件 |
其中 bufferCount 對於事件流的作用是這樣的:
是不是很神奇呢?Rxjs 非常適合遊戲、編輯器這種外部輸入極多的應用,比如有的遊戲可能有『搓大招』這個功能,即監聽使用者一系列連續的鍵盤、滑鼠輸入,比如上上下下左右左右BABA,不用事件流的思想的話,實現會非常困難且不優雅,但用 Rxjs 的話,就只是維護一個定長佇列的問題而已:
1 2 3 4 5 6 7 8 9 10 |
var inputs = []; var clicks = Rx.Observable .fromEvent(document, 'keydown') .scan((acc, cur) => { acc.push(cur.keyCode); var start = acc.length - 12 < 0 ? 0 : acc.length - 12; return acc.slice(start); }, inputs) .filter(x => x.join(',') == [38, 38, 40, 40, 37, 39, 37, 39, 66, 65, 66, 65].join(','))// 上上下下左右左右BABA,這裡用了比較奇技淫巧的陣列對比方法 .subscribe(x => console.log('!!!!!!ACE!!!!!!')); |
當然,Rxjs 的作用遠不止於此,但可以從這個範例裡看出函式響應式程式設計的一些優良的特性。
3、Cycle.js
Cycle.js 是一個基於 Rxjs 的框架,它是一個徹徹底底的 FRP 理念的框架,和 React 一樣支援 virtual DOM、JSX 語法,但現在似乎還沒有看到大型的應用經驗。
本質的講,它就是在 Rxjs 的基礎上加入了對 virtual DOM、容器和元件的支援,比如下面就是一個簡單的『開關』按鈕:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import xs from 'xstream'; import {run} from '@cycle/xstream-run'; import {makeDOMDriver} from '@cycle/dom'; import {html} from 'snabbdom-jsx'; function main(sources) { const sinks = { DOM: sources.DOM.select('input').events('click') .map(ev => ev.target.checked) .startWith(false) .map(toggled => <div> <input type="checkbox" /> Toggle me <p>{toggled ? 'ON' : 'off'}</p> </div> ) }; return sinks; } const drivers = { DOM: makeDOMDriver('#app') }; run(main, drivers); |
當然,Cycle.js 這種『侵入式』的框架適用性不是太廣,因為使用它就意味著應用中必須全部或者大部分都要圍繞它的理念設計,這對於大規模應用來說反而是負擔。
三、總結
既然是完結篇,那我們來總結一下這三篇文章究竟講了些啥?
第一篇文章裡,介紹了純函式、柯里化、Point Free、宣告式程式碼和命令式程式碼的區別,你可能忘記得差不多了,但只要記住『函式對於外部狀態的依賴是造成系統複雜性大大提高的主要原因』以及『讓函式儘可能地純淨』就行了。
第二篇文章,或許是最沒有也或許是最有乾貨的一篇,裡面介紹了『容器』的概念和 Maybe、Either、IO 這三個強大的 Functor。是的,大多數人或許都沒有機會在生產環境中自己去實現這樣的玩具級 Functor,但通過了解它們的特性會讓你產生對於函數語言程式設計的意識。
軟體工程上講『沒有銀彈』,函數語言程式設計同樣也不是萬能的,它與爛大街的 OOP 一樣,只是一種程式設計正規化而已。很多實際應用中是很難用函式式去表達的,選擇 OOP 亦或是其它程式設計正規化或許會更簡單。但我們要注意到函數語言程式設計的核心理念,如果說 OOP 降低複雜度是靠良好的封裝、繼承、多型以及介面定義的話,那麼函數語言程式設計就是通過純函式以及它們的組合、柯里化、Functor 等技術來降低系統複雜度,而 React、Rxjs、Cycle.js 正是這種理念的代言人,這可能是大勢所趨,也或許是曇花一現,但不妨礙我們去多掌握一種程式設計正規化嘛0w0