5.语法分析(3):实现简单的脚本语言

119 阅读6分钟

前两节课我们写了一个简单的解释器实现了公式的计算。那么如何让它支持更多的功能,更像一门脚本语言呢?

将继续实现一些功能,比如:

  • 支持变量声明和初始化语句,就像“int age” “int age = 45”和“int age = 17+8+20”;
  • 支持赋值语句“age = 45”;
  • 在表达式中可以使用变量,例如“age + 10 *2”;
  • 实现一个命令行终端,能够读取输入的语句并输出结果。

实现这些功能之后,我们的成果会更像一个脚本解释器。为了实现这些新的语法,我们首先要把它们用语法规则描述出来。

增加所需要的语法规则

一门脚本语言要支持语句,比如变量声明语句、赋值语句等。单独一个表达式,也可以视为语句,叫做“表达式语句”。你在终端里输入 2+3;,就能回显出 5 来,这就是表达式作为一个语句在执行。 我们用扩展巴科斯范式(EBNF)写出下面的语法规则:

programm: statement+;  	
statement	
: intDeclaration	
| expressionStatement	
| assignmentStatement	
;

变量声明语句以 int 开头,后面跟标识符,然后有可选的初始化部分,也就是一个等号和一个表达式,最后再加分号:

intDeclaration : 'int' Identifier ( '=' additiveExpression)? ';';

表达式语句目前只支持加法表达式,未来可以加其他的表达式,比如条件表达式,它后面同样加分号:

expressionStatement : additiveExpression ';';

赋值语句是标识符后面跟着等号和一个表达式,再加分号:

assignmentStatement : Identifier '=' additiveExpression ';';

为了在表达式中可以使用变量,我们还需要把 primaryExpression 改写,除了包含整型字面量以外,还要包含标识符和用括号括起来的表达式:

primaryExpression : Identifier| IntLiteral | '(' additiveExpression ')';

接下来一步步实现这些特性。

让脚本语言支持变量

之前实现的公式计算器只支持了数字字面量的运算,如果能在表达式中用上变量,会更有用,比如能够执行下面两句:

int age = 45;
age + 10 * 2;

这两个语句的语法特性包含了变量声明、给变量赋值,以及在表达式里引用变量。为了给变量赋值,我们必须在脚本语言的解释器中开辟一个存储区,记录不同的变量和它们的值:

var variables = make(map[string]int)

用 Map 作为变量存储区。在变量声明语句和赋值语句里,都可以修改这个变量存储区中的数据

通过这样一个存储机制就能支持变量了。当然,这个存储机制可能过于简单了,我们后面讲到作用域的时候,这么简单的存储机制根本不够。不过目前我们先这么用着,以后再考虑改进它。

注意

接下来的parser结构体是为了不与计算器那讲函数名起冲突才新定义的结构体。

解析赋值语句

接下来,我们来解析赋值语句,例如“age = age + 10 * 2;”:

func (p *parser)assignmentStatement(tokens *TokenReader)*ASTNode{
	var node *ASTNode
	token := tokens.peek()// 预读,看看下面是不是标识符
	if token != nil && token.getType() == TtIdentifier{
		token = tokens.read()// 读入标识符
		node = NewASTNode(AstNAssignmentStmt, token.getText())
		token = tokens.peek()// 预读,看看下面是不是等号
		if token != nil && token.getType() == TtAssignment{//等号
			tokens.read()// 取出等号
			child := p.additive(tokens)
			if child == nil{ //出错,等号右面没有一个合法的表达式
				errorReturn("等号右面没有一个合法的表达式")
			}else{
				node.addChildren(child)// 添加子节点
				token = tokens.peek()// 预读,看看后面是不是分号
				if token != nil && token.getType() == TtSemiColon{//分号
					tokens.read()// 预读,看看后面是不是分号
				}else{// 预读,看看后面是不是分号
					errorReturn("结尾没有分号")
				}
			}
		}else{
			tokens.unread()//回溯,吐出之前消化掉的标识符
			node = nil
		}
	}
	return node
}

