# 编译原理 01 - 词法分析

词法分析: 输入(程序文本 / 字符串 s)--> 输出(词法单元流)

image-20230309110603730

# 词法分析器的三种设计方法(由易到难)

  1. 词法分析器生成器(如 ANTLR)
  2. 手写词法分析器
  3. 自动化词法分析器 (自己实现一个词法分析器生成器)

生产环境下的编译器(如 gcc)通常选择手写词法分析器

# antrl 的使用

输入: 词法单元的规约 -SimpleExpr.g4

输出:词法分析器 - SimpleExprLexer.java

​ SimpleExprLexer.java 编译后 接受输入文件 并输出 token 流

# .g4 文件的结构

第一行: grammar SimpleExpr ; 给接下来的文法起个名字 名字要与文件名一致

​ ** 如果文件里只包含词法部分 用 lexer grammar SysYLexer **

​ 每一行都要以分号结尾

@header {} 括号里的东西会自动拷贝到到 java 文件中

语法规则见下面示例

SimpleExpr.g4:

grammar SimpleExpr;

import SimpleExprRules;

@header{
package simpleexpr;
}

prog : stat* EOF ;

stat : expr ';'
     | ID '=' expr ';'
     | 'if' expr ';'
     ;

expr : expr ('*' | '/') expr
     | expr ('+' | '-') expr
     | '(' expr ')'
     | ID
     | INT
     | FLOAT
     ;
    // 到这里描述的其实还是语法结构

SimpleExprRules.g4:

lexer grammar SimpleExprRules;

SEMI : ';' ;
ASSIGN : '=' ;
IF : 'if' ;
MUL : '*' ;
DIV : '/' ;
ADD : '+' ;
SUB : '-' ;
LPAREN : '(' ;
RPAREN : ')' ;

ID : (LETTER | '_') WORD* ;
INT : '0' | ([1-9] DIGIT*) ;
FLOAT : INT '.' DIGIT*
      | '.' DIGIT+
      ;

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

//SL_COMMENT : '//' .*? '\n' -> skip ;
SL_COMMENT2 : '//' ~[\n]* '\n' -> skip;
DOC_COMMENT : '/**' .*? '*/' -> skip ;
ML_COMMENT : '/*' .*? '*/' -> skip ;

fragment LETTER : [a-zA-Z] ;
fragment DIGIT : [0-9] ;
fragment WORD : LETTER | DIGIT | '_' ;
//以上才是真正的词法部分

# 用编程方式使用 ANTLR 4 生成的 xxxlexer.java

package simpleexpr;

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

import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;

public class SimpleExprTest {
  public static void main(String[] args) throws IOException {
    System.out.println("SimpleExprTest ...");

    InputStream is = System.in;

    String file;
    if (args.length > 0) {
      file = args[0];
      is = new FileInputStream(file);
    }

    CharStream input = CharStreams.fromStream(is);
    //SimpleExprLexer 是.g4文件生成的java类 input必须是CharStream格式
    SimpleExprLexer lexer = new SimpleExprLexer(input);
	
    lexer.getAllTokens().forEach(System.out::println);
  }
}

# 正则表达式

基本的知识就不记录了 记录一些重要的

  • 非贪婪匹配 .*?
  • 反向引用 要引用的用()括起来,后面引用通过子表达式下标访问,如 \1,下标从 1 开始
  • look ahead ?<= 符号,表示前面字符要满足 xxx,但不匹配 xxx
  • look back ?= 符号,表示后面字符要满足 xxx,但不匹配 xxx

# NFA 与 DFA

NFA:非确定性自动机 DFA: 确定性有穷自动机(Deterministic Finite Automaton)

image-20230320200945838

区别:

  • NFA 简洁易于理解,便于描述语言 L (A)
  • DFA 易于判断 x 属于 L (A),适合产生词法分析器

​ 其中 L (A) 是使当前自动机能进入接受状态的输入集合

一般用 NFA 描述语言,用 DFA 实现词法分析器

实现词法分析器的步骤:RE --> NFA --> DFA --> 词法分析器

如图: 根据 DFA 生成 RE 不需要掌握

image-20230320201739378

# Thompson 构造法(从 RE 到 NFA)

image-20230315141912075

构造方法如下:

image-20230320203523098

image-20230320203542881

image-20230320203600728

image-20230320203629409

这里是 “一边做构造 一边做证明”,只要保证从最基本的情况开始,每一步 NFA 的构造都保证只会有唯一的开始状态和接受状态即可。综合所有情况下的构造方法,可以看到这一点是正确的。

image-20230320203705399

image-20230320203721076

image-20230315142444609

关于第三点中的运算符合运算分量 gpt 的解释:

正则表达式语言 r 的运算符和运算分量:

常见运算符:

  • |:或运算符,用于匹配两种情况中的一种。
  • ():分组运算符,用于将多个运算分量组合起来形成一个单元,进行整体匹配或量词修饰。
  • []:字符类运算符,用于匹配方括号内任意一个字符。
  • {}:重复运算符,用于指定运算分量重复出现的次数。

