在关于 Transducer的文章中,我们看到了一种优化数组上长操作序列的方法,但解决方案并不那么简单。许多读者想知道我们是否能更简单地实现同样的结果,本文将解决这个问题:我们将不使用 Transducer(就像之前那样),但在一些函数式编程的帮助下,我们将得到同样好的解决方案。
让我们回忆一下最初的问题–这只是一个微不足道的例子,旨在强调我们遇到的问题。我们有一个数组,我们想:(1) 只取奇数值;(2) 复制它们;(3) 保持值小于 50;(4) 在它们后面加上 3。下面的代码实现了这一点:
问题出在哪里?每次执行操作时,都会创建一个新的中间数组,如果数据量很大,额外的时间可能会非常长。
在上一篇文章中,我们介绍了一种解决方案,即尝试逐个数组元素并按顺序应用所有变换,我们还为此开发了 Transducer。不过,我们可以用更简单的方法来解决!让我们来看看。
第一个(但不太好的)解决方案
我们的例子很简单明了,但有些读者提出了反对意见,他们的理由是:很明显,我们在原始数组中只取 25 个以下的奇数值,然后将它们复制并加上三个,这样一个简单的循环就能完成工作……而且我们甚至不需要使用原来的四个函数!
这样做是可行的,也是完全正确的,但很难推广。在我们这个简单的例子中,推导出哪些数字将被处理很容易,但在现实生活中,逻辑检查可能非常复杂,你不可能像我们在这里所做的那样,将它们简化为一个单一的测试。因此,虽然这表明优化是可行的,但这种方法本身并不能适用于所有情况;我们需要一种更通用的解决方案。
第二种(也是手工制作的)解决方案
与前一种情况一样,如果我们逐个元素依次应用映射和过滤器,效果会更好。
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 的解决方案,尽管在很多方面也使用了函数式编程,但可以说更容易理解。这充分说明了 “剥猫皮的方法不止一种” 这一观点的正确性,或者说,在我们的案例中,”优化阵列过程” 的方法不止一种!
原文地址:https://www.wbolt.com/maximize-javascript-performance-without-transducers.html