다중 우주 동등성

다중 우주 동등성 (Multiversal Equality)

Scala의 ==!=는 원래 "보편 동등성(universal equality)"이라서 어떤 타입의 값이든 서로 비교할 수 있었어요. 그런데 이 편리함이 타입 안전성을 위협하기도 하죠. 이 페이지에서는 보편 동등성을 더 안전하게 만드는 다중 우주 동등성(multiversal equality)과 CanEqual이라는 이진 타입 클래스의 동작 원리를 자세히 살펴볼게요.

출처: Scala 3 Reference

본문

이전에는 Scala에 보편 동등성(universal equality)이 있었어요. 어떤 타입의 두 값이든 ==!=로 서로 비교할 수 있었죠. 이것은 ==!=가 Java의 equals 메서드를 기반으로 구현됐기 때문인데, Java의 equals 역시 임의의 두 참조 타입 값을 비교할 수 있거든요.

보편 동등성은 편리해요. 하지만 타입 안전성을 해치기 때문에 위험하기도 해요. 예를 들어 리팩터링을 하다 보면 어떤 값 y가 올바른 타입 T가 아니라 S 타입을 가진 오류가 있는 프로그램이 남았다고 해볼게요.

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

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

다중 우주 동등성은 보편 동등성을 더 안전하게 만드는 옵트인(opt-in) 방식이에요. 이진 타입 클래스 scala.CanEqual을 사용해서, 주어진 두 타입의 값이 서로 비교될 수 있는지 나타내요. 위의 예제는 STCanEqual을 파생(derive)하는 클래스라면 타입 검사를 통과하지 못해요. 예를 들어

class T derives CanEqual

일반적으로 derives 절은 파라미터가 하나인 타입 클래스만 받지만, CanEqual에 대해서는 특별한 경우가 있어요.

또는 CanEqual given 인스턴스를 직접 제공할 수도 있어요. 이렇게요.

given CanEqual[T, T] = CanEqual.derived

이 정의는 본질적으로, T 타입의 값은 ==!=를 쓸 때 (오직) T 타입의 다른 값과만 비교될 수 있다고 말해요. 이 정의는 타입 검사에 영향을 주지만 런타임 동작에는 아무 의미가 없어요. ==는 항상 equals로 매핑되고 !=는 항상 equals의 부정으로 매핑되기 때문이죠. 정의의 오른쪽인 CanEqual.derived는 그 타입이 임의의 CanEqual 인스턴스인 값이에요. CanEqual 클래스와 그 동반 객체(companion object)의 정의는 다음과 같아요.

package scala
import annotation.implicitNotFound

@implicitNotFound("Values of types ${L} and ${R} cannot be compared with == or !=")
sealed trait CanEqual[-L, -R]

object CanEqual:
  object derived extends CanEqual[Any, Any]

한 타입에 대해 여러 개의 CanEqual given 인스턴스를 가질 수 있어요. 예를 들어 아래 네 정의는 A 타입과 B 타입의 값을 서로 비교 가능하게 만들지만, 다른 것과는 비교할 수 없게 해요.

given CanEqual[A, A] = CanEqual.derived
given CanEqual[B, B] = CanEqual.derived
given CanEqual[A, B] = CanEqual.derived
given CanEqual[B, A] = CanEqual.derived

scala.CanEqual 객체는 여러 CanEqual given 인스턴스를 정의하는데, 이들이 함께 표준 타입끼리 무엇을 비교할 수 있는지에 대한 "규칙집"을 만든다(자세한 내용은 아래 참조)고 볼 수 있어요.

canEqualAny라는 "폴백(fallback)" 인스턴스도 있는데, 자체적으로 CanEqual given이 없는 모든 타입을 걸쳐 비교를 허용해요. canEqualAny는 다음과 같이 정의돼요.

def canEqualAny[L, R]: CanEqual[L, R] = CanEqual.derived

canEqualAny는 given으로 선언되진 않았지만, L이나 R에 정의된 CanEqual 인스턴스가 없거나 strictEquality 언어 기능이 활성화되지 않았다면, 컴파일러는 CanEqual[L, R] 타입에 대한 암시적 탐색의 답으로 canEqualAny 인스턴스를 여전히 구성해요.

canEqualAny를 두는 가장 큰 동기는 하위 호환성(backwards compatibility)이에요. 그것이 걱정되지 않는다면 strictEquality 언어 기능을 활성화해서 canEqualAny를 비활성화할 수 있어요. 모든 언어 기능이 그렇듯이 이것도 import로 하거나

import scala.language.strictEquality

또는 명령줄 옵션 -language:strictEquality로 할 수 있어요.

CanEqual 인스턴스 파생하기 (Deriving CanEqual Instances)

