런타임 멀티-스테이지 프로그래밍
런타임 멀티-스테이지 프로그래밍 (Run-Time Multi-Stage Programming)
이 프레임워크는 컴파일 타임 메타프로그래밍과 멀티-스테이지 프로그래밍을 동시에 표현해요. 런타임에 코드를 합성해서 프로그래머에게 스테이지를 하나 더 제공할 수 있다면 어떨까요? 이 글에서는 그 아이디어와 scala.quoted.staging API, 프로젝트 설정 방법, 예시를 살펴볼게요.
본문
프레임워크는 동시에 컴파일 타임 메타프로그래밍과 멀티-스테이지 프로그래밍을 표현해요. 컴파일 타임 메타프로그래밍을 두 단계 컴파일 과정으로 생각할 수 있어요. 하나는 코드 생성을 위해 최상위 splice에 코드를 쓰는 것(매크로)이고, 다른 하나는 컴파일 타임에 필요한 모든 평가를 수행하는 것, 그리고 평소처럼 실행할 객체 프로그램(object program)이 있어요. 런타임에 코드를 합성해서 프로그래머에게 스테이지를 하나 더 제공할 수 있다면 어떨까요? 그러면 런타임에 Expr[T] 타입의 값을 가질 수 있는데, 이를 본질적으로 typed-구문 트리로 취급해서 문자열로 보여주거나(pretty-print) 컴파일·실행할 수 있어요. quote 수가 splice 수를 하나보다 많이 초과하면(실질적으로 런타임에 Expr[Expr[T]], Expr[Expr[Expr[T]]], ... 타입의 값을 다루는 것) 우리는 멀티-스테이지 프로그래밍(Multi-Stage Programming)을 말해요.
이 패러다임 뒤의 동기는 런타임 정보가 코드 생성을 영향을 주거나 안내하게 하는 거예요.
직관: 코드가 실행되는 단계(phase)는 그것이 내장된 splice 범위 수와 quote 범위 수의 차이로 결정돼요.
- splice가 quote보다 많으면, 그 코드는 컴파일 타임에, 즉 매크로로 실행돼요. 일반적인 경우 이는 typed 추상 구문 트리로 표현된 코드를 평가하는 인터프리터를 실행하는 뜻이에요. 인터프리터는 이전에 컴파일된 메서드의 애플리케이션을 평가할 때 반영적 호출로 폴백할 수 있어요. splice 초과가 하나보다 많다면, 어떤 매크로의 구현 코드(확장되는 코드가 아니라)가 다른 매크로를 호출한다는 뜻이에요. 매크로가 해석(interpretation)으로 실현된다면, 이는 인터프리터의 탑(towers of interpreters)으로 이어져요. 첫 번째 인터프리터가, 또 다른 인터프리터를 해석할지도 모르는 인터프리터 코드를 해석하고, 이런 식으로 계속되죠.
- splice 수가 quote 수와 같으면, 코드는 평소처럼 컴파일되고 실행돼요.
- quote 수가 splice 수를 초과하면, 코드는 스테이징돼요. 즉 런타임에 typed 추상 구문 트리나 타입 구조를 만들어 내요. quote 초과가 하나보다 많으면 멀티-스테이지 프로그래밍에 대응해요.
전체 언어용 인터프리터를 제공하는 것은 꽤 어렵고, 그 인터프리터를 효율적으로 돌리는 건 훨씬 더 어려워요. 그래서 우리는 현재 splice 사용에 다음 제한을 부과해요.
- 최상위 splice는 인라인 메서드 안에 나타나야 해요(그 메서드를 매크로로 바꿈).
- splice는 quoted 인자, 상수 인자, 또는 inline 인자를 전달해서 이전에 컴파일된 메서드를 호출해야 해요.
- splice 안의 splice(중간에 quote가 없이)는 허용되지 않아요.
API
지금까지 논의한 프레임워크는 코드를 스테이징, 즉 나중 단계에서 실행되도록 준비되게 해요. 그 코드를 실행하려면 Expr 클래스에 run이라는 또 다른 메서드가 있어요. $와 run 모두 Expr[T]에서 T로 매핑하지만, $만 크로스-스테이지 안전성(Cross-Stage Safety)의 대상이고 run은 그냥 일반 메서드라는 점을 주목하세요. scala.quoted.staging.run은 자신의 범위에서 표현식을 보여주는 데 쓸 수 있는 Quotes를 제공해요. 반면 scala.quoted.staging.withQuotes는 표현식을 평가하지 않고 Quotes를 제공해요.
package scala.quoted.staging
def run[T](expr: Quotes ?=> Expr[T])(using Compiler): T = ...
def withQuotes[T](thunk: Quotes ?=> T)(using Compiler): T = ...
스테이징이 활성화된 새 스칼라 3 프로젝트 만들기 (Create a new Scala 3 project with staging enabled)
sbt new scala/scala3-staging.g8
scala/scala3-staging.g8에서요.
필요한 의존성과 몇몇 예시를 가진 프로젝트를 만들어 줄 거예요.
직접 프로젝트를 만드는 걸 선호한다면, build.sbt 빌드 정의에 다음 의존성을 정의해야 해요.
libraryDependencies += "org.scala-lang" %% "scala3-staging" % scalaVersion.value
그리고 scalac/scala를 직접 쓴다면 둘 다에 -with-compiler 플래그를 쓰세요.
scalac -with-compiler -d out Test.scala
scala -with-compiler -classpath out Test
예시 (Example)
이제 매크로(Macros)에서와 정확히 같은 예시를 가져와 볼게요. 기본 double 값을 정적으로 전달하고 싶지 않고, 런타임에 코드를 생성하고 값도 런타임에 전달한다고 가정해요. 아래 6줄에서 Expr[Double => Double] 타입의 미래 단계 함수를 만드는 방법을 주목하세요. staging.run { ... }으로 런타임에 표현식을 평가할 수 있어요. staging.run의 범위 안에서 표현식에 show를 호출해서 표현식의 소스와 비슷한 표현을 얻을 수도 있어요.
import scala.quoted.*
// make available the necessary compiler for runtime code generation
given staging.Compiler = staging.Compiler.make(getClass.getClassLoader)
val power3: Double => Double = staging.run {
val stagedPower3: Expr[Double => Double] =
'{ (x: Double) => ${ unrolledPowerCode('x, 3) } }
println(stagedPower3.show) // Prints "((x: scala.Double) => x.*(x.*(x)))"
stagedPower3
}
power3.apply(2.0) // Returns 8.0