All You Need Is

Stream#toList() の罠

2024/08/08
Javaプログラミング

はじめに

本記事では Stream#toList() の注意点について記す。 Stream#toList() は、Java 16 から導入された新しいメソッドで、Stream#collect(Collectors.toList()) と同じように Stream から List への変換を行う。しかし、ここには罠がある。それにもかかわらず、単に Stream#collect(Collectors.toList())Stream#toList() に書き換えるということだけに留まる記事がある。

そこで、注意喚起として本記事を書くことにした。問題提起や結論については JDK 16 : stream.toList() に見る API 設計の難しさ - A Memorandum と重複するが、本記事ではより詳細な実装と類似の動作をするコードを確認する。

LITERALLY TL;DL

Stream から List への変換

Java で StreamList への変換するコードを考えてみよう。ここでは、件の Stream#toList() が JDK に追加される前後での動作の違いを確認する。

Java 8

Java 8 以降、Stream から List への変換はStream#collect(Collectors.toList()) で行える。ここでは jshell を使用してコードの実行結果を確認する。

Stream#collect(Collectors.toList()) の動作を理解するため、 List<Integer> を受け取って n 倍して新しい List を返すユーティリティメソッド timesN を定義する。

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

このメソッドを使うと List.of(1, 2, 3) の各要素を 2 倍したリストが得られる。

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

timesN が返したリストに対して要素を追加してみよう。

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

問題なく要素を追加することができた。これは Collectors.toList()ArrayList を返すからだ。

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

しかし、ArrayList が返されることは Javadoc に明記されていない。また、可変性についても何も保証されないことが書いてある。しかし、Open JDK の実装を確認すると 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
}
java.base/share/classes/java/util/stream/Collectors.java#L268-L283

Open JDK の実装としては ArrayList が返されるが、Collectors.toList() が返すリストが ArrayList であることを前提にコードを書くべきではない。 Java 標準ライブラリの Javadoc に従うと、仕様としては $1.add(8) のようなコードを書いたとき、その動作は未定義ということになる。もしかしたら他の JDK では ArrayList を使っていないかもしれない。

さて、現実として Stream#collect(Collectors.toList()) の結果に要素を追加してしまうようなコードは書かれてしまっているのではないだろうか。そんなことはしない、と思う人が多いかもしれない。確かに Stream#collect(Collectors.toList())List に変換した後、その場で List#add() を呼ぶことは少ないだろう (その手のコードを見たことあるが…)。 List に変換したその場で呼び出すことはなくとも、今回のように変換した後に List としてメソッドから返されると List#add() を呼んでしまう可能性が高い。

Java 16

次に Java 16 で追加された Stream#toList() を使ってみる。 Stream#collect(Collectors.toList()) と同じように List に変換できる。

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

勿論、Stream#collect(Collectors.toList()) と同じようにリストの各要素を 2 倍したリストが返ってくる。

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

Stream#collect(Collectors.toList()) と同じように要素を追加する。

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 😕

例外が発生してしまった。getClass メソッドでインスタンスのクラスを確認する。

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

どこで間違ったのだろうか?

ここで立ち止まって Stream#collect()Stream#toList() のドキュメントを見直してみよう。

ドキュメントと実装

Stream#collect(Collectors.toList())

Java の Stream#collect()可変リダクション なのだ。

記事が長くなってしまったので Stream#collect() については、独立した記事にして後日公開します。

Stream#toList()

Javadoc を確認するとメソッドの仕様について次のように書かれている。

返される List は変更できません。ミューテータ・メソッドを呼び出すと、常に UnsupportedOperationException がスローされます。

また、次のような記述もある。

実装要件:

このインタフェースの実装は、次のように生成された List を返します:

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

話は単純で Stream#toList()Stream#collect(Collectors.toList()) の代わりではないのだ。 Stream#collect(Collectors.toList()) は可変性について保証されていなかったが、 Stream#toList() では不変であることが Collections#unmodifiableList によって保証されている。

