ANTLR4權威指南 - 第7章 通過特定應用程序代碼解耦語法


第7章

通過特定應用程序代碼解耦語法

到目前為止,我們已經知道了怎么用ANTLR的語法來定義語言了,接下來我們要給我們的語法注入一些新的元素了。就語法本身而言,其用處並不大,因為它只能告訴我們一個用戶輸入的句子是否符合語言程序的語法規范。要建立一個完整的語言程序,我們就需要語法解析器在遇到特定的輸入的時候能夠產生對應的動作。“語法->動作”的映射對集合就是連接我們的語言程序(或者,至少是語言接口)的語法到大型實際相關應用之間的橋梁。

在這一章中,我們將要學習怎樣使用語法分析樹的監聽器(listener)和訪問器(visitor)來建立語言應用程序。當一個語法分析樹遍歷器發現或結束識別一個節點的時候,監聽器就會響應對應的規則進入事件和規則退出事件(分析識別事件)。某些程序可能需要控制一棵語法樹的遍歷方式,為了滿足這種需求,ANTLR生成的分析樹同樣也支持非常有名的訪問者模式(visitor pattern)。

監聽器和訪問器之間最大的差別就是,監聽器的方法不能通過調用特定的方法來訪問子節點。而訪問器則需要明確調用子節點,從而才能保證遍歷過程繼續下去(就如我們在第2.5節中看到的那樣)。這是因為這些明確的調用過程,訪問器才能夠控制遍歷的順序已經遍歷節點的數量。為了方便起見,我會用事件方法這個詞來代指監聽器回調方法或訪問器方法。

這一章中,我們的目標是了解ANTLR為我們生成樹訪問工具的作用以及原理。首先,我們監聽器的運行機制開始,了解我們怎樣利用監聽器和訪問器嵌入我們的特定應用程序代碼。然后,我們將學習怎樣讓ANTLR給規則的每個選項生成一個特定的方法。當我們對ANTLR的樹遍歷過程有了較深的了解之后,我們將看看三個計算器的實現例子,分別代表了三種不同的傳遞子表達式結果的方法。最后,我們將討論下這三種傳遞方式的優缺點。完成了這些之后,我們就已經為下一章中的實例做好充足的准備了。

7.1 從動作代碼嵌入到監聽器的演化

如果你用過之前版本的ANTLR或者是其它編譯器生成器,你一定會很驚訝,我們可以不使用嵌入動作代碼就實現語言程序。監聽器和訪問器機制使得程序代碼和語法分離開來(也就是所謂的解耦),這會產生很多令人興奮的好處。這種解耦可以很好地將程序獨立出來,而不是將其打散了分散到語法的各個部分中去。出去嵌入動作之后,我們可以實現語法重用,也就是幾乎不需要重新生成語法分析器就可以將相同的語法應用到不同的程序中去。

沒有了嵌入動作之后,ANTLR也可以實現用相同的語法使用不同的編程語言生成語法分析器。(我預期在4.0發布之后會開始支持不同的目標語言。)此外,語法錯誤的修改和語法更新也會變得更加容易,因為我們不用再擔心會因為嵌入動作導致合並沖突。

在這一節,我們要探討下從嵌入動作到語法完全獨立的這個過程。下面展示了一個屬性文件的語法規則,其中使用<<…>>的方式嵌入代碼。像“<<start_file>>”這樣的動作標識都表示這里有一段合適的Java代碼。

grammar PropertyFile;

file : {«start file»} prop+ {«finish file»} ;

prop : ID '=' STRING '\n' {«process property»} ;

ID : [a-z]+ ;

STRING: '"' .*? '"';

這樣的結合寫法就將語法和一個特定的應用程序綁定到一起了。更好的做法是建立一個PropertyFileParser(由ANTLR生成的一個類)的子類,然后將嵌入代碼轉換成其成員函數。這樣的做法可以在語法中僅保留對這些新建的函數的調用就可以了。然后,通過創建不同的子類,我們可以實現在不修改語法的前提下實現不同的程序。下面是一個這種實現的例子:

grammar PropertyFile;

@members {

   void startFile(){ } // blank implementations

   void finishFile(){ }

   voiddefineProperty(Token name, Token value) { }

}

