반영
반영 (Reflection)
반영(Reflection)은 타입이 매겨진 추상 구문 트리(Typed Abstract Syntax Trees, Typed-AST)의 검사와 구성을 가능하게 해요. 매크로를 작성한다면 먼저 매크로(Macros)를 읽어 보세요. quote 반영 없이 필요한 모든 것을 찾을 수 있을 거예요.
본문
반영은 Typed-AST의 검사와 구성을 가능하게 해요. 매크로나 멀티-스테이지 프로그래밍의 quoted 표현식(quoted.Expr)과 quoted 타입(quoted.Type), 또는 전체 TASTy 파일(tasty-inspection을 통해)에 사용할 수 있어요. 매크로를 작성한다면 먼저 매크로를 읽어 보세요. quote 반영을 쓰지 않고 필요한 모든 것을 찾을 수 있을 거예요.
Expr을 TASTy 반영 트리로, 그리고 다시 변환하기 (Converting Exprs to TASTy reflect trees and back)
quoted.Expr과 quoted.Type으로 코드를 계산할 수 있을 뿐만 아니라, AST를 검사해서 코드를 분석할 수도 있어요. 매크로는 코드 생성이 타입 올바름을 보장해요. quote 반영을 사용하면 이 보장이 깨질 수 있고 매크로 확장 시점에 실패할 수 있으므로, 추가적인 명시적 검사가 이루어져야 해요.
매크로에서 반영 기능을 제공하려면 scala.quoted.Quotes 타입의 implicit 파라미터를 추가하고, 사용하는 범위에서 그것에서 quotes.reflect.*를 import해야 해요.
import scala.quoted.*
inline def natConst(inline x: Int): Int = ${natConstImpl('{x})}
def natConstImpl(x: Expr[Int])(using Quotes): Expr[Int] =
import quotes.reflect.*
...
asTerm 확장 메서드로 Expr의 밑에 있는 typed AST에 접근할 수 있어요.
val term: Term = x.asTerm
마찬가지로 Term을 .asExpr(Expr[Any] 반환) 또는 .asExprOf[T](Expr[T] 반환, 타입이 맞지 않으면 매크로 확장 시점에 예외 발생)로 다시 Expr로 바꿀 수 있어요.
트리 구성과 분석 (Constructing and Analysing trees)
일반적으로, Typed AST를 제대로 구성하고 분석하려면 알아야 할 주요 구성 3가지가 있어요.
- Trees
- Flags를 가진 Symbols
- TypeReprs
타입이 매겨진 추상 구문 트리 (Typed Abstract Syntax Trees)
Typed AST는 타이핑 후에 얻어지는 프로그램 코드의 트리 같은 표현이에요. 반영 API에서 Tree 타입으로 표현돼요.
Terms는 어떤 값의 표현식을 나타내는 트리의 서브타입이에요. 때문에 항상 연관된 타입을 가져요(.tpe로 접근). Terms는 .asExpr로 Exprs로 변환할 수 있어요.
Trees가 실제 스칼라 코드와 어떻게 대응하는지 예시를 볼게요.
val foo: Int = 0
위 코드는 quotes reflect API에서 ValDef(Tree의 서브타입이지만 Term은 아님!)로 표현돼요.
ValDef(foo,Ident(Int),Literal(Constant(0))) // ValDef is a subtype of Tree but not Term
val foo: Int = 0
foo + 1
위 코드는 quotes reflect API에서 Block(Term의 서브타입이고, 그 자체가 Tree의 서브타입)으로 표현돼요.
Block(
List(
ValDef(foo,Ident(Int),Literal(Constant(0)))
),
Apply(
Select(Ident(foo),+),
List(Literal(Constant(1)))
)
)
서로 다른 Trees 타입 사이의 전체 계층은 reflectModule 문서에서 볼 수 있어요.
quoted 코드를 Term으로 변환해 출력함으로써 코드의 형태를 확인할 수도 있어요.
println( '{ scalaCode }.asTerm )
이것은 항상 Term을 만들어 낸다는 점을 명심하세요. 예를 들어:
'{
val foo: Int = 0
}.asTerm
는 Block(List(ValDef(foo,Ident(Int),Literal(Constant(0)))),Literal(Constant(())))로 표현되는데, 이는 실제로 Unit 타입의 Block이에요.
'{
val foo: Int = 0
()
}
import quotes.reflect.*는 quotes.reflect.Tree의 모든 추출기(extractor), apply 기반 생성자, 메서드를 제공해요. 예를 들어 아래에서 쓰는 Literal(_) 추출기를 보세요.
def natConstImpl(x: Expr[Int])(using Quotes): Expr[Int] =
import quotes.reflect.*
val tree: Term = x.asTerm
tree match
case Inlined(_, _, Literal(IntConstant(n))) =>
if n <= 0 then
report.error("Parameter must be natural number")
'{0}
else
tree.asExprOf[Int]
case _ =>
report.error("Parameter must be a known constant")
'{0}
Printer.TreeStructure.show를 사용하면 어떤 추출기·생성자가 필요한지 쉽게 알 수 있어요. 이는 트리 구조의 문자열 표현을 반환해요. 다른 프린터도 Printer 모듈에서 찾을 수 있어요.
tree.show(using Printer.TreeStructure)
// or
Printer.TreeStructure.show(tree)
같은 트리에 대한 추출기와 생성자가 서로 다른 인자로 구성될 수 있다는 점을 명심하세요. 예를 들어 ValDef의 apply 메서드는 (Symbol, Option[Term]) 인자를, unapply는 (String, TypeTree, Option[Term]) 인자를 가져요. (심볼을 직접 얻으려면 ValDef에 .symbol을 호출하면 돼요.)
Symbols
정의 Trees를 구성하려면 Symbol을 만들거나 사용해야 할 수 있어요. Symbol은 코드의 "명명된" 부분, 즉 나중에 다른 곳에서 참조할 수 있는 선언을 나타내요. 처음부터 val name: Int = 0을 만들어 보면서, Symbol을 어떻게 쓰는지 볼게요. 이런 val을 만들려면 먼저 의도한 Tree 타입과 맞는 Symbol을 만들어야 해요. ValDef에는 Symbol.newVal 메서드를 쓰면 돼요.
import quotes.reflect._
val fooSym = Symbol.newVal(
parent = Symbol.spliceOwner,
name = "foo",
tpe = TypeRepr.of[Int],
flags = Flags.EmptyFlags,
privateWithin = Symbol.noSymbol
)
val tree = ValDef(fooSym, Some(Literal(IntConstant(0))))
일반적으로 모든 Symbol은 부모·소유자(owner) Symbol이 필요해요. 이는 어디에 정의되는지를 나타내죠. 예를 들어 val을 클래스의 일부로 정의하려면 당연히 그 클래스의 심볼이 val 심볼의 소유자여야 해요. flags와 privateWithin 인자도 알아차렸을 텐데, 이는 나중에 Flags 장에서 설명해요.
생성된 val은 나중에 생성된 코드의 다른 부분에서 Ref(Term의 서브타입)를 사용해 참조할 수 있어요.
Ref(fooSym)
타입을 참조할 때는(Symbol.newType이나 Symbol.newClass로 만든 것들) TypeIdent(TypeTree의 서브타입)를 대신 사용해요.
Flags는 Symbol의 다양한 속성을 알려줘요. 접근 수정자, 심볼이 스칼라 2나 자바로 정의됐는지, inline이나 transparent인지, 컴파일러가 생성했는지 등이 포함돼요.
이들은 비트 집합으로 구현되며, .is 메서드로 주어진 Flags가 부분 집합인지 확인하고, .|와 .&로 각각 합집합이나 교집합을 얻을 수 있어요. 집합을 만들 수 있는 개별 Flags는 API 문서에서 볼 수 있어요.
개별 Flags는 일반 속성이라기보다 명시적으로 명시된 수정자라고 생각하는 게 좋아요. 예를 들어 모든 트레잇이 abstract라고 말할 수 있지만, 트레잇의 심볼은 abstract 플래그가 설정되어 있지 않아요. (대신 trait 플래그만) abstract trait은 말이 안 되기 때문이죠.
각 Symbol 생성자 메서드의 API 문서에서 언급하듯, 서로 다른 타입의 Symbol은 설정이 허용된 서로 다른 플래그를 가져요.
TypeReprs와 TypeTrees
매크로를 작성할 때 scala.quoted.Type에 접근할 수 있는데, 이는 quoted 코드에서 타입을 지정하는 데 사용해요. 하지만 반영 API의 맥락에서는 그리 쓸모가 없어요. TypeRepr.of[T]로 더 유용한 TypeRepr로 변환할 수 있고(범위 안에 given Type[T]가 있을 때), 가장 간단한 방법으로 다시 Type으로 변환할 수도 있어요.
typeRepr.asType match
case '[t] =>
// access to a given Type[t] in scope
TypeRepr는 Symbol에서 타입을 지정하고 읽을 때 사용되는 타입 표현이에요. Typed AST 트리와 유사하게 구성/읽기가 가능해요. 예를 들어:
List[String]
은 이렇게 표현돼요.
AppliedType(
TypeRef(TermRef(ThisType(TypeRef(NoPrefix,module class collection)),object immutable),List),
List(TypeRef(TermRef(ThisType(TypeRef(NoPrefix,module class java)),object lang),String))
)
Typed AST와 마찬가지로 TypeRepr 타입 계층을 reflectModule 문서에서 찾을 수 있어요. AppliedType, AndType, MethodType 같은 대부분의 노드는 자명하겠지만, TypeRef와 TermRef는 약간의 추가 문맥이 필요할 수 있어요.
TypeRef(prefix, typeSymbol)- 타입의 선택(selection)에 대응해요. 예:SomeType이prefix안에 있는 타입이고someTypeSymbol이 그Symbol이라면,TypeRef(prefix, someTypeSymbol)은prefix.SomeType에 대응해요.TermRef(prefix, termSymbol)- term에 대한 선택에 대응해요. 경로 의존 타입(path dependent type)을 얻으려 할 때도 유용할 수 있어요. 예:someVal이prefix안의 val이고someValSymbol이 그 심볼이라면,TermRef(prefix, someValSymbol)은prefix.someVal.type에 대응해요. TermRef는.widenByTermRef로 밑에 있는 비-TermRef 타입으로 넓힐 수 있어요.
일반적으로 트리 일부로 타입을 직접 넣어야 한다면(예: TypeApply로 타입 파라미터로 전달할 때), TypeTree(Tree의 서브타입)를 대신 사용해요.
TypeRepr가 Symbol을 만들고 분석하게 해주므로, Symbol의 타입을 얻는 메서드가 있으리라 기대할 수 있어요. .typeRef와 .termRef 메서드가 존재하지만, 이들은 소유자의 범위 안에서만 쓸 수 있는 TypeRef나 TermRef만 생성할 수 있어요. 예를 들어:
val value: List[String] = List("")
value의 심볼에 .typeRef를 호출하면 List[String]이 아니라 TypeRef(This(...), valueSymbol)을 얻을 거예요. Symbol은 불완전한 타입 정보를 담고 있기 때문이에요. 다음을 살펴볼게요.
class Outer[T]:
val inner: List[T] = ???
inner의 타입은 Outer의 타입 파라미터에 의존해요. 그래서 inner의 심볼만으로는(실제로 new Outer[Int].inner와 new Outer[String].inner의 심볼은 같아요, 접두사에 대한 정보가 없기 때문) 충분하지 않아요. 하지만 접두 TypeRepr가 있으면 prefix.memberType(symbol)이나 prefix.select(symbol)으로 여전히 타입을 읽을 수 있어요.
val prefix = TypeRepr.of[Outer[String]]
val innerSymbol = Symbol.classMember
prefix.memberType(innerSymbol)
// The above returns:
//
// AppliedType(
// TypeRef(TermRef(ThisType(TypeRef(NoPrefix,module class collection)),object immutable),List),
// List(TypeRef(TermRef(ThisType(TypeRef(NoPrefix,module class java)),object lang),String))
// )
API 문서 탐색 (Navigating the API documentation)
모든 Quotes 반영 API 문서는 스칼라 라이브러리 API 문서의 reflectModule 트레잇 안에서 찾을 수 있어요. 구현 세부 사항 때문에 특정 타입과 관련된 메서드는 _Module과 _Methods 트레잇 사이에 나뉘어 있어요. 예를 들어 Select 노드로 작업한다면, apply와 unapply 같은 정적 메서드는 SelectModule에서, Select 인스턴스의 메서드는 SelectMethods에서 찾을 수 있어요.
Positions
quotes.reflect.*의 Position은 ofMacroExpansion 값을 제공해요. 이는 매크로의 확장 지점에 대응해요. 매크로 작성자는 그 확장 지점에 대한 다양한 정보를 얻을 수 있어요. 아래 예시는 확장 지점에서 시작 줄, 끝 줄, 심지어 소스 코드 같은 위치 정보를 얻는 방법을 보여줘요.
def macroImpl()(quotes: Quotes): Expr[Unit] =
import quotes.reflect.*
val pos = Position.ofMacroExpansion
val jpath = pos.sourceFile.getJPath.getOrElse(report.errorAndAbort("virtual file not supported", pos))
val path = pos.sourceFile.path // fallback for a virtual file
val start = pos.start
val end = pos.end
val startLine = pos.startLine
val endLine = pos.endLine
val startColumn = pos.startColumn
val endColumn = pos.endColumn
val sourceCode = pos.sourceCode
...
트리 유틸리티 (Tree Utilities)
quotes.reflect는 트리 순회와 변환을 위한 세 가지 시설을 담고 있어요.
TreeAccumulator[X]는 트리를 순회하면서 도중에 타입 X의 데이터를 집계하게 해줘요. foldTree(x: X, tree: Tree)(owner: Symbol): X 메서드를 오버라이드해서요.
foldOverTree(x: X, tree: Tree)(owner: Symbol): X는 tree의 각 자식에 foldTree를 호출해요(fold를 사용해 각 호출에 이전 호출의 값을 전달).
예를 들어 아래 코드는 트리에서 val 정의를 수집해요.
def collectPatternVariables(tree: Tree)(using ctx: Context): List[Symbol] =
val acc = new TreeAccumulator[List[Symbol]]:
def foldTree(syms: List[Symbol], tree: Tree)(owner: Symbol): List[Symbol] = tree match
case ValDef(_, _, rhs) =>
val newSyms = tree.symbol :: syms
foldTree(newSyms, body)(tree.symbol)
case _ =>
foldOverTree(syms, tree)(owner)
acc(Nil, tree)
TreeTraverser는 TreeAccumulator[Unit]을 확장하고 같은 순회를 수행하지만 어떤 값도 반환하지 않아요.
TreeMap은 순회를 따라 트리를 변환해요. 메서드를 오버로딩해서 특정 타입의 트리만 변환할 수 있어요. 예를 들어 transformStatement는 Statement만 변환해요.
ValDef.let
quotes.reflect.ValDef 객체는 rhs(우변)를 val에 바인딩해서 body에서 사용할 수 있게 하는 let 메서드도 제공해요. 추가로 lets는 주어진 terms를 이름에 바인딩해서 body에서 사용할 수 있게 해줘요. 타입 정의는 아래와 같아요.
def let(rhs: Term)(body: Ident => Term): Term = ...
def lets(terms: List[Term])(body: List[Term] => Term): Term = ...