如何让ANTLR生成脚本解释器?

14

假设我有以下Java API,并将其全部打包成blocks.jar

public class Block {
    private Sting name;
    private int xCoord;
    private int yCoord;

    // Getters, setters, ctors, etc.

    public void setCoords(int x, int y) {
        setXCoord(x);
        setYCoord(y);
    }
}

public BlockController {
    public static moveBlock(Block block, int newXCoord, int newYCoord) {
        block.setCooords(newXCoord, newYCoord);
    }

    public static stackBlocks(Block under, Block onTop) {
        // Stack "onTop" on top of "under".
        // Don't worry about the math here, this is just for an example.
        onTop.setCoords(under.getXCoord() + onTop.getXCoord(), under.getYCoord());
    }
}

不要担心数学和(x,y)坐标不能准确地表示3D空间中的方块。重点是我们有Java代码,编译为JAR,在方块上执行操作。我现在想要构建一个轻量级的脚本语言,允许非程序员调用各种块API方法并操作块,并且我想使用ANTLR(最新版本为4.3)来实现其解释器。

这个脚本语言,我们称之为BlockSpeak,可能看起来像这样:

block A at (0, 10)   # Create block "A" at coordinates (0, 10)
block B at (0, 20)   # Create block "B" at coordinates (0, 20)
stack A on B         # Stack block A on top of block B

这可能相当于以下的Java代码:
Block A, B;
A = new Block(0, 10);
B = new Block(0, 20);
BlockController.stackBlocks(B, A);

因此,这个ANTLR生成的解释器将以*.blockspeak脚本作为输入,并使用该脚本中的命令来调用blocks.jar API操作。我阅读了一个优秀的简单示例,它使用ANTLR创建了一个简单的计算器。但是在链接中,有一个名为ExpParser的类和一个eval()方法:
ExpParser parser = new ExpParser(tokens);
parser.eval();

这里的问题在于,对于计算器而言,tokens代表需要计算的数学表达式,而eval()返回的是表达式的结果。但对于一个解释器来说,tokens应该代表我的BlockSpeak脚本,但调用eval()时不应该进行任何计算,而应该知道如何将各种BlockSpeak命令映射到Java代码中:
BlockSpeak Command:             Java code:
==========================================
block A at (0, 10)      ==>     Block A = new Block(0, 10);
block B at (0, 20)      ==>     Block B = new Block(0, 20);
stack A on B            ==>     BlockController.stackBlocks(B, A);

所以我的问题是,我在哪里执行这个“映射”?换句话说,当ANTLR遇到BlockSpeak脚本中的特定语法时,我如何指示它调用blocks.jar内部打包的各个代码块?更重要的是,有人能给我一个伪代码示例吗?


1
你考虑过使用Xtext吗?这会给你一个漂亮的编辑器等等。在《使用Xtext和Xtend实现领域特定语言》一书中,作者展示了如何在第8章中实现解释器。(如果你不想使用Eclipse作为依赖项,也可以创建独立应用程序。) - Gábor Bakos
感谢 @GáborBakos (+1) - 我很感激您的建议,但出于本问题范围之外的原因,我正在寻找基于ANTLR的解决方案! - IAmYourFaja
没有问题。(虽然Xtext也是基于antlr的。) - Gábor Bakos
3个回答

17
我会直接动态评估脚本,而不是生成需要再次编译的Java源文件。
使用ANTLR 4时,强烈建议将语法和目标特定代码分开,并将任何目标特定代码放在树监听器或访问者中。
我将快速演示如何使用监听器。
您的示例输入的语法可能如下所示:
文件:blockspeak/BlockSpeak.g4
grammar BlockSpeak;

parse
 : instruction* EOF
 ;

instruction
 : create_block
 | stack_block
 ;

create_block
 : 'block' NAME 'at' position
 ;

stack_block
 : 'stack' top=NAME 'on' bottom=NAME
 ;