Stream#toList() の動作を理解するためには、

をそれぞれ知らなければならない。

this.toArray()

ソースコードを見ると Stream#toArray() は抽象メソッドとなっている。

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();
java.base/share/classes/java/util/stream/Stream.java#L872-L881

つまり、Stream を実装したクラスがどのように書かれているかに依存する。 List インターフェースを実装するクラスの中で使われることが多いのは ArrayList のインスタンスだろう。 ArrayList の実装は 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
}
java.base/share/classes/java/util/AbstractCollection.java#L116-L149

実装としては Iterator を使って配列に要素をコピーしている。つまり、ここで一度リストの要素を先頭から末尾まで走査していることになる。

Arrays#asList

次に 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
}
java.base/share/classes/java/util/Arrays.java#L4082-L4123

ここで現れる ArrayList は、一般的に知られている java.util.ArrayList ではなく、Arrays クラスの内部クラスとして実装されている ArrayList が使われる。

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
}
java.base/share/classes/java/util/Arrays.java#L4125-L4138

ArrayList の実装を見るとコンストラクタでは引数で渡された配列をフィールドに持つだけであり、渡された配列に対しては null チェックだけを行っている。また、add のようなミュータブルな操作は ArrayList 内で実装されておらず、継承している 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
}
java.base/share/classes/java/util/AbstractList.java#L139-L154

見ての通り UnsupportedOperationException を返すような実装であり、Arrays#asList(...) が不変なリストを返していることがわかる。

ArrayList のコンストラクタ

ArrayList のコンストラクタは以下のような実装となっている。

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
}
java.base/share/classes/java/util/ArrayList.java#L172-L192

引数では任意のコレクションを受け取ってCollection#toArray() で配列に変換していることがわかる。コレクションが java.util.ArrayList の場合は、配列に変換した結果をそのままフィールドに持ち、そうでない場合は Arrays#copyOf で配列を複製している。

今回の場合、コンストラクタに渡されるのは java.util.Arrays$ArrayList (≠ java.util.ArrayList) であるため、 Arrays#copyOf によって配列が複製されることになる。

Arrays#copyOf を見る前に Arrays#asList() で返される ArrayListtoArray メソッドがどのように実装されているか見てみよう。

1
@Override
2
public Object[] toArray() {
3
return Arrays.copyOf(a, a.length, Object[].class);
4
}
java.base/share/classes/java/util/Arrays.java#L4145-L4148

Arrays#asList の内部では Arrays#copyOf が呼ばれている。なんと、この実装だと ArrayList のコンストラクタで Arrays#copyOf2 回も呼ばれている。何かを見落しているのだろうか。 ArrayList の場合を追っているから配列が 2 回コピーされてしまっているだけで、他のデータ構造であれば最初の this.toArray() は異なる動作になっているだろう。

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
}
java.base/share/classes/java/util/Arrays.java#L3484-L3517

同じサイズの配列を宣言してコピーしている。 Java で実装されたコードとして追えるのはここまでとなる。配列のコピーで System.arraycopy を使っているが、このメソッドは JVM によって提供されるナイティブメソッドのため Java による実装ではない。

ArrayList のコンストラクタでは、内部的に配列でデータを保持するコレクションが渡された場合は、効率的に配列を複製して返していることがわかった。

Collections#unmodifiableList()

最後に 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
}
java.base/share/classes/java/util/Collections.java#L1301-L1326

Collections#unmodifiableList() には java.util.ArrayList のインスタンスが渡されるため、 new UnmodifiableRandomAccessList<>(list) が返される。

UnmodifiableRandomAccesList の実装の大部分は 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
}
java.base/share/classes/java/util/Collections.java#L1427-L1455

UnmodifiableList は引数で受け取ったリストをフィールドに持ち、ミュータブルな操作を行うメソッドは UnsupportedOperationException を返すようになラッパーとなっている。

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
// ...
java.base/share/classes/java/util/Collections.java#L1328-L1353

