New Collectors Added After Java 8

7 minute read

Collectors have been quite popular among developers since they were introduced in Java 8. The predefined ones that come in the Collectors class have turned out to be quite useful for the most common operations when working with streams.

Luckily, these predefined collectors have been growing in the following Java releases after Java 8. In the next sections, we’ll go over every release where new collectors have been introduced and we’ll show how they can help us.

Java 9

Java 9 has introduced 2 new collectors to simplify some common operations when collecting items from streams. The collectors added are flatMapping() and filtering(). Let’s see how they work!

flatMapping()

This new collector is quite similar to the flatMap() operation of a Stream. It accepts the elements of the stream as input and produces another stream that will be collected by a downstream collector that we have to specify.

In order to illustrate this with an example let’s create the following Email class:

public class Email {
    private String from;
    private List<String> to;
    private List<String> cc;
    private String subject;
    private String body;

    // ... constructors, getters and setters 
}

After that, let’s create a list of emails:

List<Email> emails = new ArrayList<>();
emails.add(
    new Email(
        "John",
        Arrays.asList("Paula", "Tim"),
        Collections.singletonList("David"),
        "Greetings",
        "Greetings from John"));
emails.add(
    new Email(
        "Tim",
        Arrays.asList("Robert", "Joe"),
        Collections.emptyList(),
        "How are you?",
        "Hello, how are you?"));
emails.add(
    new Email(
        "Maria",
        Collections.singletonList("Peter"),
        Collections.emptyList(),
        "Hi",
        "Hi Peter!"));
emails.add(
    new Email(
        "Maria",
        Collections.singletonList("Joe"),
        Collections.singletonList("Anna"),
        "Hello",
        "Hello Joe!"));

A simple example to use this collector would be to obtain all the email recipients in a Set:

Set<String> recipients = emails.stream().collect(flatMapping(e -> e.getTo().stream(), toSet()));

However, this collector is not being quite useful in this case since we don’t really need it to achieve this result:

Set<String> recipients = emails.stream().flatMap(e -> e.getTo().stream()).collect(toSet());

This collector was intended to simplify multi-level reductions when using other collectors such as groupingBy() and partitioningBy().

Let’s see now a more appropiate example for this collector. Let’s modify our previous example to get the email recipients and group them by sender:

Map<String, Set<String>> recipientsBySender =
        emails.stream()
            .collect(
                Collectors.groupingBy(
                    Email::getFrom,
                    Collectors.flatMapping(e -> e.getTo().stream(), Collectors.toSet())));

As we can see, we first group the emails by sender, and then we get all the recipients in a Set. To do the latter, we pass to the flatMapping() a function that flattens the list of recipients into a stream. Then, this stream is collected by the toSet() collector to create the set of recipients.

By using this collector we avoid having to do the flattening ourselves and our collector looks more compact.

filtering()

The filtering() collector allows us to filter the input elements of a stream before passing them to a downstream collector. As before, the reasoning behind this collector was also to simplify multi-level reductions.

Let’s work with our list of emails again to see how this collector can help us! Let’s imagine that we want to get all the emails that are cc’ed and group by them by sender. Before Java 9 we could do it like this:

Map<String, List<Email>> ccEmailsBySender =
        emails.stream()
            .filter(e -> e.getCc() != null && !e.getCc().isEmpty())
            .collect(groupingBy(Email::getFrom));

But since we’re filtering before grouping, we’re not getting the persons who don’t have any cc’ed email:

{John=[Email[from='John', to=[Paula, Tim], cc=[David], subject='Greetings', body='Greetings from John']], Maria=[Email[from='Maria', to=[Joe], cc=[Anna], subject='Hi', body='Hi Joe!']]}

However, we can change this behaviour by using the filtering() collector:

Map<String, List<Email>> ccEmailsBySender =
    emails.stream()
        .collect(
            groupingBy(
                Email::getFrom,
                filtering(
                    e -> e.getCc() != null && !e.getCc().isEmpty(), Collectors.toList())));

As shown above, now we’re filtering after grouping. Therefore we have all the persons in the Map:

{John=[Email[from='John', to=[Paula, Tim], cc=[David], subject='Greetings', body='Greetings from John']], Tim=[], Maria=[Email[from='Maria', to=[Joe], cc=[Anna], subject='Hi', body='Hi Joe!']]}

As we can see, we get Tim in the output even though he doesn’t have any cc’ed email.

Java 10

All the new collectors added in Java 10 are about creating unmodifiable collections such as lists, sets, or maps. We can define an unmodifiable collection as the one that doesn’t allow us to add, remove, or replace its elements. Although the ones created by these new collectors have some other common characteristics such as not accepting null values.

