Многостороннее равенство

Раньше в Scala было универсальное равенство (universal equality): два значения любых типов можно было сравнивать друг с другом с помощью == и !=. Это произошло из-за того факта, что == и != реализованы в терминах метода equals Java, который также может сравнивать значения любых двух ссылочных типов.

Всеобщее равенство удобно, но оно также опасно, поскольку подрывает безопасность типов. Например, предположим, что после некоторого рефакторинга осталась ошибочная программа, в которой значение y имеет тип S вместо правильного типа T:

val x = ...   // типа T
val y = ...   // типа S, но должно быть типа T
x == y        // проверки типов всегда будут выдавать false

Если y сравнивается с другими значениями типа T, программа все равно будет проверять тип, так как значения всех типов можно сравнивать друг с другом. Но это, вероятно, даст неожиданные результаты и завершится ошибкой во время выполнения.

Типобезопасный язык программирования может работать лучше, а мультиуниверсальное равенство — это дополнительный способ сделать универсальное равенство более безопасным. Он использует класс двоичного типа CanEqual, чтобы указать, что значения двух заданных типов можно сравнивать друг с другом.

Разрешение сравнения экземпляров класса

По умолчанию сравнение на равенство можно создать следующим образом:

case class Cat(name: String)
case class Dog(name: String)
val d = Dog("Fido")
val c = Cat("Morris")

d == c  // false, но он компилируется

Но в Scala 3 такие сравнения можно отключить. При (а) импорте scala.language.strictEquality или (б) использовании флага компилятора -language:strictEquality это сравнение больше не компилируется:

import scala.language.strictEquality

val rover = Dog("Rover")
val fido = Dog("Fido")
println(rover == fido)   // compiler error

// compiler error message:
// Values of types Dog and Dog cannot be compared with == or !=

Включение сравнений

Есть два способа включить сравнение с помощью класса типов CanEqual. Для простых случаев класс может выводиться (derive) от класса CanEqual:

// Способ 1
case class Dog(name: String) derives CanEqual

Также можно использовать следующий синтаксис:

// Способ 2
case class Dog(name: String)
given CanEqual[Dog, Dog] = CanEqual.derived

Любой из этих двух подходов позволяет сравнивать экземпляры Dog друг с другом.

Более реалистичный пример

В более реалистичном примере представим, что есть книжный интернет-магазин и мы хотим разрешить или запретить сравнение бумажных, печатных и аудиокниг. В Scala 3 для начала необходимо включить мультиуниверсальное равенство:

// [1] добавить этот импорт или command line flag: -language:strictEquality
import scala.language.strictEquality

Затем создать объекты домена:

// [2] создание иерархии классов
trait Book:
  def author: String
  def title: String
  def year: Int

case class PrintedBook(
  author: String,
  title: String,
  year: Int,
  pages: Int
) extends Book

case class AudioBook(
  author: String,
  title: String,
  year: Int,
  lengthInMinutes: Int
) extends Book

Наконец, используем CanEqual, чтобы определить, какие сравнения необходимо разрешить:

// [3] создайте экземпляры класса типов, чтобы определить разрешенные сравнения.
//     разрешено `PrintedBook == PrintedBook`
//     разрешено `AudioBook == AudioBook`
given CanEqual[PrintedBook, PrintedBook] = CanEqual.derived
given CanEqual[AudioBook, AudioBook] = CanEqual.derived

// [4a] сравнение двух печатных книг разрешено
val p1 = PrintedBook("1984", "George Orwell", 1961, 328)
val p2 = PrintedBook("1984", "George Orwell", 1961, 328)
println(p1 == p2)         // true

// [4b] нельзя сравнивать печатную книгу и аудиокнигу
val pBook = PrintedBook("1984", "George Orwell", 1961, 328)
val aBook = AudioBook("1984", "George Orwell", 2006, 682)
println(pBook == aBook)   // compiler error

Последняя строка кода приводит к следующему сообщению компилятора об ошибке:

Values of types PrintedBook and AudioBook cannot be compared with == or !=

Вот как мультиуниверсальное равенство отлавливает недопустимые сравнения типов во время компиляции.

Включение «PrintedBook == AudioBook»

Если есть необходимость разрешить сравнение PrintedBook с AudioBook, то достаточно создать следующие два дополнительных сравнения равенства:

// разрешить `PrintedBook == AudioBook` и `AudioBook == PrintedBook`
given CanEqual[PrintedBook, AudioBook] = CanEqual.derived
given CanEqual[AudioBook, PrintedBook] = CanEqual.derived

Теперь можно сравнивать PrintedBook с AudioBook без ошибки компилятора:

println(pBook == aBook)   // false
println(aBook == pBook)   // false
Внедрение «equals»

Хотя эти сравнения теперь разрешены, они всегда будут ложными, потому что их методы equals не знают, как проводить подобные сравнения. Чтобы доработать сравнение, можно переопределить методы equals для каждого класса. Например, если переопределить метод equals для AudioBook:

case class AudioBook(
    author: String,
    title: String,
    year: Int,
    lengthInMinutes: Int
) extends Book:
    // переопределить, чтобы разрешить сравнение AudioBook с PrintedBook
    override def equals(that: Any): Boolean = that match
        case a: AudioBook =>
            if this.author == a.author
            && this.title == a.title
            && this.year == a.year
            && this.lengthInMinutes == a.lengthInMinutes
                then true else false
        case p: PrintedBook =>
            if this.author == p.author && this.title == p.title
                then true else false
        case _ =>
            false

Теперь можно сравнить AudioBook с PrintedBook:

println(aBook == pBook)   // true (работает из-за переопределенного `equals` в `AudioBook`)
println(pBook == aBook)   // false

Книга PrintedBook не имеет метода equals, поэтому второе сравнение возвращает false. Чтобы включить это сравнение, достаточно переопределить метод equals в PrintedBook.


Ссылки: