无需Transducer也能最大限度地提高JavaScript性能

无需Transducer也能最大限度地提高JavaScript性能

关于 Transducer的文章中,我们看到了一种优化数组上长操作序列的方法,但解决方案并不那么简单。许多读者想知道我们是否能更简单地实现同样的结果,本文将解决这个问题:我们将不使用 Transducer(就像之前那样),但在一些函数式编程的帮助下,我们将得到同样好的解决方案。

让我们回忆一下最初的问题–这只是一个微不足道的例子,旨在强调我们遇到的问题。我们有一个数组,我们想:(1) 只取奇数值;(2) 复制它们;(3) 保持值小于 50;(4) 在它们后面加上 3。下面的代码实现了这一点:

const testOdd = (x) => x % 2 === 1;
const duplicate = (x) => x + x;
const testUnderFifty = (x) => x < 50;
const addThree = (x) => x + 3;
const myArray = [22, 9, 60, 24, 11, 63];
const newArray = myArray
.filter(testOdd)
.map(duplicate)
.filter(testUnderFifty)
.map(addThree``
console.log(newArray); // [21,25]

问题出在哪里?每次执行操作时,都会创建一个新的中间数组,如果数据量很大,额外的时间可能会非常长。

无Transducer图像

在上一篇文章中,我们介绍了一种解决方案,即尝试逐个数组元素并按顺序应用所有变换,我们还为此开发了 Transducer。不过,我们可以用更简单的方法来解决!让我们来看看。

第一个(但不太好的)解决方案

我们的例子很简单明了,但有些读者提出了反对意见,他们的理由是:很明显,我们在原始数组中只取 25 个以下的奇数值,然后将它们复制并加上三个,这样一个简单的循环就能完成工作……而且我们甚至不需要使用原来的四个函数!

const simplifiedPass = (someArray) => {
const result = [];
someArray.forEach((value) => {
if (value % 2 && value < 25) {
result.push(value * 2 + 3);
}
});
return result;
};
const newArray2 = simplifiedPass(myArray);
console.log(newArray2);

这样做是可行的,也是完全正确的,但很难推广。在我们这个简单的例子中,推导出哪些数字将被处理很容易,但在现实生活中,逻辑检查可能非常复杂,你不可能像我们在这里所做的那样,将它们简化为一个单一的测试。因此,虽然这表明优化是可行的,但这种方法本身并不能适用于所有情况;我们需要一种更通用的解决方案。

第二种(也是手工制作的)解决方案

与前一种情况一样,如果我们逐个元素依次应用映射和过滤器,效果会更好。

const singlePassFourOps = (someArray) => {
const result = [];
someArray.forEach((value) => {
let ok = false; // ①
if (testOdd(value)) {
value = duplicate(value);
if (testUnderFifty(value)) {
value = addThree(value);
ok = true;
}
}
if (ok) { // ②
result.push(value);
}
});
return result;
};
const newArray3 = singlePassFourOps(myArray);
console.log(newArray3); // [ 21,25 ]

我们使用 ok 变量 ① 来了解原始数组中的 value 是否通过了所有测试 ②。每个 filter() 都会转化为 if,每个 map() 都会更新 value。如果操作结束,我们会将 ok 设为 true,这意味着我们会将最终值推入 result 数组。这样就成功了!不过,我们不得不从头开始编写一个函数,也许我们可以找到更通用的解决方案。

第三种(更通用的)解决方案

前面的代码要求为每一组转换编写一个函数。但是,如果将所有映射和过滤函数传递到一个数组中呢?这意味着我们可以编写一个通用函数,处理过滤器和映射的任何组合。

但有一个问题:我们怎么知道数组中的函数是指过滤器还是映射呢?让我们创建一个包含成对值的数组:首先是 “M “或 “F”,表示 “map” 或 “filter”,然后是函数本身。通过测试这对数值中的第一个值,我们可以知道如何处理第二个值,即函数。

const singlePassManyOps = (someArray, fnList) => {
const result = [];
someArray.forEach((value) => {  // ①
if (
fnList.every(([type, fn]) => {  // ②
if (type === "M") { // ③
value = fn(value);
return true;
} else {
return fn(value); // ④
}
})
) {
result.push(value);
}
});
return result;
};
const newArray4 = singlePassManyOps(myArray, [
["F", testOdd],
["M", duplicate],
["F", testUnderFifty],
["M", addThree],
]);
console.log(newArray4);

这样做的原理是:我们逐个元素地查看数组①。对于数组 ② 中的每个函数,如果它是 map ③,我们就更新 value,否则就测试是否继续 ④。为什么我们使用 every() 而不是 foreach() ?关键在于,一旦某个过滤器失效,我们就想停止遍历函数数组,但 foreach() 没有停止循环的好方法。另一方面,一旦遇到错误结果, every() 就会停止循环–如果过滤函数返回 false,就会发生这种情况。(顺便说一下,这也是我们在映射后返回 true 的原因,这样 every() 就不会停止了)。现在情况好多了!还能再简单点吗?

第四种(更简单的)解决方案

我们可以通过编写一对函数来帮助我们生成所需的数组,从而简化工作;给定一个函数,它们就会生成正确的一对值。

const applyMap = (fn) => ["M", fn];
const applyFilter = (fn) => ["F", fn];

有了这些函数,我们就可以将上面的代码改为下面的代码。

const newArray5 = singlePassManyOps(myArray, [
applyFilter(testOdd),
applyMap(duplicate),
applyFilter(testUnderFifty),
applyMap(addThree),
]);
console.log(newArray5);

这样更好看,也更具有声明性:你可以清楚地看到,我们首先应用了一个过滤器( testOdd ),然后应用了一个映射( duplicate ),等等,顺序与原始代码相同。此外,性能也达到了最佳;我们没有任何中间数组,也没有执行任何不必要的过滤器或映射。我们就大功告成了!不过…

第五种(也是最终的)解决方案

在上一篇文章中,我们提到我们并不总是希望通过生成一个数组来结束;我们可能希望应用最后的 reduce() 操作。我们可以让 result 的初始值和更新它的函数成为可选项,并使用预定义的默认值来解决这个问题。

const singlePassMoreGeneral = (
someArray,
fnList,
initialAccum = [],
reduceAccumFn = (accum, value) => {
accum.push(value);
return accum;
}
) => {
let result = initialAccum;      // ①
someArray.forEach((value) => {
if (
fnList.every(([type, fn]) => {
if (type === "M") {
value = fn(value);
return true;
} else {
return fn(value);
}
})
) {
result = reduceAccumFn(result, value); // ②
}
});
return result;
};
const newArray6 = singlePassMoreGeneral(myArray, [
applyFilter(testOdd),
applyMap(duplicate),
applyFilter(testUnderFifty),
applyMap(addThree),
]);
console.log(newArray6);

除了增加 initialAccum(用于还原操作的累加器初始值)和 reduceAccumFn (还原函数本身)之外,其他变化是 result 的初始值 ① 和 result 的更新方式 ②。我们可以通过求最终值的总和来测试这一点。

const newValue = singlePassMoreGeneral(
myArray,
[
applyFilter(testOdd),
applyMap(duplicate),
applyFilter(testUnderFifty),
applyMap(addThree),
],
0,
(x, y) => x + y
);
console.log(newValue);

这样就成功了!现在,我们可以以最佳方式应用任意序列的映射和过滤操作,如果需要的话,还可以选择以还原步骤结束。

小结

文章开始时,我们试图找到一种更简单、但同样强大的方法来优化大型数组上的操作序列。我们从一个非常简单但不够通用的解决方案,最终找到了一个完全等同于 transducer 的解决方案,尽管在很多方面也使用了函数式编程,但可以说更容易理解。这充分说明了 “剥猫皮的方法不止一种” 这一观点的正确性,或者说,在我们的案例中,”优化阵列过程” 的方法不止一种!

评论留言