JavaScript 中的函數語言程式設計

jacksonX發表於2017-05-03

函數語言程式設計(functional programming)或稱函式程式設計,又稱泛函程式設計,是一種程式設計範型,比起指令式程式設計,函數語言程式設計更加強調程式執行的結果而非執行的過程,倡導利用若干簡單的執行單元讓計算結果不斷漸進,逐層推導複雜的運算,而不是設計一個複雜的執行過程。

函數語言程式設計,近年來一直被炒得火熱,國內外的開發者好像都在議論和提倡這種程式設計正規化。在眾多的函式式語言中,Javascript 無疑是最亮眼的一個,越來越多的人開始學習和擁抱它,並使用它運用函數語言程式設計來開發實際的大型應用,開源社群也源源不斷的誕生函式式風格的框架和類庫(Angular / React / Redux)。

作為 web 平臺唯一的標準通用語言,Javascript 在軟體歷史上掀起了最大的語言熱潮,擁有當下最大的開源包管理工具(npm)的 Javascript 也從 Lisp 手中接過了維持數十年的 “最流行的函數語言程式設計語言” 的名號。在 Javascript 的世界中是天然支援函數語言程式設計的,函數語言程式設計的基本特徵有:

  • 一等函式
  • 閉包
  • 高階函式
  • 純度

本文會以 Javascript 為例子,和大家一起來了解和學習函數語言程式設計。


一等函式(First Class Functions)

一等函式這個術語最早在20世紀60年代,由英國電腦科學家 Christopher Stracheyfunctions as first-class citizens 一文中提出的。意思是指,函式和其他一等公民(Number / String...)一樣,擁有和它們一樣的能力和作用:

  • 函式儲存為變數

    const foo = () => {...}複製程式碼
  • 函式可以儲存為資料的一個元素

    const arr = [1, 2, () => {...}]複製程式碼
  • 函式可以作為物件的屬性值

    const obj = {name: 'xx', say: () => {}}複製程式碼
  • 函式可以在使用時直接建立出來

    1 + (() => { return 2; })()複製程式碼
  • 函式可以作為變數傳遞給另一個函式

    bar (name, fun) { fun(name) }
    bar('xx', (name) => { console.log(name) })複製程式碼
  • 函式可以被另一個函式返回

    foo() {
      return () => {...}
    }複製程式碼

在函數語言程式設計中,函式是作為基本單元,並且在函式之上建立程式碼和資料的封裝,以提高應用的重用和靈活性。支援一等函式的作用是顯而易見的,我們可以使用函式去完成大部分的功能。

閉包(Closure)

歷經了 30年,閉包終於成為了程式語言的主要特點。但是根據一項調查顯示,有關 Javascript 閉包的問題佔了 23% 左右,對於相當數量的開發者來說閉包仍然模糊而又神祕。對於閉包解釋我還是更傾向於 Kyle Simpson 的系列書 You Don’t Know JavaScript 中的解釋:

函式在被定義時是可以訪問當前的詞法作用域,當函式離開作用域之外被執行時,就形成了閉包。

簡而言之,閉包就是一個函式,捕獲了作用域內的外部繫結。來看個例子:

function student (people) {
  return (name) => { return people[name] }
}
var someone = student({xx: {age: 20}, jackson: {age: 21}})
someone('xx') // {age: 20}複製程式碼

在執行完 student 函式後,裡面的匿名函式形成了一個閉包,閉包是可以訪問到 people 物件。閉包為 Javascript 提供了私有訪問,這讓給開發者建立資料抽象提供了極大地便利,也可以更好地書寫函式式程式碼,建立更加強大的程式碼。

來思考一個場景,手頭上擁有一個書本的陣列,陣列裡麵包含了書本的資訊,現在需要做的是找出把書名填充到一個陣列中並且返回,我們一般都會這樣寫:

const books = [{title: '人類簡史', author: 'zz'}, {title: '禪與摩托車維修藝術', author: 'tt'}]

books.map((item) => { return item.title })複製程式碼

我們使用了 Array.prototype.map 方法,傳入了一個匿名函式,函式中 return 了書名 title。假如需要利用閉包來進一步抽象的話,要怎麼寫呢?

function plucker (key) {
  return  (obj) => {
    return (obj && obj[key])
  }
}

books.map(plucker('title'))複製程式碼

我們定義了一個 plucker 函式,它接收一個 key 引數並返回一個匿名函式,匿名函式就是一個閉包並補捕獲了 key 引數。在利用了閉包的情況下,我們可以傳入任意想要的書本資訊(比如:plucker('author')),這樣就提高了程式碼的重用性和靈活性。當我們對於閉包認識足夠充分時併合理運用到實際開發中去,將會切身體會到閉包的威力和它給我們帶來的便利。

高階函式(Higher Order Functions)

在數學和電腦科學中,高階函式式至少滿足下列一個條件的函式:

  • 接受一個或多個函式作為輸入
  • 輸出一個函式