file : {startFile();} prop+ {finishFile();} ;

prop : ID '=' STRING'\n'{defineProperty($ID, $STRING)} ;

ID : [a-z]+ ;

STRING : '"' .*? '"';

這樣可以實現語法的重用,但是這樣的語法仍然和Java關聯起來了,因為語法中使用的是Java語言的函數調用方式。我們將在稍后處理這個問題。

為了證明重構代碼的可重用性,我們來建立兩個簡單的應用程序,第一個應用程序僅僅是簡單地輸出它識別到的屬性。實現的過程也就是簡單地擴展ANTLR自動生成的語法分析類,並重載幾個語法中定義的方法。

class PropertyFilePrinterextendsPropertyFileParser {

   void defineProperty(Token name, Token value) {

      System.out.println(name.getText()+"="+value.getText());

   }

}

這個類中,我們並沒有重載startFile()和finishFile()這兩個方法,因為在父類PropertyFileParser中已經由ANTLR自動生成了其默認實現了。在這里,我們並不想去改它。

要執行我們的代碼,我們需要創建我們自己寫的PropertyFileParser的子類的實例。

PropertyFileLexer lexer = newPropertyFileLexer(input);

CommonTokenStream tokens = newCommonTokenStream(lexer);

PropertyFilePrinter parser = newPropertyFilePrinter(tokens);

parser.file(); // launch our special version of the parser

第二個程序,我們將識別到的屬性保存到一個map對象中,而不是直接輸出來。我們需要做的所有改動僅僅是創建一個新的子類,並在defineProperty()函數中寫入不同的代碼。

class PropertyFileLoaderextendsPropertyFileParser {

   Map<String,String> props =new OrderedHashMap<String,String>();

   void defineProperty(Token name, Token value) {

      props.put(name.getText(), value.getText());

   }

}

語法分析程序執行完之后,props字段中就會填充上識別到的鍵值對了。

這個語法中還有一個問題存在,那就是嵌入動作限制了我們只能生成Java語言的程序。若是想要提高語法重用性並做到與目標語言無關,我們需要徹底不使用嵌入動作。在下面的兩節中我們將展示如何使用監聽器和訪問器實現這一點。

7.2 使用分析樹監聽器實現語言程序

要建立一個獨立於應用和語法的語言程序,關鍵在於用語法分析器創建一棵語法樹,然后使用特定程序代碼遍歷這課語法樹。我們可以自己手動編寫代碼來遍歷這棵樹,當然,我們也可以使用ANTLR生成的樹遍歷工具來實現。在這一節中,我們使用ANTLR內建的ParseTreeWalker來建立一個基於監聽器版本的屬性文件程序。

首先,我們看一下沒有任何嵌入代碼的屬性文件語法。

listeners/PropertyFile.g4

file : prop+ ;

prop : ID '=' STRING'\n';

下面是一個樣例的屬性文件:

listeners/t.properties

user="parrt"

machine="maniac"

ANTLR會根據語法自動生成PropertyFileParser類,這個類會自動建立下面這樣的語法分析樹:

一旦我們建立了語法樹之后,我們就可以使用ParseTreeWalker來遍歷所有節點,並觸發對應的進入事件和退出事件。

我們先來看看ANTLR根據PropertyFile語法自動生成的PropertyFileListener這個監聽器接口。ParseTreeWalker在發現和離開節點的時候,會分別為每一個規則子樹引發進入和退出事件。因為我們的PropertyFile語法中只有兩個語法規則,所以接口中只有4個方法。

listeners/PropertyFileListener.java

import org.antlr.v4.runtime.tree.*;

import org.antlr.v4.runtime.Token;

public interface PropertyFileListenerextendsParseTreeListener {

   void enterFile(PropertyFileParser.FileContext ctx);

   void exitFile(PropertyFileParser.FileContext ctx);

   void enterProp(PropertyFileParser.PropContext ctx);

   void exitProp(PropertyFileParser.PropContext ctx);

}

FileContext和PropContext分別是語法分析樹節點具體的實現對象。它們包含了很多有用的方法。

為了方便起見,ANTLR也生成了一個PropertyFileBaseListener類來提供接口函數的默認實現,就像我們在上一節中在@members區域中寫的一樣,這個類提供了所有函數的空實現方法。

public class PropertyFileBaseVisitor<T>extendsAbstractParseTreeVisitor<T>

   implements PropertyFileVisitor<T>

{

   @Override public T visitFile(PropertyFileParser.FileContextctx) { }

   @Override public T visitProp(PropertyFileParser.PropContextctx) { }

}

 

(譯者注:上面的代碼出現得好像和文本描述的內容有點不相符,但是不影響理解。)

對接口的默認實現可以允許我們只對我們感興趣的方法進行重載和實現。例如,下面的代碼就是對屬性文件加載器的再實現,它使用了監聽器機制,卻只有一個方法:

listeners/TestPropertyFile.java

public static class PropertyFileLoaderextends PropertyFileBaseListener {

   Map<String,String> props = new OrderedHashMap<StringString>();

   public void exitProp(PropertyFileParser.PropContext ctx){

      String id = ctx.ID().getText(); // prop : ID '=' STRING '\n' ;

      String value = ctx.STRING().getText();

      props.put(id,value);

   }

}

這個版本代碼的主要特征就是其擴展了BaseListener,這個監聽器的方法會在解析器完成后調用。

在這一步我們會接觸到很多接口和類,下面,我們先看一看主要類和接口之間的繼承關系是怎樣的(接口用斜體表示)。

ParseTreeListener接口位於ANTLR運行時庫當中,所有的監聽器都繼承自它,它決定了所有監聽器都要響應visitTerminal(),enterEveryRule(),exitEveryRule()以及(依據語法錯誤)visitErrorNode()方法。ANTLR會根據PropertyFile語法生成PropertyFileListener接口,並生成了一個默認實現所有接口方法的類:PropertyFileBaseListener。唯一一個我們自己建立的類就是PropertyFileLoader,它從PropertyFileBaseListener哪里繼承了所有的空實現的函數。

exitProp()方法訪問了一個規則上下文對象:PropContext。這個對象是和prop規則緊密聯系在一起的。也就是說,對於prop規則中定義的每一個元素(ID和STRING),上下文對象中都會對應有一個方法。由於這些元素正好都是語法中的詞法節點(符號元素),所以這些對應的方法會返回語法樹節點中的TerminalNode。我們可以直接通過getText()方法獲得這些符號對應的文本內容,也可以通過getSymbol()方法獲得這些符號的Token。

現在,我們可以做一些令人興奮的事情了。讓我們來遍歷這棵樹,然后通過新的PropertyFileLoader來進行監聽。

listeners/TestPropertyFile.java

// create a standard ANTLR parse treewalker

ParseTreeWalker walker = new ParseTreeWalker();

// create listener then feed to walker

PropertyFileLoader loader = new PropertyFileLoader();

walker.walk(loader, tree); // walk parse tree

System.out.println(loader.props);// print results

下面,我們復習下怎樣運行一個ANTLR語法,首先將生成的代碼進行編譯,然后啟動一個測試程序來處理輸入文件:

$ antlr4 PropertyFile.g4

$ ls PropertyFile*.java

PropertyFileBaseListener.java PropertyFileListener.java

PropertyFileLexer.java PropertyFileParser.java

$ javac TestPropertyFile.java PropertyFile*.java

$ cat t.properties

user="parrt"

machine="maniac"

$ java TestPropertyFile t.properties

{user="parrt",machine="maniac"}

我們的測試程序成功地將屬性賦值文本轉換成內存中的map數據結構了。

基於監聽器的方法是一個很不錯的方法,因為所有的樹節點遍歷以及相應的方法都是自動進行的。然而,在某些場合下,當我們需要控制遍歷的過程的時候,就不能用自動遍歷這個方法了。例如,我們需要遍歷一個C程序的語法樹,希望跳過函數體語法子樹,即忽略函數的函數體部分。此外,監聽器無法使用函數返回值來傳遞數據。要解決以上問題,我們需要使用到訪問者模式。下面,讓我們建立一個基於訪問者模式的屬性加載器,進而對比這兩種方式的優缺點。

7.3 使用訪問器來實現語言程序

要使用訪問器來取代監聽器,我們需要讓ANTLR生成訪問器接口,實現這個接口,然后再創建一個測試程序調用visit()方法來訪問語法樹。這一節中,我們並不需要修改語法的任何信息。

