MicroAd Developers Blog

マイクロアドのエンジニアブログです。インフラ、開発、分析について発信していきます。

Scala3のMultiversal Equalityの紹介

はじめに

こんにちは。マイクロアド京都研究所の池田です。本記事では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

上記ではStringOption[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は無効になっており、次のいずれかで有効化します。

  1. コンパイラフラグに-language:strictEquailtyを追加
  2. 有効化したいソースコードで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#containsSetのメソッドの中では==が呼ばれますがそれらの使用では当然チェックされません。

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と同じですが若干異なります。

例えばStringOption[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. TとUが同じ型である
  2. TとUのどちらかがもう一方の型のliftedバージョンのサブタイプになっている 3
  3. TとUのどちらも反射的なCanEqualのインスタンスを持っていない 4

大雑把な解釈として、「これまで通り任意の型同士での等価比較は可能だけど、比較するいずれか1つの型でも反射的なCanEqualのインスタンスを持ってるとコンパイルエラーになる」ということです。

上記で説明したように、StringOptionはScalaが事前定義したCanEqualの反射的なgivenインスタンス(CanEqual[String, String]など)を提供しています。そのため、StringOption[String]の比較や、StringUserの比較は上記の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


  1. Scala 3に導入された機能で、Scala 2でのimplicitで定義されたインスタンスのこと。
  2. Strict Equalityの正確なルール
  3. ここでのliftとは、型SについてSの共変位置にある抽象型の参照をすべてその上限境界に置き換え、Sの共変位置のすべてのrefinement型をその親で置き換えることを意味する。
  4. Tが反射的なCanEqualのインスタンスを持つとは、CanEqual[T, T]のインスタンスがあること。