JavaScript 函數語言程式設計(一)

佯真愚發表於2018-08-12

零、前言

說到函數語言程式設計,想必各位或多或少都有所耳聞,然而對於函式式的內涵和本質可能又有些說不清楚。

所以本文希望針對工程師,從應用(而非學術)的角度將函數語言程式設計相關思想和實踐(以 JavaScript 為例)分享給大家。

文章內容其實主要來自於在下閱讀各類參考文獻後的再整理,所以有什麼錯誤也希望大家幫忙斧正~

slide 地址

一、什麼是函數語言程式設計?

Functional programming is a programming paradigm

1.treats computation as the evaluation of mathematical functions

2.avoids changing-state and mutable data

by wikipedia

從以上維基百科的定義來看有三個要點

  • Programming Paradigm:程式設計正規化
  • Mathematical Functions:數學函式
  • Changing-state And Mutable Data:改變狀態和可變資料

下面分別解析一下以上要點。

1.1.什麼是程式設計正規化?

programming paradigm

from Programming paradigms

程式設計正規化從概念上來講指的是程式設計的基本風格和典範模式。

換句話說其實就是程式設計師對於如何使用程式設計來解決問題的世界觀和方法論

如果把一門程式語言比作兵器,它的語法、工具和技巧等是招法,那麼它採用的程式設計正規化也就是是內功心法。

一種正規化可以在不同的語言中實現,一種語言也可以同時支援多種正規化。例如 JavaScript 就是一種多正規化的語言。

1.2.什麼是數學函式?

一般的,在一個變化過程中,假設有兩個變數 x、y,如果對於任意一個 x 都有唯一確定的一個y和它對應,那麼就稱 x 是自變數,y 是 x 的函式。x 的取值範圍叫做這個函式的定義域,相應 y 的取值範圍叫做函式的值域。

以上定義,在初中數學我們們都應該學過...

換句話說,函式只是兩種數值之間的關係:輸入和輸出。

儘管每個輸入都只會有一個輸出,但不同的輸入卻可以有相同的輸出。下圖展示了一個合法的從 x 到 y 的函式關係;

純函式

與之相反,下面這張圖表展示的就不是一種函式關係,因為輸入值 5 指向了多個輸出:

非純函式

1.2.1.什麼是純函式(Pure Functions)?

純函式是這樣一種函式,對於相同的輸入,永遠會得到相同的輸出,而且沒有任何可觀察的副作用。

根據定義可以看出純函式其實就是數學函式,即表示從輸入的引數到輸出結果的對映。

而沒有副作用的純函式顯然都是引用透明的。

引用透明性(Referential Transparency)指的是,如果一段程式碼在不改變整個程式行為的前提下,可以替換成它的執行結果。

const double = x => x * 2
const addFive = x => x + 5
const num = double(addFive(10))

num === double(10 + 5)
    === double(15)
    === 15 * 2
    === 30
複製程式碼

不過說了半天,副作用又是啥...?

1.2.2.什麼是副作用(Side Effects)?

副作用是在計算的過程中,系統狀態的一種變化,或者與外部世界進行的可觀察的互動。

副作用可能包含,但不限於一下行為:

  • 更改檔案系統
  • 往資料庫中插入記錄
  • 傳送一個 http 請求
  • 改變資料
  • 列印 log
  • 獲取使用者輸入
  • DOM 查詢
  • 訪問系統狀態
  • ...

只要是跟函式外部環境發生的互動就都是副作用——這一點可能會讓你懷疑無副作用程式設計的可行性。

函數語言程式設計的哲學就是假定副作用是造成不正當行為的主要原因。

當然這並不是說,要禁止使用一切副作用,而是說,要讓它們在可控的範圍內發生。

在後面講到函子(functor)和單子(monad)的時候我們會學習如何控制它們。

1.2.3.純函式的好處都有啥~~(誰說對了就給他)~~?

面嚮物件語言的問題是,它們永遠都要隨身攜帶那些隱式的環境。你只需要一個香蕉,但卻得到一個拿著香蕉的大猩猩...以及整個叢林

by Erlang 作者:Joe Armstrong

所以使用純函式將會有以下好處:

  • 可快取性(Cacheable)
  • 可移植性/自文件化(Portable / Self-Documenting)
  • 可測試性(Testable)
  • 合理性(Reasonable)
  • 並行程式碼(Parallel Code)

1.3.為什麼要避免改變狀態和可變資料?

Shared mutable state is the root of all evil

共享可變狀態是萬惡之源

by Pete Hunt

the-root-of-all-evil

const obj = { val: 1 }
someFn(obj)
console.log(obj) // ???
複製程式碼

shared-mutable-state

from Building Scalable, Highly Concurrent & Fault Tolerant Systems - Lessons Learned

1.4.原教旨函式式 VS 溫和派函式式?

說到函數語言程式設計語言,大家的第一反應可能是 Haskell、OCaml、Lisp、Erlang、Scala、F#...

因為它們可能有以下特性:

  • 函式是“一等公民”(first class)
  • 不可變資料
  • 使用遞迴而不是迴圈
  • 柯里化
  • 惰性求值
  • 代數資料型別
  • 模式匹配
  • ...

javascript_functional_lite

而說到 JavaScript,很多人可能第一反應認為這是一門物件導向的語言。

但是想想前面說的:函數語言程式設計只是一種程式設計正規化,而程式設計正規化就像“內功心法”,所以與以上這些語言特性不完全相關,反而與你自己的程式設計思維(即世界觀和方法論)更加相關。

在函式式方面,由於 JavaScript 支援高階函式、匿名函式、函式是一等公民、閉包、解構(模式匹配)等特性,所以它也能支援函數語言程式設計正規化。(雖然不是那麼的原教旨函式式,但還基本夠用~尤其是 ES6 新增的箭頭函式等特性~還有各種類庫 )

