はじめに
こんにちは。マイクロアド京都研究所の池田です。本記事ではScala 3で導入されたMultiversal Equalityという等価の仕組みについて、実際のコードで動作を確認しつつ解説します。
コードはScala CLIで実行可能です。ぜひお手元でも実行してみてください。 コードは複数のScalaのバージョンや異なるオプションで実行します。 各コードの先頭に実行するScala-CLIのコマンドを記載していますので、そちらでご確認ください。
本記事で扱うScalaのバージョン/オプションごとのScala-CLIのコマンド
// Scala 2で実行する場合 scala-cli repl -S 2.13.10 // Scala 3 (Strict Equality無効)で実行する場合 ※ Strict Equalityについては後で説明します scala-cli repl -S 3.2.1 // Scala 3 (Strict Equality有効)で実行する場合 scala-cli repl -S 3.2.1 -language:strictEquailty
Scala 2の等価
Scala 2では、 Anyクラスで定義された==や!=メソッドによって任意のオブジェクト同士で等価比較します。
def ==(arg: Any): Boolean def !=(arg: Any): Boolean
すべてのオブジェクトで使えるため便利ではあるものの、異なる型同士のオブジェクトでも比較できてしまうため、バグになる可能性があります。
// scala-cli repl -S 2.13.10 scala> "hello" == Option("hello") val res0: Boolean = false
上記ではStringとOption[String]を等価比較していますがScala 2ではコンパイルエラーにはなりません。
このように任意の型と比較できてしまうScala 2の等価の仕組みをUniversal Equalityと呼びます。
Scala 3の等価
Scala 2のUniversal Equalityに対して、Scala 3での等価の仕組みをMultiversal Equalityと呼びます。
Scala 3ではStrict Equalityという機能が追加されました. Strict Equalityを有効化することで等価比較できる型を制限できます。無効化すればScala 2と(ほぼ)同じになります。
特定のソースコードやスコープだけ有効化もできます。
Scala 2のUniversal Equalityは、すべての型がお互いに比較できる単一のグループになっています。
一方のScala 3では、強い型チェックする型とそうでない型のグループに分けることができるため、Multiversal Equalityと呼ぶようです。
Strict Equality
Strict Equalityは、 明示的に指定した型のみ==及び!=の使用を許可し、それ以外の型での使用をコンパイルエラーにするという機能です。
以後、==について述べるときは!=も同様に扱われるものとします。
デフォルトではStrict Equalityは無効になっており、次のいずれかで有効化します。
- コンパイラフラグに
-language:strictEquailtyを追加 - 有効化したいソースコードで
import scala.language.strictEqualityを追加
Scala CLIの場合はコマンドラインオプションで有効にできます。
scala-cli repl -S 3.2.1 -language:strictEquality
それでは、Scala3でStrict Equalityを有効にして挙動を実際に見ていきます。
// scala-cli repl -S 3.2.1 -language:strictEquality scala> case class User(name: String) scala> val alice = User("alice") scala> val bob = User("bob")
User型を定義して2つのインスタンスを生成しました。これらの等価比較を見ていきます。
scala> alice == "alice" -- Error: ---------------------------------------------------------------------- 1 |alice == "alice" |^^^^^^^^^^^^^^^^ |Values of types User and String cannot be compared with == or != 1 error found scala> alice == bob -- Error: ---------------------------------------------------------------------- 1 |alice == bob |^^^^^^^^^^^^ |Values of types User and User cannot be compared with == or != 1 error found scala> alice == alice -- Error: ---------------------------------------------------------------------- 1 |alice == alice |^^^^^^^^^^^^^^ |Values of types User and User cannot be compared with == or != 1 error found
UserとStringの値を比較しましたがコンパイルエラーになりました。 また、User同士でも(同じインスタンスの比較を含む)コンパイルエラーになりました。
Scala 2では上記はいずれもコンパイル可能ですが、Scala 3でStrict Equalityを有効化するとすべてコンパイルエラーになります。
==を許可する
続いて、Userで==を使えるようにしていきます。
Strict Equality有効時に特定の型の等価比較を可能にするにはCanEqualという型クラスを使います。
定義は下記のとおりです。
sealed trait CanEqual[-L, -R]
CanEqualは通常の型クラスとは異なり、それ自体は機能(メソッド)を提供しません。
CanEqual[A, B]のgivenインスタンス1が存在する場合に、型Aと型Bのオブジェクトの等価比較ができるようになります。
逆にいうと、CanEqual[A, B]のgivenインスタンスが存在しない場合、(a: A) == (b: B)はコンパイルエラーになります。
CanEqual[A, B]のgivenインスタンスが存在していても、オペランドを逆にした (b: B) == (a: A)はコンパイルエラーになります。
こちらも許可したければCanEqual[B, A]のgivenインスタンスが必要です。
A同士のオブジェクトを比較したければCanEqual[A, A]のgivenインスタンスが必要となります。
今はUserクラス同士の等価比較したいので、CanEqual[User, User]のgivenインスタンスを定義すればよいです。
ということで定義して確認しましょう。
// scala-cli repl -S 3.2.1 -language:strictEquality scala> given CanEqual[User, User] = CanEqual.derived scala> alice == alice val res0: Boolean = true scala> alice == bob val res1: Boolean = false
User同士で比較できるようになりました。
CanEqual.derivedは次のように定義されています。
sealed trait CanEqual[-L, -R] object CanEqual { /** A universal `CanEqual` instance. */ object derived extends CanEqual[Any, Any]
CanEqualの2つの型パラメータは反変なので、任意のCanEqual[A, B]の型に対してCanEqual[Any, Any]のインスタンスが代入できます。つまりgivenインスタンスの定義には常にCanEqual.derivedを使えばよいことになります。
なお、上記のように同じ型のCanEqualのgivenインスタンスの定義にはシンタックスシュガーが用意されています。
case class User(name: String) derives CanEqual
これで、UserのコンパニオンオブジェクトにCanEqual[User, User]のgivenインスタンスが定義され==の使用が可能となります。
事前に定義されたCanEqualのgivenインスタンス
IntやLongなどプリミティブ型、String、OptionやSeqなどいくつかの標準ライブラリにある型にはCanEqualのgivenインスタンスが定義されています。
そのため、これらについては自前で定義する必要はなく、Strict Equalityが有効でも==を使うことができます。
// scala-cli repl -S 3.2.1 -language:strictEquality scala> 1 == 1 val res1: Boolean = true scala> "hello" != "good-bye" val res2: Boolean = true
CanEqualはコンパイル対象での==の使用を許可をするだけ
CanEqualはあくまで指定した型にだけ==を許可し、許可してない型での使用をコンパイルエラーにするだけです。許可した上で、どのように等価とみなすのかは==からよばれるequalsの実装次第で、その点はScala 2もScala 3でも同じです。
また、==の使用がチェックされるのはコンパイル対象のコードのみです。CanEqualで許可されていない型の==が、アプリケーションの中で絶対に使われないことを保証するものではありません。
例えば標準ライブラリのList#containsやSetのメソッドの中では==が呼ばれますがそれらの使用では当然チェックされません。
CanEqualにより==の使用を許可していない型でも、ListやSetの要素として使っているとその型の==は呼ばれる可能性があるということです。
以下は CanEqual[User, User] のgivenインスタンスが存在しない場合でもUser#equalsが呼ばれるサンプルコードです。
// scala-cli repl -S 3.2.1 -language:strictEquality scala> case class User(name: String): override def equals(that: Any): Boolean = println("Called User#equals") super.equals(that) scala> val alice = User("alice") scala> val bob = User("bob") scala> alice == alice -- Error: ---------------------------------------------------------------------- 1 |alice == alice |^^^^^^^^^^^^^^ |Values of types User and User cannot be compared with == or != 1 error found scala> List(bob, alice).contains(alice) Called User#equals val res4: Boolean = true
Strict Equalityを無効化したとき
Strict Equalityを無効にしたときの挙動は概ねScala 2と同じですが若干異なります。
例えばStringとOption[String]の等価比較をしてみます。
// scala-cli repl -S 2.13.10 scala> "hello" == Option("hello") val res0: Boolean = false
Scala 2ではコンパイルできますが、Scala 3ではStrict Equalityを無効にしていてもコンパイルエラーになります。
// scala-cli repl -S 3.2.1 scala> "hello" == Option("hello") -- Error: ---------------------------------------------------------------------- 1 |"hello" == Option("hello") |^^^^^^^^^^^^^^^^^^^^^^^^^^ |Values of types String and Option[String] cannot be compared with == or != 1 error found
下記のコードも同様です。
// scala-cli repl -S 3.2.1 scala> case class User(name: String) scala> val alice = User("alice") scala> alice == "alice" -- Error: ---------------------------------------------------------------------- 1 |alice == "alice" |^^^^^^^^^^^^^^^^ |Values of types User and String cannot be compared with == or != 1 error found
Strict Equalityが無効の場合、2つの型TとUが==で比較可能になるためには下記の条件のいずれかを満たす必要2があります。
大雑把な解釈として、「これまで通り任意の型同士での等価比較は可能だけど、比較するいずれか1つの型でも反射的なCanEqualのインスタンスを持ってるとコンパイルエラーになる」ということです。
上記で説明したように、StringやOptionはScalaが事前定義したCanEqualの反射的なgivenインスタンス(CanEqual[String, String]など)を提供しています。そのため、StringとOption[String]の比較や、StringとUserの比較は上記の3番目の条件も満たさないためコンパイルエラーになります。
Scala 3を使うときにどうすべきか
Scala 2のUniversal Equalityで問題ないと感じている場合はStrict Equalityを無効のままにすればよさそうです。
少なくとも一方がプリミティブ・標準型の異なる型の等価比較についてはScala 2と異なりコンパイルエラーになりますが、そのようなケースはバグの可能性が高いので直せば問題ないでしょう。
一方で==を厳しく型チェックしたい場合はStrict Equalityを有効にして、許可した型のみ等価比較できるようにするのがよさそうです。
既存のScala 2のプロダクトをScala 3に移行する場合で影響箇所が多いなど、一律で導入するのが難しい場合もあります。
そういうケースではimport scala.language.strictEqualityで部分的に有効化するとよさそうです。
まとめ
Scala 3のMultiversal Equalityについて、実際の動作を確認しながら解説しました。
Strict Equalityを有効にすることで==の使用を特定の型にだけ許可できます。ある型に対して使用を許可したい場合はその型のCanEqualのgivenインスタンスを定義します。
等価比較に強い型チェックを入れたい方はぜひ使ってみてください。
参考
Scala3 Book - Multiversal Equality
Scala3 Reference - Multiversal Equality
Programming in Scala 5th - 23.4 Multiversal equality
- Scala 3に導入された機能で、Scala 2でのimplicitで定義されたインスタンスのこと。↩
- Strict Equalityの正確なルール↩
- ここでのliftとは、型SについてSの共変位置にある抽象型の参照をすべてその上限境界に置き換え、Sの共変位置のすべてのrefinement型をその親で置き換えることを意味する。↩
- Tが反射的なCanEqualのインスタンスを持つとは、CanEqual[T, T]のインスタンスがあること。↩