再談 JavaScript 函數語言程式設計的適用性

serialcoder發表於2019-02-09

最近在 Udemy 上學 Stephen Grider 的課程 Machine Learning With JavaScript。由於是個人業餘練習,課程中的程式碼我都用純函式式編寫。其中有一部分要解決這個問題:給定一個矩陣資料,例如

const data = [
  [12, 2, 5, 4],
  [13, 6, 3, 5],
  [17, 2, 5, 4],
  [14, 9, 3, 4],
  [15, 9, 3, 4]
];
複製程式碼

要求把矩陣的每列進行資料 normalization,就是說基於每列資料的最大數和最小數,將該列資料轉換成從 0 到 1 的小數。如 [1, 2, 3] 轉換成 [0, 0.5, 1]。另外要求操作列數可定製。課程給的答案如下:

function normalizeMatrix(range, data) {
  const copy = _.cloneDeep(data);
  // 只在給定的列數範圍內操作
  for (let i = 0; i < range; i++) {
    const col = copy.map(row => row[i]);
    const max = _.max(col);
    const min = _.min(col);
    for (let j = 0; j < copy.length; j++) {
      copy[j][i] = (copy[j][i] - min) / (max - min);
    }
  }
  return copy;
}
複製程式碼

為了不改變原資料,上面的函式在進行操作前,用 lodash 對資料進行了深拷貝。

我使用 Ramda 寫出的結果如下:

// Ramda 沒有 min 和 max 輔助函式,我用自己寫的
const min = list => Math.min(...list);

const max = list => Math.max(...list);

const applyMinMax = R.curry((min, max, list) =>
  list.map(num => (num - min) / (max - min))
);

const normalizeRow = R.converge(applyMinMax, [min, max, R.identity]);

const applyCalc = limit => list =>
  list.map((row, idx) => (idx >= limit ? row : normalizeRow(row)));

const normalizeMatrix = range =>
  R.compose(
    R.transpose,
    applyCalc(range),
    R.transpose
  )
複製程式碼

我寫的這個版本,先用 transpose 函式把原矩陣進行行列置換,資料操作完成後,再置換回原形狀。

看上去兩個版本都很彆扭。第一個把資料進行了深拷貝,第二個把資料行列置換了兩次。那效能比較如何?

我的電腦測試結果如下:

const getSample = length =>
  Array.from({ length }, _ =>
    Array.from({ length }, _ => Math.floor(Math.random() * 100))
  );

const sampleData = getSample(1000)

// 第一個版本
// => ​​​​​imperative: 255.112ms​​​​​
console.time('imperative')
normalizeMatrix1(1000, sampleData)
console.timeEnd('imperative')

// 第二個版本
// => ramda: 177.802ms​​​​​
console.time('ramda')
normalizeMatrix2(1000)(sampleData)
console.timeEnd('ramda')
複製程式碼

Ramda 版本效能更優。

基於這個例子我有下面這些思考:

一,指令式程式設計在某些上下文有其適用性。甚至大多數時候,主流的實踐都偏好指令式程式碼。寫指令式程式碼目的有兩個:一是考慮效能。指令式程式碼對過程控制比較細粒度,很容易優化效能。二是大多數語言對於 lambda 表示式的支援,不管是語言層面的,還是生態層面的,都不是很好,所以只能用指令式寫。但上面的例子說明了,某些情況下,按照過程式的定勢思維寫出的程式碼,不一定能達到目的。

二,即使是高階語言的指令式程式碼,其實在函數語言程式設計上下文裡面也相當於彙編指令。比如,上面用到的 transpose 函式,其實是用兩層巢狀 while 迴圈實現的,實現細節裡面也有用到臨時變數等指令式元素。而這些實施細節是隱藏不見的,對於函式使用者來說,把實施細節當做彙編指令是沒多大問題的。

上面第二點,可以參考 Haskell 繼續說明下。

經典的快排演算法,用 JS,即使用遞迴來寫,也要很多步驟:

const quickSort = list => {
  if (list.length === 0) return list;
  const [pivot, ...rest] = list;
  const smaller = [];
  const bigger = [];
  rest.forEach(x => (x < pivot ? smaller.push(x) : bigger.push(x)));

  return [...quickSort(smaller), pivot, ...quickSort(bigger)];
};
複製程式碼

Haskell 版本:

quicksort     [] = []
quicksort (x:xs) = quicksort smaller ++ [x] ++ quicksort larger
                    where 
                        smaller = [a | a <- xs, a <= x]
                        larger  = [b | b <- xs, b > x]
複製程式碼

由於 Haskell 語言層面支援惰性求值,遞迴,和 list comprehension,所以它天然支援高表達性語法,至於底層實現和優化則交給編譯器去處理,編寫者不用關心。而像 JavaScript,由於語言層面沒有 Haskell 的這些特性,所以需要某些庫,用指令式的方式實現某些 lambda 功能。用庫去解決本該由編譯器去解決的問題肯定不是最優的,這是 JavaScript 在函數語言程式設計實踐中的侷限。

總結如下:

  1. 一些 JS 函式式庫,例如 Ramda, Sanctuary 和 crocks,可以幫助開發者使用 JS 進行函數語言程式設計。crocks 的作者 evilsoft 在 egghead 上有一門課,講用 State ADT 寫 React 和 Redux 應用。課程中寫的應用邏輯稍複雜,但 evilsoft 做到了純 lambda 程式設計(全部用 expression,沒有 statement)。當然這種實踐只是一種 alternative,主要是用來學習思想。我覺得那種程式碼像清風一樣。

  2. 用 JS 進行函數語言程式設計也存在一些侷限。維護門檻高是一方面。技術層面,用開源庫去 polyfill 語言特性不是很可靠。Elm 和 PureScript 是更好的替代。

相關文章