컴파일 타임 연산
컴파일 타임 연산 (Compile-time operations)
타입에 대해 값을 다루는 컴파일 타임 연산을 지원하는 도우미 정의들이 scala.compiletime 패키지에 있어요. 이 글에서는 그 패키지를 하나씩 살펴볼게요.
본문
scala.compiletime 패키지
scala.compiletime 패키지에는 값에 대한 컴파일 타임 연산을 지원하는 도우미 정의들이 있어요. 아래에서 설명할게요.
constValue와 constValueOpt
constValue는 타입이 나타내는 상수 값을 만들어 내는 함수예요. 타입이 상수 타입이 아니면 컴파일 타임 오류가 나요.
import scala.compiletime.constValue
import scala.compiletime.ops.int.S
transparent inline def toIntC[N]: Int =
inline constValue[N] match
case 0 => 0
case _: S[n1] => 1 + toIntC[n1]
inline val ctwo = toIntC[2]
constValueOpt는 constValue와 같지만 Option[T]를 반환해서 값이 없는 상황을 처리할 수 있게 해줘요. 여기서 S는 어떤 싱글턴 타입의 후속(successor) 타입이에요. 예를 들어 타입 S[1]은 싱글턴 타입 2예요.
튜플은 그 구성 요소들이 상수 타입이라 해도 상수 타입이 아니기 때문에, constValueTuple이 있어요. 튜플 타입 (X1, ..., Xn)이 주어지면 튜플 값 (constValue[X1], ..., constValue[Xn])을 반환해요.
erasedValue
지금까지는 term(튜플과 정수)을 파라미터로 받는 인라인 메서드를 봤어요. 타입을 기준으로 case를 나누고 싶다면 어떨까요? 예를 들어 타입 T가 주어졌을 때, T의 기본값이 있으면 선택적으로 그 값을 반환하는 함수 defaultValue를 쓰고 싶은 경우를 생각해 봐요. 이건 이미 rewrite match 표현식과 간단한 헬퍼 함수 scala.compiletime.erasedValue로 표현할 수 있어요. 이 헬퍼는 다음과 같이 정의돼요.
def erasedValue[T]: T
erasedValue 함수는 타입 인자 T의 값을 반환하는 척 해요. 이 함수를 호출하면, 그 호출이 인라인 중에 코드에서 제거되지 않는 한 항상 컴파일 타임 오류가 나요.
erasedValue를 쓰면 defaultValue를 다음과 같이 정의할 수 있어요.
import scala.compiletime.erasedValue
transparent inline def defaultValue[T] =
inline erasedValue[T] match
case _: Byte => Some(0: Byte)
case _: Char => Some(0: Char)
case _: Short => Some(0: Short)
case _: Int => Some(0)
case _: Long => Some(0L)
case _: Float => Some(0.0f)
case _: Double => Some(0.0d)
case _: Boolean => Some(false)
case _: Unit => Some(())
case _ => None
그러면:
val dInt: Some[Int] = defaultValue[Int]
val dDouble: Some[Double] = defaultValue[Double]
val dBoolean: Some[Boolean] = defaultValue[Boolean]
val dAny: None.type = defaultValue[Any]
다른 예시로, 아래 toInt의 타입 수준 버전을 생각해 봐요. 즉 Peano 숫자를 나타내는 타입이 주어지면, 그에 대응하는 정수 값을 반환하는 함수죠. Inline Match 절에서와 같은 숫자 정의를 고려해 볼게요. toIntT는 이렇게 정의할 수 있어요.
transparent inline def toIntT[N <: Nat]: Int =
inline scala.compiletime.erasedValue[N] match
case _: Zero.type => 0
case _: Succ[n] => toIntT[n] + 1
inline val two = toIntT[Succ[Succ[Zero.type]]]
erasedValue는 erased 메서드이므로 사용할 수 없고 런타임 동작도 없어요. toIntT는 N의 정적 타입에 대해 정적 검사를 수행하므로, 그 반환 타입을 안전하게 정밀 검토(scrutinize)할 수 있어요(이 경우 S[S[Z]]).
error
error 메서드는 인라인 확장 중에 사용자 정의 컴파일 오류를 만들어 내는 데 쓰여요. 시그니처는 다음과 같아요.
inline def error(inline msg: String): Nothing
인라인 확장이 호출 error(msgStr)로 귀결되면 컴파일러는 주어진 msgStr을 포함한 오류 메시지를 만들어요.
import scala.compiletime.{error, codeOf}
inline def fail() =
error("failed for a reason")
fail() // error: failed for a reason
또는
inline def fail(inline p1: Any) =
error("failed on: " + codeOf(p1))
fail(identity("foo")) // error: failed on: identity[String]("foo")
scala.compiletime.ops 패키지
scala.compiletime.ops 패키지에는 싱글턴 타입에 대한 기본 연산을 지원하는 타입들이 있어요. 예를 들어 scala.compiletime.ops.int.*는 두 싱글턴 Int 타입의 곱을 지원하고, scala.compiletime.ops.boolean.&&는 두 Boolean 타입의 논리곱을 지원해요. scala.compiletime.ops의 타입에 오는 모든 인자가 싱글턴 타입이면, 컴파일러가 연산의 결과를 계산할 수 있어요.
import scala.compiletime.ops.int.*
import scala.compiletime.ops.boolean.*
val conjunction: true && true = true
val multiplication: 3 * 5 = 15
이러한 싱글턴 연산 타입 중 상당수는 중위(infix)로 쓰도록 의도됐어요(가령 SLS §3.2.10 참조).
타입 별칭은 term 수준 동등물과 같은 우선순위 규칙을 가지므로, 연산들은 기대되는 우선순위 규칙에 따라 합성돼요.
import scala.compiletime.ops.int.*
val x: 1 + 2 * 3 = 7
연산 타입들은 좌변 파라미터의 타입 이름을 딴 패키지에 위치해요. 예를 들어 scala.compiletime.ops.int.+는 두 숫자의 덧셈을 나타내고, scala.compiletime.ops.string.+는 문자열 연결을 나타내요. 둘 다 쓰면서 서로 구별하려면, match 타입이 올바른 구현으로 디스패치할 수 있어요.
import scala.compiletime.ops.*
import scala.annotation.infix
type +[X <: Int | String, Y <: Int | String] = (X, Y) match
case (Int, Int) => int.+[X, Y]
case (String, String) => string.+[X, Y]
val concat: "a" + "b" = "ab"
val addition: 1 + 1 = 2
givens 선택적으로 소환하기 (Summoning Givens Selectively)
새 summonFrom 구성은 함수형 맥락에서 implicit 검색을 가능하게 해요. 올바른 집합을 만드는 문제를 풀기 위해 다음과 같이 쓰면 돼요.
import scala.compiletime.summonFrom
inline def setFor[T]: Set[T] = summonFrom {
case ord: Ordering[T] => new TreeSet[T]()(using ord)
case _ => new HashSet[T]
}
summonFrom 호출은 패턴 매칭 클로저를 인자로 받아요. 클로저의 모든 패턴은 identifier : Type 형태의 타입 지정(type ascription)이에요.
패턴은 순서대로 시도돼요. 타입 T의 문맥 값이 소환될 수 있도록 하는 x: T 패턴을 가진 첫 번째 case가 선택돼요.
대안으로, 패턴에 바인딩된 given 인스턴스를 사용할 수도 있어요. 그러면 명시적 using 절을 피할 수 있어요. 예를 들어 setFor는 다음과 같이도 표현할 수 있어요.
import scala.compiletime.summonFrom
inline def setFor[T]: Set[T] = summonFrom {
case given Ordering[T] => new TreeSet[T]
case _ => new HashSet[T]
}
summonFrom 애플리케이션은 컴파일 타임에 축소되어야 해요.
따라서 Ordering[String]의 given 인스턴스가 implicit 범위에 있다면, 위 코드는 TreeSet[String]의 새 인스턴스를 반환해요. 그런 인스턴스는 Ordering의 컴패니언 객체에 정의되어 있으므로 항상 하나가 있어요.
summon[Ordering[String]] // Proves that an Ordering[String] is in scope
println(setFor[String].getClass) // prints class scala.collection.immutable.TreeSet
Note: summonFrom 애플리케이션은 모호성 오류를 일으킬 수 있어요. 타입 A의 두 given이 범위에 있는 다음 코드를 고려해 볼게요. f의 패턴 매치는 f가 적용되면 모호성 오류를 일으켜요.
class A
given a1: A = new A
given a2: A = new A
inline def f: Any = summonFrom {
case given _: A => ??? // error: ambiguous givens
}
summonInline
약칭 summonInline은 호출이 인라인될 때까지 지연되는 summon을 간단히 쓰는 방법을 제공해요. summonFrom과 달리 summonInline은 소환된 타입의 given 인스턴스를 찾지 못하면 implicit-not-found 오류도 낳아요.
import scala.compiletime.summonInline
import scala.annotation.implicitNotFound
@implicitNotFound("Missing One")
trait Missing1
@implicitNotFound("Missing Two")
trait Missing2
trait NotMissing
given NotMissing = ???
transparent inline def summonInlineCheck[T <: Int](inline t : T) : Any =
inline t match
case 1 => summonInline[Missing1]
case 2 => summonInline[Missing2]
case _ => summonInline[NotMissing]
val missing1 = summonInlineCheck(1) // error: Missing One
val missing2 = summonInlineCheck(2) // error: Missing Two
val notMissing : NotMissing = summonInlineCheck(3)
참고 (Reference)
컴파일 타임 연산에 대한 자세한 내용은 PR #4768과 PR #7201을 보세요. 전자는 summonFrom의 전신인 implicit matches를 타입 수준 프로그래밍과 코드 특수화에 어떻게 쓸 수 있는지, 후자는 새 summonFrom 문법을 설명해요.