はじめに
Scalaの等価比較において異なる型同士の比較を実行し、バグを引き起こしたことはありませんか?
この記事ではScala3から導入された「strictEquality」を利用し、このようなバグをコンパイル時に検出する方法を紹介します。
背景
我々のチームでは、一意な識別子としてULIDを利用しています。ドメイン層では、各エンティティのIdentityを表現するために、ULIDをラップしたId型を定義しています。
final class ULID(private val ulid: String)
final case class Id[T <: HasId[T]](value: ULID)
しかし、開発中に「Id型(例:Id[User])」と「ULID型」を誤って等価比較してしまうミスが発生しました。
val userId: Id[T] = ... // Id型
val userId2: ULID = ... // ULID型(本来はId[T]であるべき)
userId == userId2 // コンパイルは通るが、常にfalseになる
上記はコードのイメージです。Id型同士の比較を想定していたにもかかわらず、誤ってId型とULID型を比較しています。
この場合はたとえ内部の値(value)が同じであっても、==の結果は常にfalseになります。
しかも、このコードはコンパイル時には型エラーにならず、実行時に初めて意図しない挙動に気付くことになります。
この問題は他の型においても同様であり、テストにおいて混入を軽減することは可能であるものの、事故防止のためコンパイル時に検知することが理想でした。
なぜ起こるのか
Universal Equalityとは
Scalaでは各オブジェクトがAny型を継承しており、Any型には==などの等価比較メソッドが定義されています。等価比較はAny型を引数に取るため、異なる型であっても等価判定が可能です。
final def ==(that: Any): Boolean = this equals that
これはデフォルトで動作する挙動であり「Universal Equality」と呼ばれています。部分的には便利ですが、異なる型同士の比較がコンパイルエラーにならないため、Scalaの強みである型安全性を損なう場合があります。
Multiversal Equalityによる解決
対応を調査する中で、Scala 3からMultiversal Equalityと呼ばれる制御方法が追加されていることを知りました。
Scala3 Reference - Multiversal Equality
Multiversal EqualityとはUniversal Equalityの問題を解消するために導入されており、CanEqual型クラスによってどの型同士が等価比較可能かを明示的に制御することが可能な仕組みのようです。
strictEqualityを有効にすることで、CanEqualインスタンスが存在しない型同士の比較はコンパイルエラーとなり、型安全な等価比較が保証されます。
設定方法
コンパイル時にstrictEqualityをフラグとして渡すことで有効になります。
ThisBuild / scalacOptions ++= Seq(
"-language:strictEquality"
)
全体への適用が難しい場合は、ファイル単位で以下のように設定することも可能です。
import scala.language.strictEquality
デフォルトではCanEqual[Any, Any]というフォールバックが設定されており、明示的なCanEqualの設定が無い場合でも比較が許可されているようです。
object CanEqual:
object derived extends CanEqual[Any, Any]
strictEqualityを有効化するとデフォルトのフォールバックは無効化され、CanEqualが無い型同士の等価比較はコンパイルエラーとなります。
CanEqual - Scala 3 Language Documentation
コンパイルエラーの例
strictEqualityを有効にすると、異なる型同士の比較でコンパイルエラーが発生します。
val userId: Id[User] = ...
val ulid: ULID = ...
userId == ulid
// コンパイルエラー:
// Values of types Id[User] and ULID cannot be compared with == or !=
CanEqualの設定
strictEqualityでは、CanEqual型クラスで等価比較が許可された型同士のみ比較可能となります。例えばId型に対してCanEqualを設定する場合は以下のようになります。
final case class Id[T <: HasId[T]](value: ULID)
object Id {
given [T <: HasId[T]]: CanEqual[Id[T], Id[T]] = CanEqual.derived
}
ポイントとして、型パラメータTを考慮した定義にすることで、異なるエンティティのId型同士(例:Id[User]とId[Product])の比較もコンパイルエラーとして検出できます。
val userId: Id[User] = ...
val productId: Id[Product] = ...
userId == productId
// コンパイルエラー:
// Values of types Id[User] and Id[Product] cannot be compared with == or !=.
Scalaが提供するプリミティブな型(Int、String等)にはデフォルトでCanEqualが設定されているため、自前で型を定義する際にCanEqualを設定することで、安全に等価比較のコードを書くことができます。
詳細については、Predefined CanEqual Instancesを参照ください。
Derivesを利用した自動生成
未検証となりますが、CanEqualインスタンスはderivesキーワードを利用して自動生成することも可能なようです。
strictEqualityを有効化すると型安全性が高まる一方、CanEqualの追加コストが発生します。
自動生成を活用すると負担が軽減されるかもしれません。
final case class Item[T](x: T) derives CanEqual
さいごに
この記事ではScalaで異なる型同士の等価比較をstrictEqualityを利用することで防ぐ手段をご紹介しました。実行時にしかエラーに気づけないとバグの温床となるため、Scala 3を利用されている方は是非導入を検討してみてはいかがでしょうか。