3.语法分析(1):打造公式计算器

159 阅读10分钟

公式是 Excel 的灵魂。除此之外,如果你要开发一款通用报表软件,也会大量用到自定义公式来计算报表上显示的数据。总而言之,很多高级一点儿的软件,都会用到自定义公式功能。

既然公式功能如此常见和重要,我们不妨实现一个公式计算器,给自己的软件添加自定义公式功能吧!

本节课将纯手工实现一个公式计算器,借此掌握语法分析的原理递归下降算法, 并初步了解上下文无关文法。

公式计算器支持加减乘除算术运算,比如2 + 3 * 5

在学习语法分析时,我们习惯把上面的公式称为表达式。

要实现上面的表达式,你必须能分析它的语法。不过在此之前,我想先带你解析一下变量声明语句的语法,以便让你循序渐进地掌握语法分析。

解析变量声明语句:理解“下降”的含义

语法分析的结果是生成 AST。算法分为自顶向下和自底向上算法,其中 递归下降算法是一种常见的自顶向下算法。

与此同时,我给出了一个简单的代码示例,也针对“int age = 45”这个语句,画了一个语法分析算法的示意图:

image.png

我们首先把变量声明语句的规则,用形式化的方法表达一下。它的左边是一个非终结符(Non-terminal)。右边是它的产生式(Production Rule)。在语法解析的过程中,左边会被右边替代。如果替代之后还有非终结符,那么继续这个替代过程,直到最后全部都是终结符(Terminal),也就是 Token。只有终结符才可以成为 AST 的叶子节点。这个过程,也叫做推导(Derivation)过程:

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

int 类型变量的声明,需要有一个 Int 型的 Token,加一个变量标识符,后面跟一个可选的赋值表达式。我们把上面的文法翻译成程序语句,伪代码如下:

MatchIntDeclare(){
	MatchToken(Int);        // 匹配 Int 关键字
	MatchIdentifier();       // 匹配标识符
	MatchToken(equal);       // 匹配等号
	MatchExpression();       // 匹配表达式
  }

实际代码为 IntDeclare() 函数中

// 整型变量声明,如:
//  int a;
//  int a = 2 * 3;
func intDeclare(tokens *TokenReader)*ASTNode{
	var node *ASTNode
	token := tokens.peek()//预读
	if token != nil && token.getType() == TtInt{ // 匹配Int
		tokens.read()//消耗掉int
		if tokens.peek().getType() == TtIdentifier{//匹配标识符
			tokens.read()
			//创建当前节点,并把变量名记到AST节点的文本值中 
			node = NewASTNode(AstNIntDeclaration, token.getText())
			token = tokens.peek()
			if token != nil && token.getType() == TtAssignment{
				tokens.read() //消耗掉等号
				child := additive(tokens)
				if child == nil{
					errorReturn("错误的值初始化,需要表达式")
				}else{
					node.addChildren(child)
				}
			}
		}else{
			errorReturn("没有变量名")
		}

		if node != nil{
			token = tokens.peek()
			if token != nil && token.getType() == TtSemiColon{
				tokens.read()
			}else{
				errorReturn("非法的表达式,缺少;结尾")
			}
		}
	}
	return node
}

我们给tokenReader新添加一个peek方法,它与read的区别就是读取后不移动pos

func (tr *TokenReader) peek() *Token { // 返回Token流中下一个Token,但不从流中取出。 
	if tr.pos < len(tr.tokens){
		return &tr.tokens[tr.pos]
	}	
	return nil	
}

先读取第一个token判断是不是int。如果是则读取下一个token判断是否为标识符,是则创建AST节点记下变量名,再检查后面是否有跟着初始化,有则匹配一个表达式获取加法表达式的内容(先不要在意这里为什么是additive),并把新建的node插入原先的node下。最后检查是否有分号结尾。

我们通常会对产生式的每个部分建立一个子节点,比如变量声明语句会建立四个子节点,分别是 int 关键字、标识符、等号和表达式。后面的工具就是这样严格生成 AST 的。但是这里做了简化,只生成了两个子节点,就是表达式子节点和值子节点。其他两个子节点没有提供额外的信息,就直接丢弃了。

通过 peek() 方法来预读,实际上是对代码的优化,这有点儿预测的意味。我们后面会讲带有预测的自顶向下算法,它能减少回溯的次数。

我们把解析变量声明语句和表达式的算法分别写成函数。在语法分析时,调用这些函数跟后面的 Token 串做模式匹配。匹配上了,就返回一个 AST 节点,否则就返回 null。如果中间发现跟语法规则不符,就报编译错误。

在这个过程中,上级文法嵌套下级文法,上级的算法调用下级的算法。表现在生成 AST 中,上级算法生成上级节点,下级算法生成下级节点。这就是“下降”的含义。

