再谈 JavaScript 函数式编程的适用性

最近在 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 在函数式编程实践中的局限。

总结以下:

  1. 一些 JS 函数式库,例如 Ramda, Sanctuary 和 crocks,能够帮助开发者使用 JS 进行函数式编程。crocks 的做者 evilsoft 在 egghead 上有一门课,讲用 State ADT 写 React 和 Redux 应用。课程中写的应用逻辑稍复杂,但 evilsoft 作到了纯 lambda 编程(所有用 expression,没有 statement)。固然这种实践只是一种 alternative,主要是用来学习思想。我以为那种代码像清风同样。

  2. 用 JS 进行函数式编程也存在一些局限。维护门槛高是一方面。技术层面,用开源库去 polyfill 语言特性不是很可靠。Elm 和 PureScript 是更好的替代。

相关文章
相关标签/搜索