멀티버설 동등성
멀티버설 동등성 (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 메서드를 오버라이드하는 거예요. 예를 들어 AudioBook의 equals 메서드를 오버라이드하면:
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
이제 AudioBook을 PrintedBook과 비교할 수 있어요:
println(aBook == pBook) // true (works because of `equals` in `AudioBook`)
println(pBook == aBook) // false
현재 PrintedBook에는 equals 메서드가 없어서 두 번째 비교는 false를 반환해요. 그 비교를 활성화하려면 PrintedBook에서 equals 메서드를 오버라이드하면 됩니다.