跳转至

Week 3 - Lexer 2

在使用ANTLR构建Lexer时你可能会遇到的特殊情况

最长匹配原则

现在我们来讨论一个有趣的问题。假设我们有以下三条词法规则: BE: '>=' BT: '>' ASSIGN: '='

那么,当源代码中出现 x >= 5 时,>= 会被如何识别?是两个独立的符号 >=,还是一个整体的 >=

在 ANTLR 中,默认遵循 最长匹配原则(Longest Match Principle)。这意味着 ANTLR 会尽可能地匹配更多的字符。虽然 >(一个字符)能匹配成功,但 >=(两个字符)也能匹配成功,并且长度更长。因此,ANTLR 会选择后者,将 >= 识别为一个 BE 类型的 Token。

非贪婪匹配

我们再来看一个更实际的例子。假设我们要为 Java 语言定义字符串的词法规则。我们知道,Java 中的字符串的格式是: 一对双引号包裹,中间可以包含任意字符

在正则表达式中,. 可以匹配任意单个字符,* 可以匹配任意次数。于是,我们很自然地写出 ".*"。但仔细想想,.* 是一个“贪婪”的匹配模式,它会尽可能多地匹配字符。如果输入是 "aaa" "bbb".* 在匹配完 "aaa 之后并不会停下,它会继续吞噬后面的 " 和空格,直到字符串的末尾。这显然不是我们想要的结果。

