深入 Java 抽象语法树(AST)的奇妙世界

1,666 阅读8分钟

在计算机科学中,抽象语法树(Abstract Syntax Tree, AST)是一种重要的数据结构,用于表示源代码的语法结构。它将源代码解析成一个树形结构,其中每个节点代表源代码中的一种结构成分,如表达式、语句、或声明。

AST常用于编译器和解释器中,以便更方便地进行代码分析和转换。

抽象语法树的特点

  1. 抽象性:AST只保留了源代码的语法结构,而忽略了具体的细节,比如括号和分号等。
  2. 层次性:树形结构使得每个节点都有其子节点,例如一个函数调用节点可以包含多个参数节点。
  3. 无冗余:与具体语法树不同,AST不包含多余的语法元素,使得代码分析和优化更加高效。

构建抽象语法树

构建AST通常涉及两个主要步骤:

  1. 词法分析(Lexical Analysis)

    • 将源代码拆分成一系列的标记(Tokens),例如关键字、标识符、操作符等。
  2. 语法分析(Syntax Analysis)

    • 根据生成的标记序列,按照语言的语法规则构建AST。

示例:正则表达式的AST

假设我们要为一个简单的正则表达式生成AST。考虑以下正则表达式:

a(b|c)d*

这个正则表达式可以匹配以字母a开头,后跟一个bc,然后跟零个或多个d的字符串。

词法分析

首先,我们将正则表达式拆解成以下标记:

  • a
  • (
  • b
  • |
  • c
  • )
  • d
  • *

语法分析

接下来,我们根据这些标记构建AST:

        Concat
       /      \
     'a'    Closure
            /      \
         Union      'd'
        /     \
      'b'     'c'

在这个AST中:

  • 顶层是一个Concatenation节点,表示连接操作。
  • 第一个子节点是字符'a'。
  • 第二个子节点是一个Closure节点,表示零个或多个'd'。
  • Closure节点下是一个Union节点,表示'b'或'c'。

构建和使用AST

假设我们还是要匹配正则表达式 a(b|c)d*。接下来演示如何手动构建这个正则表达式的AST,并进行匹配。

首先,定义正则表达式的各个节点类:

abstract class RegexNode {
    abstract boolean match(String text, int start);
}

class CharNode extends RegexNode {
    private final char character;

    CharNode(char character) {
        this.character = character;
    }

    @Override
    boolean match(String text, int start) {
        if (start >= text.length()) return false;
        return text.charAt(start) == character;
    }
}

class ConcatNode extends RegexNode {
    private final RegexNode left;
    private final RegexNode right;

    ConcatNode(RegexNode left, RegexNode right) {
        this.left = left;
        this.right = right;
    }

    @Override
    boolean match(String text, int start) {
        for (int i = start; i <= text.length(); i++) {
            if (left.match(text, start) && right.match(text, i)) {
                return true;
            }
        }
        return false;
    }
}

class UnionNode extends RegexNode {
    private final RegexNode left;
    private final RegexNode right;

    UnionNode(RegexNode left, RegexNode right) {
        this.left = left;
        this.right = right;
    }

    @Override
    boolean match(String text, int start) {
        return left.match(text, start) || right.match(text, start);
    }
}

class ClosureNode extends RegexNode {
    private final RegexNode node;

    ClosureNode(RegexNode node) {
        this.node = node;
    }

    @Override
    boolean match(String text, int start) {
        int len = text.length();
        for (int i = start; i <= len; i++) {
            if (!node.match(text, start)) {
                break;
            }
            start++;
        }
        return true;
    }
}

然后我们手动构建正则表达式 a(b|c)d* 对应的AST:

public class RegexASTExample {
    public static void main(String[] args) {
        // 构建正则表达式 a(b|c)d* 的AST
        RegexNode literalA = new CharNode('a');
        RegexNode unionBC = new UnionNode(new CharNode('b'), new CharNode('c'));
        RegexNode closureD = new ClosureNode(new CharNode('d'));

        // 结合起来:a + (b|c) + d*
        RegexNode concatRight = new ConcatNode(unionBC, closureD);
        RegexNode root = new ConcatNode(literalA, concatRight);

        // 测试字符串
        String testString1 = "abd";
        String testString2 = "acd";
        String testString3 = "abdddd";
        String testString4 = "axd";

        System.out.println("Test string: " + testString1 + " -> " + root.match(testString1, 0));
        System.out.println("Test string: " + testString2 + " -> " + root.match(testString2, 0));
        System.out.println("Test string: " + testString3 + " -> " + root.match(testString3, 0));
        System.out.println("Test string: " + testString4 + " -> " + root.match(testString4, 0));
    }
}

输出结果

执行上面的代码,我们可以得到以下结果:

复制代码
Test string: abd -> true
Test string: acd -> true
Test string: abdddd -> true
Test string: axd -> false

这是因为前三个测试字符串均符合正则表达式 a(b|c)d* 的模式,而第四个字符串不符合,因为它包含一个不匹配的字符 'x'。

总结

