Immutable Collections using Collectors

Introduction

There are various ways to convert a Java stream to a list. We can use Google Guava to create immutable collections as well. The Java collectors did not have utility methods to return collectors which will collect the stream elements into an immutable collection before JDK 10. In this post, we will see how to collect a Java Stream as Immutable collections using Collectors in Java 10+.

Collecting a stream as a collection (list)

Let us say we have a list of strings which are actually integers. We want to convert the list to a list of integers. We do this by using Java streams – create a stream out of the input list, map each string to an integer, and collect into a list.

List<String> input = List.of("1", "2", "3", "4");

List<Integer> output = input.stream()
        .map(Integer::valueOf)
        .collect(Collectors.toList());

Using Collectors.toList returns a Collector that accumulates the input elements into a new List. But we cannot guarantee the type or the mutability of the list that will be returned.

Note: As of now, it returns an ArrayList which is mutable.

Collecting a stream as an immutable collection before Java 10

Let us look at the options we had, before Java 10, to collect a stream into an immutable collection.

Option 1 – Using Collections.unmodifiableList

Collections.unmodifiableList is a static utility method from the Collections class. It returns an unmodifiable view of the passed list. Thus, we can wrap the call to the stream pipeline in that so that the result is unmodifiable.

List<Integer> output = Collections.unmodifiableList(input.stream()
        .map(Integer::valueOf)
        .collect(Collectors.toList()));

Unmodifiable vs Immutable

It is important to note the difference between immutable and unmodifiable. An immutable collection is truly immutable. It cannot be mutated or modified. But an unmodifiable collection cannot be modified using the (unmodifiable collection) reference you have. However, it is possible that it can be mutated through some other way.

Example: When calling Collections.unmodifiableList with a mutable list, it returns an unmodifiable view of the passed mutable list. Note that this is just a view – it does not copy the passed list. Hence, both the references (the input list passed to the unmodifiableList method and the unmodifiable list it returns) point to the same list object in memory.

We cannot modify the list using the unmodifiable list the Collections.unmodifiableList method returns. But we can modify the list using the input list reference and this will be reflected through the unmodifiable list reference as well (since both refer to the same list).

This is because mutation of the list happened via some other code that had a reference to the source (underlying list) of the unmodifiable list. In other words, the unmodifiable collection was just a wrapper around the modifiable list.

When can an unmodifiable collection truly immutable?

We can guarantee an unmodifiable collection is also immutable when no code has access to the modifiable collection i.e., the underlying collection of the returned unmodifiable collection. 

In the above example, the modifiable list returned by Collectors.toList was an intermediate list which isn’t made accessible to anyone else. However, if we leak that modifiable list, it is possible for some code to mutate the underlying collection as,

List<Integer> modifiableOutput = input.stream()
        .map(Integer::valueOf)
        .collect(Collectors.toList());
List<Integer> unmodifiableOutput = Collections.unmodifiableList(modifiableOutput);

//anyone who has access to modifiableOutput can modify the list
modifiableOutput.add(5);

//oops, underlying collection is modified
System.out.println(unmodifiableOutput); //[1, 2, 3, 4, 5]

Option 2 – Using collectingAndThen

Using Collectors collectingAndThen, we can convert the list returned by toList() into an unmodifiable list (can say it is an immutable list as the source is not accessible outside the stream pipeline and hence it cannot be mutated).

List<Integer> output = input.stream()
        .map(Integer::valueOf)
        .collect(Collectors.collectingAndThen(Collectors.toList(),
                Collections::unmodifiableList));

Java 10 – Creating Immutable collections using Collectors

In Java 10, they added utility methods to the Collectors class to create immutable collections (lists, sets, and maps) from a stream. We will see about the following Collectors methods:

  • toUnmodifiableList
  • toUnmodifiableSet
  • toUnmodifiableMap

Collectors toUnmodifiableList

The Collectors.toUnmodifiableList method returns a collector which accumulates the stream elements into an unmodifiable list in encounter order. It does not allow null values to be entered into the list.

List<String> input = List.of("1", "2", "3", "4");

List<Integer> unmodifiableOutput = input.stream()
        .map(Integer::valueOf)
        .collect(Collectors.toUnmodifiableList());

System.out.println(unmodifiableOutput); //[1, 2, 3, 4]

The returned list is immutable and will throw a java.lang.UnsupportedOperationException if we attempt to mutate the list, e.g., add or remove an element from the list.

Collectors toUnmodifiableSet

Similar to Collectors.toList() method, the Collectors.toSet() also does not guarantee the type and the mutability of the set it returns.

The Collectors.toUnmodifiableSet returns a Collector that accumulates the stream elements into an unmodifiable set. (We cannot insert null values into the set).

List<String> input = List.of("1", "2", "3", "4", "2");

Set<Integer> unmodifiableOutput = input.stream()
        .map(Integer::valueOf)
        .collect(Collectors.toUnmodifiableSet());

System.out.println(unmodifiableOutput); //[1, 2, 3, 4]

Collectors toUnmodifiableMap

Let us use the same input list, but now we will collect it as a map (Map<String, Integer>).

List<String> input = List.of("1", "2", "3", "4");

Map<String, Integer> result = input.stream()
        .collect(Collectors.toMap(Function.identity(), Integer::valueOf));

System.out.println(result); //{1=1, 2=2, 3=3, 4=4}

We used Collectors.toMap in the above code. As you would have guessed, we cannot guarantee the type/mutability of the map it returns.

We can use the toUnmodifiableMap method, which returns a collector that would accumulate the elements into an unmodifiable map.

Map<String, Integer> result = input.stream()
        .collect(Collectors.toUnmodifiableMap(Function.identity(), Integer::valueOf));
System.out.println(result); //{2=2, 1=1, 4=4, 3=3}

If there are duplicate keys, then we can pass a mergeFunction – else it throws an IllegalStateException (Refer to the post on Collectors#toMap for more details).

List<String> input = List.of("1", "2", "3", "4", "2");
Map<String, Integer> result = input.stream()
        .collect(Collectors.toUnmodifiableMap(Function.identity(), Integer::valueOf,
                (old, newVal) -> newVal));
System.out.println(result); //{2=2, 1=1, 4=4, 3=3}

Note that the order of the entries in the map has changed when we were using toUnmodifiableMap. The actual type of the map it returns is an implementation detail (The same is true for the toUnmodifiableList and toUnmodifiableSet methods as well. They don’t say what type of list or set it would return, respectively). All it says it that the returned collection (list, set or map) is unmodifiable. As we have seen, since the source list/set/map is not accessible outside the stream pipeline/internals, the returned collection is immutable.

Conclusion

In this post, we learnt how we can create Immutable collections using Collectors in Java 10. We learnt about the three methods added in Java 10 (toUnmodifiableList, toUnmodifiableSet and toUnmodifiableMap). Using these, we can get an unmodifiable or immutable collection from a stream.
I recommend checking out the other methods in the Java Collectors class.

Useful references

Leave a Reply