注意这个unread函数,如果不是等号,则说明该语句不是赋值语句,可能是一个表达式什么的。那么我们就要回退刚才消耗掉的 Token,就像什么都没有发生过一样,而回退就需要tokenReader的unread函数支持

func (tr *TokenReader) unread() {//Token流回退一步。恢复原来的Token。
	if tr.pos > 0{
		tr.pos--
	}
}

理解递归下降算法中的回溯

在设计语法规则的过程中有一个陷阱,这个陷阱能帮我们更好地理解递归下降算法的一个特点:回溯。 理解这个特点能帮助你更清晰地理解递归下降算法的执行过程,从而再去想办法优化它。

考虑一下 age = 45;这个语句。肉眼看过去,你马上知道它是个赋值语句,但是当我们用算法去做模式匹配时,就会发生一些特殊的情况。看一下我们对 statement 语句的定义:

statement
: intDeclaration
| expressionStatement	
| assignmentStatement	
;

首先尝试 intDeclaration,但 age = 45;语句不是以 int 开头的,返回 nil。接着尝试 expressionStatement,看一眼下面的算法:

// 表达式语句,即表达式后面跟个分号。
func (p *parser)expressionStatement(tokens *TokenReader)*ASTNode{
	pos := tokens.getPosition()
	node := p.additive(tokens)
	if node != nil{
		token := tokens.peek()
		if token != nil && token.getType() == TtSemiColon{
			tokens.read()
		}else{
			node = nil
			tokens.setPosition(pos)
		}
	}
	return node
}

出现了什么情况呢?age = 45;语句最左边是一个标识符。根据语法规则,标识符是一个合法的 addtiveExpresion,因此 additive() 函数返回一个非空值。接下来应该扫描到一个分号,但显然不是,标识符后面跟的是等号,这证明模式匹配失败。

失败了该怎么办?我们的算法要把 Token 流的指针拨回到原来的位置,就像一切都没发生过。因为我们不知道 addtive() 这个函数往下尝试了多少步,因为它可能是一个很复杂的表达式,消耗掉了很多个 Token,所以我们必须记下算法开始时的位置,并在失败时回到这个位置。尝试一个规则不成功之后,恢复到原样,再去尝试另外的规则,这个现象就叫做“回溯”。

func (tr *TokenReader)	getPosition()int {//获取Token流当前的读取位置。
	return tr.pos
}
func (tr *TokenReader)	setPosition(pos int){ // 设置Token流当前的读取位置
	if pos > 0 && pos < len(tr.tokens){
		tr.pos = pos
	}
}

因为可能需要回溯,所以递归下降算法有时会做无用功。在 assignmentStatement 中,我们通过 unread(),回溯了一个 Token。而 expressionStatement 不确定要回溯几步,只好提前记下初始位置。匹配失败后,再去尝试匹配 assignmentStatement。这次获得了成功。

试探和回溯的过程,是递归下降算法的一个典型特征。递归下降算法虽然简单,但它通过试探和回溯,总是可以把正确的语法匹配出。缺点是回溯会拉低一点儿效率。但可以在这个基础上进行改进和优化,实现带有预测分析的递归下降,以及非递归的预测分析。

另一个问题:什么时候该回溯,什么时候该提示语法错误?

大家在阅读示例代码的过程中,应该发现里面有一些错误处理的代码,并直接退出。比如在赋值语句中,如果等号后面没有成功匹配一个加法表达式,我们认为这个语法是错的。因为在语法中,等号后面只能跟表达式,没有别的可能性。

你可能会意识到一个问题,在算法匹配不成功时应该回溯再去尝试其他可能性呀,为什么在这里报错了呢?换句话说,什么时候该回溯,什么时候该提示这里发生了语法错误呢?

