最近在 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 是更好的替代。