函數(shù)式編程指南

1. 什么是函數(shù)式編程

1.1

當考慮應用設計時,我們應該問問自己是否遵從了以下的設計原則
? 可擴展性一一我是否需要不斷地重構代碼來支持額外的功能?
? 易模塊化一一如果我更改了一個文件,另一個文件會不會受到影響?
? 可重用性一一是否有很多重復的代碼?
? 可測性一一給這些函數(shù)添加單元測試是否讓我糾結?
? 易推理性一一我寫的代碼是否非結構化嚴重并難以推理?

1.2

函數(shù)式編程需要你在思考解決問題的思路時有所變化。其實使用函數(shù)來獲得結果并不重要,函數(shù)式編程的目標是使用函數(shù)來抽象作用在數(shù)據(jù)之上的控制流與操作,從而在系統(tǒng)中消除副作用并減少對狀態(tài)的改變。
來更簡潔的描述一下:函數(shù)式編程是指為創(chuàng)建不可變的程序,通過消除外部可見的副作用,來對純函數(shù)的聲明式的求值過程。

1.3 我們首先來了解一下它所基于的基本概念

? 聲明式編程:將程序的描述與求值分離開來。它關注于如何用各種表達式來描述程序邏輯,而不一定要指明其控制流或狀態(tài)的變化
? 純函數(shù)

  1. 僅取決于提供的輸入,而不依賴于任何在函數(shù)求值期間或調用間隔時可能變化
    的隱藏狀態(tài)和外部狀態(tài) 。
  2. 不會造成超出其作用域的變化,例如修改全局對象或引用傳遞的參數(shù) 。
    ? 引用透明:相同的輸入總是產生相同的輸出
    ? 不可變性

1.4 優(yōu)點

? 促使將任務分解成簡單的函數(shù):1. 單一職責 2. 組合
? 使用流式的調用鏈來處理數(shù)據(jù):函數(shù)鏈是一種惰性計算程序,這意味著當需要時才會執(zhí)行。有效地模擬了其他函數(shù)式語言的按需調用的行為。
? 通過響應式范式降低事件驅動代碼的復雜性: observable

2. 高階函數(shù)

鑒于函數(shù)的行為與普通對象類似,其理所當然地可以作為其他函數(shù)的參數(shù)進行傳遞,或是由其他函數(shù)返回。這些函數(shù)則稱為高階函數(shù)。
因為函數(shù)的一等性和高階性, JavaScript函數(shù)具有的行為,也就是說,函數(shù)就是一個基于輸人的且尚未求值的不可變的值。

3. 函數(shù)調用的方法

? 作為全局函數(shù)一一 其中 this 的引用可以是 global 對象或是 undefined (在嚴格模式中)
? 作為方法 一一其中 this 的引用是方法的所有者,這是Javascript 的面向對象特性的重要部分
? 作為構造函數(shù)與 new 一起使用 一一 這種方式會返回新創(chuàng)建對象的引用

4. 閉包

4.1 概念

閉包是一種能夠在函數(shù)聲明過程中將環(huán)境信息與所屬函數(shù)綁定在一起的數(shù)據(jù)結構。 它是基于函數(shù)聲明的文本位置的,因此也被稱為圍繞函數(shù)定義的靜態(tài)作用域或詞法作用域 。閉包能夠使函數(shù)訪問其環(huán)境狀態(tài),使得代碼更清晰可讀。你很快就會看到,閉包不僅應用于函數(shù)式編程的高階函數(shù)中,也可用于事件處理和回調、模擬私有成員變量,還能用于彌補一些 JavaScript 的不足。

支配函數(shù)閉包行為的規(guī)則與 JavaScript 的作用域規(guī)則密切相關。作用域能夠將一組變量綁定,并定義變量定義的代碼段。從本質上講,閉包就是函數(shù)繼承而來的作用域, 這類似于對象方法是如何訪問其繼承的實例變量的,它們都具有其父類型的引用。在內嵌函數(shù)中能夠很清楚地看到閉包。

函數(shù)的閉包包括以下內容:
? 函數(shù)的所有參數(shù)
? 外部作用域的所有變量(當然也包括所有的全局變量)

