2. 打造简易词法分析器

234 阅读5分钟

词法分析器

词法分析的工作是一边读取一边识别字符串的,不是把字符串都读到内存再识别。你在听别人讲话的时候,也是一边听,一边提取信息。

字符串是一连串的字符形成的,怎么把它断开成一个个的 Token 呢?分割的依据是什么呢?

其实,我们手工打造词法分析器的过程,就是写出正则表达式,画出有限自动机的图形,然后根据图形直观地写出解析代码的过程。而我今天带你写的词法分析器,能够分析以下 3 个程序语句:

  • age >= 45
  • int age = 40
  • 2+3*5

它们分别是关系表达式、变量声明和初始化语句,以及算术表达式。

解析 age >= 45

在上一讲,我举了一个词法分析的例子,并且提出词法分析要用到有限自动机。 image.png
标识符、比较操作符和数字字面量这三种 Token 的词法规则。

  • 标识符: 第一个字符必须是字母,后面的字符可以是字母或数字。
  • 比较操作符: > 和 >= 以及其他比较操作符。
  • 数字字面量: 全部由数字构成(像带小数点的浮点数,暂时不管它)。

我们就是依据这样的规则,来构造有限自动机的。词法分析程序在遇到 age、>= 和 45 时,会分别识别成标识符、比较操作符和数字字面量。一个严格意义上的有限自动机是下面这种画法:

image.png

解释一下上图的 5 种状态。

  1. 初始状态: 刚开始启动词法分析的时候,程序所处的状态。
  2. 标识符状态:在初始状态时,当第一个字符是字母时,迁移到状态 2。当后续字符是字母和数字时,保留在状态 2。如果不是,就离开状态 2,写下该 Token,回到初始状态。
  3. 大于操作符(GT): 在初始状态时,当第一个字符是 > 时,进入这个状态。它是比较操作符的一种情况。
  4. 大于等于操作符(GE):如果状态 3 的下一个字符是 =,就进入状态 4,变成 >=。它也是比较操作符的一种情况。
  5. 数字字面量:在初始状态时,下一个字符是数字,进入这个状态。如果后续仍是数字,就保持在状态 5。

上图中的圆圈有单线的也有双线的。双线的意思是这个状态已经是一个合法的 Token 了,单线的意思是这个状态还是临时状态。

简易实现

可以先看完整篇文章再动手实现。代码是完整的,直接拼接起来就可以跑

1. token

type Token struct {
	Tokentype TokenType
	Text      string
}

func NewToken() Token {
	return Token{}
}

func (t *Token) getType() TokenType {
	return t.Tokentype
}

func (t *Token) getText() string {
	return t.Text
}

它有两个属性,一个是token的类型,一个则是token的文本值

token的类型如下:

type TokenType string

const(
    TtPlus TokenType = "Plus"
    TtMinus TokenType =  "Minus"// -
    TtStar TokenType  =  "Star" // *
    TtSlash TokenType =  "Slash" // /
    TtGE  TokenType  =  "GE"  // >=
    TtGT TokenType   =  "GT"  // >
    TtEQ  TokenType  =  "EQ"  // ==
    TtLE TokenType  =  "lE"   // <=
    TtLT  TokenType  =  "LT"  // <
    TtSemiColon TokenType = "SemiColon" // ;
    TtLeftParen TokenType = "LeftParen" // (
    TtRightParen TokenType = "RightParen" // )
    TtAssignment TokenType = "Assignment" // =
    TtIf TokenType = "If" 
    TtElse TokenType = "Else" 
    TtInt TokenType = "Int" 
    TtIdentifier TokenType = "Identifier"     //标识符
    TtIntLiteral  TokenType = "IntLiteral"    //整型字面量
    TtStringLiteral TokenType = "StringLiteral"   //字符串字面量
)

例如一个c语言语句: int a = 1;

则它的分析出的第一个token类别为Int,文本值为int

2. tokenReader

type TokenReader struct{
	tokens []Token
	pos int 
}

func NewTokenReader(tokens []Token)*TokenReader{
	return &TokenReader{tokens, 0}
}

func (tr *TokenReader) read() *Token {// 返回Token流中下一个Token,并从流中取出。 如果流已经为空,返回nilfunc
	if tr.pos < len(tr.tokens){
		tr.pos++
		return &tr.tokens[tr.pos- 1]	
	}
	return nil
}

我们打造一个token流来存储并读取token,当词法分析器分析字符串时会将拆出的token存入token reader,到时我们再调用read函数即可。

3. 判别函数

由于我们只是做词法分析器的一部分,只需要判断字符,数字和空白即可

func isAlpha(ch byte)bool {
	return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z';
}

func isDigit(ch byte)bool{
	return ch >= '0' && ch <= '9';
}

func isBlank(ch byte)bool{
	return ch == ' ' || ch == '\t' || ch == '\n';
}

4. 有限状态机的状态

用常量iota去声明所需状态

type DfaState uint16
const (
	LexInitial DfaState = iota
	LexIf 
	LexId_if1
	LexId_if2
	LexElse 
	LexId_else1
	LexId_else2 
	LexId_else3
	LexId_else4
	LexInt
	LexId_int1
	LexId_int2
	LexId_int3
	LexId
	LexGT
	LexGE
	LexAssignment 
	LexPlus 
	LexMinus
	LexStar
	LexSlash
	LexSemiColon 
    LexLeftParen 
	LexRightParen 
	LexIntLiteral 
)

5. 全局变量

通过全局变量对解析中token相关数据进行保存,最后tokens被组装成tokenReader并返回

var (
	token Token	//当前正在解析的Token
	tokens []Token //保存解析出来的Token 
	tokenText = strings.Builder{}  // 临时保存token的文本
)

6. 初始状态有限状态机的判别

进入初始状态有两种可能:

  1. 程序启动时有限状态机进入初始状态。初始状态并不停留,它马上进入其他状态。
  2. 解析时进入初始状态;某个Token解析完毕,也进入初始状态,在这里把Token记下来,然后建立一个新的Token。
func initToken(ch byte)DfaState{
	if tokenText.Len() > 0{//开始时text是空的,后面每次解析完一个token都将append进tokens
		token.Text = tokenText.String()
		tokens = append(tokens, token)
                // 重新初始化
		tokenText = strings.Builder{}
		token = NewToken()
	}

	newState := LexInitial
	if isAlpha(ch){ // 第一个字符是字母
		if ch == 'i'{
			newState = LexId_int1
		}else{
			newState = LexId
		}
		token.Tokentype = TtIdentifier
		tokenText.WriteByte(ch)
	}else if isDigit(ch){
		newState = LexIntLiteral
		token.Tokentype = TtIntLiteral
		tokenText.WriteByte(ch)
	}else if ch == '>'{
		newState = LexGT
		token.Tokentype = TtGT
		tokenText.WriteByte(ch)
	}else if ch == '+'{
		newState = LexPlus
		token.Tokentype = TtPlus
		tokenText.WriteByte(ch)
	}else if ch == '-'{
		newState = LexMinus
		token.Tokentype = TtMinus
		tokenText.WriteByte(ch)
	}else if ch == '*'{
		newState = LexStar
		token.Tokentype = TtStar
		tokenText.WriteByte(ch)
	}else if ch == '/'{
		newState = LexSlash
		token.Tokentype = TtSlash
		tokenText.WriteByte(ch)
	}else if ch == ';'{
		newState = LexSemiColon
		token.Tokentype = TtSemiColon
		tokenText.WriteByte(ch)
	}else if ch == '('{
		newState = LexLeftParen
		token.Tokentype = TtLeftParen
		tokenText.WriteByte(ch)
	}else if ch == ')'{
		newState = LexRightParen
		token.Tokentype = TtRightParen
		tokenText.WriteByte(ch)
	}else if ch == '='{
		newState = LexAssignment
		token.Tokentype = TtAssignment
		tokenText.WriteByte(ch)
	}else{
		newState = LexInitial
	}
	return newState
}

7. 有限状态机

