【Groovy翻译-进阶篇】运行时元编程与编译时元编程(下)

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 转换是实现这一点的简单方法。它需要两样东西:

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)
                )
            )
        )
    }
}
  1. 虽然不是强制要求,但如果你用 Groovy 编写 AST 转换,强烈建议使用 CompileStatic,因为它会提高编译器的性能。
  2. org.codehaus.groovy.transform.GroovyASTTransformation 注解,告诉转换需要在哪个编译阶段运行。这里是在语义分析阶段。
  3. 实现 ASTTransformation 接口
  4. 该接口只有一个 visit 方法
  5. nodes 参数是一个 2 元素的 AST 节点数组,第一个是注解节点(@WithLogging),第二个是被注解的节点(方法节点)
  6. 创建一条语句,在进入方法时打印消息
  7. 创建一条语句,在退出方法时打印消息
  8. 获取方法体,这里是一个 BlockStatement
  9. 在现有代码的第一条语句之前添加进入方法的消息
  10. 在现有代码的最后一条语句之后追加退出方法的消息
  11. 创建一个包装了 MethodCallExpressionExpressionStatement,对应 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()

要实现这一点,需要两步:

  1. META-INF/services 目录内创建 org.codehaus.groovy.transform.ASTTransformation 描述符
  2. 创建 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)
                )
            )
        )
    }
}
  1. 虽然不是强制要求,但如果你用 Groovy 编写 AST 转换,强烈建议使用 CompileStatic,因为它会提高编译器的性能。
  2. org.codehaus.groovy.transform.GroovyASTTransformation 注解,告诉转换需要在哪个编译阶段运行。这里是在语义分析阶段。
  3. 实现 ASTTransformation 接口
  4. 该接口只有一个 visit 方法
  5. sourceUnit 参数提供对正在编译的源的访问,因此我们获得当前源的 AST,并从这个文件检索方法列表
  6. 我们遍历源文件中的每个方法
  7. 创建一条语句,在进入方法时打印消息
  8. 创建一条语句,在退出方法时打印消息
  9. 获取方法体,这里是一个 BlockStatement
  10. 在现有代码的第一条语句之前添加进入方法的消息
  11. 在现有代码的最后一条语句之后追加退出方法的消息
  12. 创建一个包装了 MethodCallExpressionExpressionStatement,对应 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)
    }
}
  1. 在内部,该转换创建一个 ClassCodeExpressionTransformer
  2. 转换器需要返回源码单元
  3. 如果在参数列表中检测到字符串类型的常量表达式,就把它转换成大写版本
  4. 在被注解的方法上调用转换器
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)
    }
}
  1. 创建一个返回语句
  2. 创建一个常量表达式 "42"
  3. 把代码添加到新方法中
  4. 把新方法添加到被注解的类中

如果你不熟悉 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)
    }
}
  1. 简单多了。你想添加一个返回 "42" 的返回语句,而 macro 工具方法内部能读到的正是这一点。你的普通代码会被翻译成一个 org.codehaus.groovy.ast.stmt.ReturnStatement
  2. 把返回语句添加到新方法中
  3. 把新代码添加到被注解的类中

虽然这个例子中用 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)
    }
}
  1. 我们告诉 macro 不要把表达式包装成语句,我们只对表达式感兴趣
  2. 赋值表达式
  3. 使用 GeneralUtils 中的方法创建一个 ReturnStatement 并返回表达式
  4. 把代码添加到新方法中
  5. 把方法添加到类中
变量替换

宏很棒,但如果我们的宏不能接收参数或解析周围的变量,我们就无法创建任何有用或可复用的东西。

在下面的例子中,我们创建一个 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()
        }
    }
}
  1. 我们需要一个变量表达式的引用
  2. 如果使用标准包之外的类,我们应该添加所需的导入或使用限定名。当使用给定静态方法的限定名时,你需要确保它在正确的编译阶段被解析。在这个特定情况下,我们指示宏在 SEMANTIC_ANALYSIS 阶段解析它,这是第一个具有类型信息的编译阶段。
  3. 为了替换宏内部的任何 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 }
                }
            }
        }
    }
}
  1. 创建一个模板类
  2. 把模板类的方法添加到被注解的类中
  3. 传入引用类
  4. 提取引用类方法计数的值表达式
  5. 提取引用类字段计数的值表达式
  6. 使用引用的方法计数值表达式构建 getMethodCount() 方法
  7. 使用引用的字段计数值表达式构建 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 MavenGradle 中默认就是这种情况。

调试 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)
    }
}
  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)
    }
}
  1. 构建用作参考模式的表达式
  2. 检查当前求值的表达式是否匹配参考表达式
  3. 如果匹配,就用 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 }
                }
            }
        }
    }
}
  1. 把方法添加到被注解的类中
  2. 构建一个二元表达式。这个二元表达式在 + 记号的两边使用相同的变量表达式(查看 org.codehaus.groovy.ast.tool.GeneralUtils 中的 varX 方法)。
  3. 构建一个新的 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)
    }
}
  1. 把 ASTMatcher 用作类别
  2. 构建一个模板节点
  3. 对那个模板节点应用一些约束
  4. 告诉编译器 a 是一个占位符。
  5. 断言参考节点和当前节点相等

当然,你可以/应该总是检查实际的执行情况:

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

相关推荐
用户106911794101 小时前
SpringSercuirty整合jwt
后端
岁月如歌77861 小时前
一次讲透 Redis 缓存击穿、穿透、雪崩,从原理到实战解决方案
java·后端·架构
IT_陈寒1 小时前
Vue的v-for为啥把我的渲染顺序搞乱套了?
前端·人工智能·后端
用户106911794101 小时前
SpringSecuirty自定义接口或者资源权限
后端
天涯明月19932 小时前
SGLang深度研究报告
人工智能·后端·推理·sglang
0xBADCODE2 小时前
Flask SSTI读SECRET_KEY+伪造Session:税务系统渗透全流程
前端·后端·python·安全·web安全·网络安全·flask
程序员爱钓鱼2 小时前
Rust where详解:让泛型与Trait约束更加清晰
前端·后端·rust
程序员爱钓鱼5 小时前
Go 编程实战:函数 Function——参数、返回值与代码复用
后端·面试·go
To_OC10 小时前
跑了3个Docker容器后,我终于搞懂镜像和容器到底啥关系
后端·docker·容器