Stream#toList() の罠

2024/08/08

はじめに

本記事では 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 を定義する。

Loading code...

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

Loading code...

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

Loading code...

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

Loading code...

しかし、ArrayList が返されることは Javadoc に明記されていない。また、可変性についても何も保証されないことが書いてある。しかし、Open JDK の実装を確認すると ArrayList が返されることがわかる。

Loading code...
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 に変換できる。

Loading code...

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

Loading code...

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

Loading code...

Oops 😕

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

Loading code...

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

ここで立ち止まって 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() は抽象メソッドとなっている。

Loading code...
java.base/share/classes/java/util/stream/Stream.java#L872-L881

つまり、Stream を実装したクラスがどのように書かれているかに依存する。 List インターフェースを実装するクラスの中で使われることが多いのは ArrayList のインスタンスだろう。 ArrayList の実装は AbstractCollection クラスでされている。

Loading code...
java.base/share/classes/java/util/AbstractCollection.java#L116-L149

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

Arrays#asList

次に Arrays#asList の実装を確認する。

Loading code...
java.base/share/classes/java/util/Arrays.java#L4082-L4123

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

Loading code...
java.base/share/classes/java/util/Arrays.java#L4125-L4138

ArrayList の実装を見るとコンストラクタでは引数で渡された配列をフィールドに持つだけであり、渡された配列に対しては null チェックだけを行っている。また、add のようなミュータブルな操作は ArrayList 内で実装されておらず、継承している AbstractList の方で定義されている。

Loading code...
java.base/share/classes/java/util/AbstractList.java#L139-L154

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

ArrayList のコンストラクタ

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

Loading code...
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 メソッドがどのように実装されているか見てみよう。

Loading code...
java.base/share/classes/java/util/Arrays.java#L4145-L4148

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

Arrays#copyOf の実装を見てみよう。

Loading code...
java.base/share/classes/java/util/Arrays.java#L3484-L3517

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

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

Collections#unmodifiableList()

最後に Collections#unmodifiableList() の実装を見てみよう。

Loading code...
java.base/share/classes/java/util/Collections.java#L1301-L1326

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

UnmodifiableRandomAccesList の実装の大部分は UnmodifiableList の方でされている。

Loading code...
java.base/share/classes/java/util/Collections.java#L1427-L1455

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

Loading code...
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() の実装を見てみよう。

Loading code...
java.base/share/classes/java/util/stream/Collectors.java#L285-L309

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

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

Loading code...
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 の実装を提供しているのでそちらを見てみよう。

Loading code...
java.base/share/classes/java/util/ImmutableCollections.java#L120-L131

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

Loading code...
java.base/share/classes/java/util/ImmutableCollections.java#L195-L222

このコードから 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 で試してみる。

Loading code...

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 のリアクションで 👍 してくれると喜びます。

参考サイト

SuzumiyaAobaのプロフィール画像

SuzumiyaAoba

プログラミング、技術、その他の話題について共有するブログを書いてます。 主にScala、Java、TypeScriptなどの技術について興味あり。

ScalaJavaTypeScriptReact

Buy Me A Coffee