其实这两种方法最后的结果是一样的。提示语法错误时,是已经没有其他可能的匹配选项了,不需要浪费时间去回溯。就比如,在我们的语法中,等号后面必然跟表达式,否则就一定是语法错误。你在这里不报语法错误,等试探完其他所有选项后,还是需要报语法错误。所以提前报语法错误,实际上是我们写算法时的一种优化。

写编译程序时,不仅要能够解析正确的语法,还要尽可能针对语法错误提供友好的提示,帮助用户迅速定位错误。

到目前为止,已经能够能够处理几种不同的语句,如变量声明语句,赋值语句、表达式语句,那么我们把所有这些成果放到一起,来体会一下使用自己的脚本语言的乐趣吧!

我们需要一个交互式的界面来输入程序,并执行程序,这个交互式的界面就叫做REPL。

实现一个简单的 REPL

脚本语言一般都会提供一个命令行窗口,让你输入一条一条的语句,马上解释执行它,并得到输出结果,比如 Node.js、Python 等都提供了这样的界面。这个输入、执行、打印的循环过程就叫做 REPL(Read-Eval-Print Loop)。 你可以在 REPL 中迅速试验各种语句。

我们也实现了一个简单的 REPL。基本上就是从终端一行行的读入代码,当遇到分号的时候,就解释执行,代码如下:

var(
	variables = make(map[string]int)// 存储变量
	ASTmode = flag.Bool("A", false, "打印AST树")
	Tokensmode = flag.Bool("T", false, "打印tokens")
)

func main() {
	flag.Parse()
	fmt.Print("\n>")// 提示符
	p := parser{}
	for{
		reader := bufio.NewReader(os.Stdin)
		input, _ := reader.ReadString('\n')
		line := strings.TrimSpace(input)
		if line == "exit();"{
			fmt.Println("Good bye!")
			break
		}
		
		if strings.HasSuffix(line, ";"){
			tree := p.parse(line)	
			if *ASTmode{
				fmt.Println("===================AST Node===================")
				dumpAST(tree, "")
				fmt.Println("==============================================")
			}
			evaluatee(tree, "")
			fmt.Print("\n>")// 提示符

		}else{
			errorReturn("语句没有以;结尾")
		}
	}
}

func evaluatee(node *ASTNode, indent string)int{
	result := 0
	fmt.Println(indent , "Calculating: " , node.getType())
	switch node.getType(){
	case AstNProgramm:
		fmt.Println(len(node.getChildren()))
		for _, child := range node.getChildren(){
			result = evaluatee(child, indent + "\t")
		}
	case AstNAdditive:
		child1 := node.getChildren()[0]
		value1 := evaluatee(child1, indent + "\t")
		child2 := node.getChildren()[1]
		value2 := evaluatee(child2, indent + "\t")
		if node.getText() == "+"{
			result = value1 + value2
		}else{
			result = value1 - value2
		}
	case AstNMultiplicative:
		child1 := node.getChildren()[0]
		value1 := evaluatee(child1, indent + "\t")
		child2 := node.getChildren()[1]
		value2 := evaluatee(child2, indent + "\t")
		if node.getText() == "*"{
			result = value1 * value2
		}else{
			result = value1 / value2
		}
	case AstNIntLiteral:
		result, _ = strconv.Atoi(node.getText())
	case AstNIdentifier:
		varName := node.getText()
		if v, ok := variables[varName]; ok{
			result = v
		}else{
			errorReturn("variable " + varName + " has not been set any value")
		}
	case AstNAssignmentStmt:
		varName := node.getText()
		if _, ok := variables[varName]; !ok{
			errorReturn("unknown variable: " + varName)
		}	
		fallthrough
	case AstNIntDeclaration:
		varName := node.getText()
		if len(node.getChildren()) > 0{
			child := node.getChildren()[0]
			result = evaluatee(child, indent + "\t")
			variables[varName] = result
			
		}else{
			errorReturn("错误的int声明表达式")
		}
	}
	fmt.Println(indent, "Result: ", result)
	return result
}

代码变化量较大:简易脚本语言,修复了许多bug · 8ee7526 · LiuFuDan/编译原理 - Gitee.com