Guide to Java 8 Collectors: Definitive Guide to toList()

Introduction

Streams don't hold any data by themselves - they just stream it from a source. Yet, common code routines expect some sort of a structure to hold results after processing data. That is why, after (optional) intermediate operations, the Stream API provides ways to convert the elements that it may have acted on into collections - like lists, that you can further use in your code.

These ways include applying:

  • Predefined or custom collectors:
<R,A> R collect(Collector<? super T,A,R> collector);

This is the most common, cleanest and simple approach you can utilize, and we'll be covering that first.

  • Suppliers, accumulators, and combiners (separating a Collector into its constituent parts):
<R> R collect(Supplier<R> supplier, BiConsumer<R, ? super T> accumulator, BiConsumer<R, R> combiner);

Or, you could terminate a stream by turning it into an array. Then, turn that array into a list. This is because the API already has two methods for producing arrays. They include:

Object[] toArray();

Which returns an array containing the elements of a stream.

<A> A[] toArray(IntFunction<A[]> generator);

Where, the generator is a function which produces a new array of the desired type and the provided length

These array-producing methods are bound to make code extra verbose. And, that may make your code less readable. Yet in the end, they will still help you to convert a stream to a list.

If you'd like to read more about array to list conversion, read up on How to Convert Java Array to ArrayList.

Otherwise, this guide will look into how all these approaches work in detail. It will also throw in a few dirty hacks which will help you to convert too. Be careful with them, though - such tinkerings are bound to hurt your code's performance.

How to Convert a Stream to List using Collectors

The official documentation defines a collector as an implementation which is:

  1. Mutable;
  2. A reduction operation;

And:

[3] that accumulates input elements into a mutable result container, [4] optionally transforming the accumulated result into a final representation after all input elements have been processed.

Note how these 4 conditions seem like a mouthful. But, as we will see next, they are not as hard to fulfill.

Predefined Collectors

The Java 8 Stream API works in tandem with the Collectors API. The Collectors class offers ready-made collectors that apply the supplier-accumulator-combiner in their implementations.

Hence, using facilities from the Collectors utility class will clean up your code significantly.

The method we can use from the Collectors class is Collectors.toList().

To convert a stream into a list using pre-built Collectors, we simply collect() it into a list:

List list = Stream.of("David", "Scott", "Hiram").collect(Collectors.toList());
System.out.println(String.format("Class: %s\nList: %s", list.getClass(), list));

This example is rather simple and just deals with Strings:

Class: class java.util.ArrayList
List: [David, Scott, Hiram]

Though, if you're not working with Strings or simpler types, you'll likely have to map() your objects before collecting them, which is more often the case than not. Let's define a simple Donor object, and a BloodBank that keeps track of them, and convert a Stream of Donors into a List.

Convert Stream to List with map() and collect()

Let us start by declaring a Donor class to model a blood donor:

public class Donor implements Comparable<Donor>{

    private final String name;
    // O-, O+, A-, A+, B-, B+, AB-, AB+
    private final String bloodGroup;
    // The amount of blood donated in mls
    // (An adult can donate about 450 ml of blood)
    private final int amountDonated;

    public Donor(String name, String bloodGroup, int amountDonated) {
        // Validation of the name and the blood type should occur here
        this.name = name;
        this.bloodGroup = bloodGroup;
        this.amountDonated = amountDonated;
    }
    
    @Override
    public int compareTo(Donor otherDonor) {
        return Comparator.comparing(Donor::getName)
                .thenComparing(Donor::getBloodGroup)
                .thenComparingInt(Donor::getAmountDonated)
                .compare(this, otherDonor);
    }
}

It is advisable to implement the Comparable interface here since it facilitates the ordering and sorting of the Donor objects in collections. You can always supply custom Comparators instead, though, a Comparable entity is simply easier and cleaner to work with.

Then, we define a BloodBank interface, which specifies that blood banks can receive a donation from a Donor, as well as return all the available types:

