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

大史不說話發表於2018-10-17

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

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

二. 更貼近本能的思維方式

假如有這樣一個題目:

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

在傳統程式設計中,你的編碼過程大約是這樣:

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

我們大多時候採用的方式是把程式的執行細節用程式語言描述出來。但是如果你把這道題拿給一個不懂程式設計的學生來做,就會發現大多數時候他們的做法會是下面的樣子:

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

先對方程進行合併和簡化,最後再代入數值進行計算得到結果就可以了。有沒有發現事實上你自己在不寫程式碼的時候也是這樣做的,因為你很清楚那些中間變數對於得到正確的結果來說沒有什麼意義,而這樣解題效率更高,尤其是當前面的環節和後面的環節可以抵消掉某些互逆的運算時,這樣合併的好處可想而知

而今天的主角【函數語言程式設計】,可以看做是這種思維方式在程式設計中的應用,我並不建議非數學專業的作者從範疇論的角度去解釋函數語言程式設計,因為術語運用的準確性會造成難以評估的影響,很可能達不到技術交流的目的,反而最終誤人子弟

三. 函數語言程式設計

假如對某個需求的實現,需要傳入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);

上面的語句中,實際上變數只有fn2Resultstep()方法和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( )方法來得到一個新函式ff執行時接收一個引數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);

我們無法確定上面的輸出結果,因為ab這兩個識別符號指向了堆中的相同的地址,可外界無法知道在modify函式中是否對b的屬性做出了修改。有些場景中為了使得邏輯過程更加可靠,我們不希望後續的操作和處理對最原始的資料造成影響,這個時候我們很確定需要拿到一個資料集的複製(比如拿到表格的總資料,在實現某些過濾功能的時候,通常需要留存一個表格資料的備份,以便取消過濾時可以恢復原貌),這就引出了老生常談的深拷貝和淺拷貝的話題。

【深拷貝】是一種典型的防禦性程式設計,因為在淺拷貝的機制下,修改物件屬性的時候會影響到所有指向它的識別符號,從而造成不可預測的結果。

javascript中,常見的深拷貝都是通過遞迴來實現的,然後利用語言特性做出一些程式碼層面的優化,例如各個第三方庫中的extend( )方法或者deepClone( )。可是當一個結構很深或者複雜度很高時,深拷貝的耗時就會大幅增加,有的時候我們關注的可能只是資料結構中的一部分,也就是說新老物件中很大一部分資料是一致的,可以共享的,但深拷貝過程中忽視了這種情況而簡單粗暴地對整個物件進行遞迴遍歷和克隆。

事實上【深拷貝】並不是防禦性程式設計的唯一方法,FacebookImmutable.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基礎

    如果你能夠很清楚高階函式柯里化反柯里化這些關鍵詞的含義和一般用途,並且至少了解Arraymapreduce方法做了什麼事情,那麼就可以進行下一步。否則就需要好好複習一下javascript的基礎知識。在javascript中進行函數語言程式設計會反覆涉及到這些基本技術的運用。

  • 《javascript函數語言程式設計指南》

    地址:https://llh911001.gitbooks.io/mostly-adequate-guide-chinese/content/

    這是一本來自於gitbook的翻譯版的非常棒的開源電子書,這本書很棒,但是如果將函數語言程式設計的相關知識分為初中高階的話,這本書似乎只涵蓋了初級和高階,而省略了中級的部分,當內容涉及到範疇論和代數結構的時候,理解難度會突然一下變得很大。當你讀不懂的時候可以先停下來,用下一個資料進行過渡,然後回過頭來再繼續閱讀後續的部分。

    同時提一句,翻譯者@鬍子大哈也是之前提及的那本著名的《React小書》的主要作者。

  • Ramda.js官網博文集

    地址:https://ramdajs.com/

    Ramda.jsjavascript提供了一系列函數語言程式設計的工具函式,但官網的《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語言的另一種靈活性。

開發人員會發現自己可以從更巨集觀地角度來觀察整個業務流程,而不是往返於業務邏輯和實現細節之間。

測試人員會發現它很容易進行單元測試,不僅因為它的純函式特性,也因為資料和動作被分離了。

遊戲玩家會發現它和自己在《我的世界》裡用方塊來搭建世界就是這樣子的。

工程師會發現它和對照零件圖紙編寫整個加工流水線的工藝流程時就是這樣做的。

數學家會說用數學的思維是可以描述世界的(如果你接觸過數學建模應該會更容易明白)。

【函數語言程式設計】讓開發者理解程式設計這件事本質是是一種設計,是一種創造行為,和其他通過組合功能單元而得到更強大的功能單元的行為沒有本質區別。

相關文章