We have learned how to accumulate stream elements into a collection or a single value by using collect operation and Collectors class. However, besides that, the collect can offer other useful operations such as dividing stream elements into two or more groups or applying a collector to the result of another collector. In this topic, we will see how to sort the elements of a stream by using Collectors.partitioningBy and Collectors.groupingBy methods. We will also learn what a downstream collector is and how to use it.

Partitioning

Imagine that we want to divide a collection of accounts into two groups: accounts whose balance is greater than or equal to 10000, and accounts with a balance lower than 10000. In other words, we need to partition accounts into two groups based on a specified condition. It becomes possible by using a partitioning operation.

The partitioning operation is presented by the Collectors.partitioningBy method that accepts a predicate. It splits input elements into a Map of two lists: one list contains elements for which the predicate is true, and the other contains elements for which it is false. The keys of the Map has the Boolean type.

To illustrate the idea, let’s create the following list of accounts:

List<Account> accounts = List.of(
        new Account(3333, "530012"),
        new Account(15000, "771843"),
        new Account(0, "681891")
);

And partition them into two lists by a balance >= 10000 predicate:

Map<Boolean, List<Account>> accountsByBalance = accounts.stream()
        .collect(Collectors.partitioningBy(account -> account.getBalance() >= 10000));

The accountsByBalance map contains the following entries:

{
    false=[Account{balance=3333, number='530012'}, Account{balance=0, number='681891'}], 
    true=[Account{balance=15000, number='771843'}]
}

The partitioning operation can produce a Map with empty lists, but they will always exist.

Grouping

The grouping operation is similar to the partitioning. However, instead of splitting data into two groups based on a predicate, the grouping operation can produce any number of groups based on a classification function that maps elements to some key.

The grouping operation is presented by the Collectors.groupingBy method that accepts a classification function. The collector groupingBy also produces a Map. The keys of the Map are values produced by applying the classification function to the input elements. The corresponding values of the Map are lists containing elements mapped by the classification function.

Let’s create the Status enum and add field status to the Account class:

enum Status {
    ACTIVE,
    BLOCKED,
    REMOVED
}

public class Account {
    private long balance;
    private String number;
    private Status status;
    
    // constructors
    // getters and setters
}

Also, let’s update the list of accounts:

List<Account> accounts = List.of(
        new Account(3333L, "530012", Status.REMOVED),
        new Account(15000L, "771843", Status.ACTIVE),
        new Account(0L, "681891", Status.BLOCKED)
);

Now, we can divide all account into groups by its status:

Map<Status, List<Account>> accountsByStatus = accounts.stream()
        .collect(Collectors.groupingBy(Account::getStatus));

The accountsByStatus map contains the following entries:

{
    BLOCKED=[Account{balance=0, number='681891'}], 
    REMOVED=[Account{balance=3333, number='530012'}], 
    ACTIVE=[Account{balance=15000, number='771843'}]
}

The grouping operation produces entries when needed, which means that the resulting Map may contain any number of entries. For example, if the input is an empty stream, the resulting Map will contain no entries.

Downstream collectors

In addition to a predicate or a classification function, partitioningBy and groupingBy collectors can accept a downstream collector. Such a collector is applied to the results of another collector. For instance, groupingBy collector, which accepts a classification function and a downstream collector, groups elements according to a classification function, and then applies a specified downstream collector to the values associated with a given key.

To illustrate how it works, let’s create the following list of accounts:

List<Account> accounts = List.of(
        new Account(3333L, "530012", Status.ACTIVE),
        new Account(15000L, "771843", Status.BLOCKED),
        new Account(15000L, "234465", Status.ACTIVE),
        new Account(8800L, "110011", Status.ACTIVE),
        new Account(45000L, "462181", Status.BLOCKED),
        new Account(0L, "681891", Status.REMOVED)
);

And calculate the total balances of blockedactive, and removed accounts using a downstream collector:

Map<Status, Long> sumByStatuses = accounts.stream()
        .collect(Collectors.groupingBy(
                Account::getStatus, 
                Collectors.summingLong(Account::getBalance))
         );

The code above groups accounts by the status field and applies a downstream summingLong collector to the List values created by the groupingBy operator. The resulting map contains the following entries:

{ REMOVED=0, ACTIVE=27133, BLOCKED=60000 }

The groupingBy method can also accept a supplier to provide an empty map into which the results will be inserted. It is useful if you wish to specify the exact implementation of the Map interface, for example:

Map<Status, Long> sumByStatuses = accounts.stream()
        .collect(Collectors.groupingBy(
                Account::getStatus,
                LinkedHashMap::new,
                Collectors.summingLong(Account::getBalance))
         );

Teeing collector

Java 12 introduced the teeing collector which accepts two downstream collectors and a merger function as arguments and combines the results of the downstream collectors into the final result using the provided merger function. Let’s see how it works on an example using the same list of accounts as in the previous paragraph.

Suppose we want to extract the following information from the collection of accounts: the number of blocked accounts and the total amount of balances of the blocked accounts.

This is how we can use the teeing collector to solve our task:

long[] countAndSum = accounts
        .stream()
        .filter(account -> account.getStatus() == Status.BLOCKED)
        .collect(Collectors.teeing(
                Collectors.counting(),
                Collectors.summingLong(Account::getBalance),
                (count, sum) -> new long[]{count, sum})
        );

First, we filter the stream to get accounts with the BLOCKED status. Then we use the teeing collector and pass the counting collector and the summingLong collector to it to get the count of the filtered items and the sum of their balances. After that, we put the results of the downstream collectors to an array, which after the stream is processed will contain the following values: [2, 60000].

In a similar way, we can use the teeing collector to return any two values from a single stream.

Conclusion

To divide stream elements into exactly two groups based on a specified condition, we can use Collectors.partitioningBy collector. It accepts a predicate and produces a Map with Boolean keys and List values. If we need to divide stream elements into more than two groups, we can use Collectors.groupingBy collector. It accepts a classification function and groups elements according to it. The groupingBy also produces a Map with Lists values and keys whose type is a return type of the classification function. Both collectors can take a predicate or a classification function accordingly and a downstream collector that is applied to the results of partitioning or grouping.

Leave a Reply

Your email address will not be published.