自己动手写编译器:代码实现正则表达式到NFA状态机

Posted tyler_download

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了自己动手写编译器:代码实现正则表达式到NFA状态机相关的知识,希望对你有一定的参考价值。

在编译器开发中有两个非常重要的工具名为lex和yacc,他们是编译器的生成器。本质上我们不需要一行行去完成编译器的代码,只需要借助这两个工具,同时制定好词法解析和语法解析的规则后,这两个工具就会自动帮我们把代码生成,我们后续的任务就是使用go语言将这两个工具实现。

为了更好的理解我们要开发的GoLex,我们先熟悉一下lex工具的使用。在Centos上安装lex的命令为:

yum install flex

接下来我们看看它的使用。lex的作用主要是根据给定正则表达式,然后lex会把既定的正则表达式生成成对应的C语言代码,我们将生成的代码编译后就能得到可以针对输入进行相应识别的程序,我们看看一个具体例子。安装完flex后,我们在本地创建一个名为ch1-02.l的文件,然后输入内容如下:

%
   /*
   这里是一段直接拷贝到c文件的代码
    */
%
alpha  [a-zA-Z]
%%
[\\t]+  /*吸收所有空格*/
is |
am |
are |
where |
was |
be |
being |
been |
does |
did |
will |
would |
should |
can |
could |
has |
have |
had |
go  printf("%s: is a verb\\n", yytext);
alpha+ printf("%s: is not a verb\\n", yytext);
.|\\n   ECHO; /*normal default anywat*/
%%
int yywrap()return 1;
main() 
    yylex(); //启动识别过程

文件格式分为三部分,第一部分位于% %之间,这里面用于放置我们自己写的C语言代码,通常情况下是一些变量定义。在%和%%之间存放正则表达式的宏定义,例如“alpha [a-zA-Z]”。在%% %%之间则对应用于识别字符串的正则表达式,这里需要注意的是,在表达式后面还有一段C语言代码,他们会在字符串匹配上之后执行,因此这些代码也称为"Action"。

在第二个%%之后是我们编写的C语言代码,这里面我们定义了yywrap函数,这个函数是lex要求的回调,它的作用我们以后再考虑,在提交的main函数中,我们调用yylex函数启动对输入数据的识别过程,这个函数正是lex通过识别正则表达式,构造状态机,然后将后者转换为可执行代码后对应的函数。

有了上面文件后,我们执行如下命令:

lex ch1-02.l

完成后就会在本地生成一个名为lex.yy.c的文件,里面的代码非常复杂,同时这些代码完全由lex生成。最后我们用如下命令编译:

cc lex.yy.c

然后会在本地生成可执行文件a.out,执行a.out后程序运行起来,然后我们就可以输入相应字符串,如果对应字符串满足给定正则表达式,例如输入字符串中包含"should", “would"等单词时,对应的语句printf(”%s: is a verb\\n", yytext);就可以执行,情况如下图所示:

总结来说lex和yacc就是开发编译器的框架,他们负责识别和解析,然后把结果传递给我们提供的函数。接下来我们开发的GoLex就是我们当前介绍lex程序的go语言实现,同时我尝试把其中的C语言转换成python语言。

首先我们创建GoLex工程,然后在其中创建nfa文件夹,然后执行如下命令初始化:

go mod init nfa
go mod tidy

首先我们看看对应的.l文件,创建input.l,输入内容如下:

%
    FCON = 1
    ICON = 2
%
D  [0-9]
%%
(eD+)?
%%

我们将读取上面内容并根据给定的正则表达式创建NFA状态机。我们先在里面创建一些支持类功能的代码,第一个事debugger.go文件,它用来输出函数的调用信息,其内容如下:

package nfa

import (
	"fmt"
	"strings"
)

type Debugger struct 
	level int


var DEBUG *Debugger

func newDebugger() *Debugger 
	return &Debugger
		level: 0,
	


func DebuggerInstance() *Debugger 
	if DEBUG == nil 
		DEBUG = newDebugger()
	

	return DEBUG


func (d *Debugger) Enter(name string) 
	s := strings.Repeat("*", d.level*4) + "entering: " + name
	fmt.Println(s)
	d.level += 1


func (d *Debugger) Leave(name string) 
	d.level -= 1
	s := strings.Repeat("*", d.level*4) + "leaving: " + name
	fmt.Println(s)

debugger的功能就是显示出函数嵌套调用的次序,前面我们实现的编译器语法解析部分,函数会层级调用,因此有效显示出调用信息会帮助我们更好的查找实现逻辑中的Bug,它展示的信息在我们上一节展示过,当函数嵌套时,被调用函数的输出相对于符函数,它会向右挪到四个字符的位置。

接下来我们实现错误输出功能,创建文件parse_error.go,其内容如下:

package nfa

type ERROR_TYPE int

