はじめに
こんにちは。マイクロアド京都研究所の池田です。本記事では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]のインスタンスがあること。↩