Stream#toList() の罠
はじめに
本記事では 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#collect()
は 可変リダクション 操作を実行する。Stream#toList()
はリストの 変更不可能なビュー を返す。- 安易に
Stream#collect(Collectors.toList())
をStream#toList()
に書き換えてはいけない。 - Javadoc を読みましょう。
Stream
から List
への変換
Java で Stream
を List
への変換するコードを考えてみよう。ここでは、件の Stream#toList()
が JDK に追加される前後での動作の違いを確認する。
Java 8
Java 8 以降、Stream
から List
への変換はStream#collect(Collectors.toList())
で行える。ここでは jshell
を使用してコードの実行結果を確認する。
Stream#collect(Collectors.toList())
の動作を理解するため、
List<Integer>
を受け取って n
倍して新しい List
を返すユーティリティメソッド timesN
を定義する。
1jshell> 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 倍したリストが得られる。
1jshell> ListUtils.timesN(List.of(1, 2, 3), 2);2$1 ==> [2, 4, 6]
timesN
が返したリストに対して要素を追加してみよう。
1jshell> $1.add(8);2$2 ==> true34jshell> $15$1 ==> [2, 4, 6, 8]
問題なく要素を追加することができた。これは Collectors.toList()
は ArrayList
を返すからだ。
1jshell> $1.getClass();2$3 ==> class java.util.ArrayList
しかし、ArrayList
が返されることは Javadoc に明記されていない。また、可変性についても何も保証されないことが書いてある。しかし、Open JDK の実装を確認すると ArrayList
が返されることがわかる。
1public class Collectors {2// ...34/**5* Returns a {@code Collector} that accumulates the input elements into a6* new {@code List}. There are no guarantees on the type, mutability,7* serializability, or thread-safety of the {@code List} returned; if more8* control over the returned {@code List} is required, use {@link #toCollection(Supplier)}.9*10* @param <T> the type of the input elements11* @return a {@code Collector} which collects all the input elements into a12* {@code List}, in encounter order13*/14public static <T>15Collector<T, ?, List<T>> toList() {16return new CollectorImpl<>(ArrayList::new, List::add,17(left, right) -> { left.addAll(right); return left; },18CH_ID);19}2021// ...22}
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
に変換できる。
1jshell> 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 倍したリストが返ってくる。
1jshell> ListUtils.timesN(List.of(1, 2, 3), 2);2$4 ==> [2, 4, 6]
Stream#collect(Collectors.toList())
と同じように要素を追加する。
1jshell> $4.add(8);2| 例外java.lang.UnsupportedOperationException3| at ImmutableCollections.uoe (ImmutableCollections.java:142)4| at ImmutableCollections$AbstractImmutableCollection.add (ImmutableCollections.java:147)5| at (#4:1)
Oops 😕
例外が発生してしまった。getClass
メソッドでインスタンスのクラスを確認する。
1jshell> $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()
Arrays#asList
ArrayList
のコンストラクタCollections#unmodifiableList
をそれぞれ知らなければならない。
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">terminal5* operation</a>.6*7* @return an array, whose {@linkplain Class#getComponentType runtime component8* type} is {@code Object}, containing the elements of this stream9*/10Object[] toArray();
つまり、Stream
を実装したクラスがどのように書かれているかに依存する。
List
インターフェースを実装するクラスの中で使われることが多いのは ArrayList
のインスタンスだろう。
ArrayList
の実装は AbstractCollection
クラスでされている。
1/**2* {@inheritDoc}3*4* @implSpec5* This implementation returns an array containing all the elements6* returned by this collection's iterator, in the same order, stored in7* consecutive elements of the array, starting with index {@code 0}.8* The length of the returned array is equal to the number of elements9* returned by the iterator, even if the size of this collection changes10* during iteration, as might happen if the collection permits11* concurrent modification during iteration. The {@code size} method is12* called only as an optimization hint; the correct result is returned13* even if the iterator returns a different number of elements.14*15* <p>This method is equivalent to:16*17* <pre> {@code18* List<E> list = new ArrayList<E>(size());19* for (E e : this)20* list.add(e);21* return list.toArray();22* }</pre>23*/24public Object[] toArray() {25// Estimate size of array; be prepared to see more or fewer elements26Object[] r = new Object[size()];27Iterator<E> it = iterator();28for (int i = 0; i < r.length; i++) {29if (! it.hasNext()) // fewer elements than expected30return Arrays.copyOf(r, i);31r[i] = it.next();32}33return it.hasNext() ? finishToArray(r, it) : r;34}
実装としては Iterator
を使って配列に要素をコピーしている。つまり、ここで一度リストの要素を先頭から末尾まで走査していることになる。
Arrays#asList
次に Arrays#asList
の実装を確認する。
1/**2* Returns a fixed-size list backed by the specified array. Changes made to3* the array will be visible in the returned list, and changes made to the4* list will be visible in the array. The returned list is5* {@link Serializable} and implements {@link RandomAccess}.6*7* <p>The returned list implements the optional {@code Collection} methods, except8* those that would change the size of the returned list. Those methods leave9* the list unchanged and throw {@link UnsupportedOperationException}.10*11* @apiNote12* This method acts as bridge between array-based and collection-based13* APIs, in combination with {@link Collection#toArray}.14*15* <p>This method provides a way to wrap an existing array:16* <pre>{@code17* 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-size23* list initialized to contain several elements:24* <pre>{@code25* 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, use30* {@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 array34* @param a the array by which the list will be backed35* @return a list view of the specified array36* @throws NullPointerException if the specified array is {@code null}37*/38@SafeVarargs39@SuppressWarnings("varargs")40public static <T> List<T> asList(T... a) {41return new ArrayList<>(a);42}
ここで現れる ArrayList
は、一般的に知られている java.util.ArrayList
ではなく、Arrays
クラスの内部クラスとして実装されている ArrayList
が使われる。
1/**2* @serial include3*/4private static class ArrayList<E> extends AbstractList<E>5implements RandomAccess, java.io.Serializable6{7@java.io.Serial8private static final long serialVersionUID = -2764017481108945198L;9@SuppressWarnings("serial") // Conditionally serializable10private final E[] a;1112ArrayList(E[] array) {13a = Objects.requireNonNull(array);14}
ArrayList
の実装を見るとコンストラクタでは引数で渡された配列をフィールドに持つだけであり、渡された配列に対しては null
チェックだけを行っている。また、add
のようなミュータブルな操作は ArrayList
内で実装されておらず、継承している AbstractList
の方で定義されている。
1/**2* {@inheritDoc}3*4* @implSpec5* This implementation always throws an6* {@code UnsupportedOperationException}.7*8* @throws UnsupportedOperationException {@inheritDoc}9* @throws ClassCastException {@inheritDoc}10* @throws NullPointerException {@inheritDoc}11* @throws IllegalArgumentException {@inheritDoc}12* @throws IndexOutOfBoundsException {@inheritDoc}13*/14public void add(int index, E element) {15throw new UnsupportedOperationException();16}
見ての通り UnsupportedOperationException
を返すような実装であり、Arrays#asList(...)
が不変なリストを返していることがわかる。
ArrayList
のコンストラクタ
ArrayList
のコンストラクタは以下のような実装となっている。
1/**2* Constructs a list containing the elements of the specified3* collection, in the order they are returned by the collection's4* iterator.5*6* @param c the collection whose elements are to be placed into this list7* @throws NullPointerException if the specified collection is null8*/9public ArrayList(Collection<? extends E> c) {10Object[] a = c.toArray();11if ((size = a.length) != 0) {12if (c.getClass() == ArrayList.class) {13elementData = a;14} else {15elementData = Arrays.copyOf(a, size, Object[].class);16}17} else {18// replace with empty array.19elementData = EMPTY_ELEMENTDATA;20}21}
引数では任意のコレクションを受け取ってCollection#toArray()
で配列に変換していることがわかる。コレクションが java.util.ArrayList
の場合は、配列に変換した結果をそのままフィールドに持ち、そうでない場合は Arrays#copyOf
で配列を複製している。
今回の場合、コンストラクタに渡されるのは java.util.Arrays$ArrayList
(≠ java.util.ArrayList
) であるため、
Arrays#copyOf
によって配列が複製されることになる。
Arrays#copyOf
を見る前に Arrays#asList()
で返される ArrayList
の toArray
メソッドがどのように実装されているか見てみよう。
1@Override2public Object[] toArray() {3return Arrays.copyOf(a, a.length, Object[].class);4}
Arrays#asList
の内部では Arrays#copyOf
が呼ばれている。なんと、この実装だと ArrayList
のコンストラクタで Arrays#copyOf
が 2 回も呼ばれている。何かを見落しているのだろうか。
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 are4* valid in both the original array and the copy, the two arrays will5* contain identical values. For any indices that are valid in the6* copy but not the original, the copy will contain {@code null}.7* Such indices will exist if and only if the specified length8* 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 array12* @param <T> the class of the objects in the returned array13* @param original the array to be copied14* @param newLength the length of the copy to be returned15* @param newType the class of the copy to be returned16* @return a copy of the original array, truncated or padded with nulls17* to obtain the specified length18* @throws NegativeArraySizeException if {@code newLength} is negative19* @throws NullPointerException if {@code original} is null20* @throws ArrayStoreException if an element copied from21* {@code original} is not of a runtime type that can be stored in22* an array of class {@code newType}23* @since 1.624*/25@IntrinsicCandidate26public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {27@SuppressWarnings("unchecked")28T[] copy = ((Object)newType == (Object)Object[].class)29? (T[]) new Object[newLength]30: (T[]) Array.newInstance(newType.getComponentType(), newLength);31System.arraycopy(original, 0, copy, 0,32Math.min(original.length, newLength));33return copy;34}
同じサイズの配列を宣言してコピーしている。
Java で実装されたコードとして追えるのはここまでとなる。配列のコピーで System.arraycopy
を使っているが、このメソッドは JVM によって提供されるナイティブメソッドのため Java による実装ではない。
ArrayList
のコンストラクタでは、内部的に配列でデータを保持するコレクションが渡された場合は、効率的に配列を複製して返していることがわかった。
Collections#unmodifiableList()
最後に Collections#unmodifiableList()
の実装を見てみよう。
1/**2* Returns an <a href="Collection.html#unmodview">unmodifiable view</a> of the3* specified list. Query operations on the returned list "read through" to the4* specified list, and attempts to modify the returned list, whether5* direct or via its iterator, result in an6* {@code UnsupportedOperationException}.<p>7*8* The returned list will be serializable if the specified list9* is serializable. Similarly, the returned list will implement10* {@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 list14* @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")18public static <T> List<T> unmodifiableList(List<? extends T> list) {19if (list.getClass() == UnmodifiableList.class || list.getClass() == UnmodifiableRandomAccessList.class) {20return (List<T>) list;21}2223return (list instanceof RandomAccess ?24new UnmodifiableRandomAccessList<>(list) :25new UnmodifiableList<>(list));26}
Collections#unmodifiableList()
には java.util.ArrayList
のインスタンスが渡されるため、
new UnmodifiableRandomAccessList<>(list)
が返される。
UnmodifiableRandomAccesList
の実装の大部分は UnmodifiableList
の方でされている。
1/**2* @serial include3*/4static class UnmodifiableRandomAccessList<E> extends UnmodifiableList<E>5implements RandomAccess6{7UnmodifiableRandomAccessList(List<? extends E> list) {8super(list);9}1011public List<E> subList(int fromIndex, int toIndex) {12return new UnmodifiableRandomAccessList<>(13list.subList(fromIndex, toIndex));14}1516@java.io.Serial17private static final long serialVersionUID = -2542308836966382001L;1819/**20* Allows instances to be deserialized in pre-1.4 JREs (which do21* not have UnmodifiableRandomAccessList). UnmodifiableList has22* a readResolve method that inverts this transformation upon23* deserialization.24*/25@java.io.Serial26private Object writeReplace() {27return new UnmodifiableList<>(list);28}29}
UnmodifiableList
は引数で受け取ったリストをフィールドに持ち、ミュータブルな操作を行うメソッドは UnsupportedOperationException
を返すようになラッパーとなっている。
1/**2* @serial include3*/4static class UnmodifiableList<E> extends UnmodifiableCollection<E>5implements List<E> {6@java.io.Serial7private static final long serialVersionUID = -283967356065247728L;89@SuppressWarnings("serial") // Conditionally serializable10final List<? extends E> list;1112UnmodifiableList(List<? extends E> list) {13super(list);14this.list = list;15}1617public boolean equals(Object o) {return o == this || list.equals(o);}18public int hashCode() {return list.hashCode();}1920public E get(int index) {return list.get(index);}21public E set(int index, E element) {22throw new UnsupportedOperationException();23}24public void add(int index, E element) {25throw new UnsupportedOperationException();26}27// ...
Collections#unmodifiableList()
では、データの複製はされずに元のリストをラップして返している。結論として、Stream#toList()
では Stream
の元となっているコレクションがArrayList
の場合は、
配列の複製を 2 回行うだけでリストの先頭から末尾までの走査は行なわれない。
Collectors#unmodifiableList()
さて、ここで Java 11 以降の Stream
が提供するメソッドを眺めたことがある人は思いました。
Collectors#unmodifiableList()
があるではないか。つまり、Stream#toList()
は Stream#collect(Collectors.unmodifiableList())
の代わりなのではないか?、と。
それでは Collectors#unmodifiableList()
の実装を見てみよう。
1public class Collectors {2// ...34/**5* Returns a {@code Collector} that accumulates the input elements into an6* <a href="../List.html#unmodifiable">unmodifiable List</a> in encounter7* order. The returned Collector disallows null values and will throw8* {@code NullPointerException} if it is presented with a null value.9*10* @param <T> the type of the input elements11* @return a {@code Collector} that accumulates the input elements into an12* <a href="../List.html#unmodifiable">unmodifiable List</a> in encounter order13* @since 1014*/15public static <T>16Collector<T, ?, List<T>> toUnmodifiableList() {17return new CollectorImpl<>(ArrayList::new, List::add,18(left, right) -> { left.addAll(right); return left; },19list -> {20if (list.getClass() == ArrayList.class) { // ensure it's trusted21return SharedSecrets.getJavaUtilCollectionAccess()22.listFromTrustedArray(list.toArray());23} else {24throw new IllegalArgumentException();25}26},27CH_NOID);28}29}
Collector
の動作について触れると更に長くなってしまうため、CollectorImpl
がどのように振る舞うのか詳細は説明しない。ここで定義された CollectorImpl
のインスタンスでは、各要素を一度 ArrayList
に追加してシングルトンリストを生成する。そして、先頭のリストに対して各リストを蓄積していき、最終的に listFromTrustedArray
によって変更不可能なリストへ変換する。
ここで気になるのは SharedSecrets
だ。実装の詳細を見ていこう。
1/** A repository of "shared secrets", which are a mechanism for2calling implementation-private methods in another package without3using reflection. A package-private class implements a public4interface and provides the ability to call package-private methods5within that package; the object implementing that interface is6provided through a third package to which access is restricted.7This framework avoids the primary disadvantage of using reflection8for this purpose, namely the loss of compile-time checking. */910public class SharedSecrets {11// ...1213public static JavaUtilCollectionAccess getJavaUtilCollectionAccess() {14var access = javaUtilCollectionAccess;15if (access == null) {16try {17Class.forName("java.util.ImmutableCollections$Access", true, null);18access = javaUtilCollectionAccess;19} catch (ClassNotFoundException e) {}20}21return access;22}2324// ...25}
何故このような書き方がされているかは、Javadoc にも書かれているが StackOverflow でされている回答も参考になる。 Java 9 で導入された JPMS (Java 9 Platform Module System) を使う場合にも触れている。
ここでは java.util.ImmutableCollections$Access
が JavaUtilCollectionAccess
の実装を提供しているのでそちらを見てみよう。
1static class Access {2static {3SharedSecrets.setJavaUtilCollectionAccess(new JavaUtilCollectionAccess() {4public <E> List<E> listFromTrustedArray(Object[] array) {5return ImmutableCollections.listFromTrustedArray(array);6}7public <E> List<E> listFromTrustedArrayNullsAllowed(Object[] array) {8return ImmutableCollections.listFromTrustedArrayNullsAllowed(array);9}10});11}12}
配列から ImmutableCollections#listFromTrustedArray
で変更不可能なリストに変換している。
listFromTrustedArray
の実装は次のようになっており、各要素に対する明示的な null
チェックが List
への変換の前に行われている。そのため、リストの先頭から末尾までの走査が一度行われる。
1/**2* Creates a new List from a trusted array, checking for and rejecting null3* elements.4*5* <p>A trusted array has no references retained by the caller. It can therefore be6* safely reused as the List's internal storage, avoiding a defensive copy. The array's7* class must be Object[].class. This method is declared with a parameter type of8* Object... instead of E... so that a varargs call doesn't accidentally create an array9* of some class other than Object[].class.10*11* @param <E> the List's element type12* @param input the input array13* @return the new list14*/15@SuppressWarnings("unchecked")16static <E> List<E> listFromTrustedArray(Object... input) {17assert input.getClass() == Object[].class;18for (Object o : input) { // implicit null check of 'input' array19Objects.requireNonNull(o);20}2122return switch (input.length) {23case 0 -> (List<E>) ImmutableCollections.EMPTY_LIST;24case 1 -> (List<E>) new List12<>(input[0]);25case 2 -> (List<E>) new List12<>(input[0], input[1]);26default -> (List<E>) new ListN<>(input, false);27};28}
このコードから Collectors#toUnmodifiableList()
が返すリストは、
ImmutableCollections.EMPTY_LIST
List12
ListN
のいずれかのインスタンスであることがわかった。それぞれの実装は UnmodifiableList
と似ているためここでは省略して別の機会に確認する。
Stream#collect(Collectors.toList())
と Stream#toList()
の違い
Stream#collect(Collectors.toList())
と Stream#toList()
はどちらも変更不可能なリストを返す。二つは同じように見えるが、ここまでの実装を追った人ならば明らかに異なる点が二つあることがわかるだろう。
要素に null
が含まれているとき
Stream#collect(Collectors.unmodifiableList())
と Stream#toList()
では要素に null
が含まれていた場合の動作が異なるのだ。
実際に jshell で試してみる。
1jshell> Stream.of(1, null, 3).toList();2$1 ==> [1, null, 3]34jshell> Stream.of(1, null, 3).collect(Collectors.toUnmodifiableList());5| 例外java.lang.NullPointerException6| 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 のリアクションで 👍 してくれると喜びます。