Java Collectors.groupingBy可以将Stream作为其分组项列表返回吗?

时间:2018-05-28 21:10:51

标签: java java-stream grouping collectors

在C#Linq中,GroupBy会返回IEnumerableIGrouping项,而这些项又是所选值类型的IEnumerable项。这是一个例子:

var namesAndScores = new Dictionary<string, int>> {
    ["David"] = 90,
    ["Jane"] = 91,
    ["Bill"] = 90,
    ["Tina"] = 89)
};
var IEnumerable<IGrouping<int, string>> namesGroupedByScore =
    namesAndScores
        .GroupBy(
            kvp => kvp.Value,
            kvp => kvp.Key
        );

// Result:
// 90 : { David, Bill }
// 91 : { Jane }
// 89 : { Tina }

具体而言,请注意每个IGrouping<int, string>IEnumerable<string>,而不是List<string> 。 (它还有一个.Key属性。)

GroupBy显然必须完全枚举输入项才能发出单个分组,但是,因为它确实发出了IEnumerable<string>而不是List<string>,所以可能会有如果您没有枚举整个分组,例如刚刚执行了.First(),则会带来性能优势。

除此之外:从技术上讲,我认为GroupBy可以等到你枚举它从输入中使用一个项目,然后发出一个IGrouping,并且只枚举输入的其余部分当枚举IGrouping时,在搜索当前组中的下一个项目时将其他组收集到其内部数据结构中,但我发现这是一个不太可能且有问题的实现,并期望GroupBy将枚举完全在调用时。

以下是First()的代码:

 var oneStudentForEachNumericScore = namesGroupedByScore
     .ToDictionary(
         grouping => grouping.Key,
         grouping => grouping.First() // does not fully enumerate the values
     );
 // Result:
 // 90 : David -- Bill is missing and we don't care
 // 91 : Jane
 // 89 : Tina

现在在Java Streams中,要进行分组,您必须收集,并且您不能仅为groupingBy收集器提供第二个lambda来提取值。如果您想要一个不同于整个输入的值,则必须再次映射(但请注意,groupingBy收集器允许您在一个步骤中创建多组...组的多组)。这是上述C#代码的等效代码:

Map<Integer, List<String>> namesGroupedByScore = namesAndScores
      .entrySet().stream()
      .collect(Collectors.groupingBy(
          Map.Entry::getValue,
          Collectors.mapping(
              Map.Entry::getKey,
              Collectors.toList(),
          )
      ));

这似乎不太理想。所以我的问题是:

  1. 有没有办法更简单地表达这一点,而不必使用Collectors.mapping来获取组项目的价值?
  2. 为什么我们必须收集到完全枚举的类型?有没有办法模拟C#&#39; IEnumerable的{​​{1}}值类型并从GroupBy返回Map<Integer, Stream<String>>,或者这样做没有用,因为值无论如何,必须完全枚举项目?或者我们可以编写自己的Collectors.mapping(),它为第二个参数获取lambda并为我们完成工作,使语法更接近Linq&#39; Collectors.groupingBy并且至少具有更清晰的语法并且可能略微性能提升?
  3. 作为一种理论练习,即使没有实际用处,也可以编写我们自己的Java Stream Collector GroupBy,它返回toStream()并且不会迭代其输入,除非它被枚举(一次迭代一个元素,延期)?

2 个答案:

答案 0 :(得分:3)

虽然这些操作在某些方面看起来相似,但它们根本不同。与Linq的GroupBy操作不同,Java的groupingByCollector,旨在与Stream API的终端操作 collect一起使用,这是一个不是中间操作本身,因此,通常不能用于实现惰性流操作。

groupingBy收集器使用另一个下游Collector作为组,因此,不是通过组的元素进行流式传输,而是执行另一个操作,您可以指定一个收集器就地执行该操作,最好的情况。虽然这些收集器不支持短路,但它们不需要将组收集到List中,只是为了流过它们。考虑一下,例如groupingBy(f1, summingInt(f2))。在未指定收集器时,将组收集到List的情况已被认为足以使toList()隐含,但在以前映射元素的情况下尚未考虑这种情况。收集到列表中。

如果您经常遇到这种情况,那么很容易定义您自己的收藏家

