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

感谢    赞同    分享    收藏    关注    反对    举报    ...