멀티버설 동등성

멀티버설 동등성 (Multiversal Equality)

Scala에서 두 값을 ==로 비교할 때, 어떤 타입끼리 비교를 허용할지 정할 수 있다는 사실, 알고 있었나요? 예전 Scala는 아무 타입이나 서로 비교할 수 있는 '만능 동등성(universal equality)'이라 불리는 동작이 기본이었는데, Scala 3에서는 이를 더 안전하게 만들 수 있는 **멀티버설 동등성(multiversal equality)**을 선택적으로 켤 수 있어요. 왜 이런 기능이 필요한지부터 볼게요.

출처: Scala 3 Book

본문

예전 Scala에는 *만능 동등성(universal equality)*이 있었어요. 어떤 타입의 값이든 ==!=로 서로 비교할 수 있었죠. 이는 ==!=가 Java의 equals 메서드를 기반으로 구현되어서인데, 그 equals도 임의의 두 참조 타입 값을 비교할 수 있기 때문이에요.

만능 동등성은 편리하지만, 타입 안전성(type safety)을 훼손하기 때문에 위험하기도 해요. 예를 들어 리팩토링을 하다 보면, 값 y가 올바른 타입 T가 아니라 타입 S를 가진 잘못된 프로그램이 남게 됐다고 해볼게요:

val x = ...   // of type T
val y = ...   // of type S, but should be T
x == y        // typechecks, will always yield false

y를 타입 T의 다른 값들과 비교해도, 모든 타입의 값이 서로 비교될 수 있기 때문에 프로그램은 여전히 타입 검사를 통과해요. 하지만 아마 예상치 못한 결과가 나오고 런타임에 실패할 거예요.

타입 안전한 프로그래밍 언어라면 더 잘할 수 있어요. 멀티버설 동등성은 만능 동등성을 더 안전하게 만드는 선택적(opt-in) 방법이에요. 이진 타입 클래스인 CanEqual을 사용해서 '주어진 두 타입의 값을 서로 비교할 수 있다'는 것을 나타내요.

클래스 인스턴스의 비교 허용하기

기본적으로 Scala 3에서도 여전히 이런 동등성 비교를 만들 수 있어요:

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

d == c  // false, but it compiles

하지만 Scala 3에서는 이런 비교를 비활성화할 수 있어요. (a) scala.language.strictEquality를 임포트하거나 (b) -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 !=

비교 활성화하기

Scala 3의 CanEqual 타입 클래스를 사용해 이 비교를 활성화하는 방법은 두 가지예요. 이처럼 단순한 경우에는 클래스가 CanEqual 클래스를 *파생(derive)*할 수 있어요:

// Option 1
case class Dog(name: String) derives CanEqual

잠시 후 보겠지만, 더 유연함이 필요할 때는 이 문법을 쓸 수도 있어요:

// Option 2
case class Dog(name: String)
given CanEqual[Dog, Dog] = CanEqual.derived

두 접근 방식 중 아무거나 쓰면 이제 Dog 인스턴스들이 서로 비교될 수 있게 돼요.

좀 더 실용적인 예시

더 실용적인 예시를 들어볼게요. 온라인 서점이 있고, 종이로 인쇄된 책과 오디오북의 비교를 허용할지 말지를 정하고 싶다고 해보죠. Scala 3에서는 이전 예시처럼 먼저 멀티버설 동등성을 활성화해요:

// [1] add this import, or this command line flag: -language:strictEquality
import scala.language.strictEquality

그런 다음 평소처럼 도메인 객체를 만들어요:

// [2] create your class hierarchy
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] create type class instances to define the allowed comparisons.
//     allow `PrintedBook == PrintedBook`
//     allow `AudioBook == AudioBook`
given CanEqual[PrintedBook, PrintedBook] = CanEqual.derived
given CanEqual[AudioBook, AudioBook] = CanEqual.derived

// [4a] comparing two printed books works as desired
val p1 = PrintedBook("1984", "George Orwell", 1961, 328)
val p2 = PrintedBook("1984", "George Orwell", 1961, 328)
println(p1 == p2)         // true

// [4b] you can’t compare a printed book and an audiobook
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" 활성화하기

위 동작은 원하는 대로지만, 어떤 상황에서는 종이책과 오디오북의 비교를 허용하고 싶을 수도 있어요. 그럴 때는 이렇게 추가로 두 개의 비교를 만들어요:

// allow `PrintedBook == AudioBook`, and `AudioBook == PrintedBook`
given CanEqual[PrintedBook, AudioBook] = CanEqual.derived
given CanEqual[AudioBook, PrintedBook] = CanEqual.derived

이제 컴파일러 오류 없이 종이책과 오디오북을 비교할 수 있어요:

println(pBook == aBook)   // false
println(aBook == pBook)   // false

실제로 동작하도록 "equals" 구현하기

이 비교들이 이제 허용되긴 하지만, 그 equals 메서드가 이런 비교를 하는 방법을 모르기 때문에 항상 false일 거예요. 따라서 해결책은 각 클래스의 equals 메서드를 오버라이드하는 거예요. 예를 들어 AudioBookequals 메서드를 오버라이드하면:

case class AudioBook(
    author: String,
    title: String,
    year: Int,
    lengthInMinutes: Int
) extends Book:
    // override to allow AudioBook to be compared to PrintedBook
    override def equals(that: Any): Boolean = that match
        case a: AudioBook =>
            this.author == a.author
            && this.title == a.title
            && this.year == a.year
            && this.lengthInMinutes == a.lengthInMinutes
        case p: PrintedBook =>
            this.author == p.author && this.title == p.title
        case _ =>
            false

이제 AudioBookPrintedBook과 비교할 수 있어요:

println(aBook == pBook)   // true (works because of `equals` in `AudioBook`)
println(pBook == aBook)   // false

현재 PrintedBook에는 equals 메서드가 없어서 두 번째 비교는 false를 반환해요. 그 비교를 활성화하려면 PrintedBook에서 equals 메서드를 오버라이드하면 됩니다.

더 알아보기