Collections#unmodifiableList() では、データの複製はされずに元のリストをラップして返している。結論として、Stream#toList() では Stream の元となっているコレクションがArrayList の場合は、 配列の複製を 2 回行うだけでリストの先頭から末尾までの走査は行なわれない

Collectors#unmodifiableList()

さて、ここで Java 11 以降の Stream が提供するメソッドを眺めたことがある人は思いました。 Collectors#unmodifiableList() があるではないか。つまり、Stream#toList()Stream#collect(Collectors.unmodifiableList()) の代わりなのではないか?、と。

それでは Collectors#unmodifiableList() の実装を見てみよう。

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
}
java.base/share/classes/java/util/stream/Collectors.java#L285-L309

Collector の動作について触れると更に長くなってしまうため、CollectorImplがどのように振る舞うのか詳細は説明しない。ここで定義された CollectorImpl のインスタンスでは、各要素を一度 ArrayList に追加してシングルトンリストを生成する。そして、先頭のリストに対して各リストを蓄積していき、最終的に listFromTrustedArray によって変更不可能なリストへ変換する。

ここで気になるのは SharedSecrets だ。実装の詳細を見ていこう。

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
}
java.base/share/classes/jdk/internal/access/SharedSecrets.java#L89-L98

何故このような書き方がされているかは、Javadoc にも書かれているが StackOverflow でされている回答も参考になる。 Java 9 で導入された JPMS (Java 9 Platform Module System) を使う場合にも触れている。

ここでは java.util.ImmutableCollections$AccessJavaUtilCollectionAccess の実装を提供しているのでそちらを見てみよう。

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
}
java.base/share/classes/java/util/ImmutableCollections.java#L120-L131

配列から ImmutableCollections#listFromTrustedArray で変更不可能なリストに変換している。 listFromTrustedArray の実装は次のようになっており、各要素に対する明示的な null チェックが List への変換の前に行われている。そのため、リストの先頭から末尾までの走査が一度行われる

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
}
java.base/share/classes/java/util/ImmutableCollections.java#L195-L222

このコードから Collectors#toUnmodifiableList() が返すリストは、

のいずれかのインスタンスであることがわかった。それぞれの実装UnmodifiableList と似ているためここでは省略して別の機会に確認する。

Stream#collect(Collectors.toList())Stream#toList() の違い

Stream#collect(Collectors.toList())Stream#toList() はどちらも変更不可能なリストを返す。二つは同じように見えるが、ここまでの実装を追った人ならば明らかに異なる点が二つあることがわかるだろう。

要素に null が含まれているとき

Stream#collect(Collectors.unmodifiableList())Stream#toList() では要素に null が含まれていた場合の動作が異なるのだ。

実際に 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()null を含むリストを返すが、Stream#collect(Collectors.toUnmodifiableList())null を含むリストでは例外を投げる。これは大きな違いだろう。

実行時のオーバーヘッド

既に触れているが Stream#collect(Collectors.toUnmodifiableList()) では、配列に値を蓄積するために先頭から末尾まで一度走査した後、更に明示的な null チェックのためにもう一度先頭から末尾まで走査している。結果的にリストを 2 回走査しているため実行時のオーバーヘッドが大きい。

また、Collectors.toList()ArrayList に値を詰め直すような実装となっており、こちらもリストを先頭から末尾まで走査しているが、 null チェックをしていないためリストの走査は 1 回で済んでいる。

一方、Stream#toList() は配列の複製を 2 回行うが ArrayList に値を詰め直すことなく、Collections.unmodifiableList() でラップしているだけである。これにより、他の二つの方法と比べるとコード上は実行時間にかなり有利に働くように見える実装となっている。

実装のまとめ

ここまで調べた結果をまとめると次の表のようになる。

記述可変性/不変性null効率
Stream#collect(Collectors.toList())👎 可変👍 OK👎 Bad
Stream#collect(Collectors.toUnmodifiableList())👍 不変👎 NG👎 Worst
Stream#toList()👍 不変👍 OK👍 Best