通过上述示例,我们可以看到如何利用抽象语法树(AST)来表示和匹配正则表达式。每个节点类型(如CharNode, ConcatNode, UnionNode, ClosureNode)都实现了特定的匹配逻辑。最终,通过组合这些节点,我们可以生成一个复杂的正则表达式,并对输入字符串进行匹配操作。这样的设计不仅清晰,而且便于扩展和维护。

Java 编译器中的AST

在编译过程的前期阶段,源代码首先经过词法分析(Lexical Analysis),生成一系列标记(Token)。这些标记接下来由语法分析器(Parser)处理,构建出 AST。以下是这个过程的简要描述:

  1. 词法分析(Lexical Analysis) :将源代码转换成标记流。
  2. 语法分析(Parsing) :根据语言的语法规则,将标记流解析成 AST。

2. AST 节点类型

每个 AST 节点都对应于源代码中的一种语言构造。例如,在 Java 中,可能的节点类型包括类声明(ClassDeclaration)、方法声明(MethodDeclaration)、变量声明(VariableDeclaration)、表达式(Expression)等。

Eclipse JDT 提供了丰富的节点类型,每种节点类型都有相关联的属性和方法,用于获取和设置该节点的具体信息。

主要节点类型:
  • CompilationUnit:表示一个完整的源文件。
  • PackageDeclaration:包声明。
  • ImportDeclaration:导入声明。
  • TypeDeclaration:类型声明(类、接口)。
  • MethodDeclaration:方法声明。
  • FieldDeclaration:字段声明。
  • VariableDeclarationStatement:变量声明语句。
  • Expression:表达式,包括字面值、操作符、方法调用等。

3. AST 的遍历与操作

为了分析或操作 AST,可以利用访问者模式(Visitor Pattern)。Eclipse JDT 提供了 ASTVisitor 类,允许你通过重写其方法来遍历和处理不同类型的节点。

示例代码:

以下示例展示了如何使用 Eclipse JDT 解析一个简单的 Java 类,并打印出类名和方法名。

import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.dom.*;

import java.util.Map;

public class ASTExample {
    public static void main(String[] args) {
        String source = "public class HelloWorld { "
                      + "public static void main(String[] args) { "
                      + "System.out.println("Hello, World");"
                      + "}}";

        // 设置解析器
        ASTParser parser = ASTParser.newParser(AST.JLS_Latest);
        parser.setKind(ASTParser.K_COMPILATION_UNIT);
        parser.setSource(source.toCharArray());
        parser.setResolveBindings(true);

        // 设置编译选项
        Map<String, String> options = JavaCore.getOptions();
        options.put(JavaCore.COMPILER_SOURCE, JavaCore.VERSION_11);
        parser.setCompilerOptions(options);

        // 解析并获取 AST
        CompilationUnit cu = (CompilationUnit) parser.createAST(null);

        // 访问 AST 节点
        cu.accept(new ASTVisitor() {
            @Override
            public boolean visit(MethodDeclaration node) {
                System.out.println("Method name: " + node.getName());
                return super.visit(node);
            }

            @Override
            public boolean visit(TypeDeclaration node) {
                System.out.println("Class name: " + node.getName());
                return super.visit(node);
            }
        });
    }
}

4. AST 操作实例

除了遍历 AST,我们还可以对 AST 进行修改,例如添加、删除或修改某个节点。

添加新方法:
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.dom.*;
import org.eclipse.jface.text.Document;
import org.eclipse.text.edits.TextEdit;

import java.util.Map;