4.2 函數(shù)作用域

  1. 首先檢查變量的函數(shù)作用域。
  2. 如果不是在局部作用域內,那么逐層向外檢查各詞法作用域,搜索該變量的引用,直到全局作用域。
  3. 如果無法找到變量引用,那么 JavaScript 將返回 undefined。

4.3 實際應用

  1. 模擬私有變量。
    閉包可以用來管理的全局命名空間,以免在全局范圍內共享數(shù)據(jù)。一些庫和模塊還會使用閉包來隱藏整個模塊的私有方法和數(shù)據(jù)。這被稱為模塊模式 ,它采用了立即調用函數(shù)表達式(IIFE)


    image.png
  2. 異步服務端調用 。

  3. 創(chuàng)建人工作作用域變量 。

5. 函數(shù)鏈

5.1 鏈接方法

方法鏈是一種能夠在一個語句中調用多個方法的面向對象編程模式。當這些方法屬于同一個對象時,方法鏈又稱為方法級聯(lián), demo如下:

'Functional Programming'.substring(0, 10).toLowerCase() + ' is fun';
// 函數(shù)式風格
concat(toLowerCase(substring('Functional Programming', 1, 10))), 'is fun' 

5.2 函數(shù)鏈

函數(shù)式編程則采用了不同的方式。它不是通過創(chuàng)建一個全新的數(shù)據(jù)結構類型來滿足特定的需求,而是使用如數(shù)組這樣的普通類型,并施加在一套粗粒度的高階操作之上,這些操作是底層數(shù)據(jù)形態(tài)所不可見的。這些操作會作如下設計。
? 接收函數(shù)作為參數(shù),以便能夠注入解決特定任務的特定行為。
? 代替充斥著臨時變量與副作用的傳統(tǒng)循環(huán)結構,從而減少所要維護以及可能出錯的代碼。

5.3 lambda 表達式(箭頭函數(shù))

lambda 表達式適用于函數(shù)式的函數(shù)定義,因為它總是需要返回一個值。對于單行表達式,其返回值就是函數(shù)體的值。另一個值得注意的是一等函數(shù)與 lambda 表達式之間的關系。函數(shù)名代表的不是一個具體的值,而是一種(惰性計算的)可獲取其值的描述。換句話說,函數(shù)名指向的是代表著如何計算該數(shù)據(jù)的箭頭函數(shù)。這就是在函數(shù)式編程中可以將函數(shù)作為數(shù)值使用的原因。

5.4 Lodash惰性計算函數(shù)鏈

image.png

_.chain 函數(shù)可以添加一個輸入對象的狀態(tài),從而能夠將這些輸入轉換為所需輸出的操作鏈接在一起。與簡單地將數(shù)組包裹在(...)對象中不同,其強大之處在于可以鏈接序列中的任何函數(shù)。盡管這是一個復雜的程序,但仍然可以避免創(chuàng)建任何變量 ,并且有效地消除所有循環(huán)。
使用 _.chain 的另一個好處是可以創(chuàng)建具有惰性計算能力的復雜程序,在調用 value () 前,并不會真正地執(zhí)行任何操作。這可能會對程序產生巨大的影響,因為在不需要其結果的情況下,可以跳過運行所有函數(shù)

能夠惰性地定義程序的管道不止有可讀性這一個好處。由于以惰性計算方式編寫的程序會在運行前定義好,因此可以使用數(shù)據(jù)結構重用或者方法融合等技術對其進行優(yōu)化。這些優(yōu)化不會減少執(zhí)行函數(shù)本身所需的時間,但有助于消除不必要的調用

6. 遞歸

遞歸是一種旨在通過將問題分解成較小的自相似問題來解決問題本身的技術,將這些小的自相似問題結合在一起,就可以得到最終的解決方案。遞歸函數(shù)包含以下兩個主要部分。
? 基例(也稱為終止條件) :能夠令遞歸函數(shù)計算出具體結果的一組輸入,而不必再重復下去
? 遞歸條件:處理函數(shù)調用自身的一組輸入(必須小于原始值)。如果輸入不變小,那么遞歸就會無限期地運行,直至程序崩潰。隨著函數(shù)的遞歸,輸入會無條件地變小,最終到達觸發(fā)基例的條件,以一個值作為遞歸過程的終止。

