【问题标题】:Idiomatic Collection iteration in Java 8Java 8 中的惯用集合迭代
【发布时间】:2014-04-02 01:52:10
【问题描述】:

在 Java 8 中什么是集合的惯用迭代,为什么?

for (String foo : foos) {
  String bar = bars.get(foo);
  if (bar != null)
    System.out.println(foo);
}

foos.forEach(foo -> {
  String bar = bars.get(foo);
  if (bar != null)
    System.out.println(foo);
});

【问题讨论】:

标签: java java-8


【解决方案1】:

this answer 的评论线程中,用户Bringer128 提到了有关C# 中类似问题的这些问题:

我会告诫不要将 C# 讨论应用于 Java。可以肯定的是,讨论很有趣,而且问题表面上相似。但是,Java 和 C# 是不同的语言,因此需要考虑不同的因素。

例如,this answer 提到 C# foreach 语句更可取,因为编译器将来可能能够更好地优化循环。 Java 并非如此。在 Java 中,“增强的 for”循环被定义为语法糖,用于获取 Iterator 并重复调用其 hasNextnext 方法。这几乎可以保证每次循环迭代至少调用两个方法(尽管 JIT 有可能内联小方法)。

另一个例子来自this answer,它提到在C# 中,由列表的ForEach 方法调用的委托修改它正在迭代的列表是合法的。在 Java 中,Stream.forEach 方法全面禁止“干扰”流源,而对于增强型 for 循环,修改底层列表(或其他)的行为由 Iterator 确定。许多都是快速失败的,如果在迭代期间修改了基础列表,则会抛出ConcurrentModificationException。别人会默默地给出意想不到的结果。

无论如何,不​​要阅读 C# 讨论并假设类似的推理适用于 Java。


现在,回答这个问题。 :-)

我认为现在宣布一种风格是惯用的或优于另一种风格还为时过早。 Java 8 刚刚发布,很少有人对此有丰富的经验。 Lambda 是新的和陌生的,这会让许多程序员感到不舒服。因此,他们会想要坚持他们久经考验的 for 循环。这是完全明智的。不过,几年后,在每个人都习惯了 lambda 之后,for 循环可能会开始显得明显过时。时间会证明一切。

(我认为这发生在泛型上。当它们是新的时,它们令人生畏和可怕,尤其是通配符。不过,如今,非泛型代码看起来明显过时了,而且对我来说它有一股霉味。 )

我很早就知道这可能会变成什么样子。当然,我可能错了。

我会说对于计算固定的短循环,例如最初发布的问题:

for (String foo : foos)
    System.out.println(foo);

没关系。这可以重写为

foos.forEach(foo -> System.out.println(foo));

甚至

foos.forEach(System.out::println);

但实际上,这段代码非常简单,很难说一种方法明显更好。

在某些情况下,天平会朝一个方向或另一个方向倾斜。如果循环体可以抛出检查异常,那么 for 循环显然更好。如果循环体是可插入的(例如,Consumer 作为参数传入)或者如果内部迭代具有不同的语义(例如,在对 forEach 的整个调用期间锁定同步列表),那么新的 forEach方法有优势。

更新的示例,

for (String foo : foos) {
    String bar = bars.get(foo);
    if (bar != null)
        System.out.println(foo);
}

有点复杂,但只是稍微复杂一点。我会使用多行 lambda 编写此代码:

foos.forEach(foo -> {
    String bar = bars.get(foo);
    if (bar != null)
        System.out.println(foo);
});

