ANTLR 解析和 C++,第一部分:简介






4.95/5 (5投票s)
解释了如何使用 ANTLR 生成解析代码并在 C++ 应用程序中访问该代码
1. 引言
许多应用程序需要分析文本结构。Visual Studio 在您键入时分析代码并提供反馈和建议。现代文本编辑器分析文档以检查拼写和语法错误。在这两种情况下,分析文本结构的过程都称为 解析。
与其从头开始编写解析器,不如使用生成解析代码的工具。有许多可用的选项,包括 GNU Bison 和 Lex/Yacc。本文及下一篇文章重点介绍 ANTLR(ANother Tool for Language Recognition),可在此处免费下载。
这些文章不深入探讨解析理论,因此我不会讨论 LL 解析与 LALR 分析的优点。相反,我的目标是为 C++ 开发人员提供 ANTLR 的实用概述。本文介绍了 ANTLR 的基本用法,并逐步介绍了开发一个能够解析数学表达式的应用程序。下一篇文章将探讨 ANTLR 的高级功能。
2. 使用 ANTLR 解析文本
旧金山大学的 Terence Parr 教授在 antlr.org 网站上领导 ANTLR 开发。ANTLR 以 Java 存档 (JAR) 的形式提供,最新存档的名称是 antlr-4.9.2-complete.jar。本节解释了如何在 C++ 中生成解析代码并编译使用生成代码的应用程序。
2.1 生成解析代码
要使用 ANTLR 的 JAR 文件,您需要能够从命令行调用 java
可执行文件。这作为 Java 运行时环境的一部分提供,可以从 Oracle 下载。安装 Java 后,您可以执行生成解析代码的命令。使用 ANTLR 4.9.2 生成 C++ 代码的命令如下:
java -jar antlr-4.9.2-complete.jar -Dlanguage=Cpp <grammar-file>
第一个标志 -jar
告诉运行时执行 ANTLR JAR 文件中的代码。第二个标志 -Dlanguage
标识目标语言。ANTLR 支持几种不同的语言,包括 Java、C#、Python 和 JavaScript。本文重点介绍生成 C++ 代码,因此 -Dlanguage
应设置为 Cpp
。
命令的最后一部分标识了一个语法文件,该文件描述了要分析的语言的结构。例如,如果要生成一个分析 Python 代码的解析器,语法文件必须定义 Python 代码的总体结构。
2.2 构建示例应用程序
本文提供了两个 zip 文件,其中包含基于 ANTLR 生成代码的 C++ 项目。第一个是 expression_vs.zip,适用于运行 Visual Studio 的 Windows 系统。第二个是 expression_gnu.zip,依赖于 GNU 构建工具,适用于 Linux/macOS 系统。
这两个项目都构建了一个应用程序,用于解析包含数学表达式(如 (2+3)*5
)的 string
。语法文件是 Expression.g4,您可以使用以下命令从该语法生成代码:
java -jar antlr-4.9.2-complete.jar -Dlanguage=Cpp Expression.g4
当此命令运行时,ANTLR 会生成几个文件。要构建示例应用程序,只需要六个文件:
- ExpressionLexer.h - 声明词法分析器类
ExpressionLexer
- ExpressionLexer.cpp - 提供
ExpressionLexer
类的代码 - ExpressionParser.h - 声明解析器类
ExpressionParser
- ExpressionParser.cpp - 提供
ExpressionParser
类的代码 - ExpressionListener.h - 声明监听器类
ExpressionListener
- ExpressionListener.cpp - 提供
ExpressionListener
类的代码
对于示例应用程序,您只需要关注其中两个类:词法分析器类 (ExpressionLexer
) 和解析器类 (ExpressionParser
)。简单来说,词法分析器从文本中提取有意义的字符串(标记),解析器使用标记来确定文本的底层结构。列表 1 显示了 main.cpp 的代码,该代码创建了这些类的实例。
列表 1:main.cpp
#include <iostream>
#include "antlr4-runtime.h"
#include "ExpressionLexer.h"
#include "ExpressionParser.h"
int main(int argc, const char* argv[]) {
// Provide the input text in a stream
antlr4::ANTLRInputStream input("6*(2+3)");
// Create a lexer from the input
ExpressionLexer lexer(&input);
// Create a token stream from the lexer
antlr4::CommonTokenStream tokens(&lexer);
// Create a parser from the token stream
ExpressionParser parser(&tokens);
// Display the parse tree
std::cout << parser.expr()->toStringTree() << std::endl;
return 0;
}
要编译此代码,编译器需要 antlr4-runtime.h 头文件以及声明 ANTLR 类的其他头文件。这两个项目都在其 include 目录中包含这些头文件。
编译后,应用程序必须链接到包含 ANTLR 代码的库。我已将 ANTLR 库放置在每个项目的 lib 文件夹中。在 Visual Studio 项目中,lib 文件夹包含 antlr4-runtime.lib 和 antlr4-runtime.dll。在 GNU 项目中,lib 文件夹包含 libantlr4-runtime.so。
应用程序编译并链接后,可以作为常规可执行文件运行。示例应用程序会打印一个解析树,该解析树定义了表达式的结构。对于给定的示例,解析树的结果如下:
(6*(2+3) (6 6) * ((2+3) ( (2+3 (2 2) + (3 3)) )))
我将在本文的后面部分以及第二篇文章中讨论解析树。在此之前,我想介绍 ANTLR 的语法并展示 ANTLR 运行时的基本类。
3. ANTLR 语法介绍
编写 ANTLR 语法文件并不容易,因此在开始新的 *.g4 文件之前,我建议您在互联网上搜索现有的语法。特别是,Tom Everett 在他的 Github 仓库中提供了各种语法文件。如果其中之一足以满足您的项目需求,请随意跳过本节。
如果您的项目找不到现有文件,则需要自己编写。有五个基本点需要了解:
- 文件名后缀必须是 .g4。
- 一行可以通过在其前面加上两个斜杠 (
//
) 来注释掉。 - 语法必须用
grammar name;
标识,其中name
是所需的标识符。 - 在标识语句之后,语法文件包含一系列规则定义。
- 语法标识和规则定义必须以分号结尾。
为了理解这些点,查看一个例子会有所帮助。列表 2 展示了 Expression.g4 的内容,该文件包含在附加的 Expression_grammar.zip 文件中。
列表 2:Expression.g4
grammar Expression;
// Parser rule
expr : '-' expr | expr ( '*' | '/' ) expr |
expr ( '+' | '-' ) expr | '(' expr ')' | INT | ID;
// Lexer rules
INT : [0-9]+;
ID : [a-z]+;
WS : [ \t\r\n]+ -> skip;
此文件将自身标识为 Expression
,然后定义了四个规则。至少,规则定义有一个名称和一个用冒号分隔的描述。其通用格式如下:
name : description;
ANTLR 规则的语法基于扩展巴克斯范式 (EBNF)。下面的讨论将介绍 EBNF,然后介绍解析器规则和词法分析器规则中的不同特性。
3.1 扩展巴克斯范式 (EBNF)
在 1950 年代,研究人员寻求描述编程语言语法的方法。在 IBM,John Backus 设计了一种元语言来描述新的 ALGOL 语言。Peter Naur 在此基础上进行了扩展,由此产生的符号系统被称为巴克斯范式 (BNF)。随着时间的推移,添加了新功能,最终形成了扩展巴克斯范式,或 EBNF。实质上,EBNF 是一种描述语言的语言。
根据 EBNF,规则的描述是一个或多个 string
的组合。如果 string
用逗号或空格分隔,它们就构成一个 序列。在这种情况下,如果每个 string
都按给定顺序出现,则满足该规则。以下规则定义了一个包含四个字母的序列:
NAME : "n", "a", "m", "e";
如果描述的 string
用竖线分隔,则当任何 string
出现时,规则都将有效。例如,如果任何 string
标识一个数字,则满足以下规则:
DIGIT : "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
如果两个 string
用连字符分隔,则表示一个值范围。以下规则查找任何字母字符,无论是大写还是小写。
ALPHA_CHAR : "a"-"z" | "A"-"Z"
字符串可以与标识其预期用途的标点符号关联。
- 如果一个符号用花括号括起来,如
{digit}
,它可以重复 0 次或更多次。 - 如果一个符号用方括号括起来,如
[digit]
,它可以重复 0 次或 1 次。
花括号、方括号和圆括号用于将符号分组,一组符号可以用于形成 子规则。
- 在组内,字符可以不加引号地标识。
- 如果一个组后面跟着一个问号,如
(x|y|z)?
,则该规则将匹配无或组中的任何替代项。 - 如果一个组后面跟着一个星号,如
(x|y|z)*
,则该规则将匹配无或组中任何重复的替代项。 - 如果一个组后面跟着一个加号,如
(x|y|z)+
,则该规则将匹配任何重复一次或多次的替代项。
问号、星号和加号也可以跟在单个符号后面。如果一个规则包含 "TEST"?
,则当存在零个或一个 "TEST"
实例时,它将匹配。
3.2 词法分析器规则
ANTLR 语法中的规则可以分为两组。词法分析器规则读取字符流并提取有意义的字符串(标记)。解析器规则使用词法分析器规则和其他解析器规则来获取文本的底层结构。
词法分析器规则以大写字母开头,而解析器规则以小写字母开头。以下来自 Expression.g4 的词法分析器规则提取 INT
标记。
INT : [0-9]+;
在 ANTLR 中,词法分析器规则可以访问解析器规则无法访问的特性。这些特性包括字符集、片段、词法分析器命令和特殊符号。
3.2.1 字符集
许多词法分析器规则将一个标记与一组字符(称为 字符集)关联起来。字符集是方括号内的一组字符。字符集遵循一套不同的语法规则:
- 字符集中的字符不需要用引号括起来。
- 字符集不使用竖线来表示替代项。如果一个集合包含一个字符序列,则如果存在任何字符,则该规则将被满足。
- 字符集中的空格表示空格字符。
- 特殊字符,如引号或括号,在字符集中必须用反斜杠 (\) 进行转义。
- 与其他 EBNF 组一样,字符集后面可以跟
?
、+
和*
以表示基数。 - ANTLR 为 Unicode 字符提供了特殊的格式规则。
例如,以下规则表明一个 ID 标记由一个或多个小写字母字符组成:
ID : [a-z]+;
本文将不解释词法分析器规则如何访问 Unicode 字符。有关更多信息,请访问 ANTLR 关于词法分析器规则的文档。
3.2.2 片段
如果词法分析器规则前面有 fragment
,它的行为就像一个常规的词法分析器规则,但它不定义一种新的标记类型。使用片段可以提高语法的可读性。
为了理解片段,假设您想定义一个表示科学计数法数字的标记,例如 6.023e-23。如果您不想为指数创建单独的标记,您可以使用片段:
SCI_NUMBER : ('+'|'-')? DIGIT+ '.' DIGIT* EXPONENT?;
fragment EXPONENT : ('e'|'E') ('+'|'-') ? DIGIT+;
fragment DIGIT : '0'-'9';
片段可以被词法分析器规则访问,但不能被解析器规则访问。片段的目标是提高提取标记的规则的可读性。
3.2.3 词法分析器命令
词法分析器命令告诉词法分析器对某些标记执行特殊处理。词法分析器命令由一个箭头 (->
) 后跟一个命令名称组成。
最简单和最常见的词法分析器命令是 skip
,它告诉词法分析器丢弃它找到的任何给定类型的标记。例如,以下规则定义了一个名为 WS
(空白)的标记,并告诉表达式词法分析器忽略它遇到的任何空白:
WS : [ \t\r\n]+ -> skip;
另一个流行的命令是 type(type_name)
,它更改规则生成的标记类型。例如,以下类型命令将规则更改为生成 STRING
标记而不是 INT
。
INT : [0-9]+ -> type(STRING);
3.2.4 特殊符号
词法分析器规则可以使用解析器规则不可用的特殊符号:
- 除了连字符 (-) 之外,两个值之间的范围可以用两个点 (
..
) 来标识。 - 集合包含可以用波浪号 (~) 运算符取反。
例如,如果一个词法分析器规则想要匹配除 a
、b
或 c
之外的字符,它可以使用符号 ~[a..c]*
。
3.3 解析器规则
解析器规则使用词法分析器规则和其他解析器规则来获取文本的底层结构。解析器规则无法访问字符集和片段等有趣功能。但是,解析器规则有两个方面不适用于词法分析器规则:启动规则和自定义异常处理。
3.3.1 启动规则和 EOF
就像每个 C++ 应用程序都以 main
函数开始一样,每个 ANTLR 解析器都有一个称为 启动规则 的解析器规则。这是解析器启动的第一个规则,它定义了文本的最高级别结构。
一个例子将有助于阐明这一点。假设您想编写一个解析诗歌的解析器。一首诗包含一行或多行,因此启动规则可能如下所示:
poem : line+ EOF;
EOF
标记由 ANTLR 提供,尽管它代表 文件结束,但它适用于任何文本源。此标记表示传入文本的结束。如果省略 EOF
,ANTLR 将尽可能多地读取行,如果无法解析一行,则放弃(无错误)。
通常将启动规则作为语法中的第一个规则。以下规则应从高到低进行,词法分析器规则紧随解析器规则之后。
3.3.2 自定义异常处理
如果 ANTLR 在处理规则时遇到语法错误,它会捕获异常,报告错误,并从规则中返回。可以通过在规则后面添加 catch
块来自定义此异常处理。其一般格式如下:
catch[ExceptionClass e] { process e }
可能的异常类包括 RecognitionException
、NoViableAltException
、InputMismatchException
和 FailedPredicateException
。这些都是 Java 的 RuntimeException
类的子类,因此可以使用 printStackTrace
、getMessage
和 toString
方法。
4. ANTLR 基本类
至此,您应该对语法文件如何识别语言约定有了基本的了解。在本节中,我们将开始研究依赖 ANTLR 生成类的编码应用程序。至少,解析应用程序需要执行四个步骤:
- 创建
ANTLRInputStream
以提供字符。 - 从输入流创建词法分析器实例(在本例中为
ExpressionLexer
)。 - 使用词法分析器实例创建
CommonTokenStream
以提供标记。 - 从标记流创建解析器实例(在本例中为
ExpressionParser
)。
如果您以前从未与 ANTLR 合作过,这些类可能会让人感到困惑。为了介绍它们,本节采用一种从简单到复杂的渐进方法。每个解析应用程序都从文本中提取标记开始,因此我将首先讨论 ANTLR 的标记类。
4.1 标记类
如前所述,词法分析器在文本中查找有意义的字符串并生成标记。ANTLR 的标记由 CommonToken
类的实例表示,该类是 Token
和 WritableToken
类的后代。图 1 说明了类层次结构。
图 1:标记类层次结构
一个 Token
可以通过其函数提供大量信息。表 1 列出了 Token
类的十个函数,所有这些函数都是纯虚函数。
函数 | 返回值 |
getType() | 标记的类型 |
getLine() | 包含标记的行号 |
getText() | 与标记关联的文本 |
getCharPositionInLine() | 标记第一个字符的行位置 |
getTokenIndex() | 标记在输入流中的索引 |
getChannel() | 将标记提供给解析器的通道 |
getStartIndex() | 标记的起始字符索引 |
getStopIndex() | 标记的最后一个字符索引 |
getTokenSource() | 指向创建标记的 TokenSource 的指针 |
getInputStream() | 指向提供标记字符的 CharStream 的指针 |
Token
的第一个子类是 WritableToken
,它提供了修改标记数据的方法。例如,setText()
更改标记的文本,setType()
更改标记的类型。
Token
和 WritableToken
类是纯虚类。标记层次结构中唯一的具体类是 CommonToken
。它为 Token
和 WritableToken
类中声明的函数提供代码。
4.2 流类
ANTLR 使用 流 传递数据。流不是集合,因此您无法像更新集合中的元素一样更新流的元素。此外,您不能通过索引访问流的元素。相反,流一次提供一个元素的访问(如果元素可用)。图 2 展示了 ANTLR 流类的层次结构。
图 2:流类层次结构
层次结构的顶部是 IntStream
类,它提供了访问流元素的功能。表 2 列出了其中七个函数,它们都是纯虚函数。
函数 | 描述 |
consume() | 访问并消耗当前元素 |
LA(ssize_t i) | 读取距离当前位置 i 个位置的元素 |
mark() | 返回一个标识流中位置的句柄 |
release(ssize_t marker) | 释放位置句柄 |
index() | 返回即将到来元素的索引 |
seek(ssize_t index) | 将输入游标设置到给定位置 |
size() | 返回流中的元素数量 |
在 IntStream
之下,接下来的类是 CharStream
和 TokenStream
。CharStream
通过 getText()
提供字符数据。TokenStream
通过 get()
提供 Token
,并通过 getText()
提供字符数据。
IntStream
和 CharStream
是虚类,因此应用程序通过创建 ANTLRInputStream
和 ANTLRFileStream
的实例来提供数据。ANTLRInputStream
类有五个公共构造函数:
ANTLRInputStream()
- 创建一个空的ANTLRInputStream
ANTLRInputStream(const std::string& input)
- 创建一个包含给定字符串的ANTLRInputStream
ANTLRInputStream(std::istream &stream)
- 创建一个包含给定输入流中字符的ANTLRInputStream
ANTLRInputStream(const char* data, size_t length)
- 创建一个包含指定字符的ANTLRInputStream
ANTLRInputStream(const std::string_view& input)
- 创建一个包含给定字符串视图的ANTLRInputStream
(适用于 C++17 及更高版本)
TokenStream
有两个子类:UnbufferedTokenStream
和 BufferedTokenStream
。它们之间的主要区别在于 BufferedTokenStream
提供了几种方法来提供 Token
向量。例如,getTokens()
返回流的所有标记,而 get(int start, int stop)
返回给定值之间的标记。
流层次结构中的最后一个类 CommonTokenStream
很重要,因为它提供 ANTLR 生成的解析器所需的 CommonToken
。CommonTokenStream
构造函数需要一个指向 TokenSource
实例(例如词法分析器)的指针。然后它被 Parser
构造函数接受以提供 CommonToken
。
4.3 词法分析器类
当您从 Expression.g4 语法生成代码时,您会找到两个重要的源文件:ExpressionLexer.h 和 ExpressionLexer.cpp。第一个声明了 ExpressionLexer
类,第二个提供了其函数的代码。ExpressionLexer
是 ANTLR 的 Lexer
类的子类,图 3 说明了词法分析器和解析器的类层次结构。
图 3:词法分析器/解析器类层次结构
作为输入,ExpressionLexer
构造函数接受一个指向包含要解析文本的 CharStream
的指针。如前所述,CharStream
是纯虚的,因此应用程序通常通过创建 ANTLRInputStream
的实例来提供文本。
如果您查看 main.cpp 文件,您会看到它使用以下代码创建 ExpressionLexer
:
antlr4::ANTLRInputStream input("6*(2+3)");
ExpressionLexer lexer(&input);
创建 ExpressionLexer
后,您可以调用其方法以获取有关词法分析器规则和标记的信息。例如,调用 getRuleNames()
返回一个包含 "T__0"
、"T__1"
、"T__2"
、"T__3"
、"T__4"
、"T__5"
、"INT"
、"ID"
和 "WS"
的向量。getTokenNames()
返回一个包含 <INVALID>
、'-'、'*'
、'/'
、'+'
、'('
、')'
、INT
、ID
和 WS
的向量。
大多数应用程序不调用词法分析器函数,而只是使用词法分析器来创建解析器。因为 ExpressionLexer
继承自 TokenSource
,所以它可以用来创建 CommonTokenStream
,然后可以用来创建 ExpressionParser
。这在以下代码中显示:
antlr4::CommonTokenStream commonStream(&lexer);
ExpressionParser parser(&commonStream);
4.4 解析器类
ANTLR 的 Parser
负有文本分析的总体责任。这个类提供了几个函数,我不会一次性列出所有函数,而是将它们分为四个类别:
- 与标记相关的函数
- 与解析树和解析树监听器相关的函数
- 与错误相关的函数
- 与规则和规则上下文相关的函数
本文将探讨这些类别中的函数。有关 Parser class
的更多信息,请访问 ANTLR 的官方文档。
4.4.1 标记函数
每个 Parser
都可以访问标记源并读取单个标记。表 3 中的函数使这成为可能:
函数 | 描述 |
consume() | 消耗并返回当前标记 |
getCurrentToken() | 返回当前标记 |
isExpectedToken(size_t symbol) | 检查当前标记是否具有给定类型 |
getExpectedTokens() | 在当前上下文中提供标记 |
isMatchedEOF() | 标识当前标记是 EOF |
createTerminalNode(Token* t) | 向树中添加新的终止节点 |
createErrorNode(Token* t) | 向树中添加新的错误节点 |
match(size_t ttype) | 如果标记匹配给定类型则返回该标记 |
matchWildcard() | 将当前标记作为通配符匹配 |
getInputStream() | 返回解析器的 IntStream |
setInputStream(IntStream* is) | 设置解析器的 IntStream |
getTokenStream() | 返回解析器的 TokenStream |
setTokenStream(TokenStream* ts) | 设置解析器的 TokenStream |
getTokenFactory() | 返回解析器的 TokenFactory |
reset() | 重置解析器的状态 |
这些函数中的大多数都易于理解。应用程序可以通过调用 consume()
从流中访问每个标记来控制解析过程。应用程序还可以通过调用 createTerminalNode()
和 createErrorNode()
来向解析器的树中添加节点。
4.4.2 解析树和监听器函数
解析器的一项主要工作是从输入文本创建解析树。正如接下来的文章将解释的那样,监听器使得以编程方式访问树的节点成为可能。表 4 列出了许多与解析树和监听器相关的 Parser
函数。
函数 | 描述 |
getBuildParseTree() | 检查在解析期间是否将构建解析树 |
setBuildParseTree(bool b) | 标识是否应构建解析树 |
getTrimParseTree() | 检查在解析期间是否修剪解析树 |
setTrimParseTree(bool t) | 标识在解析期间应修剪解析树 |
getParseListeners() | 返回包含解析器监听器的向量 |
addParseListener( ParseTreeListener* ptl) | 向解析器添加一个监听器 |
removeParseListener( ParseTreeListener* ptl) | 从解析器中删除一个监听器 |
removeParseListeners() | 从解析器中删除所有监听器 |
默认情况下,解析器会为源文本中的每个节点创建一个树。可以通过调用 setTrimParseTree()
并将其参数设置为 true 来配置此行为。这会告诉解析器使用 TrimToSizeListener
来确定应从处理中删除哪些节点。
4.4.3 错误函数
解析器会跟踪解析过程中检测到的每个错误。应用程序可以使用表 5 中的函数自定义错误处理。
函数 | 描述 |
getNumberOfSyntaxErrors() | 返回语法错误的数量 |
getErrorHandler() | 返回解析器的错误处理器 |
setErrorHandler(handler) | 设置解析器的错误处理器 |
notifyErrorListeners(string msg) | 向解析器的错误监听器发送消息 |
notifyErrorListeners(Token* t, string msg, exception_ptr e) | 向解析器的错误监听器发送数据 |
setErrorHandler
函数特别重要,因为它允许应用程序自定义异常的处理方式。它接受对 ANTLRErrorStrategy
的引用,我将在接下来的文章中进一步讨论这一点。
4.4.4 规则和上下文函数
在文本分析过程中,解析器会进入和退出语法的不同规则。与每个规则关联的信息称为规则上下文。表 6 列出了访问解析器规则和规则上下文的函数。
函数 | 描述 |
enterRule(ParserRuleContext* ctx, size_t state, size_t index) | 在规则进入时调用 |
exitRule() | 在规则退出时调用 |
triggerEnterRuleEvent() | 通知监听器规则进入 |
triggerExitRuleEvent() | 通知监听器规则退出 |
getRuleIndex(string rulename) | 标识给定规则的索引 |
getPrecedence() | 获取最顶层规则的优先级 |
getContext() | 返回当前规则的上下文 |
setContext(ParserRuleContext* ctx) | 设置解析器的当前规则 |
getInvokingContext(size_t index) | 返回调用当前上下文的上下文 |
getRuleInvocationStack() | 返回处理到当前规则的规则列表 |
getRuleInvocationStack( RuleContext* ctx) | 返回处理到给定规则的规则列表 |
setContext
函数告诉解析器处理特定的上下文。这在调试复杂的文本结构时非常有用。
5. 解析树和规则上下文
当 ANTLR 生成 Parser
类时,它会创建基于语法规则命名的函数。以词法分析器规则命名的函数返回 TerminalNode
指针,以解析器规则命名的函数返回 ParserRuleContext
指针。正如我们将看到的,ParserRuleContext
在 ANTLR 应用程序中非常重要。
要了解这些函数是什么样的,我建议您打开示例代码中的 ExpressionParser.h 头文件。它定义了两个以词法分析器规则(INT
和 ID
)命名的函数,它们返回 TerminalNode
指针。它还定义了一个 expr
函数,返回指向 ExprContext
的指针。图 4 展示了 ExprContext
类的继承层次结构。
图 4:ExprContext 继承层次结构
本节将介绍 ParseTree
、RuleContext
和 ParserRuleContext
类。一旦您理解了它们,您将能够更好地在应用程序中访问 ANTLR 生成的代码。
5.1 ParseTree 类
当解析器成功分析一段文本后,文本的结构可以表示为一棵树,其节点对应于语法的规则。这称为 解析树,图 5 显示了为表达式 6*(2+3) 生成的解析树。
图 5:示例应用程序的解析树
如图所示,树的顶部节点(根)对应于语法的第一个规则。底部节点(终端节点)标识与词法分析器规则匹配的标记。
如果您查看 ANTLR C++ 运行时中的代码,您会发现一个名为 tree 的文件夹,其中包含与解析树相关的代码。树相关的类包括 ParseTree
、TerminalNode
、ParseTreeVisitor
和 ParseTreeWalker
,它们都属于 antlr4::tree
命名空间。
尽管名为 ParseTree
,但它代表解析树的一个节点,而不是整个树。它有一个 parent
属性指向其父节点(根节点为 null),还有一个名为 children
的属性,它是一个 ParseTree
指针向量,用于标识其子节点(终端节点为空)。ParseTree
还有三个有用的函数:
accept(ParseTreeVisitor*)
- 允许访问者访问节点数据toString()
- 返回一个包含节点数据的string
toStringTree(Parser* parser, bool pretty = false)
- 返回一个包含整个树数据的string
本系列的第二篇文章将解释如何使用 ParseTreeVisitor
和 ParseTreeWalker
。本文中的示例代码调用 toStringTree
来显示已解析表达式的结构。
5.2 RuleContext 类
就像 ParseTree
代表树中的单个节点一样,RuleContext
代表单个规则的调用。每个 RuleContext
都有一个名为 invokingState
的属性,用于标识它是如何被调用的。根节点的 invokingState
始终为 -1。
RuleContext
类提供了一个每个开发人员都应该知道的函数:getText()
。它返回与给定规则相关的文本。如果您在解析器启动规则的上下文上调用 getText()
,它将返回所有文本。
5.3 ParserRuleContext 类
顾名思义,ParserRuleContext
是与解析相关的规则上下文。如果规则未成功完成,则 exception
属性将指向描述问题的 RecognitionException
。如果规则成功完成,exception
将为 null
。
每个 ParserRuleContext
都会跟踪上下文中的起始和结束标记。可以通过调用 getStart
和 getStop
来访问它们。应用程序还可以通过调用 getSourceInterval()
来找出文本的哪个部分对应于上下文。
5.4 ExprContext 类
ANTLR 生成 ExprContext
类作为 Expression.g4 中 expr
规则的上下文。如 main.cpp 代码所示,应用程序可以通过调用解析器的 expr
函数来获取 ExprContext
。生成的解析器为语法中的每个规则都有一个函数,调用该函数将返回相应的规则上下文。
在 ExpressionParser.h 中,ExprContext
类是用以下代码定义的:
class ExprContext : public antlr4::ParserRuleContext {
public:
ExprContext(antlr4::ParserRuleContext *parent, size_t invokingState);
virtual size_t getRuleIndex() const override;
std::vector<ExprContext *> expr();
ExprContext* expr(size_t i);
antlr4::tree::TerminalNode *INT();
antlr4::tree::TerminalNode *ID();
virtual void enterRule(antlr4::tree::ParseTreeListener *listener) override;
virtual void exitRule(antlr4::tree::ParseTreeListener *listener) override;
};
如语法所示,expr
节点可以由其他 expr 节点组成。这些其他节点可以通过 expr()
函数访问,该函数返回一个 ExprContext
指针向量。应用程序还可以通过 INT()
和 ID()
返回的 TerminalNode
指针访问表达式的标记。
最后两个函数允许监听器在解析器开始处理规则 (enterRule
) 和完成处理规则 (exitRule
) 时做出响应。本系列的第二篇文章演示了如何创建监听器以及 enterRule
和 exitRule
在实践中的使用方法。
6. 历史记录
- 2021年8月1日:提交文章版本 1.0
- 2021年8月4日:正确标识了语法的 zip 文件