Groovy 语言支持两种风格的元编程:运行时元编程和编译时元编程。前者允许在运行时修改类模型以及程序的行为,而后者只发生在编译期。二者各有优劣,我们将在本节中详细说明。
2. 编译时元编程
Groovy 中的编译时元编程允许在编译期生成代码。这些转换会修改程序的抽象语法树(Abstract Syntax Tree,AST),因此在 Groovy 中我们称之为 AST 转换(AST transformation)。AST 转换让你可以挂接到编译过程中,修改 AST,然后继续编译过程以生成常规的字节码。与运行时元编程相比,它的优势在于让这些修改在类文件本身(也就是字节码)中可见。让修改在字节码中可见很重要,例如,如果你希望这些转换成为类契约的一部分(实现接口、继承抽象类等),或者需要你的类能被 Java(或其他 JVM 语言)调用。举例来说,AST 转换可以给类添加方法。如果你用运行时元编程来做,新方法只能从 Groovy 中看到;而如果你用编译时元编程做同样的事,这个方法也能从 Java 中看到。最后但同样重要的是,编译时元编程的性能通常会更好(因为不需要初始化阶段)。
2.2. 开发 AST 转换
转换有两种:全局转换和局部转换。
- 全局转换由编译器在正在编译的代码上应用,只要该转换适用即可。实现全局转换的已编译类位于添加到编译器 classpath 的 JAR 中,并包含服务定位文件
META-INF/services/org.codehaus.groovy.transform.ASTTransformation,其中有一行是转换类的名称。转换类必须有一个无参构造器并实现org.codehaus.groovy.transform.ASTTransformation接口。它会对编译中的每个源文件运行,所以如果你想要快速的编译时间,不要创建那种以昂贵、耗时的方式扫描整个 AST 的转换。 - 局部转换是通过注解你想要转换的代码元素来局部应用的转换。为此,我们复用注解记号,这些注解应该实现
org.codehaus.groovy.transform.ASTTransformation。编译器会发现它们,并在这些代码元素上应用转换。
2.2.1. 编译阶段指南
Groovy AST 转换必须在九个定义的编译阶段(org.codehaus.groovy.control.CompilePhase)之一中执行。
全局转换可以在任何阶段应用,但局部转换只能在语义分析阶段或之后应用。简而言之,编译器阶段是:
- Initialization(初始化):打开源文件并配置环境
- Parsing(解析):使用语法产生表示源代码的 token 树
- Conversion(转换):从 token 树创建抽象语法树(AST)
- Semantic Analysis(语义分析):执行语法无法检查的一致性和有效性检查,并解析类。
- Canonicalization(规范化):完成 AST 的构建
- Instruction Selection(指令选择):选择指令集,例如 Java 6 或 Java 7 字节码级别
- Class Generation(类生成):在内存中创建类的字节码
- Output(输出):将二进制输出写入文件系统
- Finalization(终结):执行任何最后的清理
一般来说,越靠后的阶段可用的类型信息越多。如果你的转换关心读取 AST,那么信息更丰富的靠后阶段可能是不错的选择。如果你的转换关心写入 AST,那么树更稀疏的靠前阶段可能更方便。
2.2.2. 局部转换
局部 AST 转换相对于它们应用的上下文而言。在大多数情况下,上下文由定义转换作用域的注解来定义。例如,注解一个字段意味着转换应用于 该字段,而注解类则意味着转换应用于整个类。
作为一个朴素而简单的例子,考虑编写一个 @WithLogging 转换,它会在方法调用的开始和结束处添加控制台消息。那么下面的 "Hello World" 例子实际上会打印 "Hello World" 以及开始和停止消息。
贫穷版面向切面编程
groovy
@WithLogging
def greet() {
println "Hello World"
}
greet()
局部 AST 转换是实现这一点的简单方法。它需要两样东西:
- 一个
@WithLogging注解的定义 - 一个 org.codehaus.groovy.transform.ASTTransformation 的实现,把日志表达式添加到方法中
ASTTransformation 是一个回调,它让你可以访问 org.codehaus.groovy.control.SourceUnit,通过它可以获得对 org.codehaus.groovy.ast.ModuleNode(AST)的引用。
AST(抽象语法树)是一个树结构,主要由 org.codehaus.groovy.ast.expr.Expression(表达式)或 org.codehaus.groovy.ast.expr.Statement(语句)组成。学习 AST 的一个简单方法是在调试器中探索它。一旦你有了 AST,就可以分析它以了解代码的信息,或重写它以添加新功能。
局部转换注解是简单的部分。下面是 @WithLogging 注解:
groovy
import org.codehaus.groovy.transform.GroovyASTTransformationClass
import java.lang.annotation.ElementType
import java.lang.annotation.Retention
import java.lang.annotation.RetentionPolicy
import java.lang.annotation.Target
@Retention(RetentionPolicy.SOURCE)
@Target([ElementType.METHOD])
@GroovyASTTransformationClass(["gep.WithLoggingASTTransformation"])
public @interface WithLogging {
}
注解的保留策略可以是 SOURCE,因为过了那个阶段你就不需要这个注解了。这里的元素类型是 METHOD,@WithLogging 因为该注解适用于方法。
但最重要的部分是 @GroovyASTTransformationClass 注解。它把 @WithLogging 注解链接到你要编写的 ASTTransformation 类。gep.WithLoggingASTTransformation 是我们要编写的 ASTTransformation 的全限定类名。这一行把注解接线到转换上。
有了这些,每当在一个源码单元中发现 @WithLogging,Groovy 编译器就会调用 gep.WithLoggingASTTransformation。在 LoggingASTTransformation 中设置的任何断点,现在运行示例脚本时都会在 IDE 中命中。
ASTTransformation 类要稍微复杂一些。下面是一个非常简单的、非常朴素的转换,用于为 @WithLogging 添加方法开始和停止消息:
groovy
@CompileStatic (1)
@GroovyASTTransformation(phase=CompilePhase.SEMANTIC_ANALYSIS) (2)
class WithLoggingASTTransformation implements ASTTransformation { (3)
@Override
void visit(ASTNode[] nodes, SourceUnit sourceUnit) { (4)
MethodNode method = (MethodNode) nodes[1] (5)
def startMessage = createPrintlnAst("Starting $method.name") (6)
def endMessage = createPrintlnAst("Ending $method.name") (7)
def existingStatements = ((BlockStatement)method.code).statements (8)
existingStatements.add(0, startMessage) (9)
existingStatements.add(endMessage) (10)
}
private static Statement createPrintlnAst(String message) { (11)
new ExpressionStatement(
new MethodCallExpression(
new VariableExpression("this"),
new ConstantExpression("println"),
new ArgumentListExpression(
new ConstantExpression(message)
)
)
)
}
}
- 虽然不是强制要求,但如果你用 Groovy 编写 AST 转换,强烈建议使用
CompileStatic,因为它会提高编译器的性能。 - 用 org.codehaus.groovy.transform.GroovyASTTransformation 注解,告诉转换需要在哪个编译阶段运行。这里是在语义分析阶段。
- 实现
ASTTransformation接口 - 该接口只有一个
visit方法 nodes参数是一个 2 元素的 AST 节点数组,第一个是注解节点(@WithLogging),第二个是被注解的节点(方法节点)- 创建一条语句,在进入方法时打印消息
- 创建一条语句,在退出方法时打印消息
- 获取方法体,这里是一个
BlockStatement - 在现有代码的第一条语句之前添加进入方法的消息
- 在现有代码的最后一条语句之后追加退出方法的消息
- 创建一个包装了
MethodCallExpression的ExpressionStatement,对应this.println("message")
需要注意,为了这个例子的简洁性,我们没有做必要的检查,例如检查被注解的节点确实是 MethodNode,或方法体是 BlockStatement 的实例。这个练习留给读者。
注意在 createPrintlnAst(String) 方法中创建新的 println 语句。为代码创建 AST 并不总是简单的。在这种情况下,我们需要构造一个新的方法调用,传入接收者/变量、方法名和参数列表。在创建 AST 时,把你想创建的代码写在一个 Groovy 文件中,然后在调试器中检查该代码的 AST,从而了解要创建什么,这可能会很有帮助。然后用你在调试器中学到的东西编写像 createPrintlnAst 这样的函数。
最终:
groovy
@WithLogging
def greet() {
println "Hello World"
}
greet()
会输出:
groovy
Starting greet
Hello World
Ending greet
注意: 需要注意的是,AST 转换直接参与编译过程。初学者常犯的一个错误是,把 AST 转换代码放在使用该转换的类所在的同一个源码树中。在同一个源码树中通常意味着它们是同时编译的。由于转换本身要分阶段编译,而且每个编译阶段在处理下一个阶段之前会处理同一源码单元的所有文件,所以有一个直接后果:转换不会在使用它的类之前编译好!结论是,AST 转换需要在你使用它们之前被预先编译。通常,把它们放在单独的源码树中就很容易做到。
2.2.3. 全局转换
全局 AST 转换与局部转换类似,但有一个主要区别:它们不需要注解,这意味着它们被全局应用,也就是应用于每个被编译的类。因此,把它们的使用限制在万不得已时非常重要,因为它会对编译器性能产生显著影响。
延续[局部 AST 转换](#局部 AST 转换 "#transforms-local")的例子,设想我们想跟踪所有方法,而不仅仅是那些被 @WithLogging 注解的方法。基本上,我们需要这段代码与之前被 @WithLogging 注解的代码表现相同:
groovy
def greet() {
println "Hello World"
}
greet()
要实现这一点,需要两步:
- 在
META-INF/services目录内创建org.codehaus.groovy.transform.ASTTransformation描述符 - 创建
ASTTransformation实现
描述符文件是必需的,且必须在 classpath 上被找到。它将包含一行:
META-INF/services/org.codehaus.groovy.transform.ASTTransformation
groovy
gep.WithLoggingASTTransformation
转换的代码看起来与局部情况类似,但我们需要使用 SourceUnit 而不是 ASTNode[] 参数:
gep/WithLoggingASTTransformation.groovy
groovy
@CompileStatic (1)
@GroovyASTTransformation(phase=CompilePhase.SEMANTIC_ANALYSIS) (2)
class WithLoggingASTTransformation implements ASTTransformation { (3)
@Override
void visit(ASTNode[] nodes, SourceUnit sourceUnit) { (4)
def methods = sourceUnit.AST.methods (5)
methods.each { method -> (6)
def startMessage = createPrintlnAst("Starting $method.name") (7)
def endMessage = createPrintlnAst("Ending $method.name") (8)
def existingStatements = ((BlockStatement)method.code).statements (9)
existingStatements.add(0, startMessage) (10)
existingStatements.add(endMessage) (11)
}
}
private static Statement createPrintlnAst(String message) { (12)
new ExpressionStatement(
new MethodCallExpression(
new VariableExpression("this"),
new ConstantExpression("println"),
new ArgumentListExpression(
new ConstantExpression(message)
)
)
)
}
}
- 虽然不是强制要求,但如果你用 Groovy 编写 AST 转换,强烈建议使用
CompileStatic,因为它会提高编译器的性能。 - 用 org.codehaus.groovy.transform.GroovyASTTransformation 注解,告诉转换需要在哪个编译阶段运行。这里是在语义分析阶段。
- 实现
ASTTransformation接口 - 该接口只有一个
visit方法 sourceUnit参数提供对正在编译的源的访问,因此我们获得当前源的 AST,并从这个文件检索方法列表- 我们遍历源文件中的每个方法
- 创建一条语句,在进入方法时打印消息
- 创建一条语句,在退出方法时打印消息
- 获取方法体,这里是一个
BlockStatement - 在现有代码的第一条语句之前添加进入方法的消息
- 在现有代码的最后一条语句之后追加退出方法的消息
- 创建一个包装了
MethodCallExpression的ExpressionStatement,对应this.println("message")
2.2.4. AST API 指南
AbstractASTTransformation
虽然你已经看到可以直接实现 ASTTransformation 接口,但在几乎所有情况下你都不会这样做,而是继承 org.codehaus.groovy.transform.AbstractASTTransformation 类。这个类提供了几个工具方法,使 AST 转换更容易编写。Groovy 中几乎所有的 AST 转换都继承这个类。
ClassCodeExpressionTransformer
把表达式转换成另一个表达式是一个常见用例。Groovy 提供了一个让这变得非常容易的类:org.codehaus.groovy.ast.ClassCodeExpressionTransformer
为了说明这一点,让我们创建一个 @Shout 转换,它会把方法调用参数中的所有 String 常量转换成它们的大写版本。例如:
groovy
@Shout
def greet() {
println "Hello World"
}
greet()
应该打印:
groovy
HELLO WORLD
然后,转换的代码可以使用 ClassCodeExpressionTransformer 让这变得更容易:
groovy
@CompileStatic
@GroovyASTTransformation(phase=CompilePhase.SEMANTIC_ANALYSIS)
class ShoutASTTransformation implements ASTTransformation {
@Override
void visit(ASTNode[] nodes, SourceUnit sourceUnit) {
ClassCodeExpressionTransformer trn = new ClassCodeExpressionTransformer() { (1)
private boolean inArgList = false
@Override
protected SourceUnit getSourceUnit() {
sourceUnit (2)
}
@Override
Expression transform(final Expression exp) {
if (exp instanceof ArgumentListExpression) {
inArgList = true
} else if (inArgList &&
exp instanceof ConstantExpression && exp.value instanceof String) {
return new ConstantExpression(exp.value.toUpperCase()) (3)
}
def trn = super.transform(exp)
inArgList = false
trn
}
}
trn.visitMethod((MethodNode)nodes[1]) (4)
}
}
- 在内部,该转换创建一个
ClassCodeExpressionTransformer - 转换器需要返回源码单元
- 如果在参数列表中检测到字符串类型的常量表达式,就把它转换成大写版本
- 在被注解的方法上调用转换器
AST 节点
警告 : 编写 AST 转换需要对 Groovy 内部 API 有深入的了解。特别是需要了解 AST 类。由于这些类是内部的,API 将来可能会改变,这意味着你的转换可能会出问题。尽管有这个警告,AST 随着时间推移一直非常稳定,这种情况很少发生。
抽象语法树的类属于 org.codehaus.groovy.ast 包。建议读者使用 Groovy Console,特别是 AST 浏览器工具,来了解这些类。另一个学习资源是 AST Builder 测试套件。
2.2.5. 宏(Macros)
引言
直到 2.5.0 版本,在开发 AST 转换时,开发者需要深入了解编译器如何构建 AST(抽象语法树),才能知道如何在编译期添加新表达式或语句。
虽然使用 org.codehaus.groovy.ast.tool.GeneralUtils 的静态方法可以减轻创建表达式和语句的负担,但这仍然是一种直接编写那些 AST 节点的底层方式。我们需要一些东西来让我们不必直接编写 AST,这正是 Groovy 宏(macros)诞生的目的。它们让你可以直接在编译期间添加代码,而不必把脑海中的代码翻译成 org.codehaus.groovy.ast.* 节点相关的类。
语句和表达式
让我们看一个例子,创建一个局部 AST 转换:@AddMessageMethod。当应用于给定类时,它会向该类添加一个名为 getMessage 的新方法。该方法将返回 "42"。注解非常简单:
groovy
@Retention(RetentionPolicy.SOURCE)
@Target([ElementType.TYPE])
@GroovyASTTransformationClass(["metaprogramming.AddMethodASTTransformation"])
@interface AddMethod { }
不使用宏的 AST 转换会是什么样子?大概是这样的:
groovy
@GroovyASTTransformation(phase = CompilePhase.INSTRUCTION_SELECTION)
class AddMethodASTTransformation extends AbstractASTTransformation {
@Override
void visit(ASTNode[] nodes, SourceUnit source) {
ClassNode classNode = (ClassNode) nodes[1]
ReturnStatement code =
new ReturnStatement( (1)
new ConstantExpression("42")) (2)
MethodNode methodNode =
new MethodNode(
"getMessage",
ACC_PUBLIC,
ClassHelper.make(String),
[] as Parameter[],
[] as ClassNode[],
code) (3)
classNode.addMethod(methodNode) (4)
}
}
- 创建一个返回语句
- 创建一个常量表达式 "42"
- 把代码添加到新方法中
- 把新方法添加到被注解的类中
如果你不熟悉 AST API,这肯定看起来不像你脑海中的代码。现在看看使用宏后,前面的代码如何简化。
groovy
@GroovyASTTransformation(phase = CompilePhase.INSTRUCTION_SELECTION)
class AddMethodWithMacrosASTTransformation extends AbstractASTTransformation {
@Override
void visit(ASTNode[] nodes, SourceUnit source) {
ClassNode classNode = (ClassNode) nodes[1]
ReturnStatement simplestCode = macro { return "42" } (1)
MethodNode methodNode =
new MethodNode(
"getMessage",
ACC_PUBLIC,
ClassHelper.make(String),
[] as Parameter[],
[] as ClassNode[],
simplestCode) (2)
classNode.addMethod(methodNode) (3)
}
}
- 简单多了。你想添加一个返回 "42" 的返回语句,而
macro工具方法内部能读到的正是这一点。你的普通代码会被翻译成一个org.codehaus.groovy.ast.stmt.ReturnStatement - 把返回语句添加到新方法中
- 把新代码添加到被注解的类中
虽然这个例子中用 macro 方法创建了一个语句 ,但 macro 方法也可以用来创建表达式 ,这取决于你使用哪个 macro 签名:
macro(Closure):用闭包内的代码创建给定语句。macro(Boolean,Closure):如果为 true ,把闭包内的表达式包装成语句;如果为 false,则返回一个表达式macro(CompilePhase, Closure):在特定的编译阶段,用闭包内的代码创建给定语句macro(CompilePhase, Boolean, Closure):在特定的编译阶段创建语句或表达式(true == 语句,false == 表达式)。
注意 : 所有这些签名都可以在
org.codehaus.groovy.macro.runtime.MacroGroovyMethods中找到。
有时我们可能只想创建给定的表达式,而不是整个语句。要做到这一点,我们应该使用带布尔参数的 macro 调用之一:
groovy
@GroovyASTTransformation(phase = CompilePhase.INSTRUCTION_SELECTION)
class AddGetTwoASTTransformation extends AbstractASTTransformation {
BinaryExpression onePlusOne() {
return macro(false) { 1 + 1 } (1)
}
@Override
void visit(ASTNode[] nodes, SourceUnit source) {
ClassNode classNode = nodes[1]
BinaryExpression expression = onePlusOne() (2)
ReturnStatement returnStatement = GeneralUtils.returnS(expression) (3)
MethodNode methodNode =
new MethodNode("getTwo",
ACC_PUBLIC,
ClassHelper.Integer_TYPE,
[] as Parameter[],
[] as ClassNode[],
returnStatement (4)
)
classNode.addMethod(methodNode) (5)
}
}
- 我们告诉 macro 不要把表达式包装成语句,我们只对表达式感兴趣
- 赋值表达式
- 使用
GeneralUtils中的方法创建一个ReturnStatement并返回表达式 - 把代码添加到新方法中
- 把方法添加到类中
变量替换
宏很棒,但如果我们的宏不能接收参数或解析周围的变量,我们就无法创建任何有用或可复用的东西。
在下面的例子中,我们创建一个 AST 转换 @MD5,当应用于给定的 String 字段时,它会添加一个返回该字段 MD5 值的方法。
groovy
@Retention(RetentionPolicy.SOURCE)
@Target([ElementType.FIELD])
@GroovyASTTransformationClass(["metaprogramming.MD5ASTTransformation"])
@interface MD5 { }
以及转换:
groovy
@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
class MD5ASTTransformation extends AbstractASTTransformation {
@Override
void visit(ASTNode[] nodes, SourceUnit source) {
FieldNode fieldNode = nodes[1]
ClassNode classNode = fieldNode.declaringClass
String capitalizedName = fieldNode.name.capitalize()
MethodNode methodNode = new MethodNode(
"get${capitalizedName}MD5",
ACC_PUBLIC,
ClassHelper.STRING_TYPE,
[] as Parameter[],
[] as ClassNode[],
buildMD5MethodCode(fieldNode))
classNode.addMethod(methodNode)
}
BlockStatement buildMD5MethodCode(FieldNode fieldNode) {
VariableExpression fieldVar = GeneralUtils.varX(fieldNode.name) (1)
return macro(CompilePhase.SEMANTIC_ANALYSIS, true) { (2)
return java.security.MessageDigest
.getInstance('MD5')
.digest($v { fieldVar }.getBytes()) (3)
.encodeHex()
.toString()
}
}
}
- 我们需要一个变量表达式的引用
- 如果使用标准包之外的类,我们应该添加所需的导入或使用限定名。当使用给定静态方法的限定名时,你需要确保它在正确的编译阶段被解析。在这个特定情况下,我们指示宏在 SEMANTIC_ANALYSIS 阶段解析它,这是第一个具有类型信息的编译阶段。
- 为了替换宏内部的任何
expression,我们需要使用$v方法。$v接收一个闭包作为参数,而闭包只被允许替换表达式,即继承自org.codehaus.groovy.ast.expr.Expression的类。
MacroClass
如前所述,macro 方法只能产生 语句 和 表达式。但如果我们想产生其他类型的节点,比如方法、字段等呢?
org.codehaus.groovy.macro.transform.MacroClass 可以用来在我们的转换中创建类 (ClassNode 实例),方式与我们之前用 macro 方法创建语句和表达式相同。
下一个例子是一个局部转换 @Statistics。当应用于给定类时,它会添加两个方法 getMethodCount() 和 getFieldCount(),分别返回类中方法和字段的数量。下面是标记注解。
groovy
@Retention(RetentionPolicy.SOURCE)
@Target([ElementType.TYPE])
@GroovyASTTransformationClass(["metaprogramming.StatisticsASTTransformation"])
@interface Statistics {}
以及 AST 转换:
groovy
@CompileStatic
@GroovyASTTransformation(phase = CompilePhase.INSTRUCTION_SELECTION)
class StatisticsASTTransformation extends AbstractASTTransformation {
@Override
void visit(ASTNode[] nodes, SourceUnit source) {
ClassNode classNode = (ClassNode) nodes[1]
ClassNode templateClass = buildTemplateClass(classNode) (1)
templateClass.methods.each { MethodNode node -> (2)
classNode.addMethod(node)
}
}
@CompileDynamic
ClassNode buildTemplateClass(ClassNode reference) { (3)
def methodCount = constX(reference.methods.size()) (4)
def fieldCount = constX(reference.fields.size()) (5)
return new MacroClass() {
class Statistics {
java.lang.Integer getMethodCount() { (6)
return $v { methodCount }
}
java.lang.Integer getFieldCount() { (7)
return $v { fieldCount }
}
}
}
}
}
- 创建一个模板类
- 把模板类的方法添加到被注解的类中
- 传入引用类
- 提取引用类方法计数的值表达式
- 提取引用类字段计数的值表达式
- 使用引用的方法计数值表达式构建 getMethodCount() 方法
- 使用引用的字段计数值表达式构建 getFieldCount() 方法
基本上,我们把 Statistics 类创建为模板,以避免编写底层的 AST API,然后把模板类中创建的方法复制到它们最终的目的地。
注意 :
MacroClass实现内部的类型应该在内部被解析,这就是为什么我们必须写java.lang.Integer而不是简单地写Integer。
重要 : 注意我们使用了@CompileDynamic。这是因为我们使用MacroClass的方式就像我们实际上正在实现它一样。所以如果你使用@CompileStatic,它会抱怨,因为抽象类的实现不能是另一个不同的类。
@Macro 方法
你已经看到,通过使用 macro,你可以省去大量工作,但你可能会好奇这个方法是从哪来的。你没有声明它,也没有静态导入它。你可以把它看作一个特殊的全局方法(或者,如果你愿意,看作是每个 Object 上的方法)。这很像 println 扩展方法的定义方式。但不同于 println(它在编译过程稍后成为被选中执行的方法),macro 的展开发生在编译过程的早期。把 macro 声明为可用于这种早期展开的方法之一,是通过用 @Macro 注解一个 macro 方法定义,并使用类似扩展模块的机制让该方法可用。这样的方法被称为 macro 方法,好消息是你可以定义自己的。
要定义你自己的宏方法,创建一个类似于扩展模块的类,并添加一个方法,例如:
groovy
public class ExampleMacroMethods {
@Macro
public static Expression safe(MacroContext macroContext, MethodCallExpression callExpression) {
return ternaryX(
notNullX(callExpression.getObjectExpression()),
callExpression,
constX(null)
);
}
...
}
现在,你要把它注册为一个扩展模块,使用 META-INF/groovy 目录下的 org.codehaus.groovy.runtime.ExtensionModule 文件。
现在,假设这个类和元信息文件在你的 classpath 上,你可以按以下方式使用这个宏方法:
groovy
def nullObject = null
assert null == safe(safe(nullObject.hashcode()).toString())
2.2.6. 测试 AST 转换
分离源码树
这一节是关于测试 AST 转换的良好实践。前几节强调了这样一个事实:要执行 AST 转换,它必须被预先编译。这听起来可能显而易见,但很多人会栽在这上面,试图在与定义它的同一个源码树中使用 AST 转换。
因此,测试 AST 转换的第一个技巧是把测试源码与转换的源码分开。再说一次,这只是最佳实践,但你必须确保你的构建也确实分别编译它们。这在 Apache Maven 和 Gradle 中默认就是这种情况。
调试 AST 转换
能在 AST 转换中设置断点,从而在 IDE 中调试代码,是非常方便的。不过,你可能会惊讶地发现你的 IDE 不会停在断点上。原因其实很简单:如果你的 IDE 使用 Groovy 编译器来编译你的 AST 转换的单元测试,那么编译是由 IDE 触发的,但编译这些文件的过程没有调试选项。只有在测试用例被执行时,调试选项才被设置在虚拟机上。简而言之:太晚了,类已经被编译了,你的转换已经被应用了。
一个非常简单的变通方法是使用 GroovyTestCase 类,它提供了一个 assertScript 方法。这意味着,与其在测试用例中写这个:
groovy
static class Subject {
@MyTransformToDebug
void methodToBeTested() {}
}
void testMyTransform() {
def c = new Subject()
c.methodToBeTested()
}
你应该写:
groovy
void testMyTransformWithBreakpoint() {
assertScript '''
import metaprogramming.MyTransformToDebug
class Subject {
@MyTransformToDebug
void methodToBeTested() {}
}
def c = new Subject()
c.methodToBeTested()
'''
}
区别在于,当你使用 assertScript 时,assertScript 块中的代码是在单元测试执行时 被编译的。也就是说,这一次,Subject 类将在调试激活的情况下编译,断点就会被命中。
ASTMatcher
有时你可能想对 AST 节点做断言;也许是为了过滤节点,或者确保给定的转换构建了预期的 AST 节点。
过滤节点
例如,如果你想把给定的转换只应用于特定的一组 AST 节点,你可以使用 ASTMatcher 来过滤这些节点。下面的例子展示了如何把一个给定的表达式转换成另一个。使用 ASTMatcher ,它寻找特定的表达式 1 + 1 并将其转换成 3。这就是为什么我们叫它 @Joking 例子。
首先我们创建只能应用于方法的 @Joking 注解:
groovy
@Retention(RetentionPolicy.SOURCE)
@Target([ElementType.METHOD])
@GroovyASTTransformationClass(["metaprogramming.JokingASTTransformation"])
@interface Joking { }
然后是转换,它只把 org.codehaus.groovy.ast.ClassCodeExpressionTransformer 的实例应用到方法代码块内的所有表达式上。
groovy
@CompileStatic
@GroovyASTTransformation(phase = CompilePhase.INSTRUCTION_SELECTION)
class JokingASTTransformation extends AbstractASTTransformation {
@Override
void visit(ASTNode[] nodes, SourceUnit source) {
MethodNode methodNode = (MethodNode) nodes[1]
methodNode
.getCode()
.visit(new ConvertOnePlusOneToThree(source)) (1)
}
}
- 获取方法的代码语句并应用表达式转换器
这时,ASTMatcher 被用来只把转换应用于那些匹配表达式 1 + 1 的表达式。
groovy
class ConvertOnePlusOneToThree extends ClassCodeExpressionTransformer {
SourceUnit sourceUnit
ConvertOnePlusOneToThree(SourceUnit sourceUnit) {
this.sourceUnit = sourceUnit
}
@Override
Expression transform(Expression exp) {
Expression ref = macro { 1 + 1 } (1)
if (ASTMatcher.matches(ref, exp)) { (2)
return macro { 3 } (3)
}
return super.transform(exp)
}
}
- 构建用作参考模式的表达式
- 检查当前求值的表达式是否匹配参考表达式
- 如果匹配,就用
macro构建的表达式替换当前表达式
然后你可以这样测试实现:
groovy
package metaprogramming
class Something {
@Joking
Integer getResult() {
return 1 + 1
}
}
assert new Something().result == 3
单元测试 AST 转换
通常我们测试 AST 转换,只是检查转换的最终使用是否做了我们预期的事。但如果我们能有一种简单的方法来检查,例如,转换添加的节点从一开始就是我们所期望的,那就太好了。
下面的转换给被注解的类添加了一个新方法 giveMeTwo。
groovy
@GroovyASTTransformation(phase = CompilePhase.INSTRUCTION_SELECTION)
class TwiceASTTransformation extends AbstractASTTransformation {
static final String VAR_X = 'x'
@Override
void visit(ASTNode[] nodes, SourceUnit source) {
ClassNode classNode = (ClassNode) nodes[1]
MethodNode giveMeTwo = getTemplateClass(sumExpression)
.getDeclaredMethods('giveMeTwo')
.first()
classNode.addMethod(giveMeTwo) (1)
}
BinaryExpression getSumExpression() { (2)
return macro {
$v{ varX(VAR_X) } +
$v{ varX(VAR_X) }
}
}
ClassNode getTemplateClass(Expression expression) { (3)
return new MacroClass() {
class Template {
java.lang.Integer giveMeTwo(java.lang.Integer x) {
return $v { expression }
}
}
}
}
}
- 把方法添加到被注解的类中
- 构建一个二元表达式。这个二元表达式在
+记号的两边使用相同的变量表达式(查看 org.codehaus.groovy.ast.tool.GeneralUtils 中的varX方法)。 - 构建一个新的 ClassNode ,它有一个名为
giveMeTwo的方法,返回作为参数传入的表达式的计算结果。
现在,与其创建一个在给定示例代码上执行转换的测试,我想检查二元表达式的构造是否正确:
groovy
void testTestingSumExpression() {
use(ASTMatcher) { (1)
TwiceASTTransformation sample = new TwiceASTTransformation()
Expression referenceNode = macro {
a + a (2)
}.withConstraints { (3)
placeholder 'a' (4)
}
assert sample
.sumExpression
.matches(referenceNode) (5)
}
}
- 把 ASTMatcher 用作类别
- 构建一个模板节点
- 对那个模板节点应用一些约束
- 告诉编译器
a是一个占位符。 - 断言参考节点和当前节点相等
当然,你可以/应该总是检查实际的执行情况:
groovy
void testASTBehavior() {
assertScript '''
package metaprogramming
@Twice
class AAA {
}
assert new AAA().giveMeTwo(1) == 2
'''
}
ASTTest
最后但同样重要的是,测试 AST 转换也是关于测试编译期间 AST 的状态。Groovy 为此提供了一个名为 @ASTTest 的工具:它是一个注解,让你可以对抽象语法树添加断言。更多细节请查看 [ASTTest 的文档](#ASTTest 的文档 "#xform-ASTTest")。
2.2.7. 外部参考
如果你对编写 AST 转换的逐步教程感兴趣,可以跟着这个 workshop。