アプリケーションのコレクションまわりの実装について調査していたところ、Scala 2.12から2.13にかけての更新がscala.collection.immutable.ListMap
の挙動を変化させており、実は元々2.13で見せていた振舞いは仕様外のものだったことが判明したのでメモ。
この記事では、ScalaのコレクションクラスListMap
のバージョンを跨いだ振舞いの変化について学ぶことができ、また、こういったバージョン間で発生する標準ライブラリの変化の調査を行うための知見について知ることができる。
ListMap -- キー順序を保存するMap
ScalaにはListMap
というMap
(他の言語で言うところの辞書構造)の実装があり、内部的にList
を用いてキーを保持している。
List
はfindに $O(N)$ のコストが発生するので一見非効率なデータ構造に見えるが、要素追加のコストは常に $O(1)$ で済むことと、順序の保持が可能である点が重宝されて、List
でキーを保持するListMap
が使われることがある。
import scala.collection.immutable.ListMap val m = ListMap(3 -> null, 2 -> null, 1 -> null) val n = m + (4 -> null) n.keys // => List(3, 2, 1, 4)
このように、追加した順序が保存される。これがちょうどユースケース上重要なことがあり、たまに使われる。
Scala 2.13ではコレクションメソッドの整理が行われた
さて、少し昔のことになるが、Scala 2.12から2.13へのバージョンアップに伴い、コレクションまわりの大改編が行われた。詳しくは立ち入らないが、以下のような破壊的変更が行われている:
SetBuilder
などのいくつかのクラスの削除Stream
が削除され、LazyList
で置換されたbreakOut
が削除された
詳細は以下のドキュメントを参照してほしい:
ListMapの挙動が変化している
さて、ListMap
自体はインターフェイス上の破壊的変更を受けなかった。しかし内部的な修正に起因する振舞いの変更が発生している。
- キーの順序が追加した順で保存されるという振舞いは保存される。
- ただし、キーが重複した場合の振舞いが破壊的に変更された。
- 2.12の場合: キーが重複した場合は、そのキーが最後のキーとなり、既に存在していたキーは無いものとして扱われる
- 2.13以降の場合: キーが重複した場合は、キーの順序を変更せず、値のみが変更される。
言葉だけだと伝わりにくいと思うので、実際のコードで説明する。こういうとき、自分はオンラインScala実行環境であるScastieを起動して試すようにしている。
Scala 2.12.17におけるListMap
に重複キーを追加した場合の挙動
import scala.collection.immutable.ListMap val m = ListMap(3 -> "a", 2 -> "b", 1 -> "c") val n = m + (4 -> "d") // 追加した順でキーの順序が保存される n.keys // Set(3, 2, 1, 4) n // ListMap(3 -> a, 2 -> b, 1 -> c, 4 -> d) // 同じキーで別の値を入れる val o = n + (1 -> "new a") // 最後に追加したキーは最後に移動する o.keys //Set(3, 2, 4, 1) o // ListMap(3 -> a, 2 -> b, 4 -> d, 1 -> new a)
Scala 2.13.10におけるListMap
に重複キーを追加した場合の挙動
import scala.collection.immutable.ListMap val m = ListMap(3 -> "a", 2 -> "b", 1 -> "c") val n = m + (4 -> "d") // 追加した順でキーの順序が保存される n.keys // List(3, 2, 1, 4) n // ListMap(3 -> a, 2 -> b, 1 -> c, 4 -> d) // 同じキーで別の値を入れる val o = n + (1 -> "new a") // 最後に追加したキーは順序を替えず、値のみ更新される o.keys // List(3, 2, 1, 4) o // ListMap(3 -> a, 2 -> b, 1 -> new a, 4 -> d)
このように、挙動が変更されていることがわかる。
しかし、この挙動は元々2.12の時点でバグのある実装であり、2.13になってこれが修正されたと見ることもできる。2.12の時点でのScaladocのListMap
のページを参照すると、
This class implements immutable maps using a list-based data structure. List map iterators and traversal methods visit key-value pairs in the order whey were first inserted. Scala Standard Library 2.12.17 - scala.collection.immutable.ListMap
と記述されており、そもそも
発見の経緯
Scala 2.13にする実験をしていたところ、特定のテストが落ちだしたので発覚した。
ライブラリの調査をするときの心得と知見
仕様と乖離した振舞いを頼りに実装していると思わぬ落とし穴に落ちることがあることが分かった(そのような実装の修正には痛みが伴う)。そして、様々なレベルでの破壊的変更を辿るには、以下の点が有効だと感じた:
- 標準ライブラリのドキュメントを良く読むこと
- 標準ライブラリのコードを実際に読むこと(LSPはこういうときに非常に頼りになる)
- 標準ライブラリのクラス構造をよく知っておくこと
- 実際の挙動を、処理系のバージョンを切り替えつつ手軽に使えるScastieなどの実行環境で確認すること
これらの立ち居振舞いは言語を問わず共通のものだと思う。
また、このような挙動に悩まないために、テストを行う際は1つ先のScalaバージョンを含めたマトリックステストを行うことが有効だと思われる。とはいえ、2.13の破壊的変更は多岐に渡るので、そもそもコンパイルが通らないことのほうが多そうだ。