public static <T,K,V> Collector<T,?,Map<K,List<V>>> groupingBy(
    Function<? super T, ? extends K> key, Function<? super T, ? extends V> value) {
    return Collectors.groupingBy(key, Collectors.mapping(value, Collectors.toList()));
}

并像

一样使用它
Map<Integer,List<String>> result = map.entrySet().stream()
    .collect(groupingBy(Map.Entry::getValue, Map.Entry::getKey));

并且,因为您不需要使用方法引用并希望更接近Linq原始文件:

Map<Integer,List<String>> result = map.entrySet().stream()
        .collect(groupingBy(kvp -> kvp.getValue(), kvp -> kvp.getKey()));

但是,如上所述,如果您打算在此后对此地图进行流式处理并担心此操作的非懒惰,您可能希望使用与toList()不同的收集器。

虽然这种方法对结果值提供了一些灵活性,但Map及其键是此操作中不可避免的一部分,因为Map不仅提供存储逻辑,其查找操作也是还负责形成群体,这也决定了语义。例如。当您将the variant with a map supplier() -> new TreeMap<>(customComparator)一起使用时,您可能会获得与默认HashMap完全不同的群组(例如String.CASE_INSENSITIVE_ORDER)。另一方面,当您提供EnumMap时,您可能无法获得不同的语义,但完全不同的性能特征。

相比之下,你所描述的Linq的GroupBy操作看起来像是一个在Stream API中根本没有吊坠的中间操作。正如你自己建议的那样,当第一个元素被轮询时,它仍然可以完全遍历,完全填充幕后的数据结构。即使实现尝试了一些懒惰,结果也是有限的。您可以廉价地获得第一组的第一个元素,但如果您只对该元素感兴趣,则根本不需要分组。第一组的第二个元素可能已经是源流的最后一个元素,需要完整的遍历和存储。

因此提供这样的操作意味着一些复杂性,而不是急于收集的好处。也很难想象并行能力的实现(提供优于collect操作的优势)。实际的不便不是来自这个设计决定,而是源于结果Map is not a Collection(注意单独实施Iterable wouldn’t imply having a stream() method)和决定to separate collection operations and stream operations。这两个方面导致需要使用entrySet().stream()来流式传输地图,但这超出了本问题的范围。并且,如上所述,如果您需要,请首先检查groupingBy收集器的不同下游收集器是否无法提供所需的结果。

为了完整起见,这是一个尝试实现惰性分组的解决方案:

public interface Group<K,V> {
    K key();
    Stream<V> values();
}
public static <T,K,V> Stream<Group<K,V>> group(Stream<T> s,
    Function<? super T, ? extends K> key, Function<? super T, ? extends V> value) {

    return StreamSupport.stream(new Spliterator<Group<K,V>>() {
        final Spliterator<T> sp = s.spliterator();
        final Map<K,GroupImpl<T,K,V>> map = new HashMap<>();
        ArrayDeque<Group<K,V>> pendingGroup = new ArrayDeque<>();
        Consumer<T> c;
        {
        c = t -> map.compute(key.apply(t), (k,g) -> {
            V v = value.apply(t);
            if(g == null) pendingGroup.addLast(g = new GroupImpl<>(k, v, sp, c));
            else g.add(v);
            return g;
        });
        }
        public boolean tryAdvance(Consumer<? super Group<K,V>> action) {
            do {} while(sp.tryAdvance(c) && pendingGroup.isEmpty());
            Group<K,V> g = pendingGroup.pollFirst();
            if(g == null) return false;
            action.accept(g);
            return true;
        }
        public Spliterator<Group<K,V>> trySplit() {
            return null; // that surely doesn't work in parallel
        }
        public long estimateSize() {
            return sp.estimateSize();
        }
        public int characteristics() {
            return ORDERED|NONNULL;
        }
    }, false);
}
static class GroupImpl<T,K,V> implements Group<K,V> {
    private final K key;
    private final V first;
    private final Spliterator<T> source;
    private final Consumer<T> sourceConsumer;
    private List<V> values;

