像map()、filter()和reduce()这样的声明性方法具有清晰性的优势,但在某些情况下可能会带来性能成本。本文将向您展示如何使用转换器来完全优化操作序列,而不会损失清晰度。
以声明式方式工作时,鼓励使用 map()
、 filter()
和 reduce()
方法。然而,当处理大型数组并应用一系列这样的操作时,会创建、处理和最终丢弃许多中间数组,这些都会导致延迟。当然,对于小型数组,这对性能影响不大,但如果你需要处理更大量的数据(比如数据流或大型数据库查询的结果),你就需要优化这个过程。幸运的是,有一种简洁的方法可以实现这一点:using transducers
。
对于喜欢较真的人来说,
transduce
是一个拉丁词,意思是转化、改变、转换或运输。这个术语在许多领域中常被使用,包括生物学、电子学、机器学习等等。
Transducers 是一种函数式编程的概念,用于处理集合(如数组)的转换和组合。它们提供了一种通用的方式来定义和组合转换操作,以便在不同的上下文中重用代码。
开始
让我们首先准备一些函数和数据来进行实验。
js
const testOdd = (x: number): boolean => x % 2 === 1;
const duplicate = (x: number): number => x + x;
const testUnderFifty = (x: number): boolean => x < 50;
const addThree = (x: number): number => x + 3;
我们将按顺序将这些功能应用于一个数组:
- 过滤掉偶数值,只保留奇数值;
- 将结果数字加倍;
- 只保留小于50的数字;
- 并最后将结果加上三。
js
const myArray = [22, 9, 60, 24, 11, 63];
const a0 = myArray
.filter(testOdd)
.map(duplicate)
.filter(testUnderFifty)
.map(addThree);
console.log(a0);
// Output: [ 21, 25 ]
程序的执行方式是这样的:
我们从原始数组开始,然后:
- 使用
testOdd()
对该数组进行过滤,产生了一个新的数组 - 将
duplicate()
映射应用于第二个数组,创建了第三个数组 - 使用
testUnderFifty()
对第三个数组进行过滤,得到了第四个数组 - 将
addThree()
映射应用于它,生成了最终结果,即第五个数组。
在这么小的数组中,你甚至不会注意到性能问题,但是在一个更大的数组中,所有这些数组的创建可能需要很多时间。我们必须考虑一种更好的处理方式。
更好的方法
我们如何能够优化这个步骤序列呢?本质上,我们是逐步应用所有的变换;在伪代码中,它可能类似于以下算法:
js
for each transformation to be applied:
for each element in the input list:
apply the transformation to the element
更好的方法是逐个取出元素,并按顺序对它们应用所有的转换,这样可以直接得到最终结果,无需中间数组。这种方法的伪代码如下:我们交换了两个循环。
js
for each element in the input list:
for each transformation to be applied:
apply the transformation to the element
解决方案有了,但是我们如何实施呢?让我们一起来解决这个问题。
实施新方法
问题出现了:我们如何在一步中完成所有操作?一个重要的概念是,你可以通过巧妙地使用 reduce()
来模拟 map()
和 filter()
。关键在于我们 reduce()
调用中使用什么函数;一切都取决于这个。我们将按照下面的图片所示改变执行顺序,使其在更短的时间内仍然得到相同的结果。
我们将使用 reduce()
来实现我们优化的单步骤过程。我们将把所有的转换函数组合在一起;每个函数将负责调用下一个函数。让我们看一下代码是如何实现的------看起来并不明显!稍后解释它是如何运行的。
ts
const mapTR =
<V, W>(fn: (x: V) => W) =>
<A>(reducer: (am: A, wm: W) => A) =>
(accum: A, value: V): A =>
reducer(accum, fn(value));
const filterTR =
<V>(fn: (x: V) => boolean) =>
<A>(reducer: (af: A, wf: V) => A) =>
(accum: A, value: V): A =>
fn(value) ? reducer(accum, value) : accum;
js
const mapTR = (fn) => (reducer) => (accum, value) =>
reducer(accum, fn(value));
const filterTR = (fn) => (reducer) => (accum, value) =>
fn(value) ? reducer(accum, value) : accum;
我们已经构建了两个Transducers(变换器)的辅助函数,这两个辅助函数都是高阶函数,将一个减少函数作为输入,并产生一个新的减少函数作为输出。在正常情况下, reducer()
是序列中的下一个Transducers。
mapTR
接受一个函数 fn
,该函数将类型为 V
的值转换为类型为 W
的值。然后,它返回一个函数,该函数接受一个 reducer 函数,并返回一个新的 reducer 函数。这个新的 reducer 函数在每次调用时都会将累加器 accum
和经过 fn
转换后的值 fn(value)
传递给原始的 reducer 函数。
换句话说,mapTR
的作用是将一个值转换函数 fn
应用于输入流中的每个值,并将转换后的值传递给下一个 reducer 函数。
filterTR
接受一个函数 fn
,该函数将类型为 V
的值作为输入,并返回一个布尔值,表示是否应该保留该值。然后,它返回一个函数,该函数接受一个 reducer 函数,并返回一个新的 reducer 函数。这个新的 reducer 函数在每次调用时都会检查 fn(value)
的返回值。如果返回值为 true
,则将当前值 value
传递给原始的 reducer 函数;否则,直接返回累加器 accum
。
换句话说,filterTR
的作用是根据传入的条件函数 fn
过滤输入流中的值。只有满足条件的值才会传递给下一个 reducer 函数,否则将被忽略。
我们如何使用Transducers呢?让我们来看看吧。
使用(Using transducers)
我们将通过以下代码来解决我们的优化问题。首先,我们将原始函数转换为transducers。
js
const testOddR = filterTR(testOdd);
const duplicateR = mapTR(duplicate);
const testUnderFiftyR = filterTR(testUnderFifty);
const addThreeR = mapTR(addThree);
我们转换后的函数将计算它们的结果并调用一个reducer来进一步处理。我们还需要一个最终的特殊函数 addToArray()
,通过实际创建输出数组来完成转换序列。
ts
const addToArray = <V>(a: V[], v: V): V[] => {
a.push(v);
return a;
};
所以现在的解决方案是完美的,尽管有点费事!
js
const a1 = myArray.reduce(
testOddR(duplicateR(testUnderFiftyR(addThreeR(addToArray)))),
[]
);
console.log(a1);
// Output: [ 21, 25 ], again
这一切是如何运行?
- 使用
reduce()
,将一个空数组(将成为最终结果)作为累加器 testOddR()
进行测试,如果是奇数,则将其传递给下一个函数duplicateR()
将接收到的值复制一份并传递给下一个testUnderFiftyR()
测试数值,如果小于50,则将其传递给下一个addThreeR()
将接收到的值加上3,并将该值传递给最后一个函数addToArray()
只是将其输入推送到累加器数组中
这个方法可行,而且只需要一次操作!但它还不够完美!
概括
我们可以有很多 map()
和 filter()
的调用,但我们编写的代码总是以一个数组结束--如果我们想要进行最后的 reduce()
怎么办?这将需要对数组进行新的遍历,而我们只想进行一次遍历。
有两件事情我们需要改变:我们可能不想在我们的转换系列中使用 addToArray()
作为最终函数,并且我们可能有一个不同的起始值,而不是 []
。例如,如果我们想要找到所有数字的总和,我们可以这样写:
js
const sum = (acc: number, value: number): number => acc + value;
const a2 = myArray.reduce(
testOddR(duplicateR(testUnderFiftyR(addThreeR(sum)))),
0
);
console.log(a2);
// Output: 46
请注意两个点:我们现在传递的是 sum
和 0
,而不是 addToArray
和 []
,并且我们已经优化了 map()
、 filter()
和 reduce()
调用的顺序,只需处理输入数组一次,直接生成所需的输出,而无需任何中间数组;真正的完美!
总结
使用函数式编程并不意味着会导致性能下降。
在本文中,我们已经看到了如何将任何映射、过滤和归约调用的序列转换为一次遍历,这个过程不需要中间数组并且不需要额外的时间。
我们可以通过使用函数组合高阶函数来更完美的解决这个问题!