最近在 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]
。另外要求操做列數可定製。課程給的答案以下:express
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 寫出的結果以下:app
// 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 函數把原矩陣進行行列置換,數據操做完成後,再置換回原形狀。dom
看上去兩個版本都很彆扭。第一個把數據進行了深拷貝,第二個把數據行列置換了兩次。那性能比較如何?ide
個人電腦測試結果以下:函數式編程
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 在函數式編程實踐中的侷限。
總結以下:
一些 JS 函數式庫,例如 Ramda, Sanctuary 和 crocks,能夠幫助開發者使用 JS 進行函數式編程。crocks 的做者 evilsoft 在 egghead 上有一門課,講用 State ADT 寫 React 和 Redux 應用。課程中寫的應用邏輯稍複雜,但 evilsoft 作到了純 lambda 編程(所有用 expression,沒有 statement)。固然這種實踐只是一種 alternative,主要是用來學習思想。我以爲那種代碼像清風同樣。
用 JS 進行函數式編程也存在一些侷限。維護門檻高是一方面。技術層面,用開源庫去 polyfill 語言特性不是很可靠。Elm 和 PureScript 是更好的替代。