javascript基礎修煉(8)——指向FP世界的箭頭函數
一. 箭頭函數

箭頭函數是ES6語法中加入的新特性,而它也是許多開發者對ES6僅有的了解,每當面試里被問到關于“ES6里添加了哪些新特性?”這種問題的時候,幾乎總是會拿箭頭函數來應付。箭頭函數,=>,沒有自己的this?,?arguments?,?super?,?new.target?,“書寫簡便,沒有this”在很長一段時間內涵蓋了大多數開發者對于箭頭函數的全部認知(當然也包括我自己),如果只是為了簡化書寫,把=>按照function關鍵字來解析就好了,何必要弄出一個跟普通函數特性不一樣的符號呢?答案就是:函數式編程(Functional Programming)。
如果你了解javaScript這門語言就知道,它是沒有類這個東西的,ES6新加入的Class關鍵字,也不過是語法糖而已,我們不斷被要求使用面向對象編程的思想來使用javaScript,定義很多類,用復雜的原型鏈機制去模擬類,是因為更多的開發者能夠習慣這種描述客觀世界的方式,《你不知道的javascript》中就明確指出原型鏈的機制其實只是實現了一種功能委托機制,即便不使用面向對象中的概念去描述它,這也是一種合乎邏輯的語言設計方案,并不會造成巨大的認知障礙。但需要明確的是,面向對象并不是javascript唯一的使用方式。
當然我也是接觸到【函數式編程】的思想后才意識到,我并不是說【函數式編程】優于【面向對象】,每一種編程思想都有其適用的范圍,但它的確向我展示了另一種對編程的認知方式,而且在流程控制的清晰度上,它的確比面向對象更棒,它甚至讓我開始覺得,這才是javascript該有的打開方式。
如果你也曾以為【函數式編程】就是“用箭頭函數把函數寫的精簡一些”,如果你也被各種復雜的this綁定弄的暈頭轉向,那么就一起來看看這個胖箭頭指向的新世界——Functional Programming吧!
二. 更貼近本能的思維方式
假如有這樣一個題目:
在傳統編程中,你的編碼過程大約是這樣:
let?resolveYX?=?(x)?=>?3*x*x?+?2*x?+?1;let?resolveZY?=?(y)?=>?4*y*y*y?+?5*y*y?+?6;let?resolveRZ?=?(z)?=>?(2*z*z?-?4)/3;let?y?=?resolveYX(2);let?z?=?resolveZY(y);let?result?=?resolveRZ(z);
我們大多時候采用的方式是把程序的執行細節用程序語言描述出來。但是如果你把這道題拿給一個不懂編程的學生來做,就會發現大多數時候他們的做法會是下面的樣子:
先對方程進行合并和簡化,最后再代入數值進行計算得到結果就可以了。有沒有發現事實上你自己在不寫代碼的時候也是這樣做的,因為你很清楚那些中間變量對于得到正確的結果來說沒有什么意義,而這樣解題效率更高,尤其是當前面的環節和后面的環節可以抵消掉某些互逆的運算時,這樣合并的好處可想而知。
三. 函數式編程
假如對某個需求的實現,需要傳入x,然后經歷3個步驟后得到一個答案y,你會怎樣來實現呢?
3.1 傳統代碼的實現
這樣一個需求在傳統編程中最容易想到的就是鏈式調用:
function?Task(value){????this.value?=?value; } Task.prototype.step?=?function(fn){????let?_newValue?=?fn(this.value);????return?new?Task(_newValue); } y?=?(new?Task(x)).step(fn1).step(fn2).step(fn3);
你或許在jQuery中經常見到這樣的用法,或者你已經意識到上面的函數實際上就是Promise的簡化原型(關于Promise相關的知識可以看《javascript基礎修煉(7)——Promise,異步,可靠性》這篇文章),只不過我們把每一步驟包裹在了Task這個容器里,每個動作執行完以后返回一個新的Task容器,里面裝著上一個步驟返回的結果。
3.2 函數式代碼推演
在【函數式編程】,我們不再采用程序語言按照步驟來復現一個業務邏輯,而是換一個更為抽象的角度,用數學的眼光看待所發生的事情。那么上面的代碼實際上所做的事情就是:
通過一系列變換操作,講一個數據集x變成了數據集y。
有沒有一點似曾相識的感覺?沒錯,這就是我們熟知的【方程】,或者【映射】:
$$
y=f(x)
$$
我們將原來的代碼換個樣子,就更容易看出來了:
function?prepare(){????return?function?(x){????????return?(new?Task(x)).step(fn1).step(fn2).step(fn3); ????}???? }let?f?=?prepare();let?y?=?f(x);
上面的例子中,通過高階函數prepare( )將原來的函數改變為一個延遲執行的,等待接收一個參數x并啟動一系列處理流程的新函數。再繼續進行代碼轉換,再來看一下f(x)執行到即將結束時的暫態狀況:
//fn2Result是XX.step(fn2)執行完后返回的結果(值和方法都包含在Task容器中)fn2Result.step(fn3);
上面的語句中,實際上變量只有fn2Result,step()方法和fn10都是提前定義好的,那么用函數化的思想來進行類比,這里也是實現了一個數據集x1到數據集y1的映射,所以它也可以被抽象為y = f ( x )的模式:
//先生成一個用于生成新函數的高階函數,來實現局部調用let?goStep?=?function(fn){????return?function(params){????????let?value?=?fn(params.value);????????return?new?Task(value); ????} }//fn2Result.step(fn3)這一句將被轉換為如下形式let?requireFn2Result?=?goStep(fn3);
此處的requireFn2Result( )方法,只接受一個由前置步驟執行結束后得到的暫態結果,然后將其關鍵屬性value傳入fn3進行運算并傳回一個支持繼續鏈式調用的容器。我們來對代碼進行一下轉換:
function?prepare(){????return?function?(x){????????let?fn2Result?=?(new?Task(x)).step(fn1).step(fn2);? ????????return?requireFn2Result(fn2Result); ????}???? }
同理繼續來簡化前置步驟:
//暫時先忽略函數聲明的位置let?requireFn2Result?=?goStep(fn3);let?requireFn1Result?=?goStep(fn2);let?requireInitResult?=?goStep(fn1);function?prepare(){????return?function?(x){????????let?InitResult?=?new?Task(x);????????return?requireFn2Result(requireFn1Result(requireInitResult(InitResult))); ????}???? }
既然已經這樣了,索性再向前一步,把new Task(x)也函數化好了:
let?createTask?=?function(x){????return?new?Task(x); };
3.3 函數化的代碼
或許你已經被上面的一系列轉化弄得暈頭轉向,我們暫停一下,來看看函數化后的代碼變成了什么樣子:
function?prepare(){????return?function?(x){????????return?requireFn2Result(requireFn1Result(requireInitResult(createTask(x)))); ????}???? }let?f?=?prepare();let?y?=?f(x);
這樣的編碼模式將核心業務邏輯在空間上放在一起,而把具體的實現封裝起來,讓開發者更容易看到一個需求實現過程的全貌。
3.4 休息一下
不知道你是否有注意到,在中間環節的組裝過程中,其實并沒有任何真實的數據出現,我們只使用了暫態的抽象數據來幫助我們寫出映射方法f的細節,而隨后暫態的數據又被新的函數取代,逐級迭代,直到暫態數據最終指向了最外層函數的形參,你可以重新審視一下上面的推演過程來體會函數式編程帶來的變化,這個點是非常重要的。
3.5 進一步抽象
3.3節中函數化的代碼中,存在一個很長的嵌套調用,如果業務邏輯步驟過多,那么這行代碼會變得很長,同時也很難閱讀,我們需要通過一些手段將這些中間環節的函數展開為一種扁平化的寫法。
/** *定義一個工具函數compose,接受兩個函數作為參數,返回一個新函數 *新函數接受一個x作為入參,然后實現函數的迭代調用。 */var?compose?=?function?(f,?g)?{????return?function?(x)?{????????return?f(g(x)); ????} };/** *升級版本的compose函數,接受一組函數,實現左側函數包裹右側函數的形態 */let?composeEx?=?function?(...args)?{????return?(x)=>args.reduceRight((pre,cur)=>cur(pre),x); }
看不懂的同學需要補補基礎課了,需要注意的是工具函數返回的仍然是一個函數,我們使用上面的工具函數來重寫一下3.3小節中的代碼:
let?pipeline?=?composeEx(requireFn2Result,requireFn1Result,requireInitResult,createTask);function?prepare(){????return?function?(x){????????return?pipeline(x); ????}???? }let?f?=?prepare();let?y?=?f(x);
還要繼續?必須的,希望你還沒有抓狂。代碼中我們先執行prepare( )方法來得到一個新函數f,f執行時接收一個參數x,然后把x傳入pipeline方法,并返回pipeline(x)。我們來進行一下對比:
//prepare執行后得到的新函數let?f?=?x?=>?pipeline(x);
或許你已經發現了問題所在,這里的f函數相當于pipeline方法的代理,但這個代理什么額外的動作都沒有做,相當于只是在函數調用棧中憑空增加了一層,但是執行了相同的動作。如果你能夠理解這一點,就可以得出下面的轉化結果:
let?f?=?pipeline;
是不是很神奇?順便提一下,它的術語叫做point free,當你深入學習【函數式編程】時就會接觸到。
3.6 完整的轉換代碼
我們再進行一些簡易的抽象和整理,然后得到完整的流程:
let?composeEx?=?(...args)?=>?(x)?=>?args.reduceRight((pre,cur)?=>cur(pre),x);let?getValue?=?(obj)?=>?obj.value;let?createTask?=?(x)?=>?new?Task(x);/*goStep執行后得到的函數也滿足前面提到的“let?f=(x)=>g(x)”的形式,可以將其pointfree化. let?goStep?=?(fn)=>(params)=>composeEx(createTask,?fn,?getValue)(params); let?requireFn2Result?=?goStep(fn3); */let?requireFn2Result?=?composeEx(createTask,fn3,getValue);let?requireFn1Result?=?composeEx(createTask,fn2,getValue);let?requireInitResult?=?composeEx(createTask,fn1,getValue);let?pipeline?=?composeEx(requireFn2Result,requireFn1Result,requireInitResult,createTask);let?f?=?pipeline;let?y?=?f(x);
可以看到我們定義完方法后,像搭積木一樣把它們組合在一起,就得到了一個可以實現目標功能的函數。
3.7 為什么它看起來變得更復雜了
如果只看上面的示例,的確是這樣的,上面的示例只是為了展示函數式編程讓代碼向著怎樣一個方向去變化而已,而并沒有展示出函數式編程的優勢,這種轉變和一個jQuery開發者剛開始使用諸如angular,vue,React框架時感受到的強烈不適感是很相似的,畢竟思想的轉變是非常困難的。
面向對象編程寫出的代碼看起來就像是一個巨大的關系網和邏輯流程圖,比如連續讀其中10行代碼,你或許能夠很清晰地看到某個步驟執行前和執行后程序的狀態,但是卻很難看清整體的業務邏輯流程;而函數式編程正好是相反的,你可以在短短的10行代碼中看到整個業務流程,當你想去深究某個具體步驟時,再繼續展開,另一方面,關注數據和函數組合可以將你從復雜的this和對象的關系網中解放出來。
四. 兩個主角
數據和函數是【函數式編程】中的兩大核心概念,它為我們提供了用數學的眼光看世界的獨特視角,同時它也更程序員該有的思維模式——設計程序,而不是僅僅是復現業務邏輯:
程序設計?=?數據結構?+?算法???Vs???函數式編程?=?數據?+?函數
但為了更加安全有效地使用,它們和傳統編程中的同名概念相比多了一些限制。
函數Vs純函數
函數式編程中所傳遞和使用的函數,被要求為【純函數】。純函數需要滿足如下兩個條件:
只依賴自己的參數
執行過程沒有副作用
為什么純函數只能依賴自己的參數?因為只有這樣,我們才不必在對函數進行傳遞和組合的時候小心翼翼,生怕在某個環節弄丟了this的指向,如果this直接報錯還好,如果指向了錯誤的數據,程序本身在運行時也不會報錯,這種情況的調試是非常令人頭疼的,除了逐行運行并檢查對應數據的狀態,幾乎沒什么高效的方法。面向對象的編程中,我們不得不使用很多bind函數來綁定一個函數的this指向,而純函數就不存在這樣的問題。來看這樣兩個函數:
var?a?=?1;function?inc(x){????return?a?+?x; }function?pureInc(x){????let?a?=?1;????return?x?+?a; }
對于inc這個函數來說,改變外部條件a的值就會造成inc函數對于同樣的入參得到不同的結果的情況,換言之在入參確定為3的前提下,每次執行inc(3)得到的結果是不確定的,所以它是不純的。而pureInc函數就不依賴于外界條件的變化,pureInc(3)無論執行多少次,無論外界參數如何變化,其輸出結果都是確定的。
在面向對象的編程中,我們寫的函數通常都不是純函數,因為編程中或多或少都需要在不同的函數中 共享一些標記狀態的變量,我們更傾向與將其放在更高層的作用域里,通過標識符的右查詢會沿作用域鏈尋找的機制來實現數據共享。
什么是函數的副作用呢?一個函數執行過程對產生了外部可觀察的變化那么就說這個函數是有副作用的。最常見的情況就是函數接受一個對象作為參數,但是在函數內部對其進行了修改,javascript中函數在傳遞對象參數時會將其地址傳入調用的函數,所以函數內部所做的修改也會同步反應到函數外部,這種副作用會在函數組合時造成最終數據的不可預測性,因為有關某個對象的函數都有可能得到不確定的輸出。
數據Vs不可變數據
javascript中的對象很強大也很靈活,可并不是所有的場景中我們都需要這種靈活性。來看這樣一個例子:
let?a?=?{????name:'tony'}let?b?=?a; modify(b);console.log(a.name);
我們無法確定上面的輸出結果,因為a和b這兩個標識符指向了堆中的相同的地址,可外界無法知道在modify函數中是否對b的屬性做出了修改。有些場景中為了使得邏輯過程更加可靠,我們不希望后續的操作和處理對最原始的數據造成影響,這個時候我們很確定需要拿到一個數據集的復制(比如拿到表格的總數據,在實現某些過濾功能的時候,通常需要留存一個表格數據的備份,以便取消過濾時可以恢復原貌),這就引出了老生常談的深拷貝和淺拷貝的話題。
【深拷貝】是一種典型的防御性編程,因為在淺拷貝的機制下,修改對象屬性的時候會影響到所有指向它的標識符,從而造成不可預測的結果。
在javascript中,常見的深拷貝都是通過遞歸來實現的,然后利用語言特性做出一些代碼層面的優化,例如各個第三方庫中的extend( )方法或者deepClone( )??墒钱斠粋€結構很深或者復雜度很高時,深拷貝的耗時就會大幅增加,有的時候我們關注的可能只是數據結構中的一部分,也就是說新老對象中很大一部分數據是一致的,可以共享的,但深拷貝過程中忽視了這種情況而簡單粗暴地對整個對象進行遞歸遍歷和克隆。
事實上【深拷貝】并不是防御性編程的唯一方法,Facebook的Immutable.js就用不可變數據的思路來解決這個問題,它將對象這種引用值變得更像原始值(javascript中的原始值創建后是不能修改的)。
//Immutable.js官網示例 ?var?map1?=?Immutable.Map({?a:?1,?b:?2,?c:?3?});?var?map2?=?map1.set('b',?50); ?map1.get('b');?//?2 ?map2.get('b');?//?50
你可以查看【Immutable.js官方文檔】來了解如何使用它,通常它是結合React全家桶一起使用的。如果你對其實現原理感興趣,可以查看《深入探究Immutable.js的實現機制》一文或者查看其他資料,來了解一下Hash樹和Trie樹是如何作為Immutable的算法基礎而被應用的。
當標識符指向不變的數據,當函數沒有副作用,就可以大膽廣泛地使用函數式編程了。
四. 前端的學習路線
javascript基礎
如果你能夠很清楚高階函數,柯里化,反柯里化這些關鍵詞的含義和一般用途,并且至少了解Array的map和reduce方法做了什么事情,那么就可以進行下一步。否則就需要好好復習一下javascript的基礎知識。在javascript中進行函數式編程會反復涉及到這些基本技術的運用。
《javascript函數式編程指南》
地址:https://llh911001.gitbooks.io/mostly-adequate-guide-chinese/content/
這是一本來自于gitbook的翻譯版的非常棒的開源電子書,這本書很棒,但是如果將函數式編程的相關知識分為初中高級的話,這本書似乎只涵蓋了初級和高級,而省略了中級的部分,當內容涉及到范疇論和代數結構的時候,理解難度會突然一下變得很大。當你讀不懂的時候可以先停下來,用下一個資料進行過渡,然后回過頭來再繼續閱讀后續的部分。
Ramda.js官網博文集
地址:https://ramdajs.com/
Ramda.js為javascript提供了一系列函數式編程的工具函數,但官網的《Thinking In Ramda》系列教程,是非常好的中級教程,結合Ramda的API進行講解,讓開發者更容易理解函數式編程,它正好彌補了前一個資料中沒有中級教程的問題。
Ramda.js的API
不得不說很多前端開發者都是從API開始學習函數式編程的,但很快就會發現學了和沒學差不多,因為沒有理論基礎,你很難知道該去使用它。就好像給了你最頂尖的工具,你也沒法一次性就做出好吃的牛排,因為你不會做。
Rx.js和Immutable.js
事實上筆者自己也還沒有進行到這個階段的學習,Rx.js是隸屬于Angular全家桶的,Immutable.js是隸屬于React全家桶的,即使在自己目前的工作中沒有直接使用到,你也應該了解它們。
代數結構的理論基礎
地址:https://github.com/fantasyland/fantasy-land
當你具備了基本的使用能力,想要更上一層樓的時候,就需要重新整合函數式編程的理論體系。這個項目用于解釋函數式編程的理論基礎中各類術語及相關用途。
五. 小結
【函數式編程】為我們展現了javascript語言的另一種靈活性。
開發人員會發現自己可以從更宏觀地角度來觀察整個業務流程,而不是往返于業務邏輯和實現細節之間。
測試人員會發現它很容易進行單元測試,不僅因為它的純函數特性,也因為數據和動作被分離了。
游戲玩家會發現它和自己在《我的世界》里用方塊來搭建世界就是這樣子的。
工程師會發現它和對照零件圖紙編寫整個加工流水線的工藝流程時就是這樣做的。
數學家會說用數學的思維是可以描述世界的(如果你接觸過數學建模應該會更容易明白)。
【函數式編程】讓開發者理解程序設計這件事本質是是一種設計,是一種創造行為,和其他通過組合功能單元而得到更強大的功能單元的行為沒有本質區別。
javascript
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。