在命令行中使用-visitor參數,ANTLR就會生成PropertyFileVisitor接口和一個默認實現了所有方法的PropertyFileBaseVisitor類:

public class PropertyFileBaseVisitor<T>extends AbstractParseTreeVisitor<T>

   implements PropertyFileVisitor<T>

{

   @Overridepublic T visitFile(PropertyFileParser.FileContextctx) { ... }

   @Overridepublic T visitProp(PropertyFileParser.PropContextctx) { ... }

}

我們可以將監聽器中的exitProp()函數體拷貝到visitor中關於prop規則的方法中。

listeners/TestPropertyFileVisitor.java

public static class PropertyFileVisitorextends

PropertyFileBaseVisitor<Void>

{

    Map<String,String> props = new OrderedHashMap<StringString>();

    public Void visitProp(PropertyFileParser.PropContext ctx){

       String id = ctx.ID().getText(); // prop : ID '=' STRING '\n' ;

       String value = ctx.STRING().getText();

       props.put(id,value);

       return null; // Java says must return something evenwhen Void

    }

}

(譯者注:這段代碼所屬的java文件名可能不對,我覺得文件名應該是PropertyFileVisitor.java)。

為了更好地和上一節所講的監聽器版本做比較,下面列出了訪問器接口和類的繼承關系:

訪問器通過明確調用ParseTreeVisitor接口中子節點的visit()方法來遍歷語法樹。visit方法是從AbstractParseTreeVisitor中實現的。在這個例子中,prop節點的調用中並沒有包含任何子節點,所以visitProp()方法並不需要調用visit()方法。我們將在后面看到訪問器的泛型類型參數。

監聽器和訪問器的測試代碼中,最大的差距在於訪問器不需要一個ParseTreeWalker。它們直接使用訪問器自身來遍歷解析器創建的樹。

listeners/TestPropertyFileVisitor.java

PropertyFileVisitor loader = new PropertyFileVisitor();

loader.visit(tree);

System.out.println(loader.props);// print results

一切都准備就緒之后,下面是建立和測試的結果:

$ antlr4 -visitor PropertyFile.g4 # create visitor as well this time

$ ls PropertyFile*.java

PropertyFileBaseListener.java PropertyFileListener.java

PropertyFileBaseVisitor.java PropertyFileParser.java

PropertyFileLexer.java PropertyFileVisitor.java

$ javac TestPropertyFileVisitor.java

$ cat t.properties

user="parrt"

machine="maniac"

$ java TestPropertyFileVisitor t.properties

{user="parrt", machine="maniac"}

使用訪問器和監聽器,我們幾乎能完成所有事情了。當我們進入Java的空間的時候,就已經和ANTLR沒有半毛錢關系了。我們需要知道的僅僅是語法間的關系,生成的語法樹,以及訪問器和監聽器的事件方法。除了這些,剩下的就是你的代碼編寫能力了。為了響應我們識別到的輸入短語,我們可以生成輸出,並收集信息(就像我們之前做的那樣),並通過特定方法激活短語,或進行計算。

這個讀取屬性文件的例子非常的簡單,所以我們不用考慮到帶有選項分支的規則。默認情況下,不管解析器匹配到一條規則的哪一個選項,ANTLR都只是會給一條規則只生成單一的一個事件方法。這樣是十分不方便的,因為絕大多數情況下,訪問器或監聽器都必須知道解析器匹配的是哪條規則。在下一節,我們將要更細致地討論下事件方法。

7.4 通過標記規則選項來指定事件方法

為了更好地說明問題,讓我們試着用監聽器去根據下面的表達式語法來實現一個簡單的計算器程序:

listeners/Expr.g4

grammar Expr;

s : e ;

e : e op=MULT e // MULT is '*'

   | e op=ADD e // ADD is '+'

   | INT

   ;

顯然,e規則會產生一個相當無用的監聽器,因為所有e規則的選項只會在樹遍歷器中引發同樣的方法enterE()和exitE()。

public interface ExprListenerextends ParseTreeListener {

   void enterE(ExprParser.EContext ctx);

   void exitE(ExprParser.EContext ctx);

   ...

}

