The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

前端工程研究:理解函式編程核心概念與如何進行 JavaScript 函式編程

函式編程 ( functional programming ) 在這幾年變得有點熱門,像是 React 這套 JavaScript 框架,如果想學好他,通常也都必須要先熟悉函式編程的開發方法才行。畢竟函式編程只是一種程式設計方法,只要程式語言本身可以符合函式編程的基本概念,就可以宣稱用函式編程來開發程式。今天這篇文章,就想以 JavaScript 程式語言為出發,帶大家理解 JavaScript 函式編程的魅力與重要的核心概念。

簡介函式編程

在電腦科學領域中,函式編程 ( functional programming ) 是一種程式設計方法,有別於我們較為熟悉的 物件導向編程 ( object-oriented programming ),這兩種編程之間存在有相當不同的設計概念,解決問題的思考方式也相當不同,也因此很多人會卡在「思考」這一關,當你用物件導向的方式來思考函式編程,這肯定是行不通的,大腦必須做出一點改變才行。

函式 ( functional ) 這個字,來自於「數學領域」的「數學函式」,在數學上的「函數運算」,通常會意味著會「避免改變狀態」與「避免可變的資料」。同時,這個「函數運算」還包含一個重要的「純函數」( pure functions) 特性,那就是函數在執行的時候,完全相同的參數傳入 (Input),一定會得到完全相同的結果傳出 (Output),函數在執行的過程中,是「沒有副作用」的,且函數編程更加強調「執行的結果」而非「執行的過程」,倡導利用幾個簡單的函數來計算結果,利用純函數沒有副作用的特性,將一個複雜的問題,不斷的透過純函式逐層推導出複雜的運算,而不是設計一個相對複雜的執行程序。這些特性算是在函式編程的世界裡最最最重要的核心精神,如果少了這些特性,就不能稱為函式編程 ( functional programming )。

其實有許多程式語言都能夠用函式編程的方法來做開發,像是 Perl, C++, Java 8, C# 3.0 這些「非函式程式語言」,都一樣可以用函式編程的概念來開發。我以我最熟悉的 C# 來說,雖然 C# 是個物件導向的程式語言,但從 C# 3.0 開始增加了許多語言特性,可以用來支持函式編程開發,目前即將到來的 C# 7.0 也還在不斷加入一些函式語言的特性進來。所以除了標準的「函式語言」外 ( Haskell, Miranda, Concurrent Clean, … ),通常一個現代的程式語言 ( Apple Swift, Scala, Erlang, JavaScript, C#, F#, Java 8, … ) 都會多少帶入一些函式編程的語言特性,可以讓你自由選擇用物件導向編程函式編程開發方法。

 

JavaScript 函式編程

要透過 JavaScript 程式語言來開發出函式編程的程式碼,意味著你所寫的 JavaScript 程式碼必須符合函式編程的一項或多項重要概念,其中最重要的應該就是「一等公民高階函式 ( First-class and higher-order functions )」這項了。

在 JavaScript 程式語言中,函式 ( functions ) 包含兩個非常重要的觀念:

  1. 函式為一級物件 ( first-class object )
  2. 函式提供了變數的作用域 ( scope )

在函式程式語言中,通常函式本身就要跟其他物件一樣都視為「一等公民」( First-class ),剛好 JavaScript 就具備這樣的特性。

例如 var a = 1; 這段 JavaScript 程式碼,你可以把 1 指派給 a 變數,那麼 function 也就應該可以指派給任意一個一變數,例如:

var a = function(a,b) { return a+b; }

那既然像上述範例程式碼中 1 這樣一般的物件,可以傳入到其他 function 中,也可以從其他 function 中回傳回來。

function add(a, b) { return a+b; }

add(1, 2); // 3

這也意味著,你所定義的 function 物件,不但可以當成一般物件傳入到其他 function 裡,也可以從其他 function 回傳回來。

var add = function (a, b) { return a+b; }
var calc = function (op, a, b) { return op(a, b) };

calc(add, 1, 2); // 3

這裡還有個 higher-order function (高階函式) 的特性要解釋,這個 higher-order function 不是很好翻譯,似乎在台灣大多都翻譯為「高階函式」,但中文翻譯看起來就好像這個函式要很高階一樣,其實是非常抽象且不容易理解的。

我從維基百科上翻譯一下關於 Higher-order function (高階函式) 的定義,要達成 Higher-order function 的必要條件,就是符合下列兩項任何一項以上條件,就可以稱為「高階函式」:

  • 可以將函式物件當成參數傳入另一個函式
  • 可以將函式物件當成另一個函式回傳值

以下就是一個 JavaScript 函式寫成「高階函式」的範例,這裡的 search 函式回傳的是一個函式物件,這就符合了「高階函式」的基本要求:

var search = function (pattern) {
  return function(str) {
    return str.search(pattern);
  };
}

var searchByWill = search(/Huang/);

searchByWill('Will Huang'); // 5