CanEqual 인스턴스를 직접 정의하는 대신 파생하는 것이 더 편리한 경우가 많아요. 예를 들어 볼게요.

class Box[T](x: T) derives CanEqual

타입 클래스 파생의 일반적인 규칙에 따라, 이 코드는 Box의 동반 객체에 다음 CanEqual 인스턴스를 생성해요.

given [T, U] => CanEqual[T, U] => CanEqual[Box[T], Box[U]] =
  CanEqual.derived

즉, 두 박스의 원소가 비교 가능하다면 두 박스도 ==!=로 비교 가능해요. 예를 들어 볼게요.

new Box(1) == new Box(1L)   // ok since there is an instance for `CanEqual[Int, Long]`
new Box(1) == new Box("a")  // error: can't compare
new Box(1) == 1             // error: can't compare

동등성 검사의 정확한 규칙 (Precise Rules for Equality Checking)

동등성 검사의 정확한 규칙은 다음과 같아요.

strictEquality 기능이 활성화되어 있다면, x: Ty: U 값을 x == yx != y로 비교하는 것은 CanEqual[T, U] 타입의 given이 있을 때만 합법적이에요.

strictEquality 기능이 활성화되지 않은 기본적인 경우에도 다음 중 하나라도 해당하면 비교가 합법적이에요.

  • TU가 같거나,
  • T, U 중 하나가 다른 타입의 lifted 버전의 하위 타입이거나,
  • TU도 재귀적(reflexive) CanEqual 인스턴스를 가지지 않을 때.

설명:

  • 타입 S를 lifting한다는 것은 S의 공변(covariant) 위치에 있는 모든 추상 타입 참조를 그 상한(upper bound)으로 바꾸고, S의 공변 위치에 있는 모든 정제 타입(refinement type)을 그 부모로 바꾸는 것을 말해요.
  • 타입 T가 재귀적 CanEqual 인스턴스를 가진다는 것은 CanEqual[T, T]에 대한 암시적 탐색이 성공한다는 뜻이에요.

미리 정의된 CanEqual 인스턴스 (Predefined CanEqual Instances)

CanEqual 객체는 다음 타입들을 비교하기 위한 인스턴스를 정의해요.

  • 기본 타입 Byte, Short, Char, Int, Long, Float, Double, Boolean, Unit,
  • java.lang.Number, java.lang.Boolean, java.lang.Character,
  • scala.collection.Seq, scala.collection.Set.

인스턴스는 이 타입들 각각이 재귀적 CanEqual 인스턴스를 가지도록 정의되고, 다음이 성립해요.

  • 기본 숫자 타입들은 서로 비교할 수 있어요.
  • 기본 숫자 타입들은 java.lang.Number의 하위 타입과 (그 반대도) 비교할 수 있어요.
  • Booleanjava.lang.Boolean과 (그 반대도) 비교할 수 있어요.
  • Charjava.lang.Character과 (그 반대도) 비교할 수 있어요.
  • 두 시퀀스(scala.collection.Seq의 임의의 하위 타입)는 그 원소 타입이 비교 가능하면 서로 비교할 수 있어요. 두 시퀀스 타입이 같을 필요는 없어요.
  • 두 집합(scala.collection.Set의 임의의 하위 타입)은 그 원소 타입이 비교 가능하면 서로 비교할 수 있어요. 두 집합 타입이 같을 필요는 없어요.
  • AnyRef의 임의의 하위 타입은 Null과 (그 반대도) 비교할 수 있어요.

왜 타입 파라미터가 둘일까? (Why Two Type Parameters?)

CanEqual 타입의 한 가지 특별한 특징은 타입 파라미터를 두 개 받는다는 거예요. 비교할 두 항목의 타입을 각각 나타내죠. 이와 대조적으로 전통적인 동등성 타입 클래스 구현은 양쪽 피연산자의 공통 타입을 나타내는 단일 타입 파라미터만 받아요. 타입 파라미터 하나가 둘보다 단순한데, 왜 더 복잡한 길을 가는 걸까요? 그 이유는 우리가 이전에 존재하지 않던 연산에 대한 타입 클래스를 새로 만드는 게 아니라, 이미 존재하는 보편 동등성을 정제(refine)하고 있기 때문이에요. 예를 들어 보면 가장 잘 이해돼요.

List[T]에 안전한 버전의 contains 메서드를 만들고 싶다고 해볼게요. 표준 라이브러리에서 contains의 원래 정의는 이랬어요.

class List[+T]:
  ...
  def contains(x: Any): Boolean

이 정의는 보편 동등성을 안전하지 않은 방식으로 사용해요. 리스트의 원소와 비교되도록 어떤 타입의 인자든 허용하기 때문이죠. "당연해 보이는" 대안 정의

  def contains(x: T): Boolean

