在减少操作中使用StringBuilder...作为标识值会产生不可预测的结果

问题描述

问题很直接:为什么我们不能在StringBuilder(...)流的identity function操作中将reduce(...)用作java8,但是string1.concat(string2)可以用作identity function

string1.concat(string2)可以看作与builder.append(string)类似(尽管可以理解,这些操作几乎没有区别),但是我无法理解reduce操作中的区别。考虑以下示例:

  List<String> list = Arrays.asList("1","2","3"); 
  
  // Example using the string concatenation operation
  System.out.println(list.stream().parallel()
            .reduce("",(s1,s2) -> s1 + s2,s2)->s1 + s2));

  // The same example,using the StringBuilder
  System.out.println(list.stream() .parallel()
            .reduce(new StringBuilder(""),(builder,s) -> builder
                    .append(s),(builder1,builder2) -> builder1
                    .append(builder2)));
 
 // using the actual concat(...) method
 System.out.println(list.stream().parallel()
            .reduce("",s2) -> s1.concat(s2),s2)->s1.concat(s2)));

以下是执行以上行后的输出

 123
 321321321321   // output when StringBuilder() is used as Identity
 123

builder.append(string)str1.concat(str2)一样是一个关联操作。那么concat为什么不能工作,append却不能工作?

解决方法

是的,append 确实是关联的,但这并不是对作为累加器和组合器传递的函数的唯一要求。根据{{​​3}},他们必须是:

  • 关联
  • 无干扰
  • 无状态

append不是无状态的。这是有状态的。当您执行sb.append("Hello")时,它不仅会返回在末尾附加StringBuilder的{​​{1}},而且还会更改 {em {1}}。

也来自docs

如果流操作的行为参数是有状态的,则流管道结果可能不确定或不正确。有状态的lambda(或其他实现适当功能接口的对象)是一种有状态的lambda,其结果取决于在流管道执行期间可能会改变的任何状态。

因此,一旦应用了累加器或组合器,Hello就不是有效的标识。将会向空字符串生成器中添加一些内容,并且不再满足以下所有身份必须满足的方程:

sb

并行流有可能在调用累加器和/或组合器之后利用旧的字符串构建器,并且期望它们的内容不会更改。但是,累加器和组合器会使字符串生成器变异,从而导致流产生不正确的结果。

另一方面,new StringBuilder()满足以上所有三个条件。它是无状态的,因为它不会更改调用它的字符串。它只是重新调整了一个新的串联字符串。 (combiner.apply(u,accumulator.apply(identity,t)) == accumulator.apply(u,t) 始终是不变的,不能更改:D)

无论如何,这是docsconcat的用例:

String
,

阅读文档并进行了许多测试之后,我认为reduce类似于以下步骤:

  1. 会有多个线程来做reduce,每个线程都做一个 部分减少;
  2. 对于身份,将只有一个实例。每个累加器都将使用此身份实例;
  3. 首先使用标识实例和字符串元素进行累加以获得 StringBuilder;
  4. 结合所有这些StringBuilder;

因此,问题是每个实例都累积有身份实例,而字符串元素将导致身份更改。第一次积累后的身份不再是身份。

例如,我们考虑一个包含2个元素{“ 1”,“ 2”}的列表。 将有2个线程,每个线程做1个累加,其中一个最后合并。 线程A确实使用元素“ 1”累积了身份,然后结果是StringBuilder,其内容为“ 1”(仍然是身份,因为StringBuilder.append的返回对象本身就是它),但身份也更改为内容“ 1”。然后线程B确实使用元素“ 2”累积标识,然后结果是“ 12”,不再是“ 2”。 然后做合并就是这两个累加结果的结果,它们都是身份实例本身,所以结果将是“ 1212”。 就像下面的代码片段一样:

StringBuilder identity = new StringBuilder();
StringBuilder accumulate1 = identity.append("1");
StringBuilder accumulate2 = identity.append("2");
StringBuilder combine = accumulate1.append(accumulate2);
// combine and accumulate1 and accumulate2 are all identity instance and result is "1212"
return combine; 

对于更多元素,由于线程随机运行,因此每次的结果都会有所不同。

知道原因之后,是否按以下方式修复累加器

new StringBuilder(builder).append(s)

和完整的行代码如下:

System.out.println(list.stream().parallel().reduce(new StringBuilder(),(builder,s) -> new StringBuilder(builder).append(s),(builder1,builder2) -> new StringBuilder(builder1).append(builder2)));

然后将不再有任何问题,因为累加器不会每次更改标识实例并返回新的StringBuilder。但是这样做并不值得,因为与String concat方法相比没有好处。

编辑:感谢@Holger的示例,似乎如果有过滤器功能,则可能会跳过一些累加器。因此组合器功能也需要更改为

new StringBuilder(builder1).append(builder2)
,

在已经存在实现(或像Sweeper的回答那样拥有.reduce())的情况下,请勿使用.collect()

List<String> list = Arrays.asList("1","2","3"); 
  
// Example using the string concatenation operation
System.out.println(list.stream()
   .parallel()
   .collect(Collectors.joining())
);
// prints "123"

编辑(这不适用于并行流)

取决于.joining()的实现:

final List<String> list = Arrays.asList("1","3");
System.out.println(list.stream().reduce(new StringBuilder(),StringBuilder::append,StringBuilder::append)
    .toString()
);
// prints "123"