この記事書くまで関係性について知らなかった。

みんな違ってみんないい。

金子みすゞ

Naming is important

そもそもの問題は、命名の一貫性が欠如していることにある。 Java はこれまで伝統的に List と言えば基本的には可変なリストを指していた [要出典]。現に Collectors#toList() は可変なリストを返す。そして、不変なコレクションを扱う場合は Collectors#toUnmodifiableList() のように unmodifiable- という接頭辞が付けられていたはずだ。

つまり、Stream#toList()Stream#toUnmodifiableList() という名前で実装されるべきだったのではないだろうか。どのような経緯で Stream#toList() が追加されたのか追えていないが、toList という命名をした人の罪は重い (個人の感想)。

中途半端な実装

今日においてコレクションがメソッドやクラスのようなスコープを越えて可変であることを誰が喜ぶのだろうか。コレクションが可変であることは、処理を追うの t 難しくし、バグを生み出す原因となる。

その需要に応える形として Java 10 で Collections#toUnmodifiableList() が追加されたのだろう。 Collections#toUnmodifiableList()変更不可能な Listを返す。

しかし、実装を追うと Collectiors#unmodifiableList() は明らかに他の二つの方法よりも実行時間がかかるため使うのは躊躇われる。

教訓

この話は API 設計の難しさを示している。標準ライブラリの API 設計の難しさだけではなく、標準ライブラリを利用したコードの API 設計の難しさにも話が及ぶ。我々は必要以上の情報を外部に公開してはいけないし、内部実装を API 利用者が知らなければならないような設計は避けるべきだ。本来、標準ライブラリであれば外部に公開されることに過度に慎重にならなければならないという場面は少ないはずだが、 Java のコレクションではその責任を標準ライブラリではなく利用者が負う形となっている。

そのため、コレクション回りのコードを変更する場合はインターフェースでなく、そのインスタンスが何であるのかを意識しなければならない

おわりに

本記事では、Stream#toList() の注意点について記したが、実際には Stream#collect(Collectors.toList()) と Java 標準ライブラリの問題点について触れたと言う方が正しいだろうか。まさかこんなに長い旅になるとは思わなかった。

一般論としてプログラミングではデータ構造を理解し、用途によって使い分けなければならないが、プログラミングが広く普及した現在において API 設計やデータ構造を学ぶことなくコーディングをしている人は少なくないだろう。また、プログラミングを始めたての初心者に限らず、公式ドキュメントを読んで仕様を理解した上でクラスやメソッドを利用している人は少ないように感じる。今回の問題については、Java 標準ライブラリの命名と API 設計の責任が大きいように思えた。

Java に限った話ではないかもしれないが、私の (少ないプログラミング) 経験からすると Java のコレクションは難しいだ。 不正な操作に対しては実行時例外を投げることを良しとする Java のやり方が拍車をかけている。

今回の可変性・不変性に対する Q&A は Collections Framework の FAQ に記載されている。

長々と書かれているが可変性・不変性によって API を分けてもよかったのではないかと素人ながら考えてしまう。メソッドを呼び出すまで可変か不変かわからないコレクションと戦うのは辛い。中途半端な不変性を持つコレクションがあるからこそ Stream#toList() のような罠にはまるのだろう。これならば不変なコレクションを作らずに、可変なコレクションを使ってしまえばいいのではないかと思ってしまう。 Java においては不変なコレクションを使ったとしても静的解析 (型検証) で得られる恩恵はほぼ無いということが今回の件でよくわかった。

Scala のような言語では不変コレクションが中心的に使われているが、 Java のような可変コレクションをベースに構築された言語が不変コレクションに移行することは難しいということだろう。

あぁ、Java のコレクションが再構築される日が待ち遠しい。

少なくともこの記事を最後まで読めば、Stream#toList() が怖くて夜しか眠れないということはないだろう。安心してお昼寝して欲しい。

安心できた人は Giscuss のリアクションで 👍 してくれると喜びます。

参考サイト


Buy Me A Coffee