매크로
매크로 (Macros)
스칼라 3의 매크로는 quote와 splice를 기반으로 해요. 개발할 때는 -Xcheck-macros 컴파일러 옵션을 켜서 추가 안전 검사를 실행하는 게 좋아요. 이 글에서는 멀티-스테이징, quoted 값, 매크로와 런타임 멀티-스테이징, 안전성, 스테이징된 람다·생성자·클래스, quote 패턴 매칭까지 차례로 살펴볼게요.
본문
멀티-스테이징 (Multi-Staging)
스칼라 3의 멀티-스테이지 프로그래밍은 quote '{..}로 코드 실행을 지연(즉 스테이징)하고, splice ${..}로 코드를 평가·삽입해서 quote에 넣어요. Quoted 표현식은 공변 타입 파라미터 T를 가진 Expr[T]로 타입이 매겨져요. 이 두 개념으로 정적으로 안전한 코드 생성기를 쉽게 쓸 수 있어요. 다음 예시는 $x^n$ 수학 연산의 단순한 구현을 보여줘요.
import scala.quoted.*
def unrolledPowerCode(x: Expr[Double], n: Int)(using Quotes): Expr[Double] =
if n == 0 then '{ 1.0 }
else if n == 1 then x
else '{ $x * ${ unrolledPowerCode(x, n-1) } }
'{
val x = ...
${ unrolledPowerCode('{x}, 3) } // evaluates to: x * x * x
}
Quote와 splice는 서로의 쌍대(dual)예요. 타입 T의 임의의 표현식 x에 대해 ${'{x}} = x가 성립하고, 타입 Expr[T]의 임의의 표현식 e에 대해 '{${e}} = e가 성립해요.
Quote는 타입 클래스 Type[T]를 사용해서 제네릭·추상 타입을 처리할 수 있어요. 제네릭·추상 타입 T를 참조하는 quote는 implicit 범위에 given Type[T]가 제공되도록 요구해요. 다음 예시는 T에 컨텍스트 바운드(: Type)를 붙여서 implicit Type[T]를 제공하거나, 동등한 using Type[T] 파라미터를 제공하는 방법을 보여줘요.
import scala.quoted.*
def singletonListExpr[T: Type](x: Expr[T])(using Quotes): Expr[List[T]] =
'{ List[T]($x) } // generic T used within a quote
def emptyListExpr[T](using Type[T], Quotes): Expr[List[T]] =
'{ List.empty[T] } // generic T used within a quote
다른 인스턴스가 발견되지 않으면, 기본 Type.of[T]가 사용돼요. 다음 예시는 Type.of[String]과 Type.of[Option[U]]를 암시적으로 사용해요.
val list1: Expr[List[String]] =
singletonListExpr('{"hello"}) // requires a given `Type[Sting]`
val list0: Expr[List[Option[T]]] =
emptyListExpr[Option[U]] // requires a given `Type[Option[U]]`
Type.of[T] 메서드는 컴파일러가 특별히 처리하는 원시 연산(primitive operation)이에요. 타입 T가 정적으로 알려져 있거나, T가 우리가 implicit Type[Ui]를 가진 다른 타입 Ui들을 포함하면 implicit을 제공해요. 이 예시에서 Type.of[String]은 정적으로 알려진 타입을 가지고, Type.of[Option[U]]는 범위 안의 implicit Type[U]를 요구해요.
또한 given Quotes 인스턴스로 현재 quote 문맥을 추적해요. quote '{..}를 만들려면 given Quotes 문맥이 필요한데, 이는 함수에 문맥 파라미터 (using Quotes)로 전달되어야 해요. 각 splice는 splice의 범위 안에서 새로운 Quotes 문맥을 제공해요. 따라서 quote와 splice는 다음과 같은 시그니처를 가진 메서드로 볼 수 있지만, 특별한 시맨틱을 가져요.
def '[T](x: T): Quotes ?=> Expr[T] // def '[T](x: T)(using Quotes): Expr[T]
def $[T](x: Quotes ?=> Expr[T]): T
물음표가 있는 람다 ?=>는 문맥 함수(contextual function)예요. 인자를 암시적으로 받아서 람다 구현 안에서 암시적으로 제공하는 람다죠. Quotes는 다양한 목적에 쓰이며, 그 주제를 다룰 때 언급할 거예요.
Quoted 값 (Quoted Values)
로컬 변수의 크로스-스테이지 지속(cross-stage persistence)은 불가능하지만, 다음 스테이지로 끌어올리는(lift) 것은 가능해요. 이를 위해 Expr.apply 메서드를 제공하는데, 이 메서드는 값을 받아서 값의 quoted 표현으로 끌어올려요.
val expr1plus1: Expr[Int] = '{ 1 + 1 }
val expr2: Expr[Int] = Expr(1 + 1) // lift 2 into '{ 2 }
타입 측면에서는 '{ 1 + 1 }과 비슷해 보이지만, Expr(1 + 1)의 시맨틱은 꽤 달라요. Expr(1 + 1)은 어떤 계산도 스테이징하거나 지연하지 않아요. 인자가 값으로 평가된 다음 quote로 끌어올려지죠. quote는 다음 스테이지에서 이 값의 복사본을 만들 코드를 담게 돼요. Expr은 다형이고 ToExpr 타입 클래스를 통해 사용자 확장이 가능해요.
trait ToExpr[T]:
def apply(x: T)(using Quotes): Expr[T]
given 정의를 써서 ToExpr을 구현하면 그 정의가 범위 안의 implicits에 추가돼요. 다음 예시는 임의의 리프트 가능한 타입 T에 대해 ToExpr[Option[T]]을 구현하는 방법을 보여줘요.
given OptionToExpr: [T: {Type, ToExpr}] => ToExpr[Option[T]]:
def apply(opt: Option[T])(using Quotes): Expr[Option[T]] =
opt match
case Some(x) => '{ Some[T]( ${Expr(x)} ) }
case None => '{ None }
기본 타입용 ToExpr은 시스템에서 원시 연산으로 구현되어야 해요. 우리 경우엔 리플렉션 API를 사용해서 구현해요.
unrolledPowerCode 메서드로 최적화된 코드를 생성하려면, 매크로 구현 powerCode가 먼저 파라미터 n으로 전달된 인자가 알려진 상수 값인지 판단해야 해요. 이는 우리 라이브러리 구현의 Expr.unapply 추출기를 쓰는 *언리프팅(unlifting)*으로 달성할 수 있어요. 이 추출기는 n이 quoted 상수일 때만 매치하고 그 값을 추출해요.
def powerCode(x: Expr[Double], n: Expr[Int])(using Quotes): Expr[Double] =
n match
case Expr(m) => // it is a constant: unlift code n='{m} into number m
unrolledPowerCode(x, m)
case _ => // not known: call power at run-time
'{ power($x, $n) }
대안으로 n.value 메서드로 Option[Int] 값을 얻거나, n.valueOrAbort로 값을 직접 얻을 수 있어요.
def powerCode(x: Expr[Double], n: Expr[Int])(using Quotes): Expr[Double] =
// emits an error message if `n` is not a constant
unrolledPowerCode(x, n.valueOrAbort)
Expr.unapply와 value의 모든 변형은 다형이고 given FromExpr 타입 클래스를 통해 사용자 확장이 가능해요.
trait FromExpr[T]:
def unapply(x: Expr[T])(using Quotes): Option[T]
ToExpr에서 했던 것처럼 given 정의로 FromExpr을 구현할 수 있어요. 기본 타입용 FromExpr은 시스템에서 원시 연산으로 구현되어야 해요. 우리 경우엔 리플렉션 API로 구현해요. 비기본 타입용 FromExpr을 구현하려면 quote 패턴 매칭을 사용해요(예: OptionFromExpr).
매크로와 멀티-스테이지 프로그래밍 (Macros and Multi-Stage Programming)
이 시스템은 같은 quotation 추상화로 멀티-스테이지 매크로와 런타임 멀티-스테이지 프로그래밍을 모두 지원해요.
멀티-스테이지 매크로 (Multi-Stage Macros)
splice 추상화를 일반화해서 매크로를 표현할 수 있어요. 매크로는 어떤 quote에도 중첩되지 않은 최상위 splice로 구성돼요. 개념적으로 splice의 내용은 프로그램보다 한 스테이지 일찍 평가돼요. 즉 내용은 프로그램을 컴파일하는 동안 평가돼요. 매크로에서 생성된 코드가 프로그램의 splice를 대체해요.
def power2(x: Double): Double =
${ unrolledPowerCode('x, 2) } // x * x
프로그램 중간에서 splice를 쓰는 건 함수를 호출하는 것만큼 사용자 친화적(ergonomic)이지 않으므로, 매크로의 최종 사용자에게서 스테이징 메커니즘을 숨겨요. 매크로와 일반 함수를 호출하는 균일한 방식이 있어요. 이를 위해 최상위 splice의 사용을 인라인 메서드에만 나타나도록 제한해요[^1][^2].
// inline macro definition
inline def powerMacro(x: Double, inline n: Int): Double =
${ powerCode('x, 'n) }
// user code
def power2(x: Double): Double =
powerMacro(x, 2) // x * x
매크로의 평가는 코드가 power2에 인라인될 때만 일어나요. 인라인되면 코드는 이전의 power2 정의와 동등해요. 인라인 메서드를 쓰는 결과 중 하나는 매크로의 인자나 반환 타입 중 어느 것도 Expr 타입을 언급할 필요가 없다는 거예요. 이는 메타프로그래밍의 모든 측면을 최종 사용자에게서 숨겨요.
최상위 splice를 평가할 때, 컴파일러는 splice 안의 코드를 해석(interpret)해야 해요. 전체 언어용 인터프리터를 제공하는 건 꽤 까다롭고, 그 인터프리터를 효율적으로 돌리는 건 더 어려워요. 완전한 인터프리터를 피하려면, 최상위 splice에서 코드의 평가를 단순화하기 위해 splice에 다음 제한을 부과할 수 있어요.
- 최상위 splice는 컴파일된 정적 메서드에 대한 단일 호출을 담아야 해요.
- 함수의 인자는 리터럴 상수, quoted 표현식(파라미터), 타입 파라미터용
Type.of호출, 그리고Quotes에 대한 참조예요.
특히 이 제한들은 최상위 splice 안에서 splice를 쓰는 것을 금지해요. 그런 splice는 여러 단계의 해석을 요구해서 불필요하게 비효율적일 거예요.
매크로 구현(즉 최상위 splice에서 호출되는 메서드)은 미리 컴파일된 어떤 라이브러리에서든 올 수 있어요. 이것은 컴파일 과정의 단계들 사이에 분명한 차이를 제공해요. 서로 다른 라이브러리에 정의된 다음 3개의 소스 파일을 고려해 볼게요.
// Macro.scala
def powerCode(x: Expr[Double], n: Expr[Int])(using Quotes): Expr[Double] = ...
inline def powerMacro(x: Double, inline n: Int): Double =
${ powerCode('x, 'n) }
// Lib.scala (depends on Macro.scala)
def power2(x: Double) =
${ powerCode('x, '{2}) } // inlined from a call to: powerMacro(x, 2)
// App.scala (depends on Lib.scala)
@main def app() = power2(3.14)
이를 문법적으로 시각화하는 한 방법은, 애플리케이션의 컴파일을 지연하는 quote에 애플리케이션을 넣는 거예요. 그러면 애플리케이션 의존성을, 그 quoted 애플리케이션을 포함하는 바깥쪽 quote에 놓고, 의존성의 의존성에 대해 재귀적으로 반복할 수 있어요.
'{ // macro library (compilation stage 1)
def powerCode(x: Expr[Double], n: Expr[Int])(using Quotes): Expr[Double] =
...
inline def powerMacro(x: Double, inline n: Int): Double =
${ powerCode('x, 'n) }
'{ // library using macros (compilation stage 2)
def power2(x: Double) =
${ powerCode('x, '{2}) } // inlined from a call to: powerMacro(x, 2)
'{ power2(3.14) /* app (compilation stage 3) */ }
}
}
시스템을 더 다재다능하게 만들기 위해, 일부 제한과 함께 매크로가 정의된 프로젝트 안에서 매크로를 호출하는 것을 허용해요. 예를 들어 Macro.scala와 Lib.scala를 같은 라이브러리에서 함께 컴파일하는 경우죠. 이를 위해 우리는 더 단순한 문법적 모델을 따르지 않고, 소스 파일의 시맨틱 정보에 의존해요. 소스를 컴파일할 때 아직 컴파일되지 않은 매크로에 대한 호출을 감지하면, 이 소스의 컴파일을 다음 컴파일 단계로 지연해요. 예시에서 Lib.scala는 powerCode에 대한 컴파일 타임 호출을 포함하므로 그 컴파일을 지연해요. 컴파일 단계는 모든 소스가 컴파일될 때까지, 또는 진전이 없을 때까지 반복돼요. 진전이 없다면 매크로의 정의와 사용 사이에 순환 의존성이 있었던 거예요. 또한 런타임에 매크로가 아직 컴파일되지 않은 소스에 의존하는지도 감지해야 해요. 이는 매크로를 실행하고 아직 컴파일되지 않은 클래스에 대한 JVM 링킹 오류를 확인함으로써 감지돼요.
런타임 멀티-스테이지 프로그래밍 (Run-Time Multi-Stage Programming)
런타임 멀티-스테이지 프로그래밍(Run-Time Multi-Stage Programming) 참조.
안전성 (Safety)
멀티-스테이지 프로그래밍은 설계상 정적으로 안전하고 크로스-스테이지 안전해요.
정적 안전성 (Static Safety)
모든 식별자 이름은 quote의 문맥에서 대응하는 변수에 대한 기호 참조로 해석돼요. 따라서 quote를 평가하는 동안 같은 텍스트 이름을 가진 새 변수로 참조를 실수로 다시 바인딩하는 것은 불가능해요.
quote가 잘 타입화되어 있으면, 생성된 코드도 잘 타입화돼요. 이는 각 표현식의 타입을 추적하는 것의 단순한 결과예요. Expr[T]는 타입 T의 표현식을 담은 quote에서만 만들어질 수 있어요. 반대로 Expr[T]는 타입 T를 기대하는 위치에서만 스플라이스될 수 있어요. 앞서 말했듯 Expr은 타입 파라미터에 대해 공변(covariant)이에요. 이 말은 Expr[T]가 T의 서브타입 표현식을 담을 수 있다는 뜻이에요. 타입 T를 기대하는 위치에 스플라이스될 때, 이 표현식들도 유효한 타입을 가져요.
크로스-스테이지 안전성 (Cross-Stage Safety)
어떤 코드의 *스테이징 레벨(staging level)*을 그 코드를 둘러싼 quote 수에서 splice 수를 뺀 값으로 정의해요. 로컬 변수는 같은 스테이징 레벨에서 정의되고 사용되어야 해요.
더 낮은 스테이징 레벨의 로컬 변수는 아직 존재하지 않으므로 접근하는 게 절대 불가능해요.
def badPower(x: Double, n: Int): Double =
${ unrolledPowerCode('x, n) } // error: value of `n` not known yet
매크로와 *크로스-플랫폼 휴대성(cross-platform portability)*의 맥락, 즉 한 머신에서 컴파일되지만 다른 머신에서 실행될 수 있는 매크로에서는 로컬 변수의 크로스-스테이지 지속을 지원할 수 없어요. 따라서 우리 시스템에서는 로컬 변수에 정확히 같은 스테이징 레벨에서만 접근할 수 있어요.
def badPowerCode(x: Expr[Double], n: Int)(using Quotes): Expr[Double] =
// error: `n` potentially not available in the next execution environment
'{ power($x, n) }
규칙은 unrolledPowerCode 같은 전역 정의에 대해서는 약간 달라요. '{ power(2, 4) }에서처럼 전역 정의에 대한 참조를 포함한 코드를 생성할 수 있어요. 이는 크로스-플랫폼 휴대성을 방해하지 않는 크로스-스테이지 지속의 제한된 형태인데, 이미 컴파일된 power 코드를 참조하는 거예요. 각 컴파일 단계는 전역 정의를 유지하면서 스테이징 레벨을 하나 낮춰요. 결과적으로 ${ unrolledPowerCode('x, 2) }에서처럼 매크로에서 unrolledPowerCode 같은 컴파일된 정의를 참조할 수 있어요.
레벨 일관성을 두 규칙으로 요약할 수 있어요.
- 로컬 변수는 그 정의와 같은 스테이징 레벨에서만 사용할 수 있어요.
- 전역 변수는 어떤 스테이징 레벨에서도 사용할 수 있어요.
스칼라는 타입 지우개(type erasure)를 사용하므로, 제네릭 타입은 런타임에, 따라서 그 이후의 어떤 스테이지에서든 지워져요. 제네릭 타입 T를 참조하는 어떤 quoted 표현식이 필요한 정보를 잃지 않도록, 우리는 범위 안에 given Type[T]를 요구해요. Type[T]는 타입의 지워지지 않은 표현을 다음 단계로 가져가요. 따라서 정의보다 높은 스테이징 레벨에서 사용되는 어떤 제네릭 타입도 그 Type을 요구해요.
splice의 내용 안에서, 바깥쪽 quote에 정의된 로컬 변수를 참조하는 quote를 가질 수 있어요. 이 quote가 splice 안에서 사용된다면 그 변수는 범위 안에 있어요. 하지만 그 quote가 어떻게든 splice 밖으로 *압출(extruded)*된다면, 변수는 더 이상 범위 안에 있지 않을 수도 있어요. Quoted 표현식은 가변 상태, 예외 같은 부수 효과를 사용해서 압출될 수 있어요. 다음 예시는 가변 상태를 사용해 quote를 압출하는 방법을 보여줘요.
var x: Expr[T] = null
'{ (y: T) => ${ x = 'y; 1 } }
x // has value '{y} but y is not in scope
변수가 압출될 수 있는 두 번째 방법은 run 메서드를 통해서예요. run이 quoted 변수 참조를 소모하면, 그것은 더 이상 범위 안에 있지 않게 돼요. 결과는 다음 스테이지에서 정의되는 변수를 참조하게 돼요.
'{ (x: Int) => ${ run('x); ... } }
// evaluates to: '{ (x: Int) => ${ x; ... } 1
두 압출 시나리오를 모두 잡기 위해, 우리 시스템은 quote가 splice 범위에서 압출되지 않았을 때만 스플라이스되도록 허용함으로써 quote의 사용을 제한해요. 레벨 일관성과 달리, 이것은 정적 타입 시스템을 너무 복잡하게 만들지 않기 위해 컴파일 타임이 아니라 런타임[^4]에 검사돼요.
각 Quotes 인스턴스는 고유한 범위 식별자를 포함하고 자신의 부모 범위를 참조해서, 식별자의 스택을 형성해요. Quotes 범위의 부모는 둘러싸는 quote를 만드는 데 사용된 Quotes의 범위예요. 최상위 splice와 run은 새 범위 스택을 만드세요. 모든 Expr은 자신이 만들어진 범위를 알아요. 스플라이스될 때, quote 범위가 splice 범위와 같거나 그 부모 범위인지 확인해요.
스테이징된 람다 (Staged Lambdas)
함수형 언어에서 프로그램을 스테이징할 때 두 가지 기본 추상화가 있어요. 스테이징된 람다 Expr[T => U]와 스테이징하는 람다(staging lambda) Expr[T] => Expr[U]가 그것이에요. 전자는 다음 스테이지에 존재할 함수이고, 후자는 현재 스테이지에 존재하는 함수예요. Expr[T => U]에서 Expr[T] => Expr[U]로, 그리고 그 반대로 가는 메커니즘이 있으면 보통 편리해요.
def later[T: Type, U: Type](f: Expr[T] => Expr[U]): Expr[T => U] =
'{ (x: T) => ${ f('x) } }
def now[T: Type, U: Type](f: Expr[T => U]): Expr[T] => Expr[U] =
(x: Expr[T]) => '{ $f($x) }
두 변환 모두 quote와 splice로 바로 수행할 수 있어요. 하지만 f가 알려진 람다 함수라면, '{ $f($x) }은 그 자리에서 람다를 베타 축소하지 않아요. 이 최적화는 컴파일러의 더 나중 단계에서 수행돼요. 애플리케이션을 바로 축소하지 않으면 생성된 코드의 분석이 단순해질 수 있어요. 그래도 Expr.betaReduce 메서드로 그 자리에서 람다를 베타 축소하는 건 가능해요.
def now[T: Type, U: Type](f: Expr[T => U]): Expr[T] => Expr[U] =
(x: Expr[T]) => Expr.betaReduce('{ $f($x) })
betaReduce 메서드는 가능하면(애리티와 무관하게) 표현식의 가장 바깥쪽 애플리케이션을 베타 축소해요. 표현식을 베타 축소할 수 없다면 원래 표현식을 반환해요.
스테이징된 생성자 (Staged Constructors)
더 나중 단계에서 새 클래스 인스턴스를 만들려면 팩토리 메서드(보통 object의 apply 메서드)로 만들거나, new로 인스턴스화할 수 있어요. 예를 들어 Some(1)이나 new Some(1)을 쓸 수 있는데, 같은 값을 만들어요. 스칼라 3에서 팩토리 메서드 호출 표기법을 쓰면 apply 메서드가 발견되지 않을 때 new로 폴백돼요. 팩토리 메서드를 호출할 때 우리는 일반적인 스테이징 규칙을 따라요. 마찬가지로 new C를 쓸 때, C의 생성자가 암시적으로 호출되는데, 이것도 일반적인 스테이징 규칙을 따라요. 따라서 임의의 알려진 클래스 C에 대해 '{ C(...) } 또는 '{ new C(...) } 둘 다 생성자로 쓸 수 있어요.
스테이징된 클래스 (Staged Classes)
Quoted 코드는 로컬 클래스 정의를 포함한 어떤 유효한 표현식이든 포함할 수 있어요. 이는 특수한 구현을 가진 새 클래스 생성을 허용해요. 예를 들어 어떤 최적화된 연산을 수행할 Runnable의 새 버전을 구현할 수 있어요.
def mkRunnable(x: Int)(using Quotes): Expr[Runnable] = '{
class MyRunnable extends Runnable:
def run(): Unit = ... // generate some custom code that uses `x`
new MyRunnable
}
Quoted 클래스는 로컬 클래스라서 그 타입이 둘러싼 quote를 벗어날 수 없어요. 클래스는 quote 안에서 사용되어야 하거나, 알려진 인터페이스(이 경우엔 Runnable)를 사용해 그 인스턴스를 반환할 수 있어요.
Quote 패턴 매칭 (Quote Pattern Matching)
때로는 코드의 구조를 분석하거나 코드를 부분 표현식으로 분해해야 할 필요가 있어요. 전형적인 예로는 임베디드 DSL이 있는데, 매크로가 코드를 컴파일하는 동안 재해석할 수 있는 정의 집합을 알고 있는 경우죠(예: 최적화 수행). 다음 예시에서는 이전 powCode 구현을 확장해서 x를 들여다보며 추가 최적화를 수행해요.
def fusedPowCode(x: Expr[Double], n: Expr[Int])(using Quotes): Expr[Double] =
x match
case '{ power($y, $m) } => // we have (y^m)^n
fusedPowCode(y, '{ $n * $m }) // generate code for y^(n*m)
case _ =>
'{ power($x, $n) }
Quoted 패턴에서 $는 부분 표현식을 그 case 분기에서 쓸 수 있는 Expr에 바인딩해요. Quote 패턴의 ${..} 내용은 일반적인 스칼라 패턴이에요. 예를 들어 ${..} 안에 Expr(_) 패턴을 사용해서 알려진 값일 때만 매치하고 그 값을 추출할 수 있어요.
def fusedUnrolledPowCode(x: Expr[Double], n: Int)(using Quotes): Expr[Double] =
x match
case '{ power($y, ${Expr(m)}) } => // we have (y^m)^n
fusedUnrolledPowCode(y, n * m) // generate code for y * ... * y
case _ => // ( n*m times )
unrolledPowerCode(x, n)
이런 값 추출 하위 패턴은 FromExpr의 인스턴스를 사용해 다형일 수 있어요. 다음 예시는 내부적으로 Expr(x) 패턴으로 값을 추출하는 데 FromExpr[T]를 사용하는 OptionFromExpr의 구현을 보여줘요.
given OptionFromExpr: [T: {Type, FromExpr}] => FromExpr[Option[T]]:
def unapply(x: Expr[Option[T]])(using Quotes): Option[Option[T]] =
x match
case '{ Some( ${Expr(x)} ) } => Some(Some(x))
case '{ None } => Some(None)
case _ => None
패턴은 두 종류의 참조를 포함할 수 있어요. '{ power(...) }에서 power 메서드 호출 같은 전역 참조와, case '{ (x: Int) => x }에서 x 같은 패턴 안에 정의된 바인딩에 대한 참조요. quote에서 표현식을 추출할 때, 정의된 범위에서 어떤 변수도 압출하지 않도록 보장해야 해요.
'{ (x: Int) => x + 1 } match
case '{ (y: Int) => $z } =>
// should not match, otherwise: z = '{ x + 1 }
이 예시에서 패턴이 매치되지 않아야 함을 볼 수 있어요. 그렇지 않으면 표현식 z의 어떤 사용이든 x에 대한 바운드되지 않은 참조를 담게 되거든요. 그러한 압출을 피하기 위해, 우리는 그 표현식이 패턴 안의 정의 아래에서 닫혀(closed) 있을 때만 ${..}에 매치해요. 따라서 표현식이 닫혀 있지 않으면 패턴은 매치되지 않아요.
압출된 참조를 포함할 수 있는 표현식의 추출을 허용하기 위해 우리는 고차 추상 문법(HOAS) 패턴 $f(y)(또는 $f(y1,...,yn))을 제공해요. 이 패턴은 y에 대해 부분 표현식을 eta-확장하고 f에 바인딩해요. 람다 인자가 압출되었을 수 있는 변수들을 대체해요.
'{ ((x: Int) => x + 1).apply(2) } match
case '{ ((y: Int) => $f(y): Int).apply($z: Int) } =>
// f may contain references to `x` (replaced by `$y`)
// f = '{ (y: Int) => $y + 1 }
Expr.betaReduce('{ $f($z)}) // generates '{ 2 + 1 }
HOAS 패턴 $x(y1,...,yn)은 표현식이, 집합 y1,...,yn에 없는 패턴 안에 정의된 변수에 대한 참조를 포함하지 않을 때만 매치돼요. 즉 표현식이 패턴 안에 정의된 변수 중 y1,...,yn에 있는 것들에 대한 참조만 포함하면 그 패턴이 매치돼요. HOAS 패턴 $x()는 시맨틱적으로 닫힌 패턴 $x와 동등하다는 점을 주목하세요.
표현식은 정적으로 알려지지 않은 타입을 포함할 수 있어요. 예를 들어 Expr[List[Int]]는 list가 어떤 타입의 List인 list.map(_.toInt)을 포함할 수 있어요. 가능한 모든 경우를 다루려면 가능한 모든 타입(List[Int], List[Int => Int], ...)에 대해 list를 명시적으로 매치해야 해요. 이는 무한한 타입 집합이라 무한한 패턴 case 집합이 돼요. 특정 프로그램이 사용할 수 있는 모든 가능한 타입을 알더라도, 다루기 힘들 정도로 많은 case로 끝날 수 있어요. 이를 극복하기 위해 quoted 패턴에 타입 변수를 도입하는데, 이는 어떤 타입이든 매치해요.
다음 예시에서 타입 변수 t와 u가 리스트에 대한 연속된 map 호출의 모든 가능한 쌍을 매치하는 방법을 보여줘요. quoted 패턴에서 소문자로 명명된 타입은 타입 변수로 식별돼요. 이는 일반 패턴에서 쓰이는 타입 변수와 같은 표기법을 따라요.
def fuseMapCode(x: Expr[List[Int]]): Expr[List[Int]] =
x match
case '{ ($ls: List[t]).map[u]($f).map[Int]($g) } =>
'{ $ls.map($g.compose($f)) }
...
fuseMapCode('{ List(1.2).map(f).map(g) }) // '{ List(1.2).map(g.compose(f)) }
fuseMapCode('{ List('a').map(h).map(i) }) // '{ List('a').map(i.compose(h)) }
변수 f와 g는 각각 타입 Expr[t => u]와 Expr[u => Int]로 추론돼요. 이후 $g.compose($f)가 Expr[t => Int], 즉 $ls.map(..)의 인자 타입으로 추론할 수 있어요.
타입 변수는 지워질 추상 타입이에요. 이는 두 번째 quote에서 그것들을 참조하려면 given Type[t]와 Type[u]가 필요하다는 뜻이에요. quoted 패턴은 묵시적으로 그 given 타입들을 제공할 거예요. 런타임에 패턴이 매치되면 t와 u의 타입이 알려지고, Type[t]와 Type[u]는 표현식의 정밀 타입을 담게 돼요.
Expr이 공변이므로, 표현식의 정적으로 알려진 타입이 실제 타입이 아닐 수 있어요. 타입 변수를 사용해 표현식의 정밀 타입을 복구할 수도 있어요.
def let(x: Expr[Any])(using Quotes): Expr[Any] =
x match
case '{ $x: t } =>
'{ val y: t = $x; y }
let('{1}) // will return a `Expr[Any]` that contains an `Expr[Int]]`
패턴에서 같은 타입 변수를 여러 번 참조하는 것도 가능해요.
case '{ $x: (t, t) } =>
패턴 중간에 타입 변수를 정의할 수는 있지만, 그 정규형은 패턴 시작 부분에서 소문자 이름의 type으로 정의하는 거예요.
case '{ type t; $x: t } =>
이건 조금 더 장황하지만, 변수에 바운드를 정의하는 것 같은 표현력의 이점이 있어요.
case '{ type t >: List[Int] <: Seq[Int]; $x: t } =>
타입만 있고 그 타입의 표현식은 없을 수도 있어요. 타입을 검사할 수 있게, 우리는 quoted 타입 패턴 case '[..] =>을 도입해요. 이것은 quoted 패턴과 같은 방식으로 동작하지만 타입만 담도록 제한돼요. 타입 변수를 quoted 타입 패턴에서 사용해 타입을 추출할 수 있어요.
def empty[T: Type](using Quotes): Expr[T] =
Type.of[T] match
case '[String] => '{ "" }
case '[List[t]] => '{ List.empty[t] }
case '[type t <: Option[Int]; List[t]] => '{ List.empty[t] }
...
Type.of[T]는 범위 안에서 given 인스턴스 Type[T]를 소환하는 데 사용되며, summon[Type[T]]과 동등해요.
타입 변수에 적절한 타입 바운드를 사용해서 고차 종류(higher-kinded) 타입에 매치하는 것도 가능해요.
def empty[K <: AnyKind : Type](using Quotes): Type[?] =
Type.of[K] match
case '[type f[X]; f] => Type.of[f]
case '[type f[X <: Int, Y]; f] => Type.of[f]
case '[type k <: AnyKind; k ] => Type.of[k]
isInstanceOf[Expr[T]]와 asInstanceOf[Expr[T]] 같은 Expr에 대한 인스턴스 검사와 캐스트는 인스턴스가 Expr 클래스인지만 검사할 뿐 T 인자는 검사할 수 없다는 점을 알아 두는 게 중요해요. 이런 경우는 컴파일 타임에 경고를 내지만, 무시하면 예상치 못한 동작을 일으킬 수 있어요.
이런 연산은 시스템에서 올바르게 지원될 수 있어요. 간단한 타입 테스트라면 Expr의 isExprOf[T] 메서드를 사용해 그 타입의 인스턴스인지 확인할 수 있어요. 마찬가지로 asExprOf[T]를 사용해 표현식을 주어진 타입으로 캐스트할 수 있어요. 이 연산들은 given Type[T]를 사용해 타입 지우개를 우회해요.
부분 표현식 변환 (Sub-Expression Transformation)
시스템은 표현식의 모든 부분 표현식을 변환하는 메커니즘을 제공해요. 이는 변환하려는 부분 표현식이 표현식 깊은 곳에 있을 때 유용해요. 표현식이 quoted 패턴으로 매치할 수 없는 부분 표현식(로컬 클래스 정의 같은)을 포함할 때도 필요해요.
trait ExprMap:
def transform[T](e: Expr[T])(using Type[T])(using Quotes): Expr[T]
def transformChildren[T](e: Expr[T])(using Type[T])(using Quotes): Expr[T] =
...
사용자는 ExprMap 트레잇을 확장하고 transform 메서드를 구현할 수 있어요. 이 인터페이스는 유연해서 하향식(top-down), 상향식(bottom-up), 또는 다른 변환을 구현할 수 있어요.
object OptimizeIdentity extends ExprMap:
def transform[T](e: Expr[T])(using Type[T])(using Quotes): Expr[T] =
transformChildren(e) match // bottom-up transformation
case '{ identity($x) } => x
case _ => e
transformChildren 메서드는 모든 직접 부분 표현식에 도달해 각각에 transform을 호출하는 방법을 아는 원시 연산으로 구현돼요. transform에 전달되는 타입은 그 부분 표현식의 표현식 안에서의 기대 타입이에요. 예를 들어 '{ val x: Option[Int] = Some(1); ...}에서 Some(1)을 변환하는 동안 타입은 Some[Int]가 아니라 Option[Int]예요. 이는 Some(1)을 안전하게 None으로 변환할 수 있다는 뜻이에요.
스테이징된 implicit 소환 (Staged Implicit Summoning)
summon으로 implicit 인자를 소환할 때, 현재 범위에서 given 인스턴스를 찾아요. 먼저 명시적으로 스테이징해서 스테이징된 implicit 인자를 얻기 위해 summon을 사용할 수 있어요. 다음 예시에서는 implicit Ordering[T]를 Expr[Ordering[T]]로 매크로에 전달해서 그 구현에 넘길 수 있어요. 그러면 그것을 스플라이스해서 다음 스테이지에서 암시적으로 줄 수 있어요.
inline def treeSetFor[T](using ord: Ordering[T]): Set[T] =
${ setExpr[T](using 'ord) }
def setExpr[T:Type](using ord: Expr[Ordering[T]])(using Quotes): Expr[Set[T]] =
'{ given Ordering[T] = $ord; new TreeSet[T]() }
중간 메서드가 그것을 암시적으로 전달할 수 있으므로, 우리는 그것을 implicit Expr[Ordering[T]]로 전달해요.
대안은 매크로가 호출되는 범위에서 implicit 값을 소환하는 거예요. Expr.summon 메서드를 사용하면 implicit 인스턴스를 담은 선택적 표현식을 얻어요. 이는 조건부로 implicit 인스턴스를 검색하는 능력을 제공해요.
def summon[T: Type](using Quotes): Option[Expr[T]]
inline def setFor[T]: Set[T] =
${ setForExpr[T] }
def setForExpr[T: Type]()(using Quotes): Expr[Set[T]] =
Expr.summon[Ordering[T]] match
case Some(ord) =>
'{ new TreeSet[T]()($ord) }
case _ =>
'{ new HashSet[T] }
더 많은 세부 사항 (More details)
- Specification
- Scalable Metaprogramming in Scala 3[^1]
[^1]: Scalable Metaprogramming in Scala 3 [^2]: Semantics-preserving inlining for metaprogramming [^3]: Implemented in the Scala 3 Dotty project https://github.com/lampepfl/dotty. sbt library dependency "org.scala-lang" %% "scala3-staging" % scalaVersion.value [^4]: Using the -Xcheck-macros compiler flag