带你理解了“下降”的含义之后,我们来看看如何用上下文无关文法描述算术表达式。

用上下文无关文法描述算术表达式

解析算术表达式时,会遇到更复杂的情况,这时正则文法不够用,必须用上下文无关文法来表达。你可能会问:“正则文法为什么不能表示算术表达式?”别着急,我们来分析一下算术表达式的语法规则。

算术表达式要包含加法和乘法两种运算(简单起见,我们把减法与加法等同看待,把除法也跟乘法等同看待),加法和乘法运算有不同的优先级。我们的规则要能匹配各种可能的算术表达式:

  • 2+3*5
  • 2*3+5
  • 2*3

思考一番之后,我们把规则分成两级:第一级是加法规则,第二级是乘法规则。把乘法规则作为加法规则的子规则,这样在解析形成 AST 时,乘法节点就一定是加法节点的子节点,从而被优先计算。

additiveExpression
    :   multiplicativeExpression
    |   additiveExpression Plus multiplicativeExpression
    ;
 
multiplicativeExpression
    :   IntLiteral
    |   multiplicativeExpression Star IntLiteral
    ;

上述表达的是:一个加法表达式可以是一个乘法表达式或是一个乘法表达式与一个加法表达式相加。而一个乘法表达式可以是一个Int型的字面量或是另一个乘法表达式与Int型字面量相乘

我们可以通过文法的嵌套,实现对运算优先级的支持。这样我们在解析“2 + 3 * 5”这个算术表达式时会形成类似下面的 AST:

image.png

如果要计算表达式的值,需要对根节点求值。为了完成对根节点的求值,需要对下级节点递归求值,所以我们先完成“3 * 5 = 15”,再计算“2 + 15 = 17”。

有了这个认知,我们在解析算术表达式时,便能拿加法规则去匹配。在加法规则中,会嵌套地匹配乘法规则。我们通过文法的嵌套,实现了计算的优先级。

应该注意的是,加法规则中还递归地又引用了加法规则。通过这种递归的定义,我们能展开、形成所有各种可能的算术表达式。比如“2+3*5” 的推导过程:

-->additiveExpression + multiplicativeExpression 
-->multiplicativeExpression + multiplicativeExpression
-->IntLiteral + multiplicativeExpression
-->IntLiteral + multiplicativeExpression * IntLiteral 
-->IntLiteral + IntLiteral * IntLiteral

最先我们都是先从加法表达式开始,第一行是加法表达式变为上述的第二种形态:加法表达式和乘法表达式相加。我们以a1和m1命名。

随后a1转变为第一种形态即乘法表达式,再转换为乘法表达式的第一个形态即字面量。

接着我们对m1进行转换。因为是3*5,转换为第二种形式:乘法表达式 乘 字面量。最后将乘法表达式也转换为字面量得到最后一行的结果。

这种文法比正则文法的表达能力更强,叫做 “上下文无关文法”。 正则文法是上下文无关文法的子集。区别就是上下文无关文法允许递归调用,而正则文法不允许。

上下文无关的意思是,无论在任何情况下,文法的推导规则都是一样的。比如,在变量声明语句中可能要用到一个算术表达式来做变量初始化,而在其他地方可能也会用到算术表达式。不管在什么地方,算术表达式的语法都一样,都允许用加法和乘法,计算优先级也不变。你见到的大多数计算机语言,都能用上下文无关文法来表达它的语法。

解析算术表达式:理解“递归”的含义

在讲解上下文无关文法时,我提到了文法的递归调用,你也许会问,是否在算法上也需要递归的调用呢?要不怎么叫做“递归下降算法”呢?

的确,我们之前的算法只算是用到了“下降”,没有涉及“递归”,现在我来看看如何用递归的算法翻译递归的文法。

先按照前面说的,把文法直观地翻译成算法。但是,我们遇到麻烦了。这个麻烦就是出现了无穷多次调用的情况。我们来看个例子。

为了简单化,我们采用下面这个简化的文法,去掉了乘法的层次:

additiveExpression
    :   IntLiteral
    |   additiveExpression Plus IntLiteral
    ;

在解析 2 + 3的时候,我们直观地将其翻译成算法,出现了如下的情况:

  • 首先匹配是不是整型字面量,发现不是;
  • 然后匹配是不是加法表达式,发现是的,再次进入下一轮additiveExpression的判定;
  • 会重复上面两步,无穷无尽。

additiveExpression Plus multiplicativeExpression这个文法规则的第一部分就递归地引用了自身,这种情况叫做左递归。 通过上面的分析,我们知道左递归是递归下降算法无法处理的,这是递归下降算法最大的问题。

怎么解决呢?把“additiveExpression”调换到加号后面怎么样?

additiveExpression
    :   multiplicativeExpression
    |   multiplicativeExpression Plus additiveExpression
    ;

改写成算法,这个算法确实不会出现无限调用的问题:

func additive(tokens *TokenReader)*ASTNode{
	child1 := multiplicative(tokens) // 计算第一个子节点
	node := child1 // 如果没有第二个子节点就返回这个
	token := tokens.peek()
	if child1 != nil && token != nil{
		if token.getType() == TtPlus || token.getType() == TtMinus{
			token = tokens.read()
			child2 := additive(tokens)//递归解析第二个子节点
			if child2 != nil{
				node = NewASTNode(AstNAdditive, token.getText())
				node.addChildren(child1)
				node.addChildren(child2)
			}else{
				errorReturn("错误的加法表达式,期待右值")
			}
		}
	}
	return node
}

先尝试能否匹配乘法表达式,如果不能,那么这个节点肯定不是加法节点,因为加法表达式的两个产生式都必须首先匹配乘法表达式。遇到这种情况返回 nil,这次匹配没有成功。如果乘法表达式匹配成功,就再尝试匹配加号右边的部分,也就是去递归地匹配加法表达式。成功则构造一个加法的 ASTNode 返回。

同样的,乘法的文法规则也可以做类似的改写:

multiplicativeExpression
    :   IntLiteral
    |   IntLiteral Star multiplicativeExpression
    ;

既然解决了左递归问题,那么我们补全代码跑起来试试吧

首先是ASTNodeType类型

type ASTNodeType string

const (
	AstNProgramm       ASTNodeType = "Programm"       //程序入口,根节点
	AstNIntDeclaration ASTNodeType = "IntDeclaration" //整型变量声明
	AstNExpressionStmt ASTNodeType = "ExpressionStmt" //表达式语句,即表达式后面跟个分号
	AstNAssignmentStmt ASTNodeType = "AssignmentStmt" //赋值语句

	AstNPrimary        ASTNodeType = "Primary"        //基础表达式
	AstNMultiplicative ASTNodeType = "Multiplicative" //乘法表达式
	AstNAdditive       ASTNodeType = "Additive"       //加法表达式

	AstNIdentifier ASTNodeType = "Identifier" //标识符
	AstNIntLiteral ASTNodeType = "IntLiteral" //整型字面量
)

然后便是ASTNode相关的

// 属性包括:类型、文本值、父节点、子节点。
type ASTNode struct{
	parent *ASTNode
	children []*ASTNode
	nodeType ASTNodeType
	text string
}

func NewASTNode(ntype ASTNodeType, text string)*ASTNode{
	return &ASTNode{nodeType: ntype, text: text}
}

func (n *ASTNode)getParent()*ASTNode{
	return n.parent
}

func (n *ASTNode)getChildren()[]*ASTNode{
	return n.children
}

func (n *ASTNode)getType()ASTNodeType{
	return n.nodeType
}

func (n *ASTNode)getText()string{
	return n.text
}

func (n *ASTNode)addChildren(child *ASTNode){
	n.children = append(n.children, child)
	child.parent = n
}

接着是乘法表达式的函数

func multiplicative(tokens *TokenReader) *ASTNode{
	child1 := primary(tokens)
	node := child1
	token := tokens.peek()
	if child1 != nil && token != nil{
		if token.getType() == TtStar || token.getType() == TtSlash{
			tokens.read()
			child2 := multiplicative(tokens)
			if child2 != nil{
				node = NewASTNode(AstNMultiplicative, token.getText())
				node.addChildren(child1)
				node.addChildren(child2)
			}else{
				errorReturn("错误的乘法表达式,期待右值")
			}
		}
	}
	return node
}

最后是基础表达式的函数

// 基础表达式,获取字面量或者标识符或者括号之类的
func primary(tokens *TokenReader) *ASTNode{
	var node *ASTNode
	token := tokens.peek()
	if token != nil{
		if token.getType() == TtIntLiteral{
			tokens.read()
			node = NewASTNode(AstNIntLiteral, token.getText())
		}else if token.getType() == TtIdentifier{
			tokens.read()
			node = NewASTNode(AstNIdentifier, token.getText())
		}else if token.getType() == TtLeftParen{
			tokens.read()
			node = additive(tokens)
			if node != nil{
				token = tokens.peek()
				if token != nil && token.getType() == TtRightParen{
					tokens.read()
				}else{
					errorReturn("缺少右括号")
				}
			}else{
				errorReturn("括号内缺少加法表达式")
			}
		}
	}
	return node
}

报错并退出函数以及打印AST树函数

func errorReturn(str string){
	fmt.Println(str)
	os.Exit(-1)
}

func dumpAST(node *ASTNode, indent string){
	fmt.Println(indent , node.getType() , " " , node.getText())
	for _, chilrd := range node.getChildren(){
		dumpAST(chilrd, indent + "\t")
	}
}

我们写个函数让他跑起来试试吧