In the next sections, we’ll show how we can create these kinds of collections by using the new Java 10 collectors.

toUnmodifiableList()

Before Java 10 it wasn’t possible to collect items from a stream into unmodifiable collections unless we’d use some kind of workaround. That’s because the collection where the items are being accumulated by a collector has to be modifiable to add, remove, or replace elements during the accumulation process. For this reason, Java doesn’t allow it and throws an UnsupportedOperationException:

Stream.of(1, 2, 3, 3, 4, 5).filter(v -> v > 2).collect(Collectors.toCollection(List::of));

One common workaround to solve this was to use the collectingAndThen(). This way we can transform the collected list into an unmodifiable one right after the collecting process is finished:

List<Integer> unmodifiableList =
        Stream.of(1, 2, 3, 3, 4, 5)
            .filter(v -> v > 2)
            .collect(collectingAndThen(toList(), List::copyOf));

To overcome this situation we could also create a custom collector. But that’s exactly what was done in Java 10:

List<Integer> unmodifiableList = Stream.of(1, 2, 3, 3, 4, 5).filter(v -> v > 2).collect(toUnmodifiableList());

As we can see, it works in the same way as toList().

If we take a look at the implementation of this collector, we notice that it’s almost the same as toList() but taking advantage of the finisher function of a Collector:

public static <T>
Collector<T, ?, List<T>> toUnmodifiableList() {
    return new CollectorImpl<>((Supplier<List<T>>) ArrayList::new, List::add,
                                (left, right) -> { left.addAll(right); return left; },
                                list -> (List<T>)List.of(list.toArray()),
                                CH_NOID);
    }

The finisher function transforms the modifiable list where the elements were accumulated into an unmodifiable one just before returning it.

This collector is quite convenient to save us from creating new collectors ourselves or using workarounds to create unmodifiable lists.

toUnmodifiableSet() and toUnmodifiableMap()

Besides toUnmodifiableList(), it was also added a collector to create unmodifiable sets:

Set<Integer> unmodifiableSet =
        Stream.of(1, 2, 3, 3, 4, 5).filter(v -> v > 2).collect(toUnmodifiableSet());

And also one to create unmodifiable maps:

Map<String, Email> unmodifiableMap =
        emails.stream().collect(toUnmodifiableMap(Email::getSubject, v -> v));

As we can see, they are very similar to the ones that create modifiable collections.

Java 12

With the arrival of Java 12, we also got another predefined collector: teeing(). This is a quite complex and useful collector that we’ll show how to use in the next section.

teeing()

Collectors have been proved quite useful and easy to use when reducing a stream. However, sometimes we need to reduce a stream in different ways at the same time and that can become a bit tedious to do in one single stream. The summarizing collectors already provide this functionality but they are only useful to get statistics from the stream elements.

The teeing() collector comes quite handy in the situations where we want to collect a stream in 2 different ways. It accepts 2 downstream collectors and a merger function that receives the results from the 2 colletors and returns a single result.

Let’s come back to our list of emails and let’s imagine that we want to know if a person has more sent emails than received. This is quite easy to achieve by using the teeing() collector:

String person = "Joe";
boolean moreSentEmailsThanReceived =
    emails.stream()
        .collect(
            teeing(
                filtering(e -> e.getFrom().equalsIgnoreCase(person), counting()),
                filtering(e -> e.getTo().contains(person), counting()),
                (from, to) -> from - to > 0));

In this example, the first collector counts how many emails this person has sent and the second collector counts how many he/she has received. Finally, our merger function just checks which count is higher.

Let’s see it in another example. Now we’d like to see what the difference is between the longest email body and the shortest:

int diff =
    emails.stream()
        .map(e -> e.getBody().length())
        .collect(
            teeing(
                collectingAndThen(
                    maxBy(Comparator.naturalOrder()), (Optional<Integer> v) -> v.orElse(0)),
                collectingAndThen(
                    minBy(Comparator.naturalOrder()), (Optional<Integer> v) -> v.orElse(0)),
                (max, min) -> max - min));

This time we use one collector to find the longest email body and another one to find the shortest. Finally, we just find the difference between them in the merger function.

As seen, this collector provides us with a different way of collecting results from a stream.

Conclusion

In this tutorial, we’ve seen all the new predefined collectors that have been added after Java 8. We went over every Java release that contains new collectors and showed how to use them. We’ve also shown how they can be useful to us and when we should use them.

The source code with the examples is available at github.

Leave a comment