public class ASTManipulationExample {
    public static void main(String[] args) {
        // 初始化Java源代码
        String source = "public class HelloWorld {}";

        // 设置解析器
        ASTParser parser = ASTParser.newParser(AST.JLS_Latest);
        parser.setKind(ASTParser.K_COMPILATION_UNIT);
        parser.setSource(source.toCharArray());
        parser.setResolveBindings(true);

        // 设置编译选项
        Map<String, String> options = JavaCore.getOptions();
        options.put(JavaCore.COMPILER_SOURCE, JavaCore.VERSION_11);
        parser.setCompilerOptions(options);

        // 解析并获取 AST
        CompilationUnit cu = (CompilationUnit) parser.createAST(null);
        AST ast = cu.getAST();

        // 创建重写工具
        ASTRewrite rewriter = ASTRewrite.create(ast);

        cu.accept(new ASTVisitor() {
            @Override
            public boolean visit(TypeDeclaration node) {
                // 创建新方法
                MethodDeclaration newMethod = ast.newMethodDeclaration();
                newMethod.setName(ast.newSimpleName("newMethod"));

                // 添加方法修饰符
                newMethod.modifiers().add(ast.newModifier(Modifier.ModifierKeyword.PUBLIC_KEYWORD));

                // 设置返回类型为void
                newMethod.setReturnType2(ast.newPrimitiveType(PrimitiveType.VOID));

                // 创建方法体
                Block body = ast.newBlock();
                newMethod.setBody(body);

                // 将新方法添加到类中
                ListRewrite listRewrite = rewriter.getListRewrite(node, TypeDeclaration.BODY_DECLARATIONS_PROPERTY);
                listRewrite.insertLast(newMethod, null);

                return super.visit(node);
            }
        });

        // 应用修改并输出新的源码
        try {
            Document document = new Document(source);
            TextEdit edits = rewriter.rewriteAST(document, null);
            edits.apply(document);

            // 输出修改后的源代码
            System.out.println(document.get());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

这个示例展示了如何使用 AST 和 ASTRewrite 来动态地向现有的 Java 类中添加一个新方法。生成的新方法名为 newMethod,是一个 publicvoid 方法,并且包含空的 Block 作为方法体。

关键步骤总结:

  1. 设置解析器:创建并配置 ASTParser
  2. 解析源代码:将源代码解析成 CompilationUnit
  3. 访问节点:使用 ASTVisitor 遍历 AST。
  4. 创建新节点:通过 AST 创建新的语法节点(如方法声明)。
  5. 修改结构:使用 ASTRewrite 操作 AST,将新节点插入到适当位置。
  6. 应用变更:使用 TextEdit 将修改应用到文档,并生成新的源代码文本。

深入分析 Java 抽象语法树(AST)涉及理解它的生成过程、节点类型以及如何遍历和操作这些节点。Eclipse JDT 提供了强大的工具集来处理这些任务。通过正确设置和使用 ASTParserASTVisitorASTRewrite 等类,可以方便地解析、遍历和修改 Java 源代码的抽象语法树。

补充

除了 Eclipse JDT,解析 Java 类还有其他方法和工具。这些工具各有优劣,可以根据具体需求选择合适的工具。以下是一些常见的方法和工具:

1. Javaparser

Javaparser 是一个专门用于解析、分析和修改 Java 源代码的库。它非常易用且功能强大。

使用示例:

import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.visitor.VoidVisitorAdapter;

public class JavaParserExample {
    public static void main(String[] args) {
        String source = "public class HelloWorld { "
                      + "public static void main(String[] args) { "
                      + "System.out.println("Hello, World");"
                      + "}"
                      + "}";

        // 解析源代码
        CompilationUnit cu = StaticJavaParser.parse(source);

        // 访问并处理节点
        cu.accept(new VoidVisitorAdapter<Void>() {
            @Override
            public void visit(ClassOrInterfaceDeclaration n, Void arg) {
                System.out.println("Class name: " + n.getName());
                super.visit(n, arg);
            }
        }, null);
    }
}

2. ANTLR

ANTLR 是一个更为通用的解析器生成工具,通过定义语法文件,可以生成 Java 解析器。尽管它不是专门为 Java 设计的,但可以通过提供 Java 语法定义文件来解析 Java 代码。

步骤概述:

  1. 定义语法:创建一个 .g4 文件,定义 Java 的语法。
  2. 生成解析器:使用 ANTLR 工具将 .g4 文件转换为 Java 解析器代码。
  3. 解析代码:使用生成的解析器解析 Java 源代码。

示例(假设已经定义了 Java 语法):

import org.antlr.v4.runtime.CharStreams;
import org.antlr.v4.runtime.CommonTokenStream;
import org.antlr.v4.runtime.tree.ParseTree;

public class ANTLRExample {
    public static void main(String[] args) {
        String source = "public class HelloWorld { "
                      + "public static void main(String[] args) { "
                      + "System.out.println("Hello, World");"
                      + "}"
                      + "}";

        JavaLexer lexer = new JavaLexer(CharStreams.fromString(source));
        CommonTokenStream tokens = new CommonTokenStream(lexer);
        JavaParser parser = new JavaParser(tokens);

        ParseTree tree = parser.compilationUnit();
        System.out.println(tree.toStringTree(parser)); // 打印解析树
    }
}

3. Spoon

Spoon 是一个用于解析、分析、和转换 Java 源代码的库,支持对 Java 程序进行静态分析和代码改写。

使用示例:

import spoon.Launcher;
import spoon.reflect.CtModel;
import spoon.reflect.declaration.CtClass;
import spoon.reflect.visitor.filter.TypeFilter;

public class SpoonExample {
    public static void main(String[] args) {
        Launcher launcher = new Launcher();
        launcher.addInputResource("path/to/HelloWorld.java");
        launcher.buildModel();

        CtModel model = launcher.getModel();
        for (CtClass<?> ctClass : model.getElements(new TypeFilter<>(CtClass.class))) {
            System.out.println("Class name: " + ctClass.getSimpleName());
        }
    }
}

4. ASM

ASM 是一个 Java 字节码操作框架,通常用于读取、修改和生成 Java 字节码。虽然 ASM 主要用于字节码级别的操作,但也可以用来读取类文件结构。

使用示例:

import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.Opcodes;

public class ASMExample {
    public static void main(String[] args) throws Exception {
        ClassReader reader = new ClassReader("HelloWorld");
        reader.accept(new ClassVisitor(Opcodes.ASM9) {
            @Override
            public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
                System.out.println("Class name: " + name);
                super.visit(version, access, name, signature, superName, interfaces);
            }
        }, 0);
    }
}