const (
	E_BADREXPR ERROR_TYPE = iota //表达式字符串有错误
	E_PAREN                      //少了右括号
	E_LENGTH                     //正则表达式数量过多
	E_BRACKET                    //字符集没有以[开始
	E_BOL                        // ^ 必须出现在表达式字符串的起始位置
	E_CLOSE                      //*, +, ? 等操作符前面没有表达式
	E_STRINGS                    //action 代码字符串过长
	E_NEWLINE                    //在双引号包含的字符串中出现回车换行
	E_BADMAC                     //表达式中的宏定义少了右括号
	E_NOMAC                      //宏定义不存在
	E_MACDEPTH                   //宏定义嵌套太深
)

type ParseError struct 
	err_msgs []string


func NewParseError() *ParseError 
	return &ParseError
		err_msgs: []string
			"MalFormed regular expression",
			"Missing close parenthesis",
			"Too many regular expressions or expression too long",
			"Missing [ in character class",
			"^ must be at start of expression",
			"Newline in quoted string, use \\\\n instead",
			"Missing  in macro expansion",
			"Macro doesn't exist",
			"Macro expansions nested too deeply",
		,
	


func (p *ParseError) ParseErr(errType ERROR_TYPE) 
	panic(p.err_msgs[int(errType)])


上面代码的目的是,在解析过程中发现错误时,我们打印对应的错误信息。接下来我们需要做的是识别输入文件中的宏定义,因此创建文件macro.go,里面实现代码如下:

package nfa

import (
	"errors"
	"fmt"
	"strings"
)

type Macro struct 
	//例如  "D  [0-9]" 那么D就是宏定义的名称,[0-9]就是内容
	Name string
	Text string


type MacroManager struct 
	macroMap map[string]*Macro


var macroManagerInstance *MacroManager

func GetMacroManagerInstance() *MacroManager 
	if macroManagerInstance == nil 
		macroManagerInstance = newMacroManager()
	

	return macroManagerInstance


func newMacroManager() *MacroManager 
	return &MacroManager
		macroMap: make(map[string]*Macro),
	


func (m *MacroManager) PrintMacs() 
	for _, val := range m.macroMap 
		fmt.Sprintf("mac name: %s, text %s: ", val.Name, val.Text)
	


func (m *MacroManager) NewMacro(line string) (*Macro, error) 
	//输入对应宏定义的一行内容例如 D [0-9]
	line = strings.TrimSpace(line)
	nameAndText := strings.Fields(line)
	if len(nameAndText) != 2 
		return nil, errors.New("macro string error ")
	

	/*
		如果宏定义出现重复,那么后面的定义就直接覆盖前面
		例如 :
		D  [0-9]
		D  [a-z]
		那么我们采用最后一个定义也就是D被扩展成[a-z]
	*/
	macro := &Macro
		Name: nameAndText[0],
		Text: nameAndText[1],
	

	m.macroMap[macro.Name] = macro
	return macro, nil


func (m *MacroManager) ExpandMacro(macroStr string) string 
	/*
			输入: D, 然后该函数将其转换为[0-9]
		    左括号会被调用函数去除
	*/
	valid := false
	macroName := ""
	for pos, char := range macroStr 
		if char == '' 
			valid = true
			macroName = macroStr[0:pos]
			break
		
	

	if valid != true 
		NewParseError().ParseErr(E_BADREXPR)
	

	macro, ok := m.macroMap[macroName]
	if !ok 
		NewParseError().ParseErr(E_NOMAC)
	

	return macro.Text


上面代码实现的目的是识别宏定义,将其存储在一个map中,后面在解析正则表达式时,一旦遇到宏定义,例如我们定义了宏定义:

D  [0-9]

然后在后续表达式中遇到宏定义时,例如:

(eD+)?

当读取到D时,我们会将其替换成[0-9],这个替换功能正是由ExpandMacro函数来实现。下面我们先定义状态机节点对应的数据结构,创建nfa.go,实现代码如下:

package nfa

type EdgeType int

const (
	EPSILON EdgeType = iota //epsilon 边
	CCL                     //边对应输入是字符集
)

type Anchor int

const (
	NONE  Anchor = iota
	START        //表达式开头包含符号^
	END          //表达式末尾包含$
	BOTH         //开头包含^同时末尾包含$
)

var NODE_STATE int = 0

type NFA struct 
	edge   EdgeType
	bitset map[string]bool //边对应的输入是字符集例如[A-Z]
	state  int
	next   *NFA //一个nfa节点最多有两条边
	next2  *NFA
	accept string //当进入接收状态后要执行的代码
	anchor Anchor //表达式是否在开头包含^或是在结尾包含$


func NewNFA() *NFA 
	node := &NFA
		edge:   EPSILON,
		bitset: make(map[string]bool),
		next:   nil,
		next2:  nil,
		accept: "",
		state:  NODE_STATE,
		anchor: NONE,
	

	NODE_STATE += 1
	return node