其實我們上上一個例子,也有符合「高階函式」的定義,因為 calc 函式傳入一個 add 函式當作參數,這也符合了「高階函式」的基本要求。

請注意:函式就算定義為「高階函式」,也不一定就能稱為「函式編程」,符合函式編程有一定的要件,你還必須確保該函式要能「避免改變狀態」、「避免可變的資料」以及擁有「純函式」等特性。

其實從 ECMAScript 5.1 開始,JavaScript 規格中加入了幾個陣列的 API,這幾個 API 函式算是符合函式編程高階函式,因此對於想要開始使用 JavaScript 函式編程的人來說,這幾個函式必須深入研究才行。以下就是針對這幾個高階函式的概略說明,詳細範例請參考 MDN 文件,我在文章中都有提供超連結方便查閱。

在開始說明這幾個高階函式前,我先新增一個陣列,當成之後範例程式碼的輸入資料:

var people = [
  {
    "name": {
      "first": "Will",
      "last": "Huang"
    },
    "company": "MINIASP"
  },
  {
    "name": {
      "first": "James",
      "last": "Huang"
    },
    "company": "Coolrare"
  },
  {
    "name": {
      "first": "Jeff",
      "last": "Wu"
    },
    "company": "MINIASP"
  }
]

Array.prototype.filter() - JavaScript | MDN

這個陣列的 filter() 函式就是一個高階函式,因為他在使用時必須在第一個參數傳入一個回呼函式 ( callback function ),主要用途在過濾陣列中的元素,並回傳一個新的陣列。傳入的回呼函式所傳入的第一個參數為陣列中的元素,如果陣列中有 3 個元素,該回呼函式就會依序執行 3 遍。

假設我們的需求是希望能透過程式找出 people 物件中 last name 為 'Huang' 的人,如果我們用傳統的程式風格來寫,程式碼可能會長這樣:

var i, person, filtered_people = [];

for(i=0; i<people.length; i++) {

  person = people[i];

  if(person.name.last === 'Huang') {

    filtered_people.push(person);

  }

}

console.log(filtered_people);

如果們改用函式編程的寫法,改用 Array.prototype.filter() 來過濾陣列,那麼程式碼會變成這樣:

var filtered_people = people.filter(function(person) {

  return person.name.last === 'Huang';

});

console.log(filtered_people);

此時你應該可以發現到,改用函數編程的程式碼精簡許多,但是程式碼行數多寡並不是重點,更重要的是這段程式是否日後容易維護?或更容易進行測試?

我們重新修改上述程式碼,將傳入的回呼函式獨立出來,如下:

var lastNameIsHuang = function(person) {

  return person.name.last === 'Huang';

};

var filtered_people = people.filter(lastNameIsHuang);

console.log(filtered_people);

你應該可以發現到,不但程式碼的可讀性更高,當我們想測試 lastNameIsHuang 函式的時候,由於函式編程的「純函數」特性,讓我們可以放心的將工作交給這個 callback function 來執行,單元測試在撰寫時也不用再擔心該函式是否有任何潛在的副作用發生,測試起來將更有自信,而這就是「函式編程」最重要的精隨所在!

 

Array.prototype.map() - JavaScript | MDN

這個陣列的 map() 函式也是一個高階函式,他與 filter() 不同的地方在於:

  • filter() 函式會過濾原本陣列中的資料,並回傳一個全新的陣列。
  • map() 函式則會轉換原本陣列中的每一個元素,並回傳一個全新的陣列。

這裡提到的 轉換 (transform) 跟 map (對應) 有啥關係呢?我們先來看程式碼,回頭再做名詞解釋。

假設我們的需求是希望能透過程式產生一個完全不同格式的陣列 (新陣列的元素數量相同,只是每個元素的物件格式不一樣而已),這時我們可以寫成以下程式碼:

var new_people = people.map(function(person) {

  return {

    name: person.name.first + ' ' + person.name.last,

    company: person.company

  };

});

console.log(new_people);


 

你可以發現 map() 函式會讀入每一個陣列元素,並且依序傳入 map() 的回呼函式中,每一次的回呼函式執行都只要回傳「新元素」即可,最後 map() 回傳的結果將會是一個全新的陣列,而且陣列中的每個元素也將會是全新的物件。

這邊的 map() 函式,我直接照著字面翻譯,就是一種「對應」功能,把一份完整的陣列「對應」到另一份全新的陣列,並且回傳這份全新、對應過的陣列。

 

Array.prototype.reduce() - JavaScript | MDN

這個陣列的 reduce() 函式也是一個高階函式,他也是傳入一個回呼函式並傳入兩個參數到回呼函式中,不會還要多傳入一個物件到回呼函式第一次執行的第一個參數中。

這個 reduce() 函式如果真的要照字面上翻譯,還真的會非常抽象,我們一樣先來看看範例程式碼再��吧。