运算分量:

  • 字符:指定具体的字符,例如 a、b、c 等。
  • 元字符:具有特殊意义的字符,例如 *、?、+ 等。
  • 点号:匹配除换行符以外的任意字符。

这些运算符和运算分量可以组合在一起形成复杂的正则表达式,用于模式匹配和字符串搜索。

在每一步构造的时候,最多加一个起始状态,一个终止状态,总共进行了 | r | 次,所以第三点正确

举个例子:(a|b)*abb 的构造(考试会考)

image-20230315142551213

# 子集构造法 (从 NFA 到 DFA 的转换):

image-20230315142711571

举个例子: 可以看出简化了很多,并且遇到某一词法单元改走到那一步更加明确了

image-20230610165958021

从 0 号状态开始,通过 ϵ\epsilon 转移可以到达 1,2,4,7 因此这 5 个状态合为一个状态 A,然后看 A 状态下可以通过 a 字符和 b 字符转移到哪些状态,即下图的规则三,然后利用规则二和规则一求可到达状态的ϵ\epsilon 闭包,这个过程循环下去直到结束。

什么时候算结束?

只要 DFA 的某个状态所对应的 NFA 中的状态集合中含有接受状态,则 DFA 的这个状态是接受状态

image-20230610174226627

这里 E 对应的 NFA 状态中有 10 号状态,而 10 号状态是 NFA 的接受状态,所以 E 也是接受状态

三个基本规则如下 其中 s 是单个状态 T 是一个状态子集

这个过程一定会有一个终点

image-20230315143847385

原理如下:

image-20230315143949988

复杂度:NFA 有 n 个状态 DFA 最多有 2 的 n 次方个状态 指数爆炸

# DFA 最小化算法

问题一: 如何定义等价状态

想法一: 其中波浪号意为等价

​ 即 s 状态等价与 t 状态 当且仅当 任意 a 属于字母表 s 与 t 在 a 输入下发生转移后的状态是等价的

image-20230320210948252

但是这个定义是错误的 课件上有反例

反过来是正确做法:

核心思想做划分而非合并

接受状态与非接受状态必定不等价 ,然后接着划分,直到不能再分为止。每一步做迭代 对一个状态集合的任意两个状态,如果在字符 a 的驱动下跑到了不同的组,则这两个状态一定不等价。

做之前要补齐死状态。

因为这是所谓的 DFA 最小化算法 首先得保证最小化的是一个 DFA。比如下图中如果一开始只有红字部分,需要加上黄字部分补成一个 DFA。

image-20230622220426917

如果某个等价类包含初始状态,那么合并后这个等价类就是初始状态,如果某个等价类包含结束状态,那么合并后这个等价类就是合并状态。

image-20230610182322478

# 复杂度

太复杂了

# 从 DFA 得到词法分析器

需要消除死状态 ,避免徒劳消耗输入流

模拟运行该 DFA, 直到无法继续为止(输入结束或状态无转移):假设此时状态为 s,若 s 为接受状态,则识别成功,否则,回溯(包括状态与输入流)至最近一次经过的接受状态,识别成功;若没有经过任何接受状态,则报错(忽略第一个字符,重新开始)

用在词法分析器场景下的 DFA 的最小化第一步不同,所有的接受状态一定不等价

# 根据 DFA 得到 RE (非重点)

# lab 1

编程一小时 配置环境一天的典型代表。

# 实验输入

本次实验的输入是一个包含了 SysY 源代码的文件,你的程序需要接受一个文件名作为参数

# 实验内容

# Part1 词法分析

  • 本次实验你需要完成一个词法分析器对使用 SysY 语言书写的源代码进行词法分析,要求如下
    • 本次实验要求通过标准错误输出(stderr, 如 System.err 等), 打印程序的 所有 运行结果。
    • 包含词法错误时:对于包含词法错误的文件,你需要打印所有错误信息,格式为: Error type A at Line [lineNo]:[errorMessage] ,其中 lineNo 为出错的 token 首个字符所在行的行号, errorMessage 可自行定义,本实验不做要求,只要冒号前的信息正确即可。
    • 不包含词法错误时:对于没有任何词法错误的文件,你需要打印所有识别到的 Tokens 信息,具体输出格式可以参见样例一。特别要求:输出时忽略所有注释,对十六进制和八进制数字常量输出 token 文本时需输出其十进制的值

# 样例

输入

int main() 
{
   // line comment
   /* 
     block comment
   */
   int i = 0x1;
}

输出

INT int at Line 1.
IDENT main at Line 1.
L_PAREN ( at Line 1.
R_PAREN ) at Line 1.
L_BRACE { at Line 2.
INT int at Line 7.
IDENT i at Line 7.
ASSIGN = at Line 7.
INTEGER_CONST 1 at Line 7.
SEMICOLON ; at Line 7.
R_BRACE } at Line 8.

解释:

每行输出一个 token 的信息,输出格式为

[token类型] [token文本] at Line [此token首个字符所在行的行号].复制到剪贴板复制失败复制成功!

