JavaScript函數式編程(三)
這是完結篇了,前兩篇文章在這里:
在第二篇文章里,我們介紹了 Maybe 、 Either 、 IO 等幾種常見的 Functor,或許很多看完第二篇文章的人都會有疑惑:
『這些東西有什么卵用?』
事實上,如果只是為了學習編寫函數式、副作用小的代碼的話,看完第一篇文章就足夠了。第二篇文章和這里的第三篇著重于的是一些函數式理論的實踐,是的,這些很難(但并非不可能)應用到實際的生產中,因為很多輪子都已經造好了并且很好用了。比如現在在前端大規模使用的 Promise 這種異步調用規范,其實就是一種 Monad(等下會講到);現在日趨成熟的 Redux 作為一種 FLUX 的變種實現,核心理念也是狀態機和函數式編程。
一、Monad
關于 Monad 的介紹和教程在網絡上已經層出不窮了,很多文章都寫得比我下面的更好,所以我在這里只是用一種更簡單易懂的方式介紹 Monad,當然簡單易懂帶來的壞處就是不嚴謹,所以見諒/w\
如果你對 Promise 這種規范有了解的話,應該記得 Promise 里一個很驚艷的特性:
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 的實現請參考上一篇文章:
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 ,想要運行它,只能調用:
cat("file").__value().__value(); //=> 讀取文件并打印到控制臺
很尷尬對吧,如果我們涉及到 100 個 IO 操作,那么難道要連續寫 100 個 __value() 嗎?
當然不能這樣不優雅,我們來實現一個 join 方法,它的作用就是剝開一層 Functor,把里面的東西暴露給我們:
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 方法之后,就稍微優雅那么一點兒了:
var cat = compose(join, map(print), readFile); cat("file").__value(); //=> 讀取文件并打印到控制臺
join 方法可以把 Functor 拍平(flatten),我們一般把具有這種能力的 Functor 稱之為 Monad。
這里只是非常簡單地移除了一層 Functor 的包裝,但作為優雅的程序員,我們不可能總是在 map 之后手動調用 join 來剝離多余的包裝,否則代碼會長得像這樣:
var doSomething = compose(join, map(f), join, map(g), join, map(h));
所以我們需要一個叫 chain 的方法來實現我們期望的鏈式調用,它會在調用 map 之后自動調用 join 來去除多余的包裝,這也是 Monad 的一大特性:
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 怎么寫一個『純組件』吧:
var Text = props => ( <div style={props.style}>{props.text}</div> )
咦這不就是純函數嗎?對于任意的 text 輸入,都會產生唯一的固定輸出,只不過這個輸出是一個 virtual DOM 的元素罷了。配合狀態機,就大大簡化了前端開發的復雜度:
state => virtual DOM => 真實 DOM
在 Redux 中更是可以把核心邏輯抽象成一個純函數 reducer:
reducer(currentState, action) => newState
關于 React+Redux(或者其它FLUX架構)就不在這里介紹太多了,有興趣的可以參考相關的教程。
2、Rxjs
Rxjs 從誕生以來一直都不溫不火,但它函數響應式編程(Functional Reactive Programming,FRP)的理念非常先進,雖然或許對于大部分應用環境來說,外部輸入事件并不是太頻繁,并不需要引入一個如此龐大的 FRP 體系,但我們也可以了解一下它有哪些優秀的特性。
在 Rxjs 中,所有的外部輸入(用戶輸入、網絡請求等等)都被視作一種 『事件流』:
--- 用戶點擊了按鈕 --> 網絡請求成功 --> 用戶鍵盤輸入 --> 某個定時事件發生 --> ......
舉個最簡單的例子,下面這段代碼會監聽點擊事件,每 2 次點擊事件產生一次事件響應:
var clicks = Rx.Observable .fromEvent(document, 'click') .bufferCount(2) .subscribe(x => console.log(x)); // 打印出前2次點擊事件
其中 bufferCount 對于事件流的作用是這樣的:
是不是很神奇呢?Rxjs 非常適合游戲、編輯器這種外部輸入極多的應用,比如有的游戲可能有『搓大招』這個功能,即監聽用戶一系列連續的鍵盤、鼠標輸入,比如 上上下下左右左右BABA ,不用事件流的思想的話,實現會非常困難且不優雅,但用 Rxjs 的話,就只是維護一個定長隊列的問題而已:
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、容器和組件的支持,比如下面就是一個簡單的『開關』按鈕:
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
來自:https://zhuanlan.zhihu.com/p/22094473