事實上,純函數(shù)式語言甚至沒有標準的循環(huán)結構,如 do、 for 和 while,因為所有循環(huán)都是遞歸完成的 。 遞歸使代碼更易理解,因為它是以多次在較小的輸人上重復相同的操作為基礎的

6.1 尾調用優(yōu)化

image.png

這個版本的實現(xiàn)將遞歸調用作為函數(shù)體中最后的步驟,也就是尾部位置。

6.2 遞歸定義的數(shù)據(jù)結構

遞歸算法執(zhí)行整個樹的先序遍歷,從根開始并且下降到所有子節(jié)點。由于其自相似性,從根節(jié)點遍歷樹和從任何節(jié)點遍歷子樹是完 全一樣的,這就是遞歸定義。

樹的先序遍歷按照以下步驟執(zhí)行,從根節(jié)點開始。

  1. 顯示根元素的數(shù)據(jù)部分。
  2. 通過遞歸地調用先序函數(shù)來遍歷左子樹。
  3. 以相同的方式遍歷右子樹。


    遞歸的先序遍歷,從根節(jié)點開始,一直向左下降,然后再向右移動

6.3 遞歸的弱點

如果你見過錯誤 Range Error : Maximum Call Stack Exceeded or too much recursion,就會知道遞歸有問題了。通過下面這個簡單的腳本,可以測試瀏覽器函數(shù)堆棧大概的大小:

function increment (i) { 
  console.log(i); 
  increment(++i);
}
increment(i);

不同的瀏覽器的堆找錯誤會有不同:例如在某臺計算機上, Chrome 會在 17500 次遞歸后觸發(fā)異常,而 Firefox 的會遞歸大約 213000 次。不要以這些數(shù)值作為遞歸函數(shù)的上界! 這些數(shù)字只是為了說明遞歸是有限制的。代碼預設應該要遠遠低于這些閥值,否則遞歸肯定是有問題的。
遍歷這種異常巨大的數(shù)組的另一種方式就是利用高階函數(shù),如 map、 filter 以及 reduce。使用這些函數(shù)不會產生嵌套的函數(shù)調用,因為堆棧在每次迭代循環(huán)后都能得到回收。
雖然柯里化和遞歸導致更多的內存占用,但是鑒于它們帶來的靈活性和復用性以及遞歸解決方案固有的正確性 ,又感覺這些額外的內存花費是值得的。
另外我們可以通過惰性求值來推遲函數(shù)執(zhí)行

6.4 尾遞歸調用優(yōu)化

當使用尾遞歸時,編譯器有可能幫助你做尾 部調用優(yōu)化(TCO)


image.png

TCO 也稱為尾部調用消除 ,是 ES6 添加的編譯器增強功能。 同時,在最后的位置調用別的函數(shù)也可以優(yōu)化(雖然通常是本身),該調用位置稱為尾部位置 (尾遞歸因此而得名)。
這為什么算是一種優(yōu)化?函數(shù)的最后一件事情如果是遞歸的函數(shù)調用,那么運行時會認為不必要保持當前的棧幀,因為所有工作已經完成,完全可以拋棄當前幀。在大多數(shù)情況下,只有將函數(shù)的上下文狀態(tài)作為參數(shù)傳遞給下一個函數(shù)調用(正如在遞歸階乘函數(shù)處看到的) ,才能使遞歸調用不需要依賴當前幀。通過這種方式,遞歸每次都會創(chuàng)建一個新的幀,回收舊的幀,而不是將新的幀疊在舊的上。因為 factorial 是尾遞歸 的形式,所以 factorial(4)的調用會從典型的遞歸金字塔:

factorial(4)
  4 * factorial(4)
    4 * 3 * factorial(2)
      4 * 3 * 2 * factorial(1)
        4 * 3 * 2 * 1 * factorial(0)
          4 * 3 * 2 * 1 * 1
        4 * 3 * 2 * 1
       4 * 3 * 2
    4 * 6
return 24

相對于如下上下文堆棧

factorial(4)
  factorial(3, 4)
  factorial(2, 12)
  factorial(1, 24)
  factorial(0, 24)
  return 24
return 24
尾遞歸 factorial(4)求值的詳細視圖。函數(shù)只使用了一幀。TCO 負責拋棄當前幀,為新的幀讓路,就像 factorial 在循環(huán)中求值一樣

6.5 將非尾遞歸轉為尾遞歸

const factorial = (n) => (n===1)? 1 : (n * factorial(n - 1));

遞歸調用并沒有發(fā)生在尾部,因為最后返回的表達式是 n * factorial (n - 1)。 切記,最后一個步驟一定要是遞歸,這樣才會在運行時 TCO 將 factorial 轉換成一 個循環(huán)。改成尾遞歸只需要兩步。

  1. 將當前乘法結果當作參數(shù)傳人遞歸函數(shù)。
  2. 使用 ES6 的默認參數(shù)給定一個默認值(也可以部分地應用它們,但默認參數(shù)會讓代碼更整潔 )。
const factorial = (n, current = 1) => (n === 1) ? current : factorial(n - 1, n * current) ,

這個階乘函數(shù)運行起來眼標準循環(huán)沒什么區(qū)別,沒有額外創(chuàng)建堆棧幀,同時仍保留了它原本的數(shù)學聲明的感覺。之所以能夠做這種轉換,是因為尾遞歸函數(shù)跟循環(huán)有著共同的特點


標準循環(huán)(左)及其等效的尾遞歸函數(shù)之間的相似之處。在這兩個代碼示例中,讀者可以很容易地找到基例、事后操作、累計參數(shù)和結果
function sum (arr) {
  if (_.isEmpty(arr)) {
    return 0;
  }
  return _.first(arr) + sum(_.rest(arr));
}
// 轉為尾調用
function sum(arr, ace = 0) { 
  if(_.isEmpty(arr)) {
    return 0;
  }
  return sum(rest(arr), ace+_.first(arr));
}

尾遞歸帶來遞歸循環(huán)的性能接近于 for 循環(huán)。所以對于有尾遞歸優(yōu)化的語言,比如 ES6,就可以在保持算法的正確性和 mutation 的控制,同時還能保持不會拖累性能。不過尾調用也不僅限于尾遞歸 。 也可以是調用另一個函數(shù),這種情況在 JavaScript 代碼中也很常見。不過要注意的是,這是個新的 JavaScript標準,即便 ES4就開始起草,很多瀏覽器也沒有廣泛實現(xiàn)。

還有一種解決方式是使用 trampolining。 trampolining 可以用迭代的方式模擬尾遞歸,所以可以非常理想、容易地控制 JavaScript 的堆棧。
trampolining是一個接受函數(shù)的函數(shù),它會多次調用函數(shù),直到滿足一定的條件。一個可反彈或者重復的函數(shù)被封裝在 thunk 結構中。 thunk 只不過是多了 一層函數(shù)包裹。在函數(shù)式 JavaScript背景下,可以用 thunk及簡單的匿名函數(shù)包裹期望惰性求值的值。
要檢查 TCO 和其他 ES6 特性的兼容性,可以登錄: https://kangax.github.io/compat table/es6/。

7. 函數(shù)的管道化

? 方法鏈接(緊耦合,有限的表現(xiàn)力) 。
? 函數(shù)的管道化(松耦合,靈活)。


image.png

比較命令式代碼,這的確是一個能夠極大提高代碼可讀性的語法改進。然而,它與方法所屬的對象緊緊地耦合在一起 , 限制鏈中可以使用的方法數(shù)量 ,也就限制了代碼的表現(xiàn)力。這樣就只能夠使用由 Lodash 提供的操作,而無法輕松地將不同函數(shù)庫的(或自定義的)函數(shù)連接在一起。

7.1 兼容條件

由于 JavaScirpt 是弱類型語言,因此從類型角度來看,無須像使用一些靜態(tài)類型語言一樣太過關注類型。因此,如果一個對象在應用中表現(xiàn)得像某個特定類型, 那么它就是該類型。這也被稱為鴨子類型 : “如果走起來像鴨子,并且像鴨子一樣叫,那這就是一只鴨子。”
正式地講,僅當 f 的輸出類型等同于函數(shù) g 的輸入時,兩個函數(shù) f 和 g 是類型兼容的。舉例來說, 一個處理學生社會安全號碼的簡單程序 :


image.png

使用 trim和normalize手動構建函數(shù)管道


手動構建函數(shù)管道

7.2 元數(shù)

元數(shù)定義為函數(shù)所接收的參數(shù)數(shù)量 ,也被稱為函數(shù)的長度
如何返回兩個不同的值呢?函數(shù)式語言通過一個稱為元組的結構來做到這一點。元組是有限的、有序的元素列表。
來看一個代碼示例:

return {
  status: false, 
  message: "Input is a too long"
}
// or
 return [false, "Input is too long"]; 

8. 函數(shù)執(zhí)行機制

全局上下文幀永遠駐留在堆棧的底部。每個函數(shù)的上下文幀都占用一定量的內存,實際取決于其中的局部變量的個數(shù)。如果沒有任何局部變量,一個空幀大約 48 個字節(jié)。每個數(shù)字或布爾類型的局部變量和參數(shù)會占用 8字節(jié)。所以,函數(shù)體聲明越多的變量,就需要越大的堆棧幀。 每一幀大致包含以下信息:


函數(shù)執(zhí)行機制

注意 :函數(shù)的作用域鏈與 JavaScript 對象的原型鏈不是一回事。 雖然兩者表現(xiàn)得很類似,但是原型鏈通過 prototype屬性建立對象繼承的鏈接,而作用域鏈是指內部函數(shù)能訪問到外部函數(shù)的閉包。
堆棧的行為由下列規(guī)則確定。
? JavaScript 是單線程的,這意味著執(zhí)行的同步性 。
? 有且只有一個全局上下文(與所有函數(shù)的上下文共享) 。
? 函數(shù)上下文的數(shù)量是有限制的(對客戶端代碼,不同的瀏覽器可以有不同的限制)。
? 每個函數(shù)調用會創(chuàng)建一個新的執(zhí)行上下文,遞歸調用也是如此。

9. 柯里化與函數(shù)上下文堆棧

柯里化函數(shù)時,把一次函數(shù)執(zhí)行變成了多次執(zhí)行的函數(shù)(每次消費一個參數(shù)),來看一個logger函數(shù)

const logger = function (appender, layout, name, level, message)

柯里化后會變成如下嵌套結構