public interface BloodBank {
    void receiveDonationFrom(Donor donor);
    List<String> getAvailableTypes();    
}

The next step is to create a concrete implementation of a BloodBank. Since all concrete implementations will accept donors, and only the approach to getting the available types will be implementation-dependent - let's create an abstract class as a middleman:

public abstract class AbstractBloodBank implements BloodBank {
    // Protected so as to expose the donors' records to all other blood banks that will extend this AbstractBloodBank
    protected final List<Donor> donors;

    public AbstractBloodBank() {
        this.donors = new ArrayList<>();
    }

    @Override
    public void receiveDonationFrom(Donor donor) {
        donors.add(donor);
    }

    // Classes that extend AbstractBloodBank should offer their unique implementations
    // of extracting the blood group types from the donors' records 
    @Override
    public abstract List<String> getAvailableTypes();
}

Finally, we can go ahead and create a concrete implementation and map() the Donor list to their blood type, within a Stream and collect() it back into a list, returning the available blood types:

public class CollectorsBloodBank extends AbstractBloodBank {

    @Override
    public List<String> getAvailableTypes() {
        return donors.stream().map(Donor::getBloodGroup).collect(Collectors.toList());
    }
}

You can map() the donors to any of the fields in the object and return a list of those fields, such as the amountDonated or name as well. Having a comparable field also makes it possible to sort them via sorted().

If you'd like to read more about the sorted() method, read our How to Sort a List with Stream.sorted().

You could return all of the Donor instances instead, by simply calling collect() on their Stream:

@Override
public List<Donor> getAvailableDonors() {
    return donors.stream().collect(Collectors.toList());
}
Free eBook: Git Essentials

Check out our hands-on, practical guide to learning Git, with best-practices, industry-accepted standards, and included cheat sheet. Stop Googling Git commands and actually learn it!

Though, you're not limited to just collecting a stream into a list - this is where the collectingAndThen() method comes into play.

Convert Stream to List with Collectors.collectingAndThen()

Earlier on we consulted the official documentation and, it stated that collectors have the capacity of:

…optionally transforming the accumulated result into a final representation after all input elements have been processed.

The accumulated result in CollectorsBloodBank, for example, is represented by Collectors.toList(). We can transform this result further using the method Collectors.collectingAndThen().

Good practice requires one to return immutable collection objects. So, if we were to stick to this practice, a finisher step can be added to the conversion of stream to list:

public class UnmodifiableBloodBank extends AbstractBloodBank {

    @Override
    public List<String> getAvailableTypes() {
        return donors.stream()
                .map(Donor::getBloodGroup)
                .collect(
                        Collectors.collectingAndThen(
                                // Result list
                                Collectors.toList(),
                                // Transforming the mutable list into an unmodifiable one
                                Collections::unmodifiableList
                        )
                );
    }
}

Alternatively, you can put any Function<R, RR> as a finisher here as well.

If you'd like to read more, you can also read our detailed guide on the Collectors.collectingAndThen() method (coming soon!)

Convert Stream to List with Suppliers, Accumulators, and Combiners

Instead of using predefined collectors, you can use separate Suppliers, Accumulators and Combiners instead. These are implemented as a Supplier<R>, BiConsumer<R, ? super T> and BiConsumer<R,R>, which all fit snugly into a collect() instead of a predefined Collector.

Let's take a look at how you can utilize this flexibility to return all the available types:

public class LambdaBloodBank extends AbstractBloodBank {

    @Override
    public List<String> getAvailableTypes() {
        return donors.stream() // (1)
                .map(donor -> donor.getBloodGroup()) // (2)
                .collect(
                        () -> new ArrayList<String>(), // (3)
                        (bloodGroups, bloodGroup) -> bloodGroups.add(bloodGroup), //(4)
                        (resultList, bloodGroups) -> resultList.addAll(bloodGroups) //(5)
                );
    }
}

The implementation above applies the requisite supplier-accumulator-combiner pattern in a few steps:

Firstly, it turns the donors list field into a stream of Donor elements.

Remember, the LambdaBloodBank can access the donors field because it extends AbstractBloodBank. And, the donors field has protected access in the AbstractBloodBank class.

Then, an intermediate map operation is performed on the stream of Donors. The operation creates a new stream containing the String values that represent the donors' blood group types. Then, a result container that is mutable - i.e., the collector's supplier is created. This supplier container will be henceforth known as bloodGroups.

We add each blood group type (named bloodgroup in this step) from the stream into the mutable container: bloodGroups. In other words, the accumulation is occurring at this step.

The mutable, supplier container bloodGroups is added into the result container known as the resultList in this step. This is thus the combiner step.

We can improve the LambdaBloodBank's getAvailableTypes() method further by using method references instead of lambdas:

public class MembersBloodBank extends AbstractBloodBank {

    @Override
    public List<String> getAvailableTypes() {
        return donors.stream()
                .map(Donor::getBloodGroup)
                .collect(
                        ArrayList::new,
                        ArrayList::add,
                        ArrayList::addAll
                );
    }
}

Creating Custom Collectors for Java 8 Streams

When you pass:

Collectors.collect(Supplier<R> supplier, BiConsumer<R, ? super T> accumulator, BiConsumer<R, R> combiner);

You are providing the arguments that the Collectors utility class will use to create a custom collector for you, implicitly. Otherwise, the starting point for creating a custom collector is the implementation of the Collector interface.

In our case, a collector that accumulates the blood group types would look like this CustomCollector class:

public class CustomCollector implements Collector<String, List<String>, List<String>> {

    // Defines the mutable container that will hold the results
    @Override
    public Supplier<List<String>> supplier() {
        return ArrayList::new;
    }

    // Defines how the mutable container
    // should accumulate the elements passed to it from the stream
    @Override
    public BiConsumer<List<String>, String> accumulator() {
        return List::add;
    }

    // The combiner method will only be called when you are running the stream in parallel
    // If you stick to sequential stream processing 
    // Only the supplier and accumulator will be called and, optionally the finisher method
    @Override
    public BinaryOperator<List<String>> combiner() {
        return (bloodGroups, otherBloodGroups) -> {
            bloodGroups.addAll(otherBloodGroups);
            return bloodGroups;
        };
    }

    // Defines any other transformations that should be carried out on the mutable container before
    // it is finally returned at when the stream terminates
    @Override
    public Function<List<String>, List<String>> finisher() {
        return Collections::unmodifiableList;
    }

    @Override
    public Set<Characteristics> characteristics() {
        return Collections.emptySet();
    }
}

The CustomCollector class can then help you to convert a stream to a list like in this CustomCollectorBloodBank class:

public class CustomCollectorBloodBank extends AbstractBloodBank {

    @Override
    public List<String> getAvailableTypes() {
        return donors.stream()
                .map(Donor::getBloodGroup)
                // Plug in the custom collector
                .collect(new CustomCollector());
    }
}

Note: If you were to go all out with this - you can have multiple methods, such as toList(), toMap(), etc. that return different collections, using this same class.

How to Convert a Stream to List using Arrays

The Stream API offers a way of collecting elements from a stream pipeline into arrays. And because the Arrays utility class has methods that transform arrays into lists, this is a route you can opt for. Albeit, this approach is verbose, code-wise, and it's recommended to utilize either pre-built collectors, or to define your own if the standard ones don't fit your use-case.

Arrays of Objects

Using the Stream.toArray() method, transform a stream into an array of objects. (That is, elements of the base Object class). This may turn too verbose, depending on your use case and, it risks lowering your code's readability to a considerable extent.

Take this ArrayOfObjectsBloodBank class, for example:

public class ArrayOfObjectsBloodBank extends AbstractBloodBank {