為了知道監聽器方法中匹配的是e子樹中的哪條規則,我們不得借助ctx對象中的op標簽來進行判斷。

listeners/TestEvaluator.java

public void exitE(ExprParser.EContextctx) {

    if ( ctx.getChildCount()==3 ) { // operations have 3 children

       int left = values.get(ctx.e(0));

       int right = values.get(ctx.e(1));

       if ( ctx.op.getType()==ExprParser.MULT ) {

           values.put(ctx,left * right);

       }

       else {

           values.put(ctx,left + right);

       }

    }

    else {

       values.put(ctx,values.get(ctx.getChild(0))); // an INT

    }

}

exitE()方法中出現的MULT字段是由ANTLR在ExprParser中自動生成的:

public class ExprParserextends Parser {

   public static final int MULT=1, ADD=2, INT=3, WS=4;

   ...

}

觀察下ExprParser類中的EContext子類,我們可以發現ANTLR將e規則中三個選項中的所有元素都收集起來放到同一個上下文對象中。

public static class EContextextends ParserRuleContext {

    public Token op; // derived fromlabel op

    public List<EContext> e() { ... } // get all e subtrees

    public EContext e(int i) {... } // get ith e subtree

    public TerminalNode INT() { ... } // get INT node if alt 3 of e

    ...

}

在ANTLR中,我們可以使用#運算符來給一個規則最外層的選項命名,從而生成監聽器中更多的事件方法。我們從Expr引申出新語法LExpr,並用這種方法給e的選項命名。下面是修改后的e規則:

listeners/LExpr.g4

e : e MULT e # Mult

   | e ADD e # Add

   | INT # Int

   ;

現在,ANTLR會根據e的不同的選項生成不同的監聽器方法。這樣之后,我們就不再需要op這個標簽了。對於每個選項標簽X,ANTLR都會生成enterX()和exit()兩個方法。

public interface LExprListenerextends ParseTreeListener {

   void enterMult(LExprParser.MultContext ctx);

   void exitMult(LExprParser.MultContext ctx);

   void enterAdd(LExprParser.AddContext ctx);

   void exitAdd(LExprParser.AddContext ctx);

   void enterInt(LExprParser.IntContext ctx);

   void exitInt(LExprParser.IntContext ctx);

   ...

}

需要注意的是,ANTLR同時也會根據每個選項的標簽名生成特定的上下文對象(EContext的子類)。這些特定的上下文對象只能訪問其對應語法選項中特定的元素。比如,IntContext僅僅只有一個INT()方法。我們能夠在enterInt()中調用ctx.INT(),但是在enterAdd()中卻無法調用這個方法。

監聽器和訪問器是十分不錯的工具,借助它們,我們可以讓我們的語法有着很高的可重用性和可重定向性,甚至實現封裝語言的應用,而我們要做的僅僅是更新下事件方法的代碼。ANTLR同時也會給我們生成框架代碼。事實上,到目前為止,我們建立的應用程序都還沒有遇到需要共同實現的問題,也即,事件方法有時候需要彼此之間傳遞一些部分結果或其他信息。

7.5 在事件方法之間共享信息

不管是收集信息還是計算數值,通過參數和返回值進行信息共享,對比於通過全局變量的形式而言,即方便,又能體現出良好的編程習慣。現在問題來了,ANTLR自動生成的函數簽名是不包含參數和返回值類型的。同樣,ANTLR生成的訪問器方法也是不帶特定參數的。

在這一節中,我們要繼續在不修改事件方法簽名的前提下在事件方法之間傳遞參數的方案。基於上一節的LExpr語法,我們要建立三種不同的實現方法。第一種實現方法使用了訪問器方法的返回值,第二種方法定義了一個共享事件方法的字段,第三種方法通過標記語法樹節點來貯存感興趣的值。

利用訪問器遍歷語法樹

要建立一個基於訪問器的計算器,最簡單的方法就是通過返回子表達式的值來聯系expr下的各個規則元素。例如,visitAdd()應該返回兩個子表達式的和。visitInt()應該返回整數的值。傳統的訪問器並不指定它們訪問方法的返回值。給訪問器添加返回值類型並不難,只需要擴展LExprBaseVisitor<T>,並把T參數指定為我們需要的Integer就可以了。下面是我們的訪問器的代碼:

listeners/TestLEvalVisitor.java

public static class EvalVisitorextends LExprBaseVisitor<Integer> {

   public Integer visitMult(LExprParser.MultContext ctx) {

      return visit(ctx.e(0)) * visit(ctx.e(1));

   }

 

   public Integer visitAdd(LExprParser.AddContext ctx) {

      return visit(ctx.e(0)) + visit(ctx.e(1));

   }

 

   public Integer visitInt(LExprParser.IntContext ctx) {

      return Integer.valueOf(ctx.INT().getText());

   }

}

EvalVisitor繼承了ANTLR中AbstractParseTreeVisitor類的visit()方法,我們在訪問器中將會使用這個方法來訪問子樹。

你可能注意到了EvalVisitor中並沒有關於規則s的訪問器方法。在LExprBaseVisitor中默認實現的visitS()方法會調用預定義的ParseTreeVisitor.visitChildren()方法。visitChildren()方法會返回最后一個訪問的子節點的返回值。在我們的例子當中,visitS()會返回它惟一一個子節點(e節點)所計算的表達式的返回值。就此而言,我們可以直接使用其默認實現。

在測試文件TestLEvalVisitor.java中,我們使用常規代碼來啟動LExprParser,並輸出語法樹。然后,我們需要寫代碼來啟動EvalVisitor,並在訪問樹的時候將計算到的表達式的值輸出。

listeners/TestLEvalVisitor.java

EvalVisitor evalVisitor = new EvalVisitor();

int result= evalVisitor.visit(tree);

System.out.println("visitor result = "+result);

建立我們的計算器的時候,我們通知ANTLR生成訪問器,我們可以像在屬性文件解析時那樣使用-visitor選項。(如果我們希望不要生成監聽器的話,可以使用-no-listener選項。)下面是完整的建立和測試命令:

$ antlr4 -visitor LExpr.g4

$ javac LExpr*.java TestLEvalVisitor.java

$ java TestLEvalVisitor

1+2*3

➾EOF

<(s (e (e 1) + (e (e 2) * (e 3))))

visitorresult = 7

如果我們使用Java內建的返回值機制來傳遞信息,我們的訪問器工作得非常好。如果我們希望不去手動訪問子節點的話,我們可以選擇監聽器機制。不幸的是,這也同時意味着我們不能使用Java方法的返回值。

用棧來模擬返回值

ANTLR生成的監聽器事件方法是沒有返回值的(返回值類型是void)。我們要想在監聽器的節點方法上實現訪問值的話,我們可以將部分結果存在監聽器的一個字段中。這個時候很容易就想到了棧結構,就類似於Java運行時使用CPU棧來臨時保存方法的返回值。我們的想法就是,將子表達式的值壓入棧中,而子表達式上層的方法從棧頂取結果。下面是完整的Evaluator計算器的監聽器代碼(位於文件TestLEvaluator.java中):

listeners/TestLEvaluator.java

public static class Evaluatorextends LExprBaseListener {

   Stack<Integer>stack = new Stack<Integer>();

 

   public void exitMult(LExprParser.MultContext ctx) {

      int right= stack.pop();

      int left =stack.pop();

      stack.push( left * right );

   }

 

   public void exitAdd(LExprParser.AddContext ctx) {

      int right= stack.pop();

      int left =stack.pop();

      stack.push(left + right);

   }

 

   public void exitInt(LExprParser.IntContext ctx) {

      stack.push( Integer.valueOf(ctx.INT().getText()) );

   }

}

要測試這段代碼,我們可以在TestLEvaluator測試代碼中使用ParseTreeWalker,就像我們在TestPropertyFile中那樣做。

$ antlr4 LExpr.g4

$ javac LExpr*.java TestLEvaluator.java

$ java TestLEvaluator

1+2*3

➾EOF

<(s (e (e 1) + (e (e 2) * (e 3))))

stackresult = 7

雖然使用棧有點不方便,但是也能很好工作。我們在監聽器方法中就必須小心壓棧和入棧的操作是否都是正確的。使用訪問器可以避免使用棧,但是卻需要我們手動訪問樹的節點。解決這個問題的第三個方法就是捕獲並存儲部分結果到樹節點中。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM