1. 程式人生 > >JavaScript ES6函數語言程式設計(一):閉包與高階函式

JavaScript ES6函數語言程式設計(一):閉包與高階函式

函數語言程式設計的歷史

函式的第一原則是要小,第二原則則是要更小 —— ROBERT C. MARTIN

解釋一下上面那句話,就是我們常說的一個函式只做一件事,比如:將字串首字母和尾字母都改成大寫,我們此時應該編寫兩個函式。為什麼呢?為了更好的複用,這樣做保證了函式更加的顆粒化。

早在 1950 年代,隨著 Lisp 語言的建立,函數語言程式設計( Functional Programming,簡稱 FP)就已經開始出現在大家視野。而直到近些年,函式式以其優雅,簡單的特點開始重新風靡整個程式設計界,主流語言在設計的時候無一例外都會更多的參考函式式特性( Lambda 表示式,原生支援 map ,reduce ……),Java8 開始支援函數語言程式設計。

而在前端領域,我們同樣能看到很多函數語言程式設計的影子:Lodash.js、Ramda.js庫的廣泛使用,ES6 中加入了箭頭函式,Redux 引入 Elm 思路降低 Flux 的複雜性,React16.6 開始推出 React.memo(),使得 pure functional components 成為可能,16.8 開始主推 Hooks,建議使用 pure functions 進行元件編寫……

這些無一例外的說明,函數語言程式設計這種古老的程式設計正規化並沒有隨著歲月而褪去其光彩,反而愈加生機勃勃。

什麼是函數語言程式設計

上面我們瞭解了函數語言程式設計的歷史,確定它是個很棒的東西。接下來,我們要去了解一下什麼是函數語言程式設計?

其實函式我們從小就學,什麼一元函式(f(x) = 3x),二元函式……根據學術上函式的定義,函式即是一種描述集合和集合之間的轉換關係,輸入通過函式都會返回有且只有一個輸出值。

所以,函式實際上是一個關係,或者說是一種對映,而這種對映關係是可以組合的,一旦我們知道一個函式的輸出型別可以匹配另一個函式的輸入,那他們就可以進行組合。

在程式設計的世界裡,我們需要處理其實也只有“資料”和“關係”,而“關係”就是函式,“資料”就是要傳入的實參。我們所謂的程式設計工作也不過就是在找一種對映關係,比如:將字串首字母轉為大寫。一旦關係找到了,問題就解決了,剩下的事情,就是讓資料流過這種關係,然後轉換成另一個數據返回給我們。

想象一個流水線車間的工作過程,把輸入當做原料,把輸出當做產品,資料可以不斷的從一個函式的輸出可以流入另一個函式輸入,最後再輸出結果,這不就是一套流水線嘛?

所以,現在你明確了函數語言程式設計是什麼了吧?它其實就是強調在程式設計過程中把更多的關注點放在如何去構建關係。通過構建一條高效的建流水線,一次解決所有問題。而不是把精力分散在不同的加工廠中來回奔波傳遞資料。

函數語言程式設計的特點

  • 函式是一等公民

根據維基百科,程式語言中一等公民的概念是由英國計算機學家Christopher Strachey提出來的,時間則早在上個世紀60年代,那個時候還沒有個人電腦,沒有網際網路,沒有瀏覽器,也沒有JavaScript。並且當時也沒給出清晰的定義。

關於一等公民,我找到一個權威的定義,來自於一本書《Programming Language Pragmatics》,這本書是很多大學的程式語言設計的教材。

In general, a value in a programming language is said to have first-class status if it can be passed as a parameter, returned from a subroutine, or assigned into a variable.

也就是說,在程式語言中,一等公民可以作為函式引數,可以作為函式返回值,也可以賦值給變數。

例如,字串在幾乎所有程式語言中都是一等公民,字串可以做為函式引數,字串可以作為函式返回值,字串也可以賦值給變數。

對於各種程式語言來說,函式就不一定是一等公民了,比如Java 8之前的版本。

對於JavaScript來說,函式可以賦值給變數,也可以作為函式引數,還可以作為函式返回值,因此JavaScript中函式是一等公民。

  • 宣告式程式設計 (Declarative Programming)

通過上面的例子可以看出來,函數語言程式設計大多時候都是在宣告我需要做什麼,而非怎麼去做。這種程式設計風格稱為宣告式程式設計 。

// 比如:我們要列印陣列中的每個元素
// 1. 指令式程式設計
let arr = [1, 2, 3];
for (let i = 0, len = arr.length; i < len; i++) {
  console.log(arr[i])
}

// 2. 宣告式程式設計
let arr = [1, 2, 3];
arr.forEach(item => {
  console.log(item)
})

/*
* 相對於指令式程式設計的 for 迴圈拿到每個元素,宣告式程式設計不需要自己去找每個元素
* 因為 forEach 已經幫我們拿到了,就是 item,直接打印出來就行
*/

這樣有個好處是程式碼的可讀性特別高,因為宣告式程式碼大多都是接近自然語言的,同時,它解放了大量的人力,因為它不關心具體的實現,因此它可以把優化能力交給具體的實現,這也方便我們進行分工協作。

  • 惰性執行(Lazy Evaluation)

所謂惰性執行指的是函式只在需要的時候執行,即不產生無意義的中間變數。

  • 無狀態和資料不可變 (Statelessness and Immutable data)

這是函數語言程式設計的核心概念:

資料不可變:它要求你所有的資料都是不可變的,這意味著如果你想修改一個物件,那你應該建立一個新的物件用來修改,而不是修改已有的物件。
無狀態: 主要是強調對於一個函式,不管你何時執行,它都應該像第一次執行一樣,給定相同的輸入,給出相同的輸出,完全不依賴外部狀態的變化。

  • 沒有副作用(side effect)

副作用,一般指完成分內的事情之後還帶來了不好的影響。在函式中,最常見的副作用就是隨意修改外部變數。由於js物件傳遞的是引用地址,這很容易帶來bug。

例如: map 函式的本來功能是將輸入的陣列根據一個函式轉換,生成一個新的陣列。而在 JS 中,我們經常可以看到下面這種對 map 的 “錯誤” 用法,把 map 當作一個迴圈語句,然後去直接修改陣列中的值。

const list = [...];
// 修改 list 中的 type 和 age
list.map(item => {
  item.type = 1;
  item.age++;
})

傳遞引用一時爽,程式碼重構火葬場

這樣函式最主要的輸出功能沒有了,變成了直接修改了外部變數,這就是它的副作用。而沒有副作用的寫法應該是:

const list = [...];
// 修改 list 中的 type 和 age
const newList = list.map(item => ({...item, type: 1, age:item.age + 1}));

保證函式沒有副作用,一來能保證資料的不可變性,二來能避免很多因為共享狀態帶來的問題。當你一個人維護程式碼時候可能還不明顯,但隨著專案的迭代,專案參與人數增加,大家對同一變數的依賴和引用越來越多,這種問題會越來越嚴重。最終可能連維護者自己都不清楚變數到底是在哪裡被改變而產生 Bug。

  • 純函式 (pure functions)

函數語言程式設計最關注的物件就是純函式,純函式的概念有兩點:

不依賴外部狀態(無狀態): 函式的的執行結果不依賴全域性變數,this 指標,IO 操作等。
沒有副作用(資料不變): 不修改全域性變數,不修改入參。

所以純函式才是真正意義上的 “函式”, 它也遵循引用透明性——相同的輸入,永遠會得到相同的輸出。

我們這麼強調使用純函式,純函式的意義是什麼?

便於測試和優化:這個意義在實際專案開發中意義非常大,由於純函式對於相同的輸入永遠會返回相同的結果,因此我們可以輕鬆斷言函式的執行結果,同時也可以保證函式的優化不會影響其他程式碼的執行。這十分符合測試驅動開發 TDD(Test-Driven Development ) 的思想,這樣產生的程式碼往往健壯性更強。

可快取性:因為相同的輸入總是可以返回相同的輸出,因此,我們可以提前快取函式的執行結果,有很多庫有所謂的 memoize 函式,下面以一個簡化版的 memoize 為例,這個函式就能快取函式的結果,對於像 fibonacci 這種計算,就可以起到很好的快取效果。

  function memoize(fn) {
    const cache = {};
    return function() {
      const key = JSON.stringify(arguments);
      var value = cache[key];
      if(!value) {
        value = [fn.apply(null, arguments)];  // 放在一個數組中,方便應對 undefined,null 等異常情況
        cache[key] = value; 
      }
      return value[0];
    }
  }

  const fibonacci = memoize(n => n < 2 ? n: fibonacci(n - 1) + fibonacci(n - 2));
  console.log(fibonacci(4))  // 執行後快取了 fibonacci(2), fibonacci(3),  fibonacci(4)
  console.log(fibonacci(10)) // fibonacci(2), fibonacci(3),  fibonacci(4) 的結果直接從快取中取出,同時快取其他的

閉包

定義:一個能夠讀取其他函式內部變數的函式,實質是變數的解析過程(由內而外)

閉包是ES中一個離不開的話題,而且也是是一個難懂又必須搞明白的概念!說起閉包,就不得不提與它密切相關的變數作用域和變數的生命週期。下面來看下:

變數作用域

變數作用域分為兩類:全域性作用域和區域性作用域。

  • 編寫在script標籤中的變數或者沒用var關鍵字宣告的變數,就代表全域性變數,在頁面的任意位置都可以訪問到
  • 在函式中宣告變數帶有var關鍵字的即是區域性變數,區域性變數只能在函式內才能訪問到
function fn() {
    var a = 1;     // a為區域性變數
    console.log(a);  // 1
}
fn();
console.log(a);     // a is not defined  外部訪問不到內部的變數

上面程式碼展示了在函式中宣告的區域性變數a在函式外部拿不到。可是我們就想要在函式外拿到它,怎麼辦?下面就要看發揮閉包的威力了。

函式可以創造函式作用域,在函式作用域中如果要查詢一個變數的時候,如果在該函式內沒有宣告這個變數,就會向該函式的外層繼續查詢,一直查到全域性變數為止。

所以變數的查詢是由內而外的,這也形成了所謂的作用域鏈。

var a = 7;
function outer() {
    var b = 8;
    function inner() {
        var c = 9;
        alert(b);
        alert(a);
    }
    inner();
    alert(c);   // c is not defined
}
outer();    // 呼叫函式

還是最開始的函式,利用作用域鏈,我們試著去拿到a,改造一下fn函式:

function fn() {
    var a = 1;     // a為區域性變數
    return function() {
        console.log(a);
    }
}
var fn2 = fn();
fn2();      // 1

理解了變數作用域,順著這條作用域鏈,再來回顧一下閉包的定義:閉包就是能夠讀取其他函式內部變數的函式,實質是變數的解析過程(由內而外)

變數生命週期

理解了變數作用域,再來看看變數的生命週期,直白一點就是它能在程式中存活多久。

  • 對於全域性變數而言,它的生命週期機就是永久的,除非我們手動銷燬它(這一點也是很有必要的,防止記憶體溢位)
  • 對於在函式中通過var宣告的變數而言,就沒那麼幸運了。當函式執行完畢後,它也就沒什麼利用價值了,隨之被瀏覽器的垃圾處理機制當垃圾處理掉了
    比如下面這段程式碼:
var forever = 'i am forever exist'  // 全域性變數,永生
function fn() {
    var a = 123;    // fn執行完畢後,變數a就將被銷燬了
    console.log(a);
}
fn();

函式執行完畢,內部的變數a就被無情的銷燬了。那麼我們有沒有辦法拯救這個變數呢?答案是肯定的,救星來了——閉包

閉包的建立

function outFn() {
    var i = 1;
    function inFn () {
        return ++i
    }
    return inFn;
}
var fn = outFn(); // 此處建立了一個閉包
fn();   // 2
fn();   // 3
fn();   // 4

上面的程式碼建立了一個閉包,有兩個特點:

  1. 函式inFn巢狀在函式outFn內部
  2. 函式outFn返回內部函式inFn

在執行完var fn = outFn();後,變數 fn 實際上是指向了函式 inFn,再執行 fn( ) 後就會返回 i 的值(第一次為1)。這段程式碼其實就建立了一個閉包,這是因為函式 outFn 外的變數 fn 引用了函式 outFn 內的函式inFn。也就是說,當函式 outFn 的內部函式 inFn 被函式 outFn 外的一個變數 fn 引用的時候,就建立了一個閉包(函式內部的變數 i 被儲存到記憶體中,不會被立即銷燬)。

參考連結:
閉包的建立
閉包和記憶體

高階函式

定義:高階函式就是接受函式作為引數或者返回函式作為輸出的函式。

下面分兩種情況講解,搞清這兩種應用場景,這將有助於理解並運用高階函式。

函式作為引數傳入

函式作為引數傳入最常見的就是回撥函式。例如:在 ajax 非同步請求的過程中,回撥函式使用的非常頻繁。因為非同步執行不能確定請求返回的時間,將callback回撥函式當成引數傳入,待請求完成後執行 callback 函式。

$.ajax({
  url: 'http://musicapi.leanapp.cn/search',  // 以網易雲音樂為例
  data: {
      keywords
  },
  success: function (res) {
      callback && callback(res.result.songs);
  }
})

函式作為返回值輸出

函式作為返回值輸出的應用場景那就太多了,這也體現了函數語言程式設計的思想。其實從閉包的例子中我們就已經看到了關於高階函式的相關內容了。

還記得在我們去判斷資料型別的時候,我們都是通過Object.prototype.toString來計算的,每個資料型別之間只是'[object XXX]'不一樣而已。

下面我們封裝一個高階函式,實現對不同型別變數的判斷:

function isType (type) {
    return function (obj) {
        return Object.prototype.toString.call(obj) === `[object ${type}]
    }
}

const isArray = isType('Array'); // 判斷陣列型別的函式
const isString = isType('String'); // 判斷字串型別的函式
console.log(isArray([1, 2]); // true
console.log(isString({});  // false

參考連結:
高階函式,你怎麼那麼漂亮呢!
簡明 JavaScript 函數語言程式設計——入門篇

總結

最後總結一下這次的重點:純函式、變數作用域、閉包、高階函式。

  1. 純函式的定義:給定的輸入返回相同的輸出的函式。
  2. 變數作用域是閉包的實質。根據變數作用域向上查詢的特性,閉包可以快取變數到記憶體中,函式執行完畢不會立即銷燬。
  3. 高階函式的核心是閉包,利用閉包快取一些未來會用到的變數,可以實現柯里化、偏應用...

下一節介紹柯里化、偏應用和組合