为了解决这个问题,我们需要 非贪婪匹配。通过在 * 后面加上一个 ?,我们得到 ".*?"。这个 ? 告诉 .* 不要那么“贪心”:你仍然可以匹配任意字符,但一旦遇到能够满足后续规则(在这里我们假设是 ")的字符时,就应该立刻停止匹配,将该字符让给后续规则。

对于 "aaa".* 会匹配 aaa。当遇到第一个 " 时,它发现这个字符可以被 .* 后面的 " 规则匹配,于是 .* 的匹配就先结束,等 " 匹配完第一个引号后再继续匹配后续字符。

当然,".*?" 还不完美,字符串内部出现双引号的情况它处理不好。在 Java 中,我们利用转义字符 \ 使字符串可以包含引号,例如 "a\"b"。你可以思考一下,如何修改正则表达式来支持这种字符串的识别。

空格和注释

在大多数编程语言中,空白字符(如空格、制表符、换行符)起到了分割词法单元的重要作用,但它们本身通常不参与语法分析。在编译过程中,我们既希望词法分析器能识别空白字符以实现 Token 的分割,又不希望这些空白字符被送入语法分析器,成为语法分析的负担。

否则,想象一下,因为空白字符可能出现在任意两个 Token 之间,我们在编写语法规则时就必须把所有可能的情况都考虑进去。例如,一条简单的规则:

expression: term ((ADD|SUB) term)* ;
可能就得被迫写成这样:
expression: term ( WS? (ADD|SUB) WS? term)*; // WS? 代表可选的空白
这会让语法规则变得异常复杂和混乱,极易出错。

为了解决这个问题,ANTLR 提供了一种简洁的机制:直接将某些 Token 识别后丢弃。你只需在规则定义后加上 -> skip 即可:

WS  : [ \t\r\n]+ -> skip ; 

与空白字符类似,注释也可能出现在代码中的任何地方,但我们通常也不希望它参与语法分析。你同样可以用 -> skip 的方式来处理注释。可以思考一下,单行注释和多行注释的词法规则应该如何定义。

注释你快回来!(可选)

如果我们的目标只是实现一个编译器,那么直接识别并丢弃注释通常是可行的。但假设我们想用 ANTLR 做一个代码翻译器,比如将一门旧语言的代码转换为新语言(是的,ANTLR 也能胜任这类工作,你可以将新语言看做是一种特别的中间表示),那么保留注释就变得至关重要了,否则翻译后的代码可读性会大大降低。

但我们同样不希望注释被送进语法分析器,增加语法分析负担。那么,有没有办法将这些被“跳过”的 Token(如注释、空格)存放在一个独立的“箱子”里,同时把有意义的 Token 放在另一个“箱子”里,然后只把后一个“箱子”里的内容交给语法分析器呢?

答案是肯定的。ANTLR 支持定义多个 词法通道(Channel)。这里的通道就像我们上面说的“箱子”。

需要注意的是,ANTLR 规定:如果在一个 .g4 文件中同时定义了词法规则和语法规则,就不能使用自定义通道。因此,你必须将词法规则和语法规则拆分到不同的文件中。实际上,将二者分离本身就是一种良好的实践,它提高了规则的复用性(比如让一个定义好的词法规则服务于多个语法规则),也让文件结构更清晰。

具体实现如下:

// 文件名:CalculatorLexer.g4
lexer grammar CalculatorLexer;

channels { WHITESPACE, COMMENTS }

INT : [0-9]+ ;
ADD : '+' ;
SUB : '-' ;
WS  : [ \t\r\n]+     -> channel(WHITESPACE);
SL_COMMENT : '//' .*? '\n' -> channel(COMMENTS);

首先,文件头部的 lexer grammar 声明了这是一个纯词法规则文件。同时,g4 文件的名称(CalculatorLexer.g4)必须与此处的语法名(CalculatorLexer)保持一致。

接着,我们通过 channels{...} 定义了两个自定义通道。

最后,我们将原本 -> skip 的规则改为了 -> channel(通道名称),这样匹配到的 Token 就会被发送到指定的通道中。默认情况下,未指定通道的 Token 会被发送到 默认通道(Default Channel) 传给语法分析器。

那么,我们该如何获取这些隐藏通道中的 Token 呢?ANTLR 在 CommonTokenStream 中提供了相关接口。

CharStream charStream = CharStreams.fromString("//comment\n1 + 2");
CalculatorLexer lexer = new CalculatorLexer(charStream);
CommonTokenStream tokens = new CommonTokenStream(lexer, CalculatorLexer.WHITESPACE);
tokens.fill();

// 示例:获取索引为1的Token右边的所有WHITESPACE通道的Token
List<Token> wsTokens = tokens.getHiddenTokensToRight(1, CalculatorLexer.WHITESPACE);
System.out.println(wsTokens);

List<Token> commentTokens = tokens.getHiddenTokensToLeft(1, CalculatorLexer.COMMENTS);
System.out.println(commentTokens);

getHiddenTokensToRight(tokenIndex, channelIndex) 这个函数的作用是:从 tokenIndex 对应的 Token 右侧 开始,收集所有属于 channelIndex 通道的 Token,直到遇到第一个不属于该通道的 Token 为止。getHiddenTokensToLeft 的作用则相反从左侧开始收集。

这里的 tokenIndex 指的是整个 Token 流中的索引, 也就是我图中最上面标注的从0到6的索引。

  • tokens.getHiddenTokensToRight(1, ...) 会从 tokenIndex 为1也就是 Token 1 的右边开始查找。
  • 同理tokens.getHiddenTokensToLeft(1, ...) 会从 1 的左边开始查找。

你可能会好奇,在实际应用中,我无法预知某个 Token 的具体索引,这个功能该怎么用?别着急,当我们学习到语法分析时,在遍历语法分析树的过程中,我们就可以方便地获取到每个节点的 Token,并利用这些接口来提取它周围的注释或空格,这个功能届时就会派上大用场。

词法歧义性

关键字还是标识符?

在大多数高级语言中,都存在一些 关键字(Keywords),如 ifelsewhile 等。这些关键字不能被用作标识符(如变量名)。

然而,我们定义的标识符规则,例如 IDENTIFIER: [a-zA-Z_][a-zA-Z0-9_]*,完全能够匹配上 if 这个字符串。我们不希望 if 被错误地识别为 IDENTIFIER。这该如何解决呢?

ANTLR 的处理方式非常简单:当一个字符串可以被多个词法规则匹配时,ANTLR 会优先选择在 .g4 文件中定义得更靠前的那条规则

因此,我们只需要将关键字的规则定义在标识符规则之前即可:

IF: 'if' ;
// ... 其他关键字
IDENTIFIER: [a-zA-Z_][a-zA-Z0-9_]* ;
当 ANTLR 遇到 if 字符串时,虽然 IFIDENTIFIER 都能匹配,但由于 IF 规则定义在前面,所以 if 会被正确地划分为 IF 类型的 Token。

练习

为下面的源代码编写一个 ANTLR Lexer,只识别以下 Token:

  • 关键字 (只用识别下面样例中存在的即可)

  • 标识符(IDENTIFIER)

  • 数字(NUMBER)

  • 字符串(STRING)

然后编写一个 Java 测试程序:

  • 从源文件读取内容

  • 使用 Lexer 生成 Token 流

  • 打印所有非空白 Token 的类型和文本

  • 统计空白字符(空格、制表符、换行符)的总数

int main() {
    int x = 42;
    String s = "Hello \"World\"!";
    if (x > 10) {
        x = x + 1;
    }
}

补充内容

补充内容

补充内容一般涉及高级的 ANTLR 特性。

我们将不会在上课时讲述补充内容,感兴趣的同学们可以课后自行研究。

Predicates in Lexer Rules

我们继续考虑上述词法歧义性的问题。有时,一个词是否是关键字,可能取决于上下文或编译环境。例如,enum 是在 Java 5 中才被引入的关键字。在 JDK 1.5 之前的版本中,enum 应该被视为一个合法的标识符。

我们能否实现一种机制,让某些词法规则在特定条件下生效,在其他条件下失效呢?

答案是肯定的,我们可以使用 ANTLR 的 谓词 (Predicate)来实现。

假设我们通过一个布尔变量 java5 来控制 enum 规则是否启用。当 java5true 时,ENUM: 'enum' 规则生效;为 false 时,该规则关闭,enum 将被识别为 IDENTIFIER

第一步,我们需要在生成的 Lexer.java 文件中注入一个成员变量 java5。在 .g4 文件中添加如下代码 (添加的位置为.g4 文件的顶部,与 parser/lexer 规则平级,不能写在任何规则内部):

lexer grammar CalculatorLexer;

@lexer::members {
    public boolean java5 = false; // 注入到生成的 CalculatorLexer.java 中的成员变量
}

// 下面继续写 parser 和 lexer 规则
...
@lexer::members 告诉 ANTLR,花括号内的代码将被直接复制到生成的词法分析器的类中作为成员。

之后,我们就可以在外部代码中控制这个变量:

JavaLexer lexer = new JavaLexer(charStream);
// 开启 enum 关键字支持
lexer.java5 = true; 

第二步,我们需要将这个变量与 ENUM 规则绑定。这需要用到 谓词

ENUM: 'enum' {java5}? ;
这个 {java5}? 就是一个谓词。它的含义是:只有当花括号内的布尔表达式为 true 时,这条词法规则才生效。否则,ANTLR 会忽略这条规则,仿佛它不存在一样。

事实上花括号里面可以是任意形式的布尔表达式,比如说你可以重新定义一个变量为java,它表示java的版本号,然后在括号里写java > 5

See also: https://github.com/antlr/antlr4/blob/dev/doc/predicates.md#predicates-in-lexer-rules

Lexical Modes

我们平时接触的高级语言,其源文件通常只包含一种语法。但也存在一些特殊的源文件,其中一部分是结构化语言,另一部分则是自由文本。典型的例子就是 XML 或 HTML。

在 HTML 文件中,标签(如 <div id="main">)内部的文本需要遵循严格的语法,而标签外部的文本(如 Hello World)则是任意字符串,不受语法限制。标签内部的结构化文本就像被自由文本的“海洋”包围的“孤岛”,因此这类语法被称为 孤岛语法(Island Grammar)

ANTLR 提供了 词法模式(Lexical Modes) 机制来优雅地处理这种情况。它允许你在一个词法文件中定义多套规则,并通过特定 Token 作为“开关”在不同模式间切换。同样,使用词法模式也要求将词法规则单独放在一个文件中。

// 默认模式 (DEFAULT_MODE)
// 看到 '<',进入 ISLAND 模式
OPEN: '<' -> mode(ISLAND);
TEXT: .*? ; // 匹配标签外的任意文本

// 声明 ISLAND 模式
mode ISLAND;
// 在 ISLAND 模式下,看到 '>',返回默认模式
CLOSE: '>' -> mode(DEFAULT_MODE); 
// 在 ISLAND 模式下的其他规则...
ID: [a-zA-Z]+ ;
EQ: '=' ;
STRING: '"' .*? '"' ;
  • -> mode(ISLAND) 指令告诉词法分析器,在匹配到 OPEN 这个 Token 后,立即切换到 ISLAND 模式。
  • mode ISLAND; 用于声明一个新的模式,其下的规则只在该模式下生效。
  • -> mode(DEFAULT_MODE) 则用于从当前模式返回到默认模式。DEFAULT_MODE 是 ANTLR 预定义的,无需手动声明。

通过这种方式,我们可以为“孤岛”和“海洋”分别定义不同的词法规则,使解析更加清晰和准确。

See also: - https://github.com/antlr/antlr4/blob/dev/doc/lexer-rules.md#lexical-modes - https://github.com/antlr/antlr4/blob/dev/doc/lexer-rules.md#mode-pushmode-popmode-and-more