其中需要关注的是biteset,它用来实现字符串,如果一条边对应输入为[a-zA-Z],那么我们就把字符当做键,true当做值插入到这个map,我们还可以执行取反操作,例如[^a-zA-Z]表示匹配不是字母的字符,这样我们把值设置成false即可。

接下来我们看两个最关键,也是实现难度最大的组件,第一个类似于我们前面实现的lexer,它负责从输入文件中读取数据,然后将读到的字符转换成对应的token,与前面章节不同的是,我们这次一次只读取一个字符进行分析,创建名为LexReader.go文件,由于它的内容比较多,我们一点点展开,首先看其定义部分:

package nfa

import (
	"bufio"
	"fmt"
	"os"
	"strings"
	"unicode"
)

type TOKEN int

const (
	ASCII_CHAR_COUNT = 256
)

/*
我们需要对正则表达式字符串进行逐个字符解析,每次读取一个字符时将其转换成特定的token,
这里将不同字符对应的token定义出来
*/
const (
	EOS          TOKEN = iota //读到一行末尾
	ANY                       // .
	AT_BOL                    //^
	AT_EOL                    //$
	CCL_END                   // ]
	CCL_START                 // [
	CLOSE_CURLY               // 
	CLOSE_PARAN               // )
	CLOSURE                   //*
	DASH                      //-
	END_OF_INPUT              // 文件末尾
	L                         //字符常量
	OPEN_CULY                 //
	OPEN_PAREN                //(
	OPTIONAL                  // ?
	OR                        // |
	PLUS_CLOSE                // +
)

type LexReader struct 
	Verbose        bool   //打印辅助信息
	ActualLineNo   int    //当前读取行号
	LineNo         int    //如果表达式有多行,该变量表明当前读到第几行
	InputFileName  string //读取的文件名
	Lexeme         int    //当前读取字符对应ASCII的数值
	inquoted       bool   //读取到双引号之一
	OutputFileName string
	lookAhead      uint8          //当前读取字符的数值
	tokenMap       []TOKEN        //将读取的字符对应到其对应的token值
	currentToken   TOKEN          //当前字符对应的token
	scanner        *bufio.Scanner //用于读取输入文件,我们需要一行行读取文件内容
	macroMgr       *MacroManager
	currentInput   string   //当前读到的行
	IFile          *os.File //读入的文件
	OFile          *os.File //写出的文件
	lineStack      []string //用于对正则表达式中的宏定义进行展开
	inComment      bool     //是否读取到了注释内容


func NewLexReader(inputFile string, outputFile string) (*LexReader, error) 
	reader := &LexReader
		Verbose:        true,
		ActualLineNo:   0,
		LineNo:         0,
		InputFileName:  inputFile,
		OutputFileName: outputFile,
		Lexeme:         0,
		inquoted:       false,
		currentInput:   "",
		currentToken:   EOS,
		lineStack:      make([]string, 0),
		macroMgr:       GetMacroManagerInstance(),
		inComment:      false,
	

	var err error
	reader.IFile, err = os.Open(inputFile)
	reader.OFile, err = os.Create(outputFile)
	if err == nil 
		reader.scanner = bufio.NewScanner(reader.IFile)
	
	reader.initTokenMap()

	return reader, err


func (l *LexReader) initTokenMap() 
	l.tokenMap = make([]TOKEN, ASCII_CHAR_COUNT)
	for i := 0; i < len(l.tokenMap); i++ 
		l.tokenMap[i] = L
	
    //将对应字符对应到相应的token值
	l.tokenMap[uint8('$')] = AT_EOL
	l.tokenMap[uint8('(')] = OPEN_PAREN
	l.tokenMap[uint8(')')] = CLOSE_PARAN
	l.tokenMap[uint8('*')] = CLOSURE
	l.tokenMap[uint8('+')] = PLUS_CLOSE
	l.tokenMap[uint8('-')] = DASH
	l.tokenMap[uint8('.')] = ANY
	l.tokenMap[uint8('?')] = OPTIONAL
	l.tokenMap[uint8('[')] = CCL_START
	l.tokenMap[uint8(']')] = CCL_END
	l.tokenMap[uint8('^')] = AT_BOL
	l.tokenMap[uint8('')] = OPEN_CULY
	l.tokenMap[uint8('|')] = OR
	l.tokenMap[uint8('')] = CLOSE_CURLY

接下来我们要实现的函数,就是将输入函数中% %对应部分直接拷贝到输出文件,我们使用Head函数来实现,其代码如下:

func (l *LexReader) Head() 
	/*
		读取和解析宏定义部分
	*/
	transparent := false
	for l.scanner.Scan() 
		l.ActualLineNo += 1
		l.currentInput = l.scanner.Text()
		if l.Verbose 
			fmt.Printf("h%d: %s\\n", l.ActualLineNo, l.currentInput)
		

		if l.currentInput[0] == '%' 
			if l.currentInput[1] == '%' 
				//头部读取完毕
				l.OFile.WriteString("\\n")
				break
			 else 
				if l.currentInput[1] == '' 
					//拷贝头部代码
					transparent = true
				 else if l.currentInput[1] == '' 
					//头部代码拷贝完毕
					transparent = false
				 else 
					err := fmt.Sprintf("illegal directive :%s \\n", l.currentInput[1])
					panic(err)
				
			
		 else if transparent || l.currentInput[0] == ' ' 
			l.OFile.WriteString(l.currentInput + "\\n")
		 else 
			//解析宏定义
			l.macroMgr.NewMacro(l.currentInput)
			l.OFile.WriteString("\\n")
		
	

	if l.Verbose 
		//将当前解析的宏定义打印出来
		l.printMacs()
	


func (l *LexReader) printMacs() 
	l.macroMgr.PrintMacs()


func (l *LexReader) Match(t TOKEN) bool 
	return l.currentToken == t

在Head函数中,它首先一行行读取输入文件的内容,然后对输入内容进行识别,如果发现读入的行中出现"%%",这意味着读取任务结束,也就是输入文件的头部内容已经被读取完毕。如果首先读取到符号%,然后接着读取到,那么我们进入拷贝模式,也就是把所有内容拷贝到输出文件,直到遇到符号 %为止。一旦读取到%之后,代码进入到宏定义的识别过程,所有内容在读取符号%%之前都对应宏定义,此时每读取一行内容都是宏定义,代码讲读取的内容输入到l.macroMgr.NewMacro(l.currentInput),它会将读取内容分解成宏定义的名称和内容然后存储起来。

接下来我们进入到最为复杂的一个函数,它一次读取一个字符,然后将字符转换成对应token,它之所以复杂和繁琐就是因为要处理一些特殊情况,我们先看代码实现:

func (l *LexReader) Advance() TOKEN 
	/*
			一次读取一个字符然后判断其所属类别,麻烦在于处理转义符和双引号,
		   如果读到 "\\s"那么我们要将其对应到空格
	*/
	sawEsc := false //释放看到转义符
	parseErr := NewParseError()
	macroMgr := GetMacroManagerInstance()

	if l.currentToken == EOS 
		if l.inquoted 
			parseErr.ParseErr(E_NEWLINE)
		

		l.currentInput = l.GetExpr()
		if len(l.currentInput) == 0 
			l.currentToken = END_OF_INPUT
			return l.currentToken
		
	

	/*
		在解析正则表达式字符串时,我们会遇到宏定义,例如:
		  D*D
		当我们读取到最左边的时,我们需要将D替换成[0-9],此时我们需要先将后面的字符串加入栈,
		也就是将字符串"*D"放入lineStack,然后讲D转换成[0-9],接着解析字符串"[0-9]",
		解析完后再讲原来放入栈的字符串拿出来继续解析
	*/
	for len(l.currentInput) == 0 
		if len(l.lineStack) == 0 
			l.currentToken = EOS
			return l.currentToken
		 else 
			l.currentInput = l.lineStack[len(l.lineStack)-1]
			l.lineStack = l.lineStack[0 : len(l.lineStack)-1]
		
	

	if !l.inquoted 
		for l.currentInput[0] == ''  //宏定义里面可能还会嵌套宏定义
			//此时需要展开宏定义
			l.currentInput = l.currentInput[1:]
			expandedMacro := macroMgr.ExpandMacro(l.currentInput)
			var i int
			for i = 0; i < len(l.currentInput); i++ 
				if l.currentInput[i] == '' 
					break
				
			
			l.lineStack = append(l.lineStack, l.currentInput[i+1:])
			l.currentInput = expandedMacro
		
	

	if l.currentInput[0] == '"' 
		l.inquoted = !l.inquoted
		l.currentInput = l.currentInput[1:]
		if len(l.currentInput) == 0 
			l.currentToken = EOS
			return l.currentToken
		
	

	sawEsc = l.currentInput[0] == '\\\\'
	if !l.inquoted 
		if l.currentInput[0] == ' ' 
			l.currentToken = EOS
			return l.cu

以上是关于自己动手写编译器:代码实现正则表达式到NFA状态机的主要内容,如果未能解决你的问题,请参考以下文章

自己动手写编译器:从正则表达式到NFA状态机

自己动手写编译器:从正则表达式到NFA状态机

自己动手写编译器:使用NFA状态机识别字符串

自己动手写编译器:使用NFA状态机识别字符串

自己动手写编译器:使用NFA识别字符串

自己动手写编译器:使用NFA识别字符串