position
 : '(' x=INT ',' y=INT ')'
 ;

COMMENT
 : '#' ~[\r\n]* -> skip
 ;

INT
 : [0-9]+
 ;

NAME
 : [a-zA-Z]+
 ;

SPACES
 : [ \t\r\n] -> skip
 ;

一些支持Java类:

文件:blockspeak/Main.java

package blockspeak;

import org.antlr.v4.runtime.ANTLRInputStream;
import org.antlr.v4.runtime.CommonTokenStream;
import org.antlr.v4.runtime.tree.ParseTreeWalker;

import java.util.Scanner;

public class Main {

    public static void main(String[] args) throws Exception {

        Scanner keyboard = new Scanner(System.in);

        // Some initial input to let the parser have a go at.
        String input = "block A at (0, 10)   # Create block \"A\" at coordinates (0, 10)\n" +
                "block B at (0, 20)   # Create block \"B\" at coordinates (0, 20)\n" +
                "stack A on B         # Stack block A on top of block B";

        EvalBlockSpeakListener listener = new EvalBlockSpeakListener();

        // Keep asking for input until the user presses 'q'.
        while(!input.equals("q")) {

            // Create a lexer and parser for `input`.
            BlockSpeakLexer lexer = new BlockSpeakLexer(new ANTLRInputStream(input));
            BlockSpeakParser parser = new BlockSpeakParser(new CommonTokenStream(lexer));

            // Now parse the `input` and attach our listener to it. We want to reuse 
            // the same listener because it will hold out Blocks-map.
            ParseTreeWalker.DEFAULT.walk(listener, parser.parse());

            // Let's see if the user wants to continue.
            System.out.print("Type a command and press return (q to quit) $ ");
            input = keyboard.nextLine();
        }

        System.out.println("Bye!");
    }
}

// You can place this Block class inside Main.java as well.
class Block {

    final String name;
    int x;
    int y;

    Block(String name, int x, int y) {
        this.name = name;
        this.x = x;
        this.y = y;
    }

    void onTopOf(Block that) {
        // TODO
    }
}

这个主类非常容易理解,并且有行内注释。棘手的部分是监听器应该长什么样子。好吧,这就是它的样子:

文件:blockspeak/EvalBlockSpeakListener.java

package blockspeak;

import org.antlr.v4.runtime.misc.NotNull;

import java.util.HashMap;
import java.util.Map;

/**
 * A class extending the `BlockSpeakBaseListener` (which will be generated
 * by ANTLR) in which we override the methods in which to create blocks, and
 * in which to stack blocks.
 */
public class EvalBlockSpeakListener extends BlockSpeakBaseListener {

    // A map that keeps track of our Blocks.
    private final Map<String, Block> blocks = new HashMap<String, Block>();

    @Override
    public void enterCreate_block(@NotNull BlockSpeakParser.Create_blockContext ctx) {

        String name = ctx.NAME().getText();
        Integer x = Integer.valueOf(ctx.position().x.getText());
        Integer y = Integer.valueOf(ctx.position().y.getText());

        Block block = new Block(name, x, y);

        System.out.printf("creating block: %s\n", name);

        blocks.put(block.name, block);
    }

    @Override
    public void enterStack_block(@NotNull BlockSpeakParser.Stack_blockContext ctx) {

        Block bottom = this.blocks.get(ctx.bottom.getText());
        Block top = this.blocks.get(ctx.top.getText());

        if (bottom == null) {
            System.out.printf("no such block: %s\n", ctx.bottom.getText());
        }
        else if (top == null) {
            System.out.printf("no such block: %s\n", ctx.top.getText());
        }
        else {
            System.out.printf("putting %s on top of %s\n", top.name, bottom.name);
            top.onTopOf(bottom);
        }
    }
}

上述监听器定义了2个方法,分别对应以下解析规则:
create_block
 : 'block' NAME 'at' position
 ;

stack_block
 : 'stack' top=NAME 'on' bottom=NAME
 ;

