宏规范
形式化
- 使用生成式和分析式宏的多阶段编程[^2]
- 多阶段宏演算,Scala 3 可扩展元编程[^1] 的第 4 章。包含并扩展了 *使用生成式和分析式宏的多阶段编程* 的演算,并加入了类型多态性。
语法
使用 '
和 $
的引用语法旨在模仿 Scala 的字符串插值语法。与字符串双引号类似,单引号块可以包含拼接。但是,与字符串不同,拼接可以包含使用相同规则的引号。
s" Hello $name" s" Hello ${name}"
'{ hello($name) } '{ hello(${name}) }
${ hello('name) } ${ hello('{name}) }
引号
引号有四种类型:引用标识符、引用块、引用块模式和引用类型模式。Scala 2 使用引用标识符来表示 Symbol
字面量。它们在 Scala 3 中已弃用,允许将语法用于引用。
SimpleExpr ::= ...
| `'` alphaid // quoted identifier
| `'` `{` Block `}` // quoted block
Pattern ::= ...
| `'` `{` Block `}` // quoted block pattern
| `'` `[` Type `]` // quoted type pattern
引用块和引用块模式包含一个表达式,等效于正常的代码块。当进入其中任何一个时,我们跟踪我们处于引用块中(inQuoteBlock
),这用于拼接标识符。当进入引用块模式时,我们还会跟踪我们处于引用模式中(inQuotePattern
),这用于区分拼接块和拼接模式。最后,引用类型模式只包含一个类型。
拼接
拼接有三种类型:拼接标识符、拼接块和拼接模式。Scala 指定包含 $
的标识符为有效标识符,但仅供编译器和标准库使用。不幸的是,许多库在 Scala 2 中使用了此类标识符。因此,为了减轻迁移成本,我们仍然支持它们。我们通过仅允许在引用块或引用块模式(inQuoteBlock
)中使用拼接标识符[^3] 来解决这个问题。拼接块和拼接模式可以分别包含任意块或模式。它们根据其周围的引号(inQuotePattern
)进行区分,引用块将包含拼接块,而引用块模式将包含拼接模式。
SimpleExpr ::= ...
| `$` alphaid if inQuoteBlock // spliced identifier
| `$` `{` Block `}` if !inQuotePattern // spliced block
| `$` `{` Pattern `}` if inQuotePattern // splice pattern
引用模式类型变量
引用模式中的引用模式类型变量和引用类型模式不需要额外的语法。任何以小写字母组成的名称的类型定义或引用在键入时都被假定为模式类型变量定义。带小写字母的反引号类型名称被解释为对具有该名称的类型的引用。
实现
运行时表示
标准库定义了Quotes
接口,其中包含所有逻辑和抽象类Expr
和Type
。编译器实现了Quotes
接口,并提供了Expr
和Type
的实现。
class Expr
类型为Expr[T]
的表达式由以下抽象类表示
abstract class Expr[+T] private[scala]
Expr
的唯一实现是在编译器中,以及Quotes
的实现。它是一个类,它包装了一个类型化的AST和一个Scope
对象,没有自己的方法。Scope
对象用于跟踪当前的拼接范围并检测范围外推。
object Expr
Expr
的伴生对象包含一些有用的静态方法;apply
/unapply
方法可以轻松使用ToExpr
/FromExpr
;betaReduce
和summon
方法。它还包含从表达式列表或序列创建表达式的方法:block
、ofSeq
、ofList
、ofTupleFromSeq
和ofTuple
。
object Expr:
def apply[T](x: T)(using ToExpr[T])(using Quotes): Expr[T] = ...
def unapply[T](x: Expr[T])(using FromExpr[T])(using Quotes): Option[T] = ...
def betaReduce[T](e: Expr[T])(using Quotes): Expr[T] = ...
def summon[T: Type](using Quotes): Option[Expr[T]] = ...
def block[T](stats: List[Expr[Any]], e: Expr[T])(using Quotes): Expr[T] = ...
def ofSeq[T: Type](xs: Seq[Expr[T]])(using Quotes): Expr[Seq[T]] = ...
def ofList[T: Type](xs: Seq[Expr[T]])(using Quotes): Expr[List[T]] = ...
def ofTupleFromSeq(xs: Seq[Expr[Any]])(using Quotes): Expr[Tuple] = ...
def ofTuple[T <: Tuple: Tuple.IsMappedBy[Expr]: Type](tup: T)(using Quotes):
Expr[Tuple.InverseMap[T, Expr]] = ...
class Type
类型为Type[T]
的类型由以下抽象类表示
abstract class Type[T <: AnyKind] private[scala]:
type Underlying = T
Type
的唯一实现是在编译器中,以及Quotes
的实现。它是一个类,它包装了类型的AST和一个Scope
对象,没有自己的方法。T
的上限是AnyKind
,这意味着T
可能是一个更高阶的类型。Underlying
别名用于从Type
实例中选择类型。用户永远不需要使用此别名,因为他们始终可以直接使用T
。Underlying
用于编译代码时的内部编码(参见类型修复)。
object Type
Type
的伴生对象包含一些有用的静态方法。第一个也是最重要的一个是 Type.of
给定的定义。当没有其他实例可用时,默认情况下会调用此 Type[T]
实例。of
操作是一个内在操作,编译器会将其转换为在运行时生成 Type[T]
的代码。其次,Type.show[T]
操作将显示类型的字符串表示形式,这在调试时通常很有用。最后,该对象定义了 valueOfConstant
(和 valueOfTuple
),它们可以将单例类型(或单例类型的元组)转换为其值。
object Type:
given of[T <: AnyKind](using Quotes): Type[T] = ...
def show[T <: AnyKind](using Type[T])(using Quotes): String = ...
def valueOfConstant[T](using Type[T])(using Quotes): Option[T] = ...
def valueOfTuple[T <: Tuple](using Type[T])(using Quotes): Option[T] = ...
Quotes
Quotes
接口定义了引号系统的大多数基本操作。
Quotes 将所有 Expr[T]
方法定义为扩展方法。Type[T]
没有方法,因此这里没有出现。只要在当前范围内隐式给出 Quotes
,这些方法就可用。
Quotes
实例也是通过 reflect
对象进入 反射 API 的入口点。
最后,Quotes
提供了引号反序列化(QuoteUnpickler
)中使用的内部逻辑,以及引号模式匹配(QuoteMatching
)。这些接口被添加到特性的自类型中,以确保它们在该对象上实现,但对 Quotes
的用户不可见。
在内部,Quotes
的实现也将跟踪其当前的拼接范围 Scope
。此范围将附加到使用此 Quotes
实例创建的任何表达式。
trait Quotes:
this: runtime.QuoteUnpickler & runtime.QuoteMatching =>
extension [T](self: Expr[T])
def show: String
def matches(that: Expr[Any]): Boolean
def value(using FromExpr[T]): Option[T]
def valueOrAbort(using FromExpr[T]): T
end extension
extension (self: Expr[Any])
def isExprOf[X](using Type[X]): Boolean
def asExprOf[X](using Type[X]): Expr[X]
end extension
// abstract object reflect ...
Scope
拼接上下文表示为 Scope
对象的堆栈(不可变列表)。每个 Scope
包含拼接的位置(用于错误报告)以及对封闭拼接范围 Scope
的引用。如果另一个范围包含在父范围内,则一个范围是另一个范围的子范围。当使用 Quotes
中当前范围提供的 Scope
和 Expr
或 Type
中的 Scope
将表达式拼接到另一个表达式时,会执行此检查。
入口点
多阶段编程的两个入口点是宏和 run
操作。
宏
内联宏定义将内联顶级拼接(嵌套在引号中的拼接)。此拼接需要在编译时进行评估。在避免完整的解释器[^1] 中,我们声明了以下限制
- 顶级拼接必须包含对已编译静态方法的单个调用。
- 函数的参数要么是文字常量,要么是引用的表达式(参数),要么是类型参数的
Type.of
,要么是对Quotes
的引用。
这些限制使得解释器的实现非常简单。Java 反射被用来调用顶层拼接中的单个函数调用。该函数的执行完全在编译后的字节码上完成。这些是 Scala 静态方法,可能不会总是成为 Java 静态方法,它们可能位于模块对象内部。由于模块被编码为类实例,我们需要解释方法的前缀以实例化它,然后才能调用该方法。
参数的代码尚未编译,因此需要由编译器进行解释。解释字面常量就像从表示字面量的 AST 中提取常量一样简单。解释引用的表达式时,引用的内容将作为 AST 保留,并包装在 Expr
的实现中。对 Type.of[T]
的调用也会将类型的 AST 包装在 Type
的实现中。最后,对 Quotes
的引用应该指的是拼接提供的引用。此引用被解释为 Quotes
的一个新实例,它包含一个新的初始 Scope
,没有父级。
通过 Java 反射调用方法的结果将返回一个 Expr
,其中包含由该宏的实现生成的新的 AST。此 Expr
的作用域将被检查以确保它没有从某个拼接或 run
操作中挤出。然后,AST 将从 Expr
中提取出来,并插入到包含顶层拼接的 AST 的替换位置。
运行时多阶段编程
为了能够编译代码,scala.quoted.staging
库定义了 Compiler
特性。staging.Compiler
的实例是普通 Scala~3 编译器的包装器。要实例化它,需要应用程序的 JVM 类加载器 的实例。
import scala.quoted.staging.*
given Compiler = Compiler.make(getClass.getClassLoader)
类加载器是编译器了解哪些依赖项已加载以及使用相同的类加载器加载生成的代码所必需的。下面是一个示例方法 mkPower2
,它被传递给 staging.run
def mkPower2()(using Quotes): Expr[Double => Double] = ...
run(mkPower2())
要运行前面的示例,编译器将创建等效于以下类的代码并使用一个没有父级的新的 Scope
进行编译。
class RunInstance:
def exec(): Double => Double = ${ mkPower2() }
最后,run
将解释 (new RunInstance).exec()
以评估引用的内容。为此,生成的 RunInstance
类将使用 Java 反射加载到 JVM 中,实例化,然后调用 exec
方法。
编译
引用和拼接是生成的类型化抽象语法树中的基本形式。这些需要使用一些额外的规则进行类型检查,例如,需要检查分期级别,并且需要调整对泛型类型的引用。最后,将在运行时生成的引用表达式需要被编码(序列化/腌制)和解码(反序列化/解腌制)。
类型引用表达式
引用表达式和使用 Expr
的拼接的类型推断过程相对简单。本质上,引用被糖化为对 quote
的调用,拼接被糖化为对 splice
的调用。我们在糖化为这些方法时跟踪引用级别。
def quote[T](x: T): Quotes ?=> Expr[T]
def splice[T](x: Quotes ?=> Expr[T]): T
如果用户直接编写对这些方法的调用,将无法跟踪引用级别。要确定它是否是对其中一个方法的调用,我们需要先对其进行类型推断,但要进行类型推断,我们需要知道它是否是一个这些方法才能更新引用级别。因此,这些方法只能由编译器使用。
在运行时,拼接需要引用创建其周围引用的 Quotes
。为了简化后续阶段,我们跟踪当前的 Quotes
并使用 nestedSplice
而不是 splice
在拼接中直接编码引用。
def nestedSplice[T](q: Quotes)(x: q.Nested ?=> Expr[T]): T
有了这个补充,原始的 splice
仅用于顶层拼接。
这些级别主要用于识别在类型推断时需要评估的顶层拼接。我们不使用引用级别来影响类型推断过程。级别检查在后续阶段执行。这确保了引用中的源表达式与引用之外的源表达式具有相同的推断结果。
引用模式匹配
模式匹配在 QuoteMatching
特性中定义,该特性是 Quotes
自身类型的部分。它由 Quotes
实现,但 Quotes
的用户无法访问它。为了访问它,编译器会生成从 Quotes
到 QuoteMatching
的强制转换,然后选择其两个成员之一:ExprMatch
或 TypeMatch
。ExprMatch
定义了一个 unapply
提取器方法,用于编码引用模式,而 TypeMatch
定义了一个用于引用类型模式的 unapply
方法。
trait Quotes:
self: runtime.QuoteMatching & ... =>
...
trait QuoteMatching:
object ExprMatch:
def unapply[TypeBindings <: Tuple, Tup <: Tuple]
(scrutinee: Expr[Any])
(using pattern: Expr[Any]): Option[Tup] = ...
object TypeMatch:
...
这些提取器方法仅用于编译器生成的代码。生成的提取器调用具有无法在源代码中编写的已推断形式,即显式类型参数和显式上下文参数。
此提取器返回一个元组类型 Tup
,该类型无法从方法签名中的类型推断出来。此类型将在键入引号模式时计算,并将显式添加到提取器调用中。为了在 Tup
的任意位置引用类型变量,我们需要在使用之前定义所有类型变量,因此我们有 TypeBindings
,它将包含所有模式类型变量定义。提取器还接收一个类型为 Expr[Any]
的给定参数,它将包含表示模式的表达式。编译器将显式添加此模式表达式。我们使用给定参数,因为这些是我们允许在模式位置添加到提取器调用的唯一参数。
此提取器有点复杂,但它编码了所有特定于引用的功能。它将模式编译成模式匹配器编译器阶段理解的表示形式。
引号模式被编码成两个部分:一个负责提取匹配结果的元组模式和一个表示模式的引号表达式。例如,如果模式没有 $
,我们将有一个 EmptyTuple
作为模式,以及 '{1}
来表示模式。
case '{ 1 } =>
// is elaborated to
case ExprMatch(EmptyTuple)(using '{1}) =>
// ^^^^^^^^^^ ^^^^^^^^^^
// pattern expression
在提取表达式时,包含在拼接 ${..}
中的每个模式将按顺序放置在元组模式中。在以下情况下,f
和 x
被放置在元组模式 (f, x)
中。元组的类型编码在 Tup
中,而不仅仅是在元组本身中。否则,提取器将返回一个元组 Tuple
,需要对其类型进行测试,而这又由于类型擦除而无法实现。
case '{ ((y: Int) => $f(y)).apply($x) } =>
// is elaborated to
case ExprMatch[.., (Expr[Int => Int], Expr[Int])]((f, x))(using pattern) =>
// pattern = '{ ((y: Int) => pat[Int](y)).apply(pat[Int]()) }
引号的内容通过用标记表达式 pat[T](..)
替换拼接来转换为有效的引号表达式。类型 T
取自拼接的类型,参数是 HOAS 参数。这意味着 pat[T]()
是一个封闭模式,而 pat[T](y)
是一个可以引用 y
的 HOAS 模式。
引号模式中的类型变量首先被规范化为在模式开头具有所有定义。对于模式中类型变量 t
的每个定义,我们将在 TypeBindings
中添加一个类型变量定义。每个定义都将有一个对应的 Type[t]
,如果模式匹配,它将被提取。这些 Type[t]
也列在 Tup
中,并添加到元组模式中。此外,它在模式中被标记为 using
,使其在这种情况下隐式可用。
case '{ type t; ($xs: List[t]).map[t](identity[t]) } =>
// is elaborated to
case ExprMatch[(t), (Type[t], Expr[List[t]])]((using t, xs))(using p) =>
// ^^^ ^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^ ^^^^^^^
// type bindings result type pattern expression
// p = '{ @patternType type u; pat[List[u]]().map[u](identity[u]) }
引用中的内容通过用不超出引用范围的新鲜变量替换类型变量来转换为有效的引用表达式。这些变量也被注释为易于识别为模式变量。
级别一致性检查
级别一致性检查是在将程序类型化为静态检查后执行的。为了检查级别一致性,我们自顶向下遍历树,记住上下文分段级别。每个作用域内的局部定义都记录了其级别,并且每个对定义的术语引用都根据当前分段级别进行检查。
// level 0
'{ // level 1
val x = ... // level 1 with (x -> 1)
${ // level 0 (x -> 1)
val y = ... // level 0 with (x -> 1, y -> 0)
x // error: defined at level 1 but used in level 0
}
// level 1 (x -> 1)
x // x is ok
}
类型修复
在未来阶段使用泛型类型 T
时,需要在作用域内有一个给定的 Type[T]
。编译器需要识别这些引用并将它们与 Type[T]
的实例链接起来。例如,考虑以下示例
def emptyList[T](using t: Type[T])(using Quotes): Expr[List[T]] =
'{ List.empty[T] }
对于在级别 0 定义并在级别 1 或更高级别使用的泛型类型 T
的每个引用,编译器将调用一个 Type[T]
。这通常是作为参数提供的给定类型,在本例中为 t
。我们可以使用类型 t.Underlying
来替换 T
,因为它是的别名。但是 t.Underlying
包含额外的信息,即 t
将用于引用评估。从某种意义上说,Underlying
充当类型的拼接。
def emptyList[T](using t: Type[T])(using Quotes): Expr[List[T]] =
'{ List.empty[t.Underlying] }
由于一些技术限制,并非总是可以将类型引用替换为包含 t.Underlying
的 AST。为了克服这个限制,我们可以在引用的开头简单地定义一个类型别名列表,并在其中插入 t.Underlying
。这样做还有一个好处,即我们不必在引用中重复插入 t.Underlying
。
def emptyList[T](using t: Type[T])(using Quotes): Expr[List[T]] =
'{ type U = t.Underlying; List.empty[U] }
这些别名可以在引用内的任何级别使用,并且此转换仅对级别为 0 的引用执行。
'{ List.empty[T] ... '{ List.empty[T] } ... }
// becomes
'{ type U = t.Underlying; List.empty[U] ... '{ List.empty[U] } ... }
如果我们在级别 1 或更高级别定义泛型类型,它将不会受到此转换的影响。在未来的某个编译阶段,当泛型类型的定义在级别 0 时,它将受到此转换的影响。这简化了转换逻辑,并避免将编码泄漏到宏可以检查的代码中。
'{
def emptyList[T: Type](using Quotes): Expr[List[T]] = '{ List.empty[T] }
...
}
对 Type.of[T]
执行类似的转换。T
中的任何泛型类型都需要在作用域内有一个隐式给定的 Type[T]
,它也将用作路径。示例
def empty[T](using t: Type[T])(using Quotes): Expr[T] =
Type.of[T] match ...
// becomes
def empty[T](using t: Type[T])(using Quotes): Expr[T] =
Type.of[t.Underlying] match ...
// then becomes
def empty[T](using t: Type[T])(using Quotes): Expr[T] =
t match ...
操作 Type.of[t.Underlying]
可以优化为 t
。但并非总是如此。如果泛型引用嵌套在类型中,我们需要保留 Type.of
。
def matchOnList[T](using t: Type[T])(using Quotes): Expr[List[T]] =
Type.of[List[T]] match ...
// becomes
def matchOnList[T](using t: Type[T])(using Quotes): Expr[List[T]] =
Type.of[List[t.Underlying]] match ...
通过这种转换,我们确保在 Type.of
中使用的每个抽象类型 U
都有一个隐式的 Type[U]
在作用域内。这种表示方法使识别静态已知类型部分和动态已知类型部分变得更加简单。类型别名也添加到 Type.of
的类型中,尽管这些不是有效的源代码。如果在源代码中编写,它们将类似于 Type.of[{type U = t.Underlying; Map[U, U]}]
。
拼接规范化
拼接的内容可能引用封闭引号中定义的变量。这会使引号内容的序列化过程变得复杂。为了简化序列化,我们首先转换每个一级拼接的内容。考虑以下示例
def power5to(n: Expr[Int]): Expr[Double] = '{
val x: Int = 5
${ powerCode('{x}, n) }
}
变量 x
在引号中定义并在拼接中使用。规范形式将提取所有对 x
的引用,并将其替换为 x
的分段版本。我们将用 $y
替换类型为 T
的 x
的引用,其中 y
的类型为 Expr[T]
。然后,我们将拼接的新内容包装在一个定义 y
的 lambda 中,并将其应用于 x
的引号版本。经过这种转换后,我们得到了两个部分:一个不引用引号的 lambda,它知道如何计算拼接的内容,以及一个引用 lambda 中定义的变量的引号参数序列。
def power5to(n: Expr[Int]): Expr[Double] = '{
val x: Int = 5
${ ((y: Expr[Int]) => powerCode('{$y}, n)).apply('x) }
}
一般来说,拼接规范形式具有 ${ <lambda>.apply(<args>*) }
的形状,并具有以下约束
<lambda>
一个不引用外部引号中定义的变量的 lambda 表达式<args>
引号表达式序列或Type.of
,包含对封闭引号中定义的变量的引用,但不包含对封闭引号外部定义的局部变量的引用
函数引用规范化
对接收参数的函数 f
的引用在 Scala 中不是有效的值。这种函数引用 f
可以通过 eta 展开为 x => f(x)
来用作 lambda 值。因此,函数引用不能像其他表达式那样直接通过规范化进行转换,因为我们无法用方法引用类型表示 '{f}
。我们可以在规范形式中使用 f
的 eta 展开形式。例如,考虑以下对 f
的引用。
'{
def f(a: Int)(b: Int, c: Int): Int = 2 + a + b + c
${ '{ f(3)(4, 5) } }
}
为了规范化这段代码,我们可以对 f
的引用进行 eta 展开,并将其放在包含适当表达式的引号中。因此,参数 '{f}
的规范形式变为引号 lambda '{ (a: Int) => (b: Int, c: Int) => f(a)(b, c) }
,并且是类型为 Expr[Int => (Int, Int) => Int]
的表达式。eta 展开为每个参数列表生成一个柯里化 lambda。应用 f(3)(4, 5)
不会变为 $g(3)(4, 5)
,而是 $g.apply(3).apply(4, 5)
。我们添加了 apply
,因为 g
不是对函数的引号引用,而是一个柯里化 lambda。
'{
def f(a: Int)(b: Int, c: Int): Int = 2 + a + b + c
${
(
(g: Expr[Int => (Int, Int) => Int]) => '{$g.apply(3).apply(4, 5)}
).apply('{ (a: Int) => (b: Int, c: Int) => f(a)(b, c) })
}
}
然后,我们可以在生成代码时应用它并对应用进行 beta 归约。
(g: Expr[Int => Int => Int]) => betaReduce('{$g.apply(3).apply(4)})
变量赋值规范化
赋值左侧对可变变量的引用不能直接转换,因为它不在表达式位置。
'{
var x: Int = 5
${ g('{x = 2}) }
}
我们可以使用与函数引用相同的策略,通过 eta 扩展赋值操作 x = _
为 y => x = y
。
'{
var x: Int = 5
${
g(
(
(f: Expr[Int => Unit]) => betaReduce('{$f(2)})
).apply('{ (y: Int) => x = $y })
)
}
}
类型规范化
在引号中定义的类型会进行类似的转换。在这个例子中,T
在级别 1 的引号中定义,并在级别 1 的拼接中再次使用。
'{ def f[T] = ${ '{g[T]} } }
规范化将在 lambda 中添加一个 Type[T]
,我们将插入此引用。不同之处在于它将添加一个类似于类型修复中使用的别名。在这个例子中,我们创建了一个 type U
来作为阶段类型的别名。
'{
def f[T] = ${
(
(t: Type[T]) => '{type U = t.Underling; g[U]}
).apply(Type.of[T])
}
}
序列化
引用的代码需要被序列化,以便在下一个编译阶段的运行时可用。我们通过将 AST 序列化为 TASTy 二进制文件来实现这一点。
TASTy
TASTy 格式是 Scala 3 的类型化抽象语法树序列化格式。它通常在类型检查后对完全展开的代码进行序列化,并与生成的 Java 类文件一起保存。
序列化
我们使用 TASTy 作为引号内容的序列化格式。为了展示如何进行序列化,我们将使用以下示例。
'{
val (x, n): (Double, Int) = (5, 2)
${ powerCode('{x}, '{n}) } * ${ powerCode('{2}, '{n}) }
}
当规范化拼接时,此引号将转换为以下代码。
'{
val (x, n): (Double, Int) = (5, 2)
${
((y: Expr[Double], m: Expr[Int]) => powerCode(y, m)).apply('x, 'n)
} * ${
((m: Expr[Int]) => powerCode('{2}, m)).apply('n)
}
}
拼接规范化是序列化过程的关键部分,因为它只允许在拼接中 lambda 的参数中引用在引号中定义的变量。这使得能够轻松地创建引号的封闭表示。第一步是删除所有拼接并用空洞替换它们。空洞类似于拼接,但它缺乏关于如何计算拼接内容的知识。相反,它知道空洞的索引和拼接参数的内容。我们可以在以下示例中看到这种转换,其中空洞用 << idx; holeType; args* >>
表示。
${ ((y: Expr[Double], m: Expr[Int]) => powerCode(y, m)).apply('x, 'n) }
// becomes
<< 0; Double; x, n >>
由于这是第一个洞,它的索引为 0。洞的类型是 Double
,现在我们需要记住它,因为我们无法从拼接的内容中推断出来。拼接的参数是 x
和 n
;请注意,它们不需要加引号,因为它们已从拼接中移出。
对已修复类型的引用以类似的方式处理。考虑 emptyList
示例,它显示了插入到引号中的类型别名。
'{ List.empty[T] }
// type healed to
'{ type U = t.Underlying; List.empty[U] }
我们不是替换拼接,而是用类型洞替换 t.Underlying
类型。类型洞由 << idx; bounds >>
表示。
'{ type U = << 0; Nothing..Any >>; List.empty[U] }
这里,Nothing..Any
的边界是原始 T
类型的边界。Type.of
的类型以相同的方式转换。
通过这些转换,引号或 Type.of
的内容保证是封闭的,因此可以被腌制。AST 被腌制成 TASTy,它是一系列字节。这系列字节需要在字节码中实例化,但不幸的是它不能作为字节转储到类文件中。为了重新实例化它,我们将字节编码成 Java String
。在以下示例中,我们以人类可读的形式显示此编码,使用虚构的 |tasty"..."|
字符串文字。
// pickled AST bytes encoded in a base64 string
tasty"""
val (x, n): (Double, Int) = (5, 2)
<< 0; Double; x, n >> * << 1; Double; n >>
"""
// or
tasty"""
type U = << 0; Nothing..Any; >>
List.empty[U]
"""
引号或 Type.of
的内容并不总是被腌制。在某些情况下,生成等效(更小和/或更快)的代码来计算表达式会更好。文字值被编译成对 Expr(<literal>)
的调用,使用 ToExpr
的实现来创建引用的表达式。这目前仅对文字值执行,但可以扩展到我们已在标准库中定义了 ToExpr
的任何值。类似地,对于非泛型类型,我们可以使用它们各自的 java.lang.Class
并使用反射 API 中定义的原始操作 typeConstructorOf
将它们转换为 Type
。
解腌制
现在我们已经了解了如何腌制引号,我们可以看看如何解腌制它。我们将继续使用前面的示例。
洞被用来替换引号中的拼接。当我们执行此转换时,我们还需要记住来自拼接的 lambda 及其洞索引。当解腌制一个洞时,将使用相应的拼接 lambda 来计算洞的内容。lambda 将接收洞参数的引用版本作为参数。例如,要计算 << 0; Double; x, n >>
的内容,我们将评估以下代码
((y: Expr[Double], m: Expr[Int]) => powerCode(y, m)).apply('x, 'n)
评估并不像看起来那样简单,因为 lambda 来自编译后的代码,而其余部分是必须解释的代码。我们将 x
和 n
的 AST 放入 Expr
对象中以模拟引号,然后使用 Java 反射调用 apply
方法。
我们可能在一个引号中有多个空洞,因此也可能有多个 lambda。为了避免实例化多个 lambda,我们可以将它们合并成一个 lambda。除了参数列表之外,这个 lambda 还将接收正在评估的空洞的索引。它将在索引上执行 switch 匹配,并在每个分支中调用相应的 lambda。每个分支还将根据 lambda 的定义提取参数。原始 lambda 的应用将被 beta 归约以避免额外的开销。
(idx: Int, args: Seq[Any]) =>
idx match
case 0 => // for << 0; Double; x, n >>
val x = args(0).asInstanceOf[Expr[Double]]
val n = args(1).asInstanceOf[Expr[Int]]
powerCode(x, n)
case 1 => // for << 1; Double; n >>
val n = args(0).asInstanceOf[Expr[Int]]
powerCode('{2}, n)
这类似于我们对拼接的操作,当我们用空洞替换类型别名时,我们会跟踪空洞的索引。我们将拥有一个指向 Type
实例的引用列表,而不是 lambda。从下面的示例中,我们将提取 t
、u
等。
'{ type T1 = t1.Underlying; type Tn = tn.Underlying; ... }
// with holes
'{ type T1 = << 0; ... >>; type Tn = << n-1; ... >>; ... }
由于类型空洞位于引号的开头,它们将拥有前 N
个索引。这意味着我们可以将引用放置在一个序列 Seq(t, u, ...)
中,其中序列中的索引与空洞索引相同。
最后,引号本身被替换为对 QuoteUnpickler.unpickleExpr
的调用,该调用将解开 AST,评估空洞(即拼接),并将结果 AST 包装在 Expr[Int]
中。此方法接收腌制的 |tasty"..."|
、类型和空洞 lambda。类似地,Type.of
被替换为对 QuoteUnpickler.unpickleType
的调用,但只接收腌制的 |tasty"..."|
和类型。因为 QuoteUnpickler
是 Quotes
类自类型的部分,所以我们必须将实例强制转换,但知道这种强制转换将始终成功。
quotes.asInstanceOf[runtime.QuoteUnpickler].unpickleExpr[T](
pickled = tasty"...",
types = Seq(...),
holes = (idx: Int, args: Seq[Any]) => idx match ...
)
[^1]: Scala 3 中的可扩展元编程 [^2]: 使用生成式和分析式宏的多阶段编程。[^3]: 在引号中,以 $
开头的标识符必须用反引号 (`$`
) 括起来。例如 scala.Predef
中的 $conforms
。