    GroupImpl(K k, V firstValue, Spliterator<T> s, Consumer<T> c) {
        key = k;
        first = firstValue;
        source = s;
        sourceConsumer = c;
    }
    public K key() {
        return key;
    }
    public Stream<V> values() {
        return StreamSupport.stream(
            new Spliterators.AbstractSpliterator<V>(1, Spliterator.ORDERED) {
            int pos;
            public boolean tryAdvance(Consumer<? super V> action) {
                if(pos == 0) {
                    pos++;
                    action.accept(first);
                    return true;
                }
                do {} while((values==null || values.size()<pos)
                           &&source.tryAdvance(sourceConsumer));
                if(values==null || values.size()<pos) return false;
                action.accept(values.get(pos++ -1));
                return true;
            }
        }, false);
    }
    void add(V value) {
        if(values == null) values = new ArrayList<>();
        values.add(value);
    }
}

您可以使用以下示例对其进行测试:

group(
    Stream.of("foo", "bar", "baz", "hello", "world", "a", "b", "c")
          .peek(s -> System.out.println("source traversal: "+s)),
        String::length,
        String::toUpperCase)
    .filter(h -> h.values().anyMatch(s -> s.startsWith("B")))
    .findFirst()
    .ifPresent(g -> System.out.println("group with key "+g.key()));

将打印:

source traversal: foo
source traversal: bar
group with key 3

表明懒惰尽可能地发挥作用。但

  • 每个需要知道所有组/键的操作都需要完整遍历源,因为最后一个元素可能会引入一个新组
  • 每个需要处理至少一个组的所有元素的操作都需要完整遍历,因为源的最后一个元素可能属于该组
  • 如果不能及早停止,前一点甚至适用于短路操作。例如,在上面的例子中,在第二组中找到匹配意味着第一组的完全遍历不成功,因此完全遍历源
  • 上面的例子可以改写为

    Stream.of("foo", "bar", "baz", "hello", "world", "a", "b", "c")
          .peek(s -> System.out.println("source traversal: "+s))
          .filter(s -> s.toUpperCase().startsWith("H"))
          .map(String::length)
          .findFirst()
          .ifPresent(key -> System.out.println("group with key "+key));
    

    提供更好的懒惰(例如,如果匹配不在第一组内)。

    当然,这个例子是设计的,但我有强烈的感觉,几乎所有具有延迟处理潜力的操作,即不需要所有组,并且不需要至少一个组的所有元素,都可以被重写进入一个根本不需要分组的操作。

答案 1 :(得分:0)

以下是StreamEx和我的图书馆AbacusUtil

部分问题的解决方案
Map<String, Integer> namesAndScores 
             = N.asMap("David", 90, "Jane", 91, "Bill", 90, "Tina", 89);

// By StreamEx
Map<Integer, List<String>> namesGroupedByScore = EntryStream.of(namesAndScores)
                                .invert().grouping();

// By AbacusUtil
Map<Integer, List<String>> namesGroupedByScore = EntryStream.of(namesAndScores)
                                   .groupTo(Fn.value(), Fn.key());
// Or
Map<Integer, Stream<String>> namesGroupedByScore2 = 
        EntryStream.of(namesAndScores).toMap(Fn.value(), collectingAndThen(mapping(Fn.key()), Stream::of));

如果您只想在分组后保存名字:

Map<Integer, List<String>> namesAndScores3 = 
      EntryStream.of(namesAndScores).distinctByValue().groupTo(Fn.value(), Fn.key());
// Or
Map<Integer, String> namesAndScores4 = 
          EntryStream.of(namesAndScores).distinctByValue().toMap(Fn.value(), Fn.key());

如果您想保存最多2个值。

Map<Integer, List<String>> namesAndScores5 = EntryStream.of(namesAndScores).toMap(Fn.value(),
        MoreCollectors.mapping(Fn.key(), MoreCollectors.toList(2)));

对于其他问题,我相信Holger所说的话:“......但我有强烈的感觉,几乎任何具有懒惰处理潜力的操作,即不需要所有组,也不需要所有元素至少有一个组,可以重写为一个根本不需要分组的操作。“

在任何情况下,如果需要groupBy,我认为在没有迭代所有元素的情况下存在这样的实现,无论你使用哪种语言。如果不需要迭代所有元素,则很可能groupBy不必要或被滥用。