불투명 타입 별칭

불투명 타입 별칭 (Opaque Type Aliases)

타입 추상화를 하면서도 런타임 오버헤드를 전혀 만들고 싶지 않을 때가 있어요. Scala 3의 '불투명 타입 별칭(opaque type alias)'은 정의된 스코프 안에서는 그냥 별칭처럼 동작하지만, 바깥 세상에는 내부 표현을 완전히 감추는 추상 타입처럼 보이게 해 줍니다. '불투명'이라는 이름이 바로 그 성격을 담고 있어요.

출처: Scala 3 Reference

본문

불투명 타입 별칭은 오버헤드 없이 타입 추상화를 제공해요. 예를 봅시다:

object MyMath:

  opaque type Logarithm = Double

  object Logarithm:

    // These are the two ways to lift to the Logarithm type

    def apply(d: Double): Logarithm = math.log(d)

    def safe(d: Double): Option[Logarithm] =
      if d > 0.0 then Some(math.log(d)) else None

  end Logarithm

  // Extension methods define opaque types' public APIs
  extension (x: Logarithm)
    def toDouble: Double = math.exp(x)
    def + (y: Logarithm): Logarithm = Logarithm(math.exp(x) + math.exp(y))
    def * (y: Logarithm): Logarithm = x + y

end MyMath

이 코드는 Logarithm이라는 새로운 추상 타입을 도입하는데, 실제로는 Double로 구현돼요. LogarithmDouble과 같다는 사실은 Logarithm이 정의된 스코프, 즉 위 예시에서 object MyMath 안에서만 알 수 있어요. 다시 말해 스코프 안에서는 타입 별칭처럼 취급되지만, 바깥 세상에는 이 사실이 불투명하게 감춰져서 LogarithmDouble과 아무 관련 없는 추상 타입으로 보이는 거예요.

Logarithm의 공개 API는 동반 객체에 정의된 applysafe 메서드로 구성돼요. 이 메서드들은 DoubleLogarithm 값으로 변환해 주죠. 게다가 반대 방향으로 변환하는 toDouble 연산, 그리고 +* 연산이 Logarithm 값의 확장 메서드로 정의되어 있어요. 다음 연산들은 MyMath 객체에 구현된 기능을 사용하므로 유효합니다.

import MyMath.Logarithm

val l = Logarithm(1.0)
val l2 = Logarithm(2.0)
val l3 = l * l2
val l4 = l + l2

하지만 다음 연산들은 타입 오류로 이어져요.

val d: Double = l       // error: found: Logarithm, required: Double
val l2: Logarithm = 1.0 // error: found: Double, required: Logarithm
l * 2                   // error: found: Int(2), required: Logarithm
l / l2                  // error: `/` is not a member of Logarithm

불투명 타입 별칭의 경계 (Bounds For Opaque Type Aliases)

불투명 타입 별칭은 경계(bounds)도 가질 수 있어요. 예시를 보겠습니다.

object Access:

  opaque type Permissions = Int
  opaque type PermissionChoice = Int
  opaque type Permission <: Permissions & PermissionChoice = Int

  extension (x: PermissionChoice)
    def | (y: PermissionChoice): PermissionChoice = x | y
  extension (x: Permissions)
    def & (y: Permissions): Permissions = x | y
  extension (granted: Permissions)
    def is(required: Permissions) = (granted & required) == required
    def isOneOf(required: PermissionChoice) = (granted & required) != 0

  val NoPermission: Permission = 0
  val Read: Permission = 1
  val Write: Permission = 2
  val ReadWrite: Permissions = Read | Write
  val ReadOrWrite: PermissionChoice = Read | Write

end Access

Access 객체는 세 개의 불투명 타입 별칭을 정의해요:

  • Permission: 단일 권한을 나타내요.
  • Permissions: "이 권한들이 모두 부여됨"이라는 의미의 권한 집합을 나타내요.
  • PermissionChoice: "이 권한들 중 적어도 하나가 부여됨"이라는 의미의 권한 집합을 나타내요.