在我看来,这与直接的 for 循环相比没有任何优势,并且 lambda 的不同语义由第一行角上的小箭头表示。但是,(类似于Bringer128's answer)我会将它从一个大的forEach 块重新转换为流管道:

foos.stream()
    .filter(foo -> bars.get(foo) != null)
    .forEach(System.out::println)

我认为 lambda/streams 方法在这里开始显示出一点优势,但只是一点点,因为这仍然是一个非常简单的示例。使用 lambda/streams 将一些条件控制逻辑替换为数据过滤操作。这可能对某些操作有意义,但对其他操作没有意义。

随着事情变得越来越复杂,这两种方法之间的区别开始变得更加清晰。简单的例子非常简单,很明显它们做了什么。现实世界的例子可能要复杂得多。考虑 JDK 的方法 Class.getEnclosingMethod 中的这段代码(滚动到第 1023-1052 行):

Class<?> enclosingCandidate = enclosingInfo.getEnclosingClass();
// ...
for(Method m: enclosingCandidate.getDeclaredMethods()) {
    if (m.getName().equals(enclosingInfo.getName()) ) {
        Class<?>[] candidateParamClasses = m.getParameterTypes();
        if (candidateParamClasses.length == parameterClasses.length) {
            boolean matches = true;
            for(int i = 0; i < candidateParamClasses.length; i++) {
                if (!candidateParamClasses[i].equals(parameterClasses[i])) {
                    matches = false;
                    break;
                }
            }

            if (matches) { // finally, check return type
                if (m.getReturnType().equals(returnType) )
                    return m;
            }
        }
    }
}

throw new InternalError("Enclosing method not found");

(为了示例,省略了一些安全检查和 cmets。)

这里我们有几个嵌套的 for 循环,带有几个级别的条件逻辑和一个布尔标志。阅读这段代码一段时间,看看你是否能弄清楚它的作用。

使用 lambda 和流,这段代码可以重写如下:

return Arrays.stream(enclosingInfo.getEnclosingClass().getDeclaredMethods())
             .filter(m -> Objects.equals(m.getName(), enclosingInfo.getName()))
             .filter(m -> Arrays.equals(m.getParameterTypes(), parameterClasses))
             .filter(m -> Objects.equals(m.getReturnType(), returnType))
             .findFirst()
             .orElseThrow(() -> new InternalError("Enclosing method not found");

经典版本中发生的事情是循环控制和条件逻辑都是关于在数据结构中搜索匹配项。它有点扭曲,因为如果它检测到不匹配,它会提前退出内部循环,但如果它确实找到匹配,则提前从方法返回。但是一旦你盯着这段代码足够长的时间,你会发现它正在搜索第一个匹配一系列条件的元素,并返回它;如果找不到,则会引发错误。一旦你意识到这一点,lambda/streams 方法就会立即出现。它不仅更短,而且更容易理解它在做什么。

肯定有 for 循环会产生奇怪的条件和副作用,不能轻易地转化为流。但是有很多 for 循环只是搜索数据结构,有条件地处理元素,返回第一个匹配项,或者累积匹配的集合,或者累积转换后的元素。这些操作很自然地适合被重写为流,我敢说,以一种惯用的方式。

【讨论】:

  • 如果你不知道,C#中的foreach循环相当于Java增强的for;即它是用于创建 IEnumerator 并访问其 Current 和 MoveNext 方法的语法糖。不过,您对集合的 ConcurrentModification 提出了一个很好的观点。避免潜在的错误通常是惯用编程的来源。
  • @Bringer128 是的,我对 C# 了解不多。可能 C# 的 foreach 循环是 IEnumerator 等的语法糖,但问题是它是否 defined 是语法糖,或者它当前的实现是否恰好是糖。链接的文章似乎说 C# 编译器将来可以以不同的方式编译它。 Java 的增强型被定义为糖:见JLS 14.14.2, The Enhanced For Statement
  • C# 5 规范(第 8.8.4 节)指出,foreach 循环扩展为围绕枚举数的 while 循环。这并不意味着该规范不会在未来的版本中被修改,因为 .NET 在历史上不是二进制向后兼容的(它通常是源向后兼容的)。
  • @Bringer128 Java 和 C# 关于二进制兼容性的有趣差异;在 Java 中,我们假设未来的版本将是二进制兼容的。感谢您的信息。
【解决方案2】:

一般来说,lambda 形式更适合单语句循环,而非 lambda 更适合多语句循环。 (如果可能,这会忽略组合成更实用的样式)。

你没有提到的另一种风格是方法参考:

foos.forEach(System.out::println);

编辑:

当您正在寻找更一般的答案时;您可能会发现,由于 lambda 在 Java 中是新的,因此 List.forEach 方法在实践中的使用较少。

在回答“那么为什么非 lambda 表达式更适合多语句?”时,恰恰相反,多语句 lambda 在大多数语言中都不是惯用的。 Lambdas 往往用于组合,所以如果我要从你的问题中举个例子并将其组合成一个函数式样式:

// Thanks to @skiwi for fixing this code
foos.stream().filter(foo -> bars.get(foo) != null).forEach(System.out::println);

在上面的示例中,使用多语句 lambda 会使其更难阅读而不是更容易。

【讨论】:

  • 谢谢 - 我的意思是当循环涉及的不仅仅是单个语句时,一般来说更惯用的是什么。我更新了我的答案,以便在循环中有更多内容:)
  • 那么为什么非 lambda 更适合多语句呢?
  • 这个答案有一些问题,总的来说它确实显示了一个正确的方法。问题是 1) 你不能在List&lt;E&gt; 上调用任何流操作,你首先需要调用列表上的stream() 来获得Stream&lt;E&gt;; 2)您当前正在打印出bar 值,而OP 打算打印出foo 值。
  • @JoshStone,Bringer128,请参阅我在 C# 讨论中对 cme​​ts 的回答。
【解决方案3】:

你应该只使用新的流/列表的forEach,如果它确实让你的代码更简洁,否则坚持旧版本,特别是对于线性执行的代码。

我会将您的陈述重写为以下内容,确实对流有意义:

foos.stream()
        .filter(foo -> (bars.get(foo) != null))
        .forEach(System.out::println);

这是一种功能性方法,它将:

  1. 将您的List&lt;String&gt; 变成Stream&lt;String&gt;
  2. 过滤对象,以便保留bars.get(foo) 不为空的所有元素,即Predicate&lt;String&gt; 类型。
  3. 然后在Stream&lt;String&gt; 上调用System.out::println,它会解析为bar -&gt; System.out.println(bar),它的类型是Consumer&lt;String&gt;

所以用更普通的话来说:

  1. 获取流。
  2. 过滤掉所有不需要的元素,保留想要的元素。
  3. 使用流中的所有元素。

【讨论】:

    猜你喜欢
    • 2015-08-17
    • 2013-01-02
    • 2011-04-25
    • 1970-01-01
    • 2010-10-04
    • 1970-01-01
    • 1970-01-01
    • 2019-12-26
    • 1970-01-01
    相关资源
    最近更新 更多