func tokenize(code string)*TokenReader{
	tokens = []Token{} // 对全局tokens进行初始化
	var ch byte
	state := LexInitial
	for i := 0; i < len(code); i++{ // 遍历code
		ch = code[i]
		switch state{
			case LexInitial:
				state = initToken(ch)
			case LexId:
				if isAlpha(ch) || isDigit(ch){ //保持标识符状态
					tokenText.WriteByte(ch)
				}else{
					state = initToken(ch);      //退出标识符状态,并保存Token
				}
			case LexGT:
				if ch == '='{
					token.Tokentype = TtGE
					state = LexGE
					tokenText.WriteByte(ch)
				}else{
					state = initToken(ch);      //退出GT状态,并保存Token
				}
			case LexGE:
				fallthrough
			case LexAssignment:
				fallthrough
			case LexPlus:
				fallthrough
			case LexMinus:
				fallthrough
			case LexStar:
				fallthrough
			case LexSlash:
				fallthrough
			case LexSemiColon:
				fallthrough
			case LexLeftParen:
				fallthrough
			case LexRightParen:
				state = initToken(ch);          //退出当前状态,并保存Token
			case LexIntLiteral:
				if isDigit(ch){
					tokenText.WriteByte(ch) //继续保持在数字字面量状态
				}else {
					state = initToken(ch);      //退出当前状态,并保存Token
				}
			case LexId_int1:
				if ch == 'n'{
					state = LexId_int2
					tokenText.WriteByte(ch)
				}else if isDigit(ch) || isAlpha(ch){
					state = LexId
					tokenText.WriteByte(ch)
				}else{
					state = initToken(ch)
				}
			case LexId_int2:
				if ch == 't'{
					state = LexId_int3
					tokenText.WriteByte(ch)
				}else if isDigit(ch) || isAlpha(ch){
					state = LexId
					tokenText.WriteByte(ch)
				}else{
					state = initToken(ch)
				}
			case LexId_int3:
				if isBlank(ch){
					token.Tokentype = TtInt
					state = initToken(ch)
				}else{
					state = LexId
					tokenText.WriteByte(ch)
				}
		}
	}
	if tokenText.Len() > 0{// 如果text还有字符说明还有token没有被加入tokens,进入函数中加入并返回
		initToken(ch)
	}
	return NewTokenReader(tokens)
}

8. tokenReader的输出

执行tokenReader的read函数依次读取token并打印他们的文本和类型

func dump(tr *TokenReader){
	fmt.Println("text\ttype")
	token := tr.read()
	for token  != nil{
		fmt.Println(token.getText() + "\t" + string(token.getType()))
		token = tr.read()
	}
}

9. 执行

func main(){
	script := "int age = 45;"
	fmt.Printf("parse %s \n", script)
	tokenReader := tokenize(script)
	dump(tokenReader)
}

image.png

问题

刚刚在看代码的过程中不知道你有没有发现有限状态机的状态中有三个怪怪的状态

LexId_int1 
LexId_int2 
LexId_int3

你有没有想过,int这个字符串既可能是标识符又可能是关键字呢?(虽然很多语言不让用int当标识符,但这不是我们词法分析器该解决的)。而关键字的优先级明显比标识符高,至少真拿关键字当标识符的人不多。

所以我们在initToken函数中进行了特别判断

if isAlpha(ch){ // 第一个字符是字母
	if ch == 'i'{
		newState = LexId_int1
	}else{
		newState = LexId
	}

如果第一个字符是 i 的话将进入 LexId_int1 状态。

而在tokenize中有对应的解析方式

case LexId_int1:
	if ch == 'n'{
            state = LexId_int2
            tokenText.WriteByte(ch)
	}else if isDigit(ch) || isAlpha(ch){
            state = LexId
            tokenText.WriteByte(ch)
	}else{
            state = initToken(ch)
	}
case LexId_int2:
	if ch == 't'{
		state = LexId_int3
		tokenText.WriteByte(ch)
	}else if isDigit(ch) || isAlpha(ch){
		state = LexId
		tokenText.WriteByte(ch)
	}else{
		state = initToken(ch)
	}
case LexId_int3:
	if isBlank(ch){
		token.Tokentype = TtInt
		state = initToken(ch)
	}else{
		state = LexId
		tokenText.WriteByte(ch)
	}

当状态为LexId_int1时对下一个字符进行解析,如果是n则进入LexId_int2,如果不是则改为正常的标识符状态。case LexId_int2同理。case LexId_int3时判断下一个字符是否为空,因为int是关键字但inta,intb却不是。所以需要进行判别,若是空格则说明这确实是int关键字,那么这个token算是解析完成了,重新进入initToken函数。

代码仓库

gitee.com/liu-fudan/b…