const l ogger =
function (appender) {
  return function (layout) ( 
    return function (name) {
      return function (level) { 
        return function (message) {

嵌套結構的函數(shù)會使用更多的堆棧。 先來解釋 logger 函數(shù)的非柯里化的執(zhí)行。 由于 JavaScript 的同步執(zhí)行機制,調用 logger 會暫停全局上下文的執(zhí)行, 好讓 logger 運行, 創(chuàng)建新的活躍上下文,并引用全局上下文中的所有變量。


image.png

調用任何函數(shù)時,如 logger,單線程 JavaScript 運行時會暫停當前全局上下文并激活新函數(shù)創(chuàng)建的上下文 。 此時,還會通過 scopeChain 創(chuàng)建到全局上下文的鏈接。
一旦 logger 返回,它的執(zhí)行上下文也會被彈出堆棧,全局上下文將恢復

當 logger 函數(shù)調用其他函數(shù) (如 Log4js)時, 會在堆棧上產生新函數(shù)的上下文。 由于 JavaScript的閉包,內部函數(shù)調用的上下文會在外部函數(shù)上下文堆棧的上面占用分配給它的存儲器, 并經由 scopeChain 鏈接起來


運行嵌套函數(shù)時函數(shù)上下文的變化 。 因為每個函數(shù)會產生新的堆棧幀, 所以堆棧增長跟函數(shù)嵌套的層級成正比。 柯里化與遞歸都依賴于嵌套的函數(shù)調用

一旦 Log4js 代碼運行完,它就會被彈出堆棧 ; logger 函數(shù)也會在之后被彈出, 運行時環(huán)境恢復到只有全局上下文的狀態(tài)。這就是 JavaScript 的閉包背后的魔法。
雖然這種方法強大,但是嵌套深的函數(shù)會消耗大量的內存。

柯里化版本:


柯里化將每一個參數(shù)都轉換成內部嵌套調用??梢赃B續(xù)提供參數(shù)帶來了靈活性,卻額外占用了堆??臻g

柯里化所有函數(shù)看起來是不錯的主意,但是過度使用會導致其占用較大的堆??臻g,進而導致程序運行速度顯著降低。

10. 惰性求值

當輸人很大但只有一個小的子集有效時, 避免不必要的函數(shù)調用可以體現(xiàn)出許多性能優(yōu)勢,例如函數(shù)式語言 Haskell 就內置了惰性函數(shù)求值
JavaScript 使用的是更主流的函數(shù)求值策略一一及早求值。及早求值會在表達式綁定到變量時求值,不管結果是否會被用到,所以也稱為貪婪求值


image.png

10.1 緩存(記憶化)

image.png

10.2 給函數(shù)添加記憶化

image.png

10.3 記憶化遞歸調用

image.png

由于記憶化了階乘函數(shù),在第二次迭代時吞吐量有顯著的提升。在第二次運行時,函 數(shù)“記住”了使用公式“101!=101×100!”,并且可以重復使用 factorial(lOO)的值, 使得整個算法立即返回,并對戰(zhàn)幀的管理以及污染堆棧方面都有好處


運行記憶化的 factorial(lOO)在第一次會創(chuàng)建 100 的堆棧幀 , 因為它需要計算 100!在第二次 調用 101 的階乘時通過記憶化能夠重復使用 factorial (100)的結果, 所以只會創(chuàng)建 2個核幀

10.4 生成惰性數(shù)據(jù)

ES6 最強大的功能之一是可以通過暫停函數(shù)執(zhí)行而不用一次運行完。這帶來了許多(可能無限的)機會,比如函數(shù)可以生成惰性數(shù)據(jù),而不必一次處理大量的數(shù)據(jù)。這稱為生成器(generator)。
下面來看一個簡單的例子,該例只取前 3 個元素,而不會產生無數(shù)的列表 :


image.png

使用生成器,可以惰性地從無限集中取一定數(shù)量的元素:

function take (amount , generator) { 
  let result= [];
  for (let n of generator) {
    result.push(n);
    if(n ===amount) { break };
  }
  return result
}
take(3, range(1, Infinity)); 11-> [1, 2, 3]

除了一些限制,生成器的行為與任何標準函數(shù)調用非常相似??梢酝ㄟ^給它傳遞參數(shù),(也許是一個函數(shù))來操作生成的值:


image.png

11. 生成器

11.1 生成器與遞歸

就像任何函數(shù)調用 一樣,也可以在生成器中調用其他生成器。這對于將嵌套對象集合扁平化非常有用,比如樹的遍歷。因為可以用 for...of 遍歷生成器,調用另一個生成器就類似于合并兩個集合并遍歷。


每個節(jié)點代表一個 student 對象,每個箭頭代表“學生關系”

可以使用生成器輕松地對這棵樹進行建模


image.png

下面的代碼使用遞歸遍歷這棵樹(每個節(jié)點包含一個 Person 對象):
image.png

11.2 迭代器協(xié)議

生成函數(shù)返回符合迭代器協(xié)議的 Generator 對象。這意味著它實現(xiàn)一個名為 next ()的方法,該方法返回使用 yield 關鍵字 return 的值。此對象具有以下屬性。
? done一一如果迭代器到達序列結尾,則值為 true;否則,值為 false,表示迭代器還可以生成下一個值 。
? value一一迭代器返回的值。


image.png

可以以這種形式創(chuàng)建符合某種規(guī)范的數(shù)據(jù)。例如平方生成器:


image.png

JavaScript 中有許多內含@@iterator屬性的可迭代對象。數(shù)組是可以這樣使用的 :

var iter = ['s', 't', 'r', 'e', 'a'][Symbol.iterator]()
iter.next().value // s
iter.next().value // t

字符串也可以迭代:

var iter= ’Stream’[Symbol.iterator](); 
iter.next() .value // -> S
 iter.next().value // -> t

12. Observable

Rx.Observable 對象將函數(shù)式編程與響應式編程結合在一起


image.png
從 Observable應用函數(shù) filter 和 map 的過程

響應式編程傾向于和函數(shù)式編程一起使用,從而產生函數(shù)式晌應式編程

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內容