    @Override
    public List<String> getAvailableTypes() {
        // Transform the stream into an array of objects
        Object[] bloodGroupObjects = donors.stream()
                .map(Donor::getBloodGroup)
                .toArray();
        // Initialize another array with the same length as that of the array of objects from the stream
        String[] bloodGroups = new String[bloodGroupObjects.length];
        // Iterate over the array of objects to read each object sequentially
        for (int i = 0; i < bloodGroupObjects.length; i++) {
            Object bloodGroupObject = bloodGroupObjects[i];
            // Cast each object into an equivalent string representation
            bloodGroups[i] = String.class.cast(bloodGroupObject);
        }
        // Transform the array of blood group string representations into a list
        return Arrays.asList(bloodGroups);
    }
}

This approach is fickle, requires classic for loops and iteration, manual casting and is considerably less readable than previous approaches - but it works.

Arrays Requiring an IntFunction Generator

Another way that the Stream API offers for turning a stream of elements into an array is the Stream.toArray(IntFunction<A[]> generator) method. Whereas the preceding tactic of deriving an array of objects demanded the use of considerably many lines of code, the generator approach is quite succinct:

public class ArrayBloodBank extends AbstractBloodBank {

    @Override
    public List<String> getAvailableTypes() {
        // Transform the stream into an array holding elements of the same class type
        // like those in the stream pipeline
        String[] bloodGroupArr = donors.stream()
                .map(Donor::getBloodGroup)
                .toArray(String[]::new);
        // Transform the array into a list
        return Arrays.asList(bloodGroupArr);
    }
}

This is much better than the previous approach, and actually isn't all that bad - though, there's still a simply redundant conversion between an array and list here.

Other (Discouraged) Tactics of Converting Streams to Lists

The Stream API discourages the introduction of side effects into the stream pipeline. Because streams may be exposed to parallel threads, it is dangerous to attempt to modify an externally declared source container.

Thus, the two following examples of using Stream.forEach() and Stream.reduce() when you want to convert a stream to list are bad hacks.

Piggybacking on Stream.forEach()

public class ForEachBloodBank extends AbstractBloodBank {

    @Override
    public List<String> getAvailableTypes() {
        List<String> bloodGroups  = new ArrayList<>();
        
        donors.stream()
                .map(Donor::getBloodGroup)
                // Side effects are introduced here - this is bad for parallelism
                .forEach(bloodGroups::add);
        return bloodGroups;
    }
}

Without parallelism, this works just fine and the code will produce the results you want but it's not future-proof and is best avoided.

Convert a Stream to List using Stream.reduce()

public class StreamReduceBloodBank extends AbstractBloodBank {

    @Override
    public List<String> getAvailableTypes() {
        return donors.stream()
                .map(Donor::getBloodGroup)
                .reduce(
                        // Identity
                        new ArrayList<>(),
                        // Accumulator function
                        (bloodGroups, bloodGroup) -> {
                            bloodGroups.add(bloodGroup);
                            return bloodGroups;
                        },
                        // Combiner function
                        (bloodGroups, otherBloodGroups) -> {
                            bloodGroups.addAll(otherBloodGroups);
                            return bloodGroups;
                        }
                );
    }
}

Conclusion

The Stream API introduced multiple ways of making Java more functional in nature. Because streams help operations to run in parallel, it is important that optional intermediate and terminal operations uphold the principles of:

  • Non-interference
  • Minimizing side effects
  • Keeping operation behaviors stateless

Among the tactics that this article has explored, the use of collectors is the one that promises to help you achieve all the three principles. It is thus important that as you continue working with streams, you improve your skills of handling both predefined and custom collectors.

The source code for this guide is available on GitHub.

Last Updated: October 17th, 2023
Was this article helpful?

Improve your dev skills!

Get tutorials, guides, and dev jobs in your inbox.

No spam ever. Unsubscribe at any time. Read our Privacy Policy.

Hiram KamauAuthor

In addition to catching code errors and going through debugging hell, I also obsess over whether writing in an active voice is truly better than doing it in passive.

© 2013-2024 Stack Abuse. All rights reserved.

AboutDisclosurePrivacyTerms