事實上 JavaScript 是一門基於原型(prototype-based)的多正規化語言。

1.5.作為函式式語言 JavaScript 還差什麼?

1.5.1.不可變資料結構

JavaScript 一共有 6 種原始型別(包括 ES6 新新增的 Symbol 型別),它們分別是 Boolean,Null,Undefined,Number,String 和 Symbol。 除了這些原始型別,其他的型別都是 Object,而 Object 都是可變的。

data-type-in-js

1.5.2.惰性求值

惰性(lazy)指求值的過程並不會立刻發生。

比如一些數學題,我們可能一開始並不需要把所有表示式都求值,這樣可以在計算的過程中將一些表示式消掉。

惰性求值是相對於**及早求值(eager evaluation)**的。

比如大部分語言中,引數中的表示式都會被先求值,這也稱為應用序語言。

比如看下面這樣一個 JavaScript 的函式:

wholeNameOf(getFirstName(), getLastName())
複製程式碼

getFirstNamegetLastName 會依次執行,返回值作為 wholeNameOf 函式的引數, wholeNameOf 函式最後才被呼叫。

另外,對於陣列操作時,大部分語言也同樣採用的是應用序。

[1, 2, 3, 4].map(x => x + 1)
複製程式碼

所以,這個表示式立刻會返回結果 [2, 3, 4, 5] 。

當然這並不是說 JavaScript 語言使用應用序有問題,但是沒有提供惰性序列的支援就是 JavaScript 的不對了。如果 map 一個大陣列後我們發現其實只需要前 10 個元素時,去計算所有元素就顯得是多餘的了。

1.5.3.函式組合

物件導向通常被比喻為名詞,而函數語言程式設計是動詞。物件導向抽象的是物件,對於物件的的描述自然是名詞。

物件導向把所有操作和資料都封裝在物件內,通過接受訊息做相應的操作。比如,物件 Kitty,它們可以接受“打招呼”的訊息,然後做相應的動作。

而函式式的抽象方式剛好相反,是把動作抽象出來,比如“打招呼”就是一個函式,而函式引數就是作為資料傳入的 Kitty(即 Kitty 進入函式“打招呼”,出來的應該是 Hello Kitty)。

物件導向可以通過繼承和組合在物件之間分享一些行為或者說屬性,函式式的思路就是通過組合已有的函式形成一個新的函式。

然而 JavaScript 語言雖然支援高階函式,但是並沒有一個原生的利於組合函式產生新函式的方式。而這些強大的函式組合方式卻往往被類似 Underscore,Lodash 等工具庫的光芒掩蓋掉(後面會說到這些庫的問題)。

1.5.4.尾遞迴優化

tail-calls

函數語言程式設計語言中因為不可變資料結構的原因,沒辦法實現迴圈。所以都是通過遞迴來實現迴圈。

然而遞迴使用不當很容易棧溢位(Stack Overflow),所以一般採用尾遞迴的方式來優化。

雖然 ES6 規範中規定了尾遞迴優化規範,然而提供實現的直譯器還非常的少,詳情可以查閱這個連結

5.代數型別系統

JavaScript 作為一種弱型別的語言,沒有靜態型別系統。不過使用一些 TypeScript 等預編譯的語言可以作為補充~

二、宣告式 VS 命令式

Declarative VS Imperative,這兩者的區別簡單來說其實就是 What VS How。

2.1.“意識形態”上的區別~

宣告式:

  • 程式抽象了控制流過程,程式碼描述的是 —— 資料流:即做什麼。
  • 更多依賴表示式。

表示式是指一小段程式碼,它用來計算某個值。表示式通常是某些函式呼叫的複合、一些值和操作符,用來計算出結果值。

命令式:

  • 程式碼描述用來達成期望結果的特定步驟 —— 控制流:即如何做。
  • 頻繁使用語句。

語句是指一小段程式碼,它用來完成某個行為。通用的語句例子包括 for、if、switch、throw,等等……

2.2.舉一些栗子?...

例1:希望得到一個陣列每個資料平方後的和

// 命令式
function mysteryFn (nums) {
  let squares = []
  let sum = 0                           // 1. 建立中間變數

  for (let i = 0; i < nums.length; i++) {
    squares.push(nums[i] * nums[i])     // 2. 迴圈計算平方
  }

  for (let i = 0; i < squares.length; i++) {
    sum += squares[i]                   // 3. 迴圈累加
  }

  return sum
}

// 以上程式碼都是 how 而不是 what...

// 函式式
const mysteryFn = (nums) => nums
  .map(x => x * x)                      // a. 平方
  .reduce((acc, cur) => acc + cur, 0)   // b. 累加
複製程式碼

例2:希望得到一個陣列所有偶數值的一半的平均值

// 命令式
function mysteryFn(nums) {
  let sum = 0
  let tally = 0                         // 1. 建立中間變數

  for (let i = 0; i < nums.length; i++) {
    if (nums[i] % 2 === 0) {            // 2. 迴圈,值為偶數時累加該值的一半並記錄數量
      sum += nums[i] / 2
      tally++
    }
  }

  return tally === 0 ? 0 : sum / tally  // 3. 返回平均值
}

// 函式式
const mysteryFn = (nums) => nums
  .filter(x => x % 2 === 0)             // a. 過濾非偶數
  .map(x => x / 2)                      // b. 折半
  .reduce((acc, cur, idx, { length }) => (
    idx < length - 1
      ? acc + cur                       // c. 累加
      : (acc + cur) / length            // d. 計算平均值
  ), 0)
複製程式碼

參考資料

相關文章

以上 to be continued...

相關文章