2024-08-08

The Trap of Stream#toList()

Programming
Notes
This article was translated by GPT-5.2-Codex. The original is here.

Introduction

This article notes the caveats of Stream#toList(). Stream#toList() is a new method introduced in Java 16, and like Stream#collect(Collectors.toList()), it converts a Stream to a List. However, there is a trap. Even so, some articles merely say "replace Stream#collect(Collectors.toList()) with Stream#toList()" and stop there.

So I wrote this as a warning. The problem statement and conclusion overlap with JDK 16 : stream.toList() に見る API 設計の難しさ - A Memorandum, but here I look at more detailed implementations and code with similar behavior.

LITERALLY TL;DL

Converting Stream to List

Let's think about code that converts a Stream to a List in Java. Here, we compare behavior before and after Stream#toList() was added to the JDK.

Java 8

Since Java 8, you can convert Stream to List with Stream#collect(Collectors.toList()). Here we use jshell to check behavior.

To understand Stream#collect(Collectors.toList()), define a utility method timesN that takes a List<Integer> and returns a new List with each element multiplied by n.

1
jshell> class ListUtils {
2
...> static List<Integer> timesN(List<Integer> xs, int n) {
3
...> return xs.stream()
4
...> .map(x -> x * n)
5
...> .collect(Collectors.toList());
6
...> }
7
...> }
8
| 次を作成しました: クラス ListUtils

Using this method, you get a list where each element of List.of(1, 2, 3) is doubled.

1
jshell> ListUtils.timesN(List.of(1, 2, 3), 2);
2
$1 ==> [2, 4, 6]

Now try adding an element to the list returned by timesN.

1
jshell> $1.add(8);
2
$2 ==> true
3
4
jshell> $1
5
$1 ==> [2, 4, 6, 8]

You can add elements without issue. This is because Collectors.toList() returns an ArrayList.

1
jshell> $1.getClass();
2
$3 ==> class java.util.ArrayList

However, the Javadoc does not state that it returns an ArrayList. It also says nothing is guaranteed about mutability. But if you check the OpenJDK implementation, you can see it returns ArrayList.

1
public class Collectors {
2
// ...
3
4
/**
5
* Returns a {@code Collector} that accumulates the input elements into a
6
* new {@code List}. There are no guarantees on the type, mutability,
7
* serializability, or thread-safety of the {@code List} returned; if more
8
* control over the returned {@code List} is required, use {@link #toCollection(Supplier)}.
9
*
10
* @param <T> the type of the input elements
11
* @return a {@code Collector} which collects all the input elements into a
12
* {@code List}, in encounter order
13
*/
14
public static <T>
15
Collector<T, ?, List<T>> toList() {
16
return new CollectorImpl<>(ArrayList::new, List::add,
17
(left, right) -> { left.addAll(right); return left; },
18
CH_ID);
19
}
20
21
// ...
22
}

OpenJDK returns ArrayList, but you should not write code assuming Collectors.toList() returns ArrayList. According to the standard Javadoc, the behavior of code like $1.add(8) is undefined. Another JDK might not use ArrayList.

In reality, code that adds elements to the result of Stream#collect(Collectors.toList()) is probably being written. Many people might think they never do that. It's true that you rarely call List#add() immediately after collecting, but if you return the List from a method (as here), someone could easily call add() later.

Java 16

Now try Stream#toList() added in Java 16. It converts to List similarly.

1
jshell> class ListUtils {
2
...> static List<Integer> timesN(List<Integer> xs, int n) {
3
...> return xs.stream()
4
...> .map(x -> x * n)
5
...> .toList();
6
...> }
7
...> }
8
| 次を作成しました: クラス ListUtils

Of course, it returns the doubled list just like collect(toList()).

1
jshell> ListUtils.timesN(List.of(1, 2, 3), 2);
2
$4 ==> [2, 4, 6]

Now try adding an element as before.