在上述的 plucker 函式就是一個例子,還有我們熟知的 Array.prototype 相關的方法,比如 .map、.sort 等等都是高階函式,因為它們滿足接受一個函式作為引數的條件。
那麼先來看一個一階函式的例子,定義一個函式,它會將陣列中4個字母的單詞給過濾掉:

const words = ['foo', 'bar', 'test', 'some']; 
const filter = words => {
  let arr = [];
  for(let i = 0, { length } = words; i < length; i++) {
    const word = words[i];
    if(word.length !== 4) {
      arr.push(word);
    }
  }
  return arr;
}

filter(words); // ['foo', 'bar']複製程式碼

假如現在又需要過濾陣列中,以 ‘b’ 字母開頭的單詞?那麼再定義一個函式:

const startWith = words => {
  let arr = [];
  for(let i = 0, { length } = words; i < length; i++) {
    const word = words[i];
    if(word.indexOf('b') !== 0) {
      arr.push(word);
    }
  }
  return arr;
}
filter(words); // ['foo', 'test', 'some']複製程式碼

根據上面兩個函式的對比來看,其實主要程式碼的邏輯都是相似的,先遍歷陣列再進行條件判斷,最後 push 到陣列中。其實,遍歷和過濾都可以抽象出來,可以方便其他的類似函式去呼叫,畢竟在陣列中根據條件過濾是很常見的需求。

const reduce = (reducer, init, arr) => {
  let acc = init;
  for(let i = 0,{ length } = arr; i < length; i++) {
    acc = reducer(acc, arr[i]);
  }
  return acc;
}
reduce((acc, curr) => acc + curr, 0, [1, 2, 3]);    // 6複製程式碼

如果使用過 Underscore 庫的話,就會發現 reduce 和 _.reduce 作用是一樣的,實現的是累計的功能。reduce 接受了 3 個引數:ruducer 函式、累計的初始值和一個陣列,遍歷時將每個陣列元素作為 reducer 的引數傳入,返回值又賦值給累計變數 init,遍歷完成時也就完成了累計的功能。

現在如果將 rudece 應用到第一個需求上(過濾四個字母的單詞):

const func = (fn ,arr) => {
  return reduce((acc, curr) => fn(curr) ? acc.concat([curr]) : acc, [], arr)
}
console.log(func(word => word.length !== 4, words)); // ["foo", "bar"]複製程式碼

可以發現,將公共程式碼抽象出來之後,filter 的函式實現非常簡潔,只需傳入不同的條件函式,就能為我們去處理符合各種條件的資料。高階函式可以用來實現函式的多型性,並且相對於一階函式,高階函式的複用性和靈活性更好。

純度(Purity)

函數語言程式設計不僅僅只關心函式,也是思考如何儘量地降低軟體複雜性的一種方式。在一些函數語言程式設計語言中,純度是被強制執行的,不允許使用有副作用的表示式。但是在 Javascript 中,純度必須通過管理區實現,並且非常容易在偶然間建立和使用非純函式。

一個純函式需要滿足以下三個條件:

  • 函式結果只能通過引數來計算得出
  • 不能依賴於能被外部操作改變的資料
  • 不能改變外部狀態

根據這上述條件來看,在 Javascript 的世界中去維持絕對純淨是不可能的,因為缺少了大多數函式式語言中使用的高效、不變的資料結構。我們知道在 Javascript 擁有能力去freeze()物件,但是隻能對接物件的頂級屬性,這就意味著一個巢狀物件下的屬性是仍然能夠被更改的。

var obj = Object.freeze({
    foo: 'hello',
    bar: {
        text: 'world'
    }
})

obj.foo = 'goodbye';
console.log(obj.foo); // hello

obj.bar.text = 'goobye';
console.log(obj.bar.text); // goodbye複製程式碼

在 ES6 中新增的 const 關鍵字,使用 const 可以定義一個不能夠被重新賦值為不同的值,但是一個 const 物件的屬性還是可變的。

const obj = 'hello';
obj = 'goodbye';    // Uncaught TypeError: Assignment to constant variable.

const obj = {
    foo: 'hello',
    bar: 'world'
}

obj.foo = 'goodbye';
console.log(obj);     // {foo: 'goodbye', bar: 'world'}複製程式碼

在 Javascrpt 中實現綜合不變性還有很長的路要走。換句話來說,雖然不能夠保證絕對的純淨,但是我們可以將純淨的部分抽離出來,將變化的影響降到最低,使得程式碼變得更加通用和容易測試。

總結:

  • 函數語言程式設計是支援一等函式的,函式具有其他資料型別相同的功能
  • 函數語言程式設計中使用閉包來進行資料的封裝
  • 使用高階函式來建立程式碼的抽象,使程式碼更加靈活通用
  • 儘量抽離純函式來保持程式碼的可測性和通用性

ps: 如果文中有出現錯誤的地方,歡迎大家指正,我會盡快修正,非常感謝 :)

參考文獻:

相關文章