假設我們的需求是希望能透過程式計算出每個元素的 company 屬性的總字元數,傳統的寫法一定是先宣告一個變數,然後跑個迴圈計算每個元素中的數值,不過函數編程的寫法就可以靠 reduce() 函式來幫我們完成這個連續計算作業。

var total_company_chars = people.reduce(function(sum, person) {

  return sum + person.company.length;

}, 0);

console.log(total_company_chars);

 

上述程式的執行過程如下:

  1. reduce() 函式會將 people 陣列中每個元素依序傳入回呼函式中執行
  2. 第一次執行回呼函數
    1. 第一個參數 sum 會傳入 reduce() 函式傳入的第 2 個參數 0
    2. 第二個參數 person 則會傳入陣列中的第 1 個元素
    3. 回呼函式的回傳值,預設會是第 2 次執行回呼函式的第一個參數
  3. 第二次執行回呼函數
    1. 第一個參數 sum 會傳入上一次執行回呼函數的回傳值
    2. 第二個參數 person 則會傳入陣列中的第 2 個元素
    3. 回呼函式的回傳值,預設會是第 3 次執行回呼函式的第一個參數
  4. 依此類推 … 直到傳入陣列中的最後一個元素

這邊的 reduce() 函式,我直接照著字面翻譯,可以說是「縮減」的意思,他跟 map() 函式有著一定程度的呼應關係:

  • map() 函式則會將原本陣列中的每一個元素「對應」成另一個全新的陣列。
  • reduce() 函式則會將陣列中的元素中每個元素「縮減」成一個結果,你也可以把 reduce() 想像成「彙整所有陣列元素,透過回呼函式的連續計算獲得一個縮減後的結果」。

剛剛講的這三個高階函式 filter()、map() 與 reduce() 是可以搭配使用的,如果正確使用這 3 個高階函數,並使用函式編程的方式來撰寫,我們的程式碼就會非常易讀、易懂、方便測試。

如果我修改一下需求:

  • 我想過濾出 last name 為 Huang 的陣列元素
  • 將篩選過後的陣列對應出全新的物件格式
  • 計算所有新陣列中每一個元素 name 屬性累計的字元數

這個需求的程式碼如下:

people
    .filter(function(person) {
      return person.name.last === 'Huang';
    })
    .map(function(person) {
      return {
        name: person.name.first + ' ' + person.name.last,
        company: person.company
      };
    })
    .reduce(function(sum, person) {
        return sum + person.company.length;
    }, 0);

 

你從上述程式可以看出,這段程式確實符合函式編程的幾個重要特性:

  • 避免改變狀態
    • 所有的函式都不包含任何物件狀態,也沒有變更任何狀態。
  • 避免可變的資料
    • 這段程式的過程中,所有物件操作都沒有影響到原本 people 陣列中的內容,且執行函式也沒有任何副作用存在。
  • 純函式
    • 這三段函式都非常簡單,無論傳入甚麼樣的物件,只要傳入完全相同的參數,回傳的物件也將會是固定的結果。
  • 延遲評估 (Lazy evaluation)
    • 其實函式語言中還有個「延遲評估」的觀念,這個特性在 JavaScript 之中也是渾然天成的,延遲評估的意思是,讓傳入的參數在需要的時候才執行,當我們傳入函式物件時,函式物件本來就還不會執行,而是要等到需要執行該函式時才會去執行,所以我們在寫 JavaScript 的時候,這一點可以不用特別在意它的存在。

JavaScript 的陣列並不只有這些高階函式,像是 forEach()some()every()find()findIndex() 也都是高階函式。

 

函式語言並非萬能,別忘了函式編程 ( functional programming ) 只是一種程式設計方法,他用不同的思考方式來解決問題,在某些情境下,使用函式編程確實能帶來極大效益,但不代表他很適合用來解決所有問題。有的時候使用函式編程反而會犧牲許多程式的執行效率,這是拿非函式變成語言來寫函式編程的常見問題,用 JavaScript 來寫函數編程也會有相同的問題存在,而且通常改用函數編程後,執行效能會比跑一般迴圈慢個 3 ~ 5 倍之多 (比較程序編程與函數編程的效能差異),當你處理資料過大時,就比較會有機會遇到效能問題。不過撰寫網頁應用程式時,似乎不太容易發現有這麼大的效能差異,因為我們本來就不會在網頁中處理大量的資料來源,而且現今的電腦與瀏覽器在執行 JavaScript 的時候,真的還蠻快的!

最後,推薦一個由 ReactiveX 設計的 Functional Programming 教學網頁 ( Functional Programming in Javascript ),這個頁面總共有 41 個 Functional Programming 練習題,你要一關一關過才行,建議不要跳關,做到最後,我保證你一定可以完全理解如何在 JavaScript 使用 Functional Programming 開發程式!

其實函式編程還有很多能講,光是函式語言的由來與歷史就很有意思,如果有興趣完整了解的人,可以多多參考本文最後的相關連結。 

相關連結