1
jshell> $4.add(8);
2
| 例外java.lang.UnsupportedOperationException
3
| at ImmutableCollections.uoe (ImmutableCollections.java:142)
4
| at ImmutableCollections$AbstractImmutableCollection.add (ImmutableCollections.java:147)
5
| at (#4:1)

Oops 😕

An exception occurred. Check the class.

1
jshell> $4.getClass();
2
$5 ==> class java.util.ImmutableCollections$ListN

Where did we go wrong?

Let's stop and re-read the docs for Stream#collect() and Stream#toList().

Docs and implementation

Stream#collect(Collectors.toList())

Java's Stream#collect() performs mutable reduction.

This article is already long, so I will write a separate article about Stream#collect() later.

Stream#toList()

The Javadoc says:

The returned List is unmodifiable. Calls to mutator methods always throw UnsupportedOperationException.

It also says:

Implementation Requirements:

Implementations return a List created as follows:

Collections.unmodifiableList(new ArrayList<>(Arrays.asList(this.toArray())))

The point is simple: Stream#toList() is not a replacement for Stream#collect(Collectors.toList()). Stream#collect(Collectors.toList()) does not guarantee mutability, but Stream#toList() guarantees immutability via Collections#unmodifiableList.

To understand Stream#toList(), you need to know:

  • this.toArray()
  • Arrays#asList
  • ArrayList constructor
  • Collections#unmodifiableList
this.toArray()

Looking at the source, Stream#toArray() is abstract.

1
/**
2
* Returns an array containing the elements of this stream.
3
*
4
* <p>This is a <a href="package-summary.html#StreamOps">terminal
5
* operation</a>.
6
*
7
* @return an array, whose {@linkplain Class#getComponentType runtime component
8
* type} is {@code Object}, containing the elements of this stream
9
*/
10
Object[] toArray();

So it depends on how the Stream implementation is written. The most common implementation used in classes implementing List is probably ArrayList. ArrayList implements toArray() in AbstractCollection.

1
/**
2
* {@inheritDoc}
3
*
4
* @implSpec
5
* This implementation returns an array containing all the elements
6
* returned by this collection's iterator, in the same order, stored in
7
* consecutive elements of the array, starting with index {@code 0}.
8
* The length of the returned array is equal to the number of elements
9
* returned by the iterator, even if the size of this collection changes
10
* during iteration, as might happen if the collection permits
11
* concurrent modification during iteration. The {@code size} method is
12
* called only as an optimization hint; the correct result is returned
13
* even if the iterator returns a different number of elements.
14
*
15
* <p>This method is equivalent to:
16
*
17
* <pre> {@code
18
* List<E> list = new ArrayList<E>(size());
19
* for (E e : this)
20
* list.add(e);
21
* return list.toArray();
22
* }</pre>
23
*/
24
public Object[] toArray() {
25
// Estimate size of array; be prepared to see more or fewer elements
26
Object[] r = new Object[size()];
27
Iterator<E> it = iterator();
28
for (int i = 0; i < r.length; i++) {
29
if (! it.hasNext()) // fewer elements than expected
30
return Arrays.copyOf(r, i);
31
r[i] = it.next();
32
}
33
return it.hasNext() ? finishToArray(r, it) : r;
34
}

It copies elements into an array using an Iterator. So it scans the list from beginning to end once.

Arrays#asList

Next, check the implementation of Arrays#asList.

1
/**
2
* Returns a fixed-size list backed by the specified array. Changes made to
3
* the array will be visible in the returned list, and changes made to the
4
* list will be visible in the array. The returned list is
5
* {@link Serializable} and implements {@link RandomAccess}.
6
*
7
* <p>The returned list implements the optional {@code Collection} methods, except
8
* those that would change the size of the returned list. Those methods leave
9
* the list unchanged and throw {@link UnsupportedOperationException}.
10
*
11
* @apiNote
12
* This method acts as bridge between array-based and collection-based
13
* APIs, in combination with {@link Collection#toArray}.
14
*
15
* <p>This method provides a way to wrap an existing array:
16
* <pre>{@code
17
* Integer[] numbers = ...
18
* ...
19
* List<Integer> values = Arrays.asList(numbers);
20
* }</pre>
21
*
22
* <p>This method also provides a convenient way to create a fixed-size
23
* list initialized to contain several elements:
24
* <pre>{@code
25
* List<String> stooges = Arrays.asList("Larry", "Moe", "Curly");
26
* }</pre>
27
*
28
* <p><em>The list returned by this method is modifiable.</em>
29
* To create an unmodifiable list, use
30
* {@link Collections#unmodifiableList Collections.unmodifiableList}
31
* or <a href="List.html#unmodifiable">Unmodifiable Lists</a>.
32
*
33
* @param <T> the class of the objects in the array
34
* @param a the array by which the list will be backed
35
* @return a list view of the specified array
36
* @throws NullPointerException if the specified array is {@code null}
37
*/
38
@SafeVarargs
39
@SuppressWarnings("varargs")
40
public static <T> List<T> asList(T... a) {
41
return new ArrayList<>(a);
42
}

The ArrayList here is not java.util.ArrayList, but Arrays.ArrayList, an inner class of Arrays.

1
/**
2
* @serial include
3
*/
4
private static class ArrayList<E> extends AbstractList<E>
5
implements RandomAccess, java.io.Serializable
6
{
7
@java.io.Serial
8
private static final long serialVersionUID = -2764017481108945198L;
9
@SuppressWarnings("serial") // Conditionally serializable
10
private final E[] a;
11
12
ArrayList(E[] array) {
13
a = Objects.requireNonNull(array);
14
}

The constructor just keeps the passed array after a null check. Mutable operations like add are not implemented in this ArrayList, but in its parent AbstractList.

1
/**
2
* {@inheritDoc}
3
*
4
* @implSpec
5
* This implementation always throws an
6
* {@code UnsupportedOperationException}.
7
*
8
* @throws UnsupportedOperationException {@inheritDoc}
9
* @throws ClassCastException {@inheritDoc}
10
* @throws NullPointerException {@inheritDoc}
11
* @throws IllegalArgumentException {@inheritDoc}
12
* @throws IndexOutOfBoundsException {@inheritDoc}
13
*/
14
public void add(int index, E element) {
15
throw new UnsupportedOperationException();
16
}

As you can see, it always throws UnsupportedOperationException, so Arrays#asList(...) returns an unmodifiable list.

ArrayList constructor

ArrayList's constructor is implemented as follows.

1
/**
2
* Constructs a list containing the elements of the specified
3
* collection, in the order they are returned by the collection's
4
* iterator.
5
*
6
* @param c the collection whose elements are to be placed into this list
7
* @throws NullPointerException if the specified collection is null
8
*/
9
public ArrayList(Collection<? extends E> c) {
10
Object[] a = c.toArray();
11
if ((size = a.length) != 0) {
12
if (c.getClass() == ArrayList.class) {
13
elementData = a;
14
} else {
15
elementData = Arrays.copyOf(a, size, Object[].class);
16
}
17
} else {
18
// replace with empty array.
19
elementData = EMPTY_ELEMENTDATA;
20
}
21
}

It takes any collection and converts it to an array with Collection#toArray(). If the collection is java.util.ArrayList, it keeps the array as-is; otherwise it copies it with Arrays#copyOf.

In our case, the constructor receives java.util.Arrays$ArrayList (not java.util.ArrayList), so the array is copied via Arrays#copyOf.

Before checking Arrays#copyOf, let's see how toArray is implemented in the ArrayList returned by Arrays#asList().

1
@Override
2
public Object[] toArray() {
3
return Arrays.copyOf(a, a.length, Object[].class);
4
}

Inside Arrays#asList, Arrays#copyOf is already called. Surprisingly, in this flow, ArrayList's constructor calls Arrays#copyOf twice. Maybe I'm missing something. This double copy only happens for ArrayList; for other data structures, this.toArray() might behave differently.

Now let's check Arrays#copyOf.

1
/**
2
* Copies the specified array, truncating or padding with nulls (if necessary)
3
* so the copy has the specified length. For all indices that are
4
* valid in both the original array and the copy, the two arrays will
5
* contain identical values. For any indices that are valid in the
6
* copy but not the original, the copy will contain {@code null}.
7
* Such indices will exist if and only if the specified length
8
* is greater than that of the original array.
9
* The resulting array is of the class {@code newType}.
10
*
11
* @param <U> the class of the objects in the original array
12
* @param <T> the class of the objects in the returned array
13
* @param original the array to be copied
14
* @param newLength the length of the copy to be returned
15
* @param newType the class of the copy to be returned
16
* @return a copy of the original array, truncated or padded with nulls
17
* to obtain the specified length
18
* @throws NegativeArraySizeException if {@code newLength} is negative
19
* @throws NullPointerException if {@code original} is null
20
* @throws ArrayStoreException if an element copied from
21
* {@code original} is not of a runtime type that can be stored in
22
* an array of class {@code newType}
23
* @since 1.6
24
*/
25
@IntrinsicCandidate
26
public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
27
@SuppressWarnings("unchecked")
28
T[] copy = ((Object)newType == (Object)Object[].class)
29
? (T[]) new Object[newLength]
30
: (T[]) Array.newInstance(newType.getComponentType(), newLength);
31
System.arraycopy(original, 0, copy, 0,
32
Math.min(original.length, newLength));
33
return copy;
34
}

It allocates a same-size array and copies it. This is as far as we can follow in Java. It uses System.arraycopy, which is a JVM native method, not Java code.

So in ArrayList's constructor, if the collection stores data in an array, it copies it efficiently.

Collections#unmodifiableList()

Finally, let's check Collections#unmodifiableList().

1
/**
2
* Returns an <a href="Collection.html#unmodview">unmodifiable view</a> of the
3
* specified list. Query operations on the returned list "read through" to the
4
* specified list, and attempts to modify the returned list, whether
5
* direct or via its iterator, result in an
6
* {@code UnsupportedOperationException}.<p>
7
*
8
* The returned list will be serializable if the specified list
9
* is serializable. Similarly, the returned list will implement
10
* {@link RandomAccess} if the specified list does.
11
*
12
* @implNote This method may return its argument if the argument is already unmodifiable.
13
* @param <T> the class of the objects in the list
14
* @param list the list for which an unmodifiable view is to be returned.
15
* @return an unmodifiable view of the specified list.
16
*/
17
@SuppressWarnings("unchecked")
18
public static <T> List<T> unmodifiableList(List<? extends T> list) {
19
if (list.getClass() == UnmodifiableList.class || list.getClass() == UnmodifiableRandomAccessList.class) {
20
return (List<T>) list;
21
}
22
23
return (list instanceof RandomAccess ?
24
new UnmodifiableRandomAccessList<>(list) :
25
new UnmodifiableList<>(list));
26
}

Since Collections#unmodifiableList() receives a java.util.ArrayList instance, it returns new UnmodifiableRandomAccessList<>(list).

Most of UnmodifiableRandomAccessList is implemented in UnmodifiableList.

1
/**
2
* @serial include
3
*/
4
static class UnmodifiableRandomAccessList<E> extends UnmodifiableList<E>
5
implements RandomAccess
6
{
7
UnmodifiableRandomAccessList(List<? extends E> list) {
8
super(list);
9
}
10
11
public List<E> subList(int fromIndex, int toIndex) {
12
return new UnmodifiableRandomAccessList<>(
13
list.subList(fromIndex, toIndex));
14
}
15
16
@java.io.Serial
17
private static final long serialVersionUID = -2542308836966382001L;
18
19
/**
20
* Allows instances to be deserialized in pre-1.4 JREs (which do
21
* not have UnmodifiableRandomAccessList). UnmodifiableList has
22
* a readResolve method that inverts this transformation upon
23
* deserialization.
24
*/
25
@java.io.Serial
26
private Object writeReplace() {
27
return new UnmodifiableList<>(list);
28
}
29
}

UnmodifiableList wraps the provided list and throws UnsupportedOperationException for mutating operations.

1
/**
2
* @serial include
3
*/
4
static class UnmodifiableList<E> extends UnmodifiableCollection<E>
5
implements List<E> {
6
@java.io.Serial
7
private static final long serialVersionUID = -283967356065247728L;
8
9
@SuppressWarnings("serial") // Conditionally serializable
10
final List<? extends E> list;
11
12
UnmodifiableList(List<? extends E> list) {
13
super(list);
14
this.list = list;
15
}
16
17
public boolean equals(Object o) {return o == this || list.equals(o);}
18
public int hashCode() {return list.hashCode();}
19
20
public E get(int index) {return list.get(index);}
21
public E set(int index, E element) {
22
throw new UnsupportedOperationException();
23
}
24
public void add(int index, E element) {
25
throw new UnsupportedOperationException();
26
}
27
// ...

Collections#unmodifiableList() wraps the original list without copying data. Therefore, in Stream#toList(), if the source collection is ArrayList, there is no scan from head to tail—only two array copies.

Collectors#toUnmodifiableList()

If you've looked at Stream methods since Java 11, you might think: "There is Collectors#toUnmodifiableList(). So isn't Stream#toList() a replacement for Stream#collect(Collectors.toUnmodifiableList())?"

Let's look at the implementation.

1
public class Collectors {
2
// ...
3
4
/**
5
* Returns a {@code Collector} that accumulates the input elements into an
6
* <a href="../List.html#unmodifiable">unmodifiable List</a> in encounter
7
* order. The returned Collector disallows null values and will throw
8
* {@code NullPointerException} if it is presented with a null value.
9
*
10
* @param <T> the type of the input elements
11
* @return a {@code Collector} that accumulates the input elements into an
12
* <a href="../List.html#unmodifiable">unmodifiable List</a> in encounter order
13
* @since 10
14
*/
15
public static <T>
16
Collector<T, ?, List<T>> toUnmodifiableList() {
17
return new CollectorImpl<>(ArrayList::new, List::add,
18
(left, right) -> { left.addAll(right); return left; },
19
list -> {
20
if (list.getClass() == ArrayList.class) { // ensure it's trusted
21
return SharedSecrets.getJavaUtilCollectionAccess()
22
.listFromTrustedArray(list.toArray());
23
} else {
24
throw new IllegalArgumentException();
25
}
26
},
27
CH_NOID);
28
}
29
}

Going into Collector behavior would make this even longer, so I won't explain CollectorImpl in detail. This CollectorImpl accumulates elements into an ArrayList, merges lists, and finally converts to an unmodifiable list via listFromTrustedArray.

The interesting part is SharedSecrets. Let's look at it.

1
/** A repository of "shared secrets", which are a mechanism for
2
calling implementation-private methods in another package without
3
using reflection. A package-private class implements a public
4
interface and provides the ability to call package-private methods
5
within that package; the object implementing that interface is
6
provided through a third package to which access is restricted.
7
This framework avoids the primary disadvantage of using reflection
8
for this purpose, namely the loss of compile-time checking. */
9
10
public class SharedSecrets {
11
// ...
12
13
public static JavaUtilCollectionAccess getJavaUtilCollectionAccess() {
14
var access = javaUtilCollectionAccess;
15
if (access == null) {
16
try {
17
Class.forName("java.util.ImmutableCollections$Access", true, null);
18
access = javaUtilCollectionAccess;
19
} catch (ClassNotFoundException e) {}
20
}
21
return access;
22
}
23
24
// ...
25
}

Why is it written this way? The Javadoc explains it, and so does this StackOverflow answer, which also touches on JPMS introduced in Java 9.

Here java.util.ImmutableCollections$Access provides a JavaUtilCollectionAccess implementation, so let's check that.

1
static class Access {
2
static {
3
SharedSecrets.setJavaUtilCollectionAccess(new JavaUtilCollectionAccess() {
4
public <E> List<E> listFromTrustedArray(Object[] array) {
5
return ImmutableCollections.listFromTrustedArray(array);
6
}
7
public <E> List<E> listFromTrustedArrayNullsAllowed(Object[] array) {
8
return ImmutableCollections.listFromTrustedArrayNullsAllowed(array);
9
}
10
});
11
}
12
}

It converts an array to an unmodifiable list via ImmutableCollections#listFromTrustedArray. The implementation is below, and it explicitly checks for null on each element before conversion. So it scans the list from beginning to end once.

1
/**
2
* Creates a new List from a trusted array, checking for and rejecting null
3
* elements.
4
*
5
* <p>A trusted array has no references retained by the caller. It can therefore be
6
* safely reused as the List's internal storage, avoiding a defensive copy. The array's
7
* class must be Object[].class. This method is declared with a parameter type of
8
* Object... instead of E... so that a varargs call doesn't accidentally create an array
9
* of some class other than Object[].class.
10
*
11
* @param <E> the List's element type
12
* @param input the input array
13
* @return the new list
14
*/
15
@SuppressWarnings("unchecked")
16
static <E> List<E> listFromTrustedArray(Object... input) {
17
assert input.getClass() == Object[].class;
18
for (Object o : input) { // implicit null check of 'input' array
19
Objects.requireNonNull(o);
20
}
21
22
return switch (input.length) {
23
case 0 -> (List<E>) ImmutableCollections.EMPTY_LIST;
24
case 1 -> (List<E>) new List12<>(input[0]);
25
case 2 -> (List<E>) new List12<>(input[0], input[1]);
26
default -> (List<E>) new ListN<>(input, false);
27
};
28
}

From this code, the list returned by Collectors#toUnmodifiableList() is one of:

  • ImmutableCollections.EMPTY_LIST
  • List12
  • ListN

Their implementations are similar to UnmodifiableList, so I skip them here.

Differences between Stream#collect(Collectors.toList()) and Stream#toList()

Stream#collect(Collectors.toList()) and Stream#toList() both return unmodifiable lists. They look the same, but if you followed the implementation, you should see two clear differences.

When elements include null

Stream#collect(Collectors.toUnmodifiableList()) and Stream#toList() behave differently when elements contain null.

Let's check in jshell.

1
jshell> Stream.of(1, null, 3).toList();
2
$1 ==> [1, null, 3]
3
4
jshell> Stream.of(1, null, 3).collect(Collectors.toUnmodifiableList());
5
| 例外java.lang.NullPointerException
6
| at Objects.requireNonNull (Objects.java:209)
7
| at ImmutableCollections.listFromTrustedArray (ImmutableCollections.java:213)
8
| at ImmutableCollections$Access$1.listFromTrustedArray (ImmutableCollections.java:124)
9
| at Collectors.lambda$toUnmodifiableList$6 (Collectors.java:303)
10
| at ReferencePipeline.collect (ReferencePipeline.java:686)
11
| at (#13:1)

Stream#toList() allows null, but Stream#collect(Collectors.toUnmodifiableList()) throws when null is present. That's a big difference.

Runtime overhead

As mentioned, Stream#collect(Collectors.toUnmodifiableList()) first scans the list to accumulate into an array, then scans again to perform explicit null checks. So it scans the list twice, causing more overhead.

Collectors.toList() also scans once to fill an ArrayList, but since it doesn't do null checks, it scans once.

Stream#toList() copies the array twice, but does not refill an ArrayList; it just wraps it with Collections.unmodifiableList(). So in code, it looks much faster than the other two.

Summary of implementations

Summarizing the findings:

ExpressionMutabilityNullEfficiency
Stream#collect(Collectors.toList())👎 Mutable👍 OK👎 Bad
Stream#collect(Collectors.toUnmodifiableList())👍 Immutable👎 NG👎 Worst
Stream#toList()👍 Immutable👍 OK👍 Best

I did not know this relationship before writing the article.

Everyone is different and everyone is good.

Misuzu Kaneko

Naming is important

The core problem is lack of naming consistency. Traditionally in Java, List typically meant a mutable list [citation needed]. Indeed, Collectors#toList() returns a mutable list. And for unmodifiable collections, methods were named with the unmodifiable- prefix, like Collectors#toUnmodifiableList().

So perhaps Stream#toList() should have been named Stream#toUnmodifiableList(). I haven't traced why Stream#toList() was added, but the person who chose the name bears a heavy sin (personal opinion).

A half-baked implementation

Who is happy about collections being mutable across method/class boundaries today? Mutability makes code harder to follow and causes bugs.

To meet that need, Java 10 added Collectors#toUnmodifiableList(). It returns an unmodifiable List.

But looking at its implementation, Collectors#toUnmodifiableList() is clearly slower than the other two methods, so it is hard to recommend.

Lesson

This shows the difficulty of API design. Not only standard library API design, but also API design in code that uses the standard library. We should not expose more information than necessary, and we should avoid APIs that require users to know internal details. In a standard library, you would think you could be less careful, but with Java collections, the burden falls on users.

Therefore, when changing collection-related code, you must pay attention not just to the interface but the concrete instance.

Conclusion

This article was about Stream#toList()'s caveats, but it is more accurate to say it is about Stream#collect(Collectors.toList()) and the issues in the Java standard library. I didn't expect it to become such a long journey.

In general, programmers must understand data structures and choose appropriately, but today many people code without learning API design or data structures. And not only beginners, but even experienced developers often do not read official docs before using classes and methods. For this issue, the naming and API design of the Java standard library seem responsible.

This may not be unique to Java, but in my (limited) experience, Java collections are hard. The Java practice of throwing runtime exceptions for invalid operations makes it worse.

This mutability Q&A is described in the Collections Framework FAQ.

It is long, but I still think it might have been better to separate APIs by mutability. Fighting collections where mutability is unknown until you call a method is painful. Half-baked immutability leads to traps like Stream#toList(). At that point, you might think: why not just always use mutable collections? This case shows that in Java, immutable collections give almost no static-analysis benefit.

Languages like Scala center on immutable collections, but it seems hard for a language built around mutable collections like Java to transition.

I look forward to the day Java collections are rebuilt.

At least after reading this article, you won't be so afraid of Stream#toList() that you can only sleep at night. Please nap peacefully.

If you feel relieved, I'd be happy if you react with 👍 in Giscus.

References

Amazon アソシエイトについて

この記事には Amazon アソシエイトのリンクが含まれています。Amazonのアソシエイトとして、SuzumiyaAoba は適格販売により収入を得ています。