最近在 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 在函数式编程实践中的局限。
总结如下:
-
一些 JS 函数式库,例如 Ramda, Sanctuary 和 crocks,可以帮助开发者使用 JS 进行函数式编程。crocks 的作者 evilsoft 在 egghead 上有一门课,讲用 State ADT 写 React 和 Redux 应用。课程中写的应用逻辑稍复杂,但 evilsoft 做到了纯 lambda 编程(全部用 expression,没有 statement)。当然这种实践只是一种 alternative,主要是用来学习思想。我觉得那种代码像清风一样。
-
用 JS 进行函数式编程也存在一些局限。维护门槛高是一方面。技术层面,用开源库去 polyfill 语言特性不是很可靠。Elm 和 PureScript 是更好的替代。