func calculator(){
	script := "int a = b+3;";
	tokens := tokenize(script)
	for _, token := range tokens.tokens{
		fmt.Println(token.getText() + "\t" + string(token.getType()))
	}
	fmt.Println()
	node := intDeclare(tokens)
	dumpAST(node, "")
}

image.png

是不是看上去一切正常?可如果让这个程序解析“2+3+4”呢?

image.png

问题是什么呢?计算顺序发生错误了。表达式应该从左向右计算。但我们生成的 AST 变成从右向左了,先计算“3+4”,然后才跟“2”相加。

为什么产生上面的问题呢?因为我们修改了文法,把文法中加号左右两边的部分调换了一下。造成的影响是什么呢?你可以推导一下“2+3+4”的解析过程:

  • 首先调用乘法表达式匹配函数 multiplicative(),成功,返回了一个字面量节点 2。
  • 接着看看右边是否能递归地匹配加法表达式。
  • 匹配的结果,真的返回了一个加法表达式“3+4”,这个变成了第二个子节点。错误就出在这里了。这样的匹配顺序,“3+4”一定会成为子节点,在求值时被优先计算。

所以,我们前面的方法其实并没有完美地解决左递归,因为它改变了加法运算的结合性规则。那么,我们能否既解决左递归问题,又不产生计算顺序的错误呢?答案是肯定的。不过我们下一讲再来解决它。目前先忍耐一下,凑合着用这个“半吊子”的算法吧。

实现表达式求值

上面帮助你理解了“递归”的含义,接下来,我要带你实现表达式的求值。其实,要实现一个表达式计算,只需要基于 AST 做求值运算。这个计算过程比较简单,只需要对这棵树做深度优先的遍历就好了。

深度优先的遍历也是一个递归算法。以上文中2 + 3 * 5的 AST 为例。

  • 对表达式的求值,等价于对 AST 根节点求值。
  • 首先求左边子节点,算出是 2。
  • 接着对右边子节点求值,这时候需要递归计算下一层。计算完了以后,返回是 15(3*5)。
  • 把左右节点相加,计算出根节点的值 17。
// 解析脚本,并返回根节点
func parse(code string)*ASTNode{
	tokens := tokenize(code)
	rootNode := prog(tokens)
	return rootNode
}
// 表达式运算
func evaluate(code string){
	tree := parse(code)
	dumpAST(tree, "")
	fmt.Println("----------------------------------------------------")
	evaluaten(tree, "")
}

// 语法解析根节点
func prog(tokens *TokenReader)*ASTNode{
	node := NewASTNode(AstNProgramm, "calculator")
	child := additive(tokens)
	if child != nil{
		node.addChildren(child)
	}
	return node
}

// 对某个AST节点求值,并打印求值过程。
func evaluaten(node *ASTNode, indent string)int{
	result := 0
	fmt.Println(indent , "Calculating: " , node.getType())
	switch node.getType(){
	case AstNProgramm: // 程序根节点,遍历所有子节点
		for _, child := range node.getChildren(){
			result = evaluaten(child, indent + "\t")
		}
	case AstNAdditive:
		child1 := node.getChildren()[0]
		value1 := evaluaten(child1, indent + "\t")
		child2 := node.getChildren()[1]
		value2 := evaluaten(child2, indent + "\t")
		if node.getText() == "+"{
			result = value1 + value2
		}else{
			result = value1 - value2
		}
	case AstNMultiplicative:
		child1 := node.getChildren()[0]
		value1 := evaluaten(child1, indent + "\t")
		child2 := node.getChildren()[1]
		value2 := evaluaten(child2, indent + "\t")
		if node.getText() == "*"{
			result = value1 * value2
		}else{
			result = value1 / value2
		}
	case AstNIntLiteral:
		result, _ = strconv.Atoi(node.getText())
	}
	fmt.Println(indent, "Result: ", result)
	return result
}

跑起来吧:

// 测试表达式
script := "2+3*5"
fmt.Println("\n计算: " + script + ",看上去一切正常。")
evaluate(script)
fmt.Println("===============================================")
// 测试语法错误
script = "2+"
fmt.Println("\n: " + script + ",应该有语法错误。")
evaluate(script)
fmt.Println("===============================================")
script = "2+3+4"
fmt.Println("\n计算: " + script + ",结合性出现错误。")
evaluate(script)

但这时我发现了上一讲中的一些bug:由于我们是多次调用tokenize函数,而其依赖的是下面三个全局变量。但是我们并没有在函数开始前对其进行清空。所以在tokenize函数的开头添加以下

tokens = []Token{}
token = NewToken()
tokenText = strings.Builder{}

跑起来吧,这时我们看到执行到第二个式子的时候由于表达式不对,直接退出了。
image.png

很正常,毕竟你代码写的不对编译器也是不会让你执行下去的嘛

代码仓库

gitee.com/liu-fudan/b…