为什么在将类型转换的Java 8中,reduce方法需要使用组合器?

226

我对 Streams 的 reduce 方法中的 combiner 所扮演的角色仍有些不太理解。

比如,下面的代码无法编译:

int length = asList("str1", "str2").stream()
            .reduce(0, (accumulatedInt, str) -> accumulatedInt + str.length());

编译错误信息如下: (参数不匹配;无法将int转换为java.lang.String)

但是这段代码确实可以编译:

int length = asList("str1", "str2").stream()  
    .reduce(0, (accumulatedInt, str ) -> accumulatedInt + str.length(), 
                (accumulatedInt, accumulatedInt2) -> accumulatedInt + accumulatedInt2);

我知道组合器方法在并行流中使用 - 因此在我的示例中,它正在将两个中间累积的整数相加。

但是,我不明白为什么第一个示例没有组合器就无法编译,以及组合器如何解决将字符串转换为整数的问题,因为它只是将两个整数相加。

有人可以解释一下吗?


相关问题:https://dev59.com/PWAf5IYBdhLWcg3w2Vwo - nosid
7
啊哈,这是为了并行流...我说这个抽象层有问题! - Andy
2
我遇到了类似的问题。我想要进行MapReduce操作。我希望Stream的“reduce”方法有一个重载版本,允许映射到与输入类型不同的类型,但不强制我编写组合器。据我所知,Java没有这样的方法。因为有些人(比如我)期望找到它,但它不存在,这就造成了困惑。注意:我不想编写组合器,因为输出是一个复杂对象,组合器不现实。 - user2367418
4个回答

334

Eran的回答描述了reduce的两个参数和三个参数版本之间的差异,前者将Stream<T>缩减为T,而后者将Stream<T>缩减为U。然而,它并没有解释在将Stream<T>缩减为U时需要额外的组合器函数。

流API的设计原则之一是API不应在顺序流和并行流之间有所不同,或者换句话说,特定的API不应妨碍流以顺序或并行方式正确运行。如果您的lambda具有正确的属性(结合性、非干扰性等),则顺序或并行运行流应该给出相同的结果。

让我们首先考虑缩减的两个参数版本:

T reduce(I, (T, T) -> T)

顺序实现很直接。身份值I与第零个数据流元素“累加”,得到一个结果。将此结果与第一个数据流元素相结合,得到另一个结果,然后这个结果再与第二个数据流元素相结合,以此类推。当最后一个元素被累加时,返回最终结果。

并行实现首先将流分成若干部分。每个部分都按照我上面描述的顺序,由自己的线程进行处理。现在,如果我们有N个线程,就会有N个中间结果。这些需要缩减为一个结果。由于每个中间结果都是类型T,而我们有多个结果,我们可以使用同样的累加器函数将这N个中间结果缩减为一个结果。

现在让我们考虑一个假想的两个参数的缩减操作,将Stream<T>缩减为U。在其他语言中,这称为"折叠"或"fold-left"操作,因此我将在此处称其为折叠操作。请注意,Java中不存在这个操作。

U foldLeft(I, (U, T) -> U)

(请注意,标识值I的类型为U。) foldLeft的顺序版本与reduce的顺序版本非常相似,只是中间值的类型为U而不是T。但它其余部分都是一样的。(假设foldRight操作将类似,只是操作将从右到左执行而不是从左到右。)
现在考虑foldLeft的并行版本。让我们首先将流分成段。然后,我们可以让每个N个线程将其段中的T值缩减为类型为U的N个中间值。现在怎么办?我们如何从类型为U的N个值转换为类型为U的单个结果?
缺少的是另一个函数,该函数将类型为U的多个中间结果合并为类型为U的单个结果。如果我们有一个将两个U值组合成一个值的函数,那么就足以将任意数量的值缩减为一个值-就像上面的原始缩减一样。因此,产生不同类型结果的缩减操作需要两个函数:
U reduce(I, (U, T) -> U, (U, U) -> U)

或者,使用Java语法:
<U> U reduce(U identity, BiFunction<U,? super T,U> accumulator, BinaryOperator<U> combiner)

总之,要对不同的结果类型进行并行归约,我们需要两个函数:一个将 T 元素累加到中间 U 值的函数,以及一个将中间 U 值组合成单个 U 结果的函数。如果我们没有切换类型,那么累加器函数就等同于组合函数。这就是为什么同一类型的归约只有累加器函数,而不同类型的归约需要单独的累加器和组合函数。
最后,Java 不提供 foldLeft 和 foldRight 操作,因为它们暗示了一种固有的顺序操作,这与上述提供支持顺序和并行操作的 API 的设计原则相冲突。

14
如果计算依赖于先前的结果且不能并行化,那么如果需要使用 foldLeft,你可以做什么? - amoebe
5
你可以使用 forEachOrdered 实现自己的 foldLeft。然而,中间状态必须保存在一个捕获变量中。 - Stuart Marks
1
@StuartMarks 谢谢,我最终使用了 jOOλ。他们有一个很棒的foldLeft实现 - amoebe
1
喜欢这个答案!如果我错了,请纠正我:这解释了为什么 OP 的运行示例(第二个)在流是顺序的情况下永远不会调用组合器。 - Luigi Cortese
2
它几乎解释了所有的东西...除了:为什么这应该排除基于顺序的约简。在我的情况下,不可能并行执行,因为我的约简通过在其前任结果的中间结果上调用每个函数来将函数列表缩减为U。这根本无法并行执行,也没有办法描述一个组合器。我可以使用什么方法来完成这个任务? - Zordid
显示剩余9条评论

186

既然我喜欢涂鸦和箭头来澄清概念...让我们开始吧!

从字符串到字符串(顺序流)