는 동작하지 않아요. 공변 파라미터 T를 무공변(nonvariant) 위치에서 참조하기 때문이에요. contains에서 타입 파라미터 T를 쓰는 방식 중 오직 분산(variance)에 맞는 유일한 방법은 하한(lower bound)으로 쓰는 거예요.

  def contains[U >: T](x: U): Boolean

이 일반 버전의 contains가 현재(Scala 2.13) 버전의 List에서 쓰여요. 형태는 달라 보이지만 우리가 시작했던 contains(x: Any) 정의와 정확히 같은 적용을 허용해요. 그런데 CanEqual 파라미터를 추가하면 더 유용하게(즉, 더 제한적으로) 만들 수 있어요.

  def contains[U >: T](x: U)(using CanEqual[T, U]): Boolean // (1)

이 버전의 contains는 동등성에 안전해요! 더 정확히 말하면, x: T, xs: List[T], y: U가 주어졌을 때 xs.contains(y)가 타입에 맞는 것은 x == y가 타입에 맞는 것과 정확히 일치해요.

불행히도, 단일 타입 파라미터를 가진 동등성 클래스로 제한하면 "동등성 타입 검사를 단순 동등성과 패턴 매칭에서 임의의 사용자 정의 연산으로 lifting"하는 결정적인 능력이 사라져요. 가상의 CanEqual1[T] 타입 클래스를 가진 다음 contains 시그니처를 생각해 볼게요.

  def contains[U >: T](x: U)(using CanEqual1[U]): Boolean   // (2)

이 버전은 원래의 contains(x: Any) 메서드만큼 넓게 적용될 수 있어요. CanEqual1[Any] 폴백이 항상 가능하기 때문이죠! 그래서 얻는 게 없어요. 단일 파라미터 타입 클래스로 전환하면서 잃은 것은, AB가 모두 재귀적 CanEqual 인스턴스를 가지지 않을 때에만 CanEqual[A, B]를 사용할 수 있다는 원래 규칙이었어요. CanEqual에 타입 파라미터가 하나뿐이라면 그 규칙은 도저히 표현할 수 없어요.

상황은 -language:strictEquality 아래에서 다르져. 이 경우 CanEqual[Any, Any]CanEqual1[Any] 인스턴스는 절대 이용할 수 없고, 단일 파라미터 버전과 이중 파라미터 버전이 대부분의 실용적 목적에 대해 실제로 일치해요.

하지만 어디서나 즉시 -language:strictEquality를 가정하는 것은 아마 극복 불가능한 마이그레이션 문제를 만들 거예요. 표준 라이브러리에 있는 contains를 다시 생각해 볼게요. (1)처럼 CanEqual 타입 클래스로 그것을 매개변수화하는 것은 즉시 이득이에요. 말이 안 되는 적용을 배제하면서도 말이 되는 적용은 모두 허용하기 때문이죠. 그래서 바이너리 호환성 문제만 제외하면 거의 언제든 할 수 있어요. 반면 (2)처럼 containsCanEqual1로 매개변수화하면, 아직 CanEqual1 인스턴스를 선언하지 않은 모든 타입(Java에서 오는 모든 타입을 포함해서)에 대해 contains를 쓸 수 없게 돼요. 이것은 분명 용납할 수 없어요. 기존 라이브러리를 안전한 동등성으로 마이그레이션하는 대신, 유일한 업그레이드 경로가 병렬 라이브러리를 두는 것(새 버전은 CanEqual1을 파생하는 타입만, 옛 버전은 나머지 전부를 다루는)이 되는 상황을 낳을 거예요. 생태계가 이렇게 분열되는 것은 매우 문제가 많아서, 치료가 질병보다 더 나쁜 꼴일 거예요.

이런 이유로, 이중 타입 파라미터 타입 클래스가 유일한 전진 방법으로 보여요. 기존 생태계를 있는 그대로 받아들이고, 점점 더 많은 코드가 안전한 동등성을 쓰는 미래로 옮겨갈 수 있기 때문이죠.

-language:strictEquality가 기본인 애플리케이션에서는 다음과 같은 단일 파라미터 타입 별칭을 도입할 수도 있어요.

type Eq[-T] = CanEqual[T, T]

안전한 동등성이 필요한 연산은 이중 파라미터 CanEqual 클래스 대신 이 별칭을 쓸 수 있어요. 하지만 -language:strictEquality 아래에서만 동작해요. 그렇지 않으면 보편 Eq[Any] 인스턴스가 어디서나 이용 가능해지기 때문이죠.

다중 우주 동등성에 대한 더 많은 내용은 블로그 게시물과 GitHub 이슈에서 찾을 수 있어요.