输出时忽略所有注释,对十六进制和八进制数字常量输出 token 文本时需输出其十进制的值

特别注意,遇到如 int 2i = 08; 这种输入时,请将 2i 识别为 INTEGER_CONSTIDENT08 识别为两个 INTEGER_CONST ,这种我们不认为是词法错误,这种错误将在后面的实验中处理

# 样例二

输入:

int main(){
  int i = 1;
  int j = ~i;
}复制到剪贴板复制失败复制成功!

输出:

Error type A at Line 3: Mysterious character "~".

# 实验过程

  • 第一个难题就是怎么在 windows 里的 IDEA 编程,但是运行和调试环境是虚拟机中的 ubuntu20.04+lab0 配置好的环境 想法就是用 IDEA 的 remote deployment 功能,在网上搜索教程后发现我的 IDEA 竟然没有这个功能,原因是我的是 community 版本,所以卸载了之前的 IDEA 装了专业版。然后 remote deployment 原理应该是使用 ssh 连接,虽然虚拟机是装在电脑里,但其实和与远程服务器相连原理是一样的。

  • 然后编写.g4 文件比较顺利,生成了 SysYlexer.java 文件,但是在 main 函数中使用 SysYlexer 类也遇见了困难,首先是导入 antlr,IDEA 一直报错无法解析 symbol antlr,但是 lab0 中我应该是配好了 antlr 环境的,不知道为什么,所以还是用 IDEA 的 libraries 中导入了 antlr 才好 (回来再看,IDEA 是可以导入本地的 jar 包的)

  • 第三个难题就是删除 SysYLexer 中自带的 ErrorListeners, 使用自己编写的 errorlisteners,这里蚂蚁老师上课应该是没有讲的,所以也是无从下手。后面借助搜索引擎和 ChatGPT 学了很久才会。原理很简单,先放上自己实现的 myErrorListener 如下:

    public static class myErrorListener extends BaseErrorListener{
    		public void syntaxError(Recognizer<?, ?> recognizer,
    								Object offendingSymbol,
    								int line, int charPositionInLine,
    								String msg,
    								RecognitionException e)
    		{
    			System.err.println("Error type A at Line "+line+": "+msg);
    			error = true;
    		}

    主要是重写了 syntaxError 这个函数,他里面的参数应该是报错相关的信息,这里只用到了 line(出错的行)和 msg(具体的报错信息),其它的是什么意思忘记了。应该是每出现一个错误就会调用一次 syntaxError 函数。

  • 然后放一下 main 函数部分:

    import org.antlr.v4.runtime.*;
    import java.io.IOException;
    import java.util.Collection;
    import java.util.List;
    public class Main
    {
    	public static boolean error = false;
    	public static void main(String[] args) throws IOException {
        	if (args.length < 1) {
    			System.err.println("input path is required");
    		}
    		String source = args[0];
    		CharStream input = CharStreams.fromFileName(source);
    		SysYLexer sysYLexer = new SysYLexer(input);
    		sysYLexer.removeErrorListeners();
    		sysYLexer.addErrorListener(new myErrorListener());
    		List<Token> tokens = (List<Token>) sysYLexer.getAllTokens();
    		if (error)
    			return;
    		String[] rulenames = sysYLexer.getRuleNames();
    		for (Token token : tokens) {
    			String tokenType = rulenames[token.getType()-1];
    			String tokenText = toDemical(token.getText());
    			String tokenLine = ""+token.getLine();
    			System.err.println(tokenType + " " + tokenText + " at Line "+tokenLine+'.');
    		}
        }
    	public static class myErrorListener extends BaseErrorListener{
    		public void syntaxError(Recognizer<?, ?> recognizer,
    								Object offendingSymbol,
    								int line, int charPositionInLine,
    								String msg,
    								RecognitionException e)
    		{
    			System.err.println("Error type A at Line "+line+": "+msg);
    			error = true;
    		}
    	}
    	public static String toDemical(String text) {
    		if(text.matches("0[0-7]+")) {
    			return String.valueOf(Integer.parseInt(text.substring(1),8));
    		} else if (text.matches("0[xX][0-9a-fA-F]+")) {
    			return String.valueOf(Integer.parseInt(text.substring(2),16));
    		} else {
    			return text;
    		}
    	}
    }

    反正做的时候除了文档里给的框架几乎每一行都想了很久。

  • 代码写完后上传又有问题,可能是之前 make compile , make clean 太多次了,导致压缩包超过了 10M 的限制,后来参考助教给的记一次删除 Git 记录中的大文件的过程 - HollisChuang's Blog 按里面的步骤一步步做才完成上传,比较幸运的是一次就 AC 了。(至于为什么压缩包会那么大,我的理解是改动了太多次代码 make compile 了太多次,且每次 git 都会保存版本信息以便于回退,所以改动的所有版本其实都还在 git 保存的隐藏文件夹下的,所以整个目录就会变得很大了)

更新于

请我喝[茶]~( ̄▽ ̄)~*

MikeMao 微信支付

微信支付

MikeMao 支付宝

支付宝

MikeMao 贝宝

贝宝