每当解析器“进入”这样的解析器规则时,监听器内部的相应方法将被调用。因此,每当调用enterCreate_block(解析器进入create_block规则)时,我们创建(并保存)一个块,并且当调用enterStack_block时,我们检索操作中涉及的2个块,并将其中一个叠放在另一个上面。
要查看上述3个类的实际效果,请下载ANTLR 4.4,并将其放置在包含blockspeak/目录和.g4.java文件的目录中。
打开控制台并执行以下3个步骤:

1. 生成ANTLR文件:

java -cp antlr-4.4-complete.jar org.antlr.v4.Tool blockspeak/BlockSpeak.g4 -package blockspeak

2. 编译所有Java源文件:

javac -cp ./antlr-4.4-complete.jar blockspeak/*.java

3. 运行主类:

java -cp .:antlr-4.4-complete.jar blockspeak.Main

java -cp .;antlr-4.4-complete.jar blockspeak.Main

以下是运行 Main 类的示例会话:

bart@hades:~/Temp/demo$ java -cp .:antlr-4.4-complete.jar blockspeak.Main
creating block: A
creating block: B
putting A on top of B
Type a command and press return (q to quit) $ block X at (0,0)
creating block: X
Type a command and press return (q to quit) $ stack Y on X
no such block: Y
Type a command and press return (q to quit) $ stack A on X 
putting A on top of X
Type a command and press return (q to quit) $ q
Bye!
bart@hades:~/Temp/demo$ 

关于树形监听器的更多信息:https://theantlrguy.atlassian.net/wiki/display/ANTLR4/Parse+Tree+Listeners


4
我个人会编写一个语法来生成Java程序,针对每个脚本都能单独编译和运行... 即一个两步骤的过程。
例如,使用以下简单语法(我没有测试过并且需要您进行扩展和适应),您可以将上述示例中的 parser.eval() 语句替换为 parser.program(); (同时在全文中用 "BlockSpeak" 替换 "Exp"),这将输出与脚本匹配的Java代码到 stdout,您可以将其重定向到 .java 文件中,进行编译(与jar一起)并运行。 BlockSpeak.g:
grammar BlockSpeak;

program 
    @init { System.out.println("//import com.whatever.stuff;\n\npublic class BlockProgram {\n    public static void main(String[] args) {\n\n"); }
    @after { System.out.println("\n    } // main()\n} // class BlockProgram\n\n"); }
    : inss=instructions                         { if (null != $inss.insList) for (String ins : $inss.insList) { System.out.println(ins); } }
    ;

instructions returns [ArrayList<String> insList]
    @init { $insList = new ArrayList<String>(); }
    : (instruction { $insList.add($instruction.ins); })* 
    ;

instruction returns [String ins]
    :  ( create { $ins = $create.ins; } | move  { $ins = $move.ins; } | stack { $ins = $stack.ins; } ) ';' 
    ;

create returns [String ins]
    :  'block' id=BlockId 'at' c=coordinates    { $ins = "        Block " + $id.text + " = new Block(" + $c.coords + ");\n"; }
    ;

move returns [String ins]
    :  'move' id=BlockId 'to' c=coordinates     { $ins = "        BlockController.moveBlock(" + $id.text + ", " + $c.coords + ");\n"; }
    ;

stack returns [String ins]
    :  'stack' id1=BlockId 'on' id2=BlockId     { $ins = "        BlockController.stackBlocks(" + $id1.text + ", " + $id2.text + ");\n"; }
    ;

coordinates returns [String coords]
    :    '(' x=PosInt ',' y=PosInt ')'          { $coords = $x.text + ", " + $y.text; }
    ;

BlockId
    :    ('A'..'Z')+
    ;

PosInt
    :    ('0'..'9') ('0'..'9')* 
    ;

WS  
    :   (' ' | '\t' | '\r'| '\n')               -> channel(HIDDEN)
    ;

(请注意,为了简便起见,此语法要求使用分号来分隔每个指令。)
当然还有其他方法来完成这种事情,但我认为这是最简单的方法。
祝好运!
更新:
所以我继续“完成”了我的原始帖子(修复了上面语法中的一些错误),并在一个简单的脚本上进行了测试。
这是我用来测试上述语法的.java文件(取自您以上发布的代码存根)。请注意,在您的情况下,您可能需要将脚本文件名(在我的代码中为“script.blockspeak”)变成命令行参数。当然,BlockBlockController类将代替您的jar。
import org.antlr.v4.runtime.*;

class Block {
    private String name;
    private int xCoord;
    private int yCoord;

    // Other Getters, setters, ctors, etc.
    public Block(int x, int y) { xCoord = x; yCoord = y; }

    public int getXCoord() { return xCoord; }
    public int getYCoord() { return yCoord; }

    public void setXCoord(int x) { xCoord = x; }
    public void setYCoord(int y) { yCoord = y; }

    public void setCoords(int x, int y) {
        setXCoord(x);
        setYCoord(y);
    }
}

class BlockController {
    public static void moveBlock(Block block, int newXCoord, int newYCoord) {
        block.setCoords(newXCoord, newYCoord);
    }

    public static void stackBlocks(Block under, Block onTop) {
        // Stack "onTop" on top of "under".
        // Don't worry about the math here, this is just for an example.
        onTop.setCoords(under.getXCoord() + onTop.getXCoord(), under.getYCoord());
    }
}

public class BlocksTest {
    public static void main(String[] args) throws Exception {
        ANTLRFileStream in = new ANTLRFileStream("script.blockspeak");
        BlockSpeakLexer lexer = new BlockSpeakLexer(in);
        CommonTokenStream tokens = new CommonTokenStream(lexer);
        BlockSpeakParser parser = new BlockSpeakParser(tokens);
        parser.program();
    }
}

以下是我在 MacBook Pro 上使用的命令行:


> java -jar antlr-4.4-complete.jar BlockSpeak.g
> javac -cp .:antlr-4.4-complete.jar *.java
> java -cp .:antlr-4.4-complete.jar BlocksTest > BlockProgram.java

这是输入脚本: :
block A at (0, 10);                                                                                                                                            
block B at (0, 20);
stack A on B;

以下是输出结果:

BlockProgram.java

//import com.whatever.stuff;

public class BlockProgram {
    public static void main(String[] args) {


        Block A = new Block(0, 10);

        Block B = new Block(0, 20);

        BlockController.stackBlocks(A, B);


    } // main()
} // class BlockProgram

当然,你需要为每个脚本编译和运行BlockProgram.java。
回答您评论中一个问题(#3),我最初考虑了一些更复杂的选项,可能会简化您的“用户体验”。
(A) 不使用语法来生成您必须编译和运行的java程序,而是直接将对BlockController的调用嵌入到ANTLR操作中。 在我创建字符串并将它们从一个非终端传递到另一个非终端的位置,您可以在识别“instruction”规则时直接放置java代码以执行您的块命令。 这将需要更多与ANTLR语法和导入相关的复杂性,但它是可行的。
(B) 如果您选择选项A,则可以更进一步地创建一个交互式解释器(“ shell”),其中用户被提示,并在提示符处输入“ blockspeak”命令,这些命令随后被解析并直接执行,将结果显示给用户。
这两个选项在复杂性方面都不是很难实现,但它们都需要做更多的编码工作,超出了Stack Overflow答案的范围。 这就是为什么我选择在这里提供一个“更简单”的解决方案的原因。

@Turix 回答得非常好! (+1) - 不过还有一些跟进问题! (1) 在你的示例语法中,你引用了 SystemBlockBlockController 类... ANTLR 在运行时如何链接到它们,因为它们没有使用完全限定的包名“imported”?例如,它如何知道您正在调用 java.lang.System 而不是 com.foo.bar.System?**(2)** parser 是什么类类型?我在 ANTLR 4.3 javadocs 中找不到像 ParserAbstractParser 这样的类,这些类在其中具有 eval()program() 方法。 - IAmYourFaja
而且 (3) 你的回答让我很好奇;你一开始说“我个人会编写一个语法来生成Java程序……”,然后后来又说“当然还有其他方法来完成这种事情……”。这让我想到这只是实现我所需的东西的许多策略之一。如果是这样,你能详细说明一下吗?你能提供任何链接让我可以阅读吗?并不是我不喜欢你的方法,它可能绝对是最好的,我只是想尽可能了解。再次感谢! - IAmYourFaja
1
@IAmYourFaja 我刚刚更新了我的帖子,加入了更多细节。(所有的都按照所呈现的方式工作。)关于问题(1),ANTLR非常聪明。对于System,不需要导入任何内容,但是对于我使用的ArrayList,ANTLR知道要导入java.util.ArrayList。如果您想要使用我的选项(A),那么您需要在语法的开头添加一个@header块来包含您的自定义导入。关于问题(2),这是一个由ANTLR生成的BlockSpeakParser,用于扩展其parser类。program()匹配语法中顶级非终结符的名称。关于问题(3),我已经在我的更新中添加了它。祝你好运! - Turix
1
@IamYourFaja 没问题。如果您决定采用(A)或(B)之一,请告诉我。它们都不是很难。 (A) 需要更多地了解您的 BlockController 将如何处理每个指令(例如,更新数据库模型?),特别是在验证指令方面(例如,您是否尝试移动未知块?操作是否合法等),以及如果验证失败应该发生什么。 (B) 需要一个输入循环,为用户输入的每行重复调用 parser.instruction() 并处理来自 BlockController 的任何(错误)响应。 - Turix
1
抱歉听起来有点严厉,我知道你是出于好意。但是所建议的解决方案不是我推荐实施(和维护)的内容。 - Bart Kiers
显示剩余5条评论

1
< p>在ExpParser中,eval()是通过方法调用来实现的;只是这些调用有形式上的操作符快捷语法。

作为练习,可以改变ExpParser,添加一个Calculator类,其中包含(未实现的)数学运算符方法,如add(), multiply(), divide()等,然后更改规则,使用这些方法代替操作符。因此,您将理解您的BlockSpeak解释器所需做的基础知识。

additionExp returns [double value]
    :    m1=multiplyExp       {$value =  $m1.value;} 
         ( '+' m2=multiplyExp {$value = Calculator.add($value, $m2.value);} 
         | '-' m2=multiplyExp {$value = Calculator.subtract($value, $m2.value);}
         )* 
    ;

谢谢@Apalala (+1) - 但这里有几个问题!(1)您是说我应该修改ExpParser类,还是根本不使用它?如果您是说前者,那么这似乎不是一个非常可维护的过程,因为每当语法规则因任何原因而更改时,我都需要修改ANTLR给我的输出ExpParser。(2)我如何将语法文件中的“计算器”与例如“com.me.myorg.Calculator”连接起来? - IAmYourFaja
你能否提供一个完整的代码示例,展示你对ExpParser和Calculator的推荐更改?我猜我仍然有些看不清楚整体情况,再次感谢! - IAmYourFaja
1
您可以使用ExpParser作为模板。我认为您会将“BlockSpeakBuilder bsc”或“Block block”参数传递给语法中的规则,因此您的规则可以执行诸如“{$value = bsc.newBlock();}”或“{block.setSomeFeature($var);}”之类的操作。 - Apalala
1
为了给您提供更具体的例子,我需要一个 BlockSpeak 的语法,这应该是您的起点。尝试编写和测试没有语义动作的语法,并使用结果更新您的问题。 - Apalala
感谢@Apalala(+2分)-请给我大约24小时,我会更新我的问题,并提供BlockSpeak的具体语法!再次感谢! - IAmYourFaja

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接