假设有4个字符串:你的目标是将这些字符串连接成一个。你基本上从一个类型开始,并以相同的类型结束。

你可以通过以下方式实现

String res = Arrays.asList("one", "two","three","four")
        .stream()
        .reduce("",
                (accumulatedStr, str) -> accumulatedStr + str);  //accumulator

这有助于您可视化正在发生的事情:

enter image description here

累加器函数逐步将红色流中的元素转换为最终的缩减(绿色)值。累加器函数简单地将一个String对象转换为另一个String

从字符串到整数(并行流)

假设有相同的4个字符串:您的新目标是计算它们的长度总和,并且您想要并行化您的流。

你需要像这样实现:

int length = Arrays.asList("one", "two","three","four")
        .parallelStream()
        .reduce(0,
                (accumulatedInt, str) -> accumulatedInt + str.length(),                 //accumulator
                (accumulatedInt, accumulatedInt2) -> accumulatedInt + accumulatedInt2); //combiner

这是正在发生的事情的示意图。

enter image description here

在这里,累加器函数(一个 BiFunction)允许将您的 String 数据转换为 int 数据。由于流是并行的,它被分成两个(红色)部分,每个部分都独立处理,并产生同样多的部分(橙色)结果。定义一个合并器是必要的,以提供将部分 int 结果合并为最终(绿色)int 结果的规则。

从 String 转换为 int(顺序流)

如果不想并行处理流怎么办?好吧,仍需提供一个合并器,但是由于不会生成任何部分结果,因此永远不会调用它。


11
谢谢这个。我甚至不需要阅读。我希望他们能加入一个可折叠函数。 - Lodewijk Bogaards
1
@LodewijkBogaards 很高兴能帮到你!这里的JavaDoc确实有点晦涩难懂。 - Luigi Cortese
3
非常感谢您清晰而有用的回答。我想重复一下您所说的内容:“嗯,无论如何都需要提供一个组合器,但它永远不会被调用。” 这是Java函数式编程崭新世界的一部分,我已经被无数次保证过,这将“使您的代码更简洁、更易读”。希望这种(引号手势)简明清晰的例子尽可能少。 - dnuttle
5
这是最佳答案,毫无疑问。 - Mingtao Sun
1
谢谢您的回答。有一个问题:在将字符串转换为整数(顺序流)的情况下,如果组合器永远不会被调用,为什么函数签名包含组合器?这只是为了满足接口吗?话虽如此,是否有一种简洁的方法来提供虚拟组合器? - lnogueir
显示剩余4条评论

92

你尝试使用的两个和三个参数版本的reduce不接受相同类型的accumulator

两个参数的reduce已被定义为

T reduce(T identity,
         BinaryOperator<T> accumulator)

在您的情况下,T为字符串,因此BinaryOperator<T>应接受两个字符串参数并返回一个字符串。但是,您将一个整数和一个字符串传递给它,这导致了编译错误 - argument mismatch; int cannot be converted to java.lang.String。实际上,我认为在这里将0作为标识值传递也是错误的,因为预期是一个字符串(T)。
另请注意,此版本的reduce处理Ts流并返回T,因此您无法使用它将String流减少为int。
三个参数的reduce定义为
<U> U reduce(U identity,
             BiFunction<U,? super T,U> accumulator,
             BinaryOperator<U> combiner)

在您的情况下,U是整数而T是字符串,因此该方法将把一系列字符串缩减为一个整数。

对于BiFunction<U,? super T,U>累加器,您可以传递两种不同类型(U和? super T)的参数,这在您的情况下是整数和字符串。此外,标识值U在您的情况下接受一个整数,因此传递0是可以的。

另一种实现您所需的方法:

int length = asList("str1", "str2").stream().mapToInt (s -> s.length())
            .reduce(0, (accumulatedInt, len) -> accumulatedInt + len);

这里流的类型与reduce的返回类型匹配,因此可以使用reduce的两个参数版本。

当然,你也不一定非要使用reduce

int length = asList("str1", "str2").stream().mapToInt (s -> s.length())
            .sum();

9
在你最后的代码中,作为第二个选项,你也可以使用mapToInt(String::length)代替mapToInt(s -> s.length()),不确定哪个更好,但我更喜欢前者的可读性。 - skiwi
44
许多人会发现这个问题,因为他们不明白为什么需要“combiner”,为什么没有“accumulator”就不够了。在这种情况下,“combiner”只在并行流中需要,用于合并线程的“累积”结果。 - ddekany
13
我认为你的回答并不是特别有用——因为你根本没有解释组合器应该做什么以及我如何在没有它的情况下工作!在我的情况下,我想要将类型T缩减为U,但是这根本无法以任何并行方式完成。这是不可能的。你怎么告诉系统我不想/不需要并行处理,因此可以省略组合器? - Zordid
@Zordid,Streams API没有提供将类型T减少到U的选项,而不传递组合器。 - Eran
3
这个答案并没有解释组合器,只是说明为什么 OP 需要使用非组合器版本。 - Benny Bottema

1

没有不带combinerreduce版本可以同时处理两种不同类型的数据(不确定为什么这是一个要求),因为它不能并行执行。由于accumulator必须是可结合的,所以这个接口几乎没有用处。

list.stream().reduce(identity,
                     accumulator,
                     combiner);

产生的结果与以下代码相同:

list.stream().map(i -> accumulator(identity, i))
             .reduce(identity,
                     combiner);

这样的“map”技巧取决于特定的“累加器”和“组合器”,可能会使事情变得非常缓慢。 - Tagir Valeev
或者,您现在可以通过删除第一个参数来简化“累加器”,从而显着加快速度。 - quiz123
并行规约是可能的,这取决于您的计算。在您的情况下,您必须意识到组合器的复杂度,以及累加器对身份和其他实例的影响。 - LoganMzz

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接