Access 객체 바깥에서 Permissions 타입의 값은 & 연산자로 결합할 수 있는데, x & y는 "xy에 있는 모든 권한이 부여됨"을 뜻해요. PermissionChoice 타입의 값은 | 연산자로 결합할 수 있으며, x | y는 "xy에 있는 권한이 부여됨"을 의미해요.

Access 객체 안에서는 &| 연산자가 항상 Int의 해당 메서드로 해석된다는 점을 주목하세요. 멤버가 확장 메서드보다 항상 우선하기 때문이에요. 그래서 Access| 확장 메서드는 무한 재귀를 일으키지 않습니다.

특히 ReadWrite의 정의는 Int의 비트 연산자인 |를 반드시 사용해야 해요. Access 바깥의 클라이언트 코드가 Permissions의 확장 메서드인 &를 쓰는 것과는 대조적이죠. ReadWriteReadOrWrite의 내부 표현은 동일하지만, 이 사실은 아래 예시처럼 Permissions의 의미에만 관심 있는 클라이언트에게는 보이지 않아요.

세 불투명 타입 별칭 모두 같은 하부 표현 타입 Int를 가져요. Permission 타입은 상한으로 Permissions & PermissionChoice를 가집니다. 덕분에 Access 바깥에서도 Permission이 다른 두 타입의 부분타입이라는 것이 알려져요. 그래서 다음 사용 시나리오는 타입 체크를 통과합니다.

object User:
  import Access.*

  case class Item(rights: Permissions)
  extension (item: Item)
    def +(other: Item): Item = Item(item.rights & other.rights)

  val roItem = Item(Read)  // OK, since Permission <: Permissions
  val woItem = Item(Write)
  val rwItem = Item(ReadWrite)
  val noItem = Item(NoPermission)

  assert(!roItem.rights.is(ReadWrite))
  assert(roItem.rights.isOneOf(ReadOrWrite))

  assert(rwItem.rights.is(ReadWrite))
  assert(rwItem.rights.isOneOf(ReadOrWrite))

  assert(!noItem.rights.is(ReadWrite))
  assert(!noItem.rights.isOneOf(ReadOrWrite))

  assert((roItem + woItem).rights.is(ReadWrite))
end User

반면 roItem.rights.isOneOf(ReadWrite) 호출은 타입 오류를 내요:

  assert(roItem.rights.isOneOf(ReadWrite))
                               ^^^^^^^^^
                               Found:    (Access.ReadWrite : Access.Permissions)
                               Required: Access.PermissionChoice

PermissionsPermissionChoiceAccess 바깥에서는 서로 다르고 연관도 없는 타입이에요.

클래스의 불투명 타입 멤버 (Opaque Type Members on Classes)

보통 불투명 타입은 객체와 함께 쓰여 모듈의 구현 세부 사항을 숨기지만, 클래스와 함께 쓸 수도 있어요.

예를 들어 위의 Logarithm 예시를 클래스로 재정의할 수 있어요.

class Logarithms:

  opaque type Logarithm = Double

  def apply(d: Double): Logarithm = math.log(d)

  def safe(d: Double): Option[Logarithm] =
    if d > 0.0 then Some(math.log(d)) else None

  def mul(x: Logarithm, y: Logarithm) = x + y

서로 다른 인스턴스의 불투명 타입 멤버는 서로 다른 것으로 취급돼요:

val l1 = new Logarithms
val l2 = new Logarithms
val x = l1(1.5)
val y = l1(2.6)
val z = l2(3.1)
l1.mul(x, y) // type checks
l1.mul(x, z) // error: found l2.Logarithm, required l1.Logarithm

일반적으로 불투명 타입은 private[this]의 스코프 안에서만 투명하다고 생각하면 돼요. (타입이 최상위 정의인 경우는 예외인데, 이때는 정의된 파일 안에서만 투명해요.)

더 많은 세부 사항 (More details)