简单计算器
设计语言的“标准流程”
在实现计算器之前,我们需要简单的了解一下,设计一门语言的所谓的“标准”流程。
这里所谓的“标准”是针对于树遍历解释器(Tree walking interpreter)来说的。
这里的步骤从表面上看起来还是比较简单的:
- 词法分析器接受输入(一般是文件或者字符串),然后输出一系列的词元(Token)。
- 语法分析器对词元(Token)进行处理,生成对应的抽象语法树(AST)。
- 解释器对AST进行解释,执行相应的脚本动作。
对于词法分析器,有的语言使用Scanner术语,有的使用Lexer术语,但结果都是输出词元(Token)。
AST : Abstract Syntax Tree(抽象语法树),对于代码来说,其实就是一个
go语言的结构。
请记住上面的“标准”流程。下面让我们开始一步一步实现我们的简单计算器。
词元(Token)
【词元】简单的说,就是解析语言的最小单元。
这里还是使用例子来进行说明,假设我们的语言支持如下的语句(statement):
let num = 10 + 20;
我们需要把这个句子拆分成一个个独立的单元(类似于中文语言中一句话中的单词)。我们的词法分析器(Lexer),遇到上面的输入,输出的词元(Token)类型(Token Type)如下:
TOKEN_LET // let
TOKEN_IDENTIFIER // num
TOKEN_ASSIGN // =
TOKEN_NUMBER // 10
TOKEN_PLUS // +
TOKEN_NUMBER // 20
TOKEN_SEMICOLON // ;
那么这里所说的词元(Token)需要包含哪些信息呢?一个词元(Token)需要包含如下的信息:
- 词元类型(Token Type),通常将其定义为常量
- 词元位置(Position),作为将来调试用或者报错用
- 词元字面量(Literal),词元所表示的实际值(或者字面量)
字面量:顾名思义就是字面本身的值。例如
2.5的字面量就是2.5。Hello的字面量就是Hello,var1的字面量就是var1。
词元类型(Token Type)
假设我们的简单四则运算器的输入如下:
2 + (3 * 4) / ( 6 - 3 ) + 10 * 1.5 + 2 ** 3
从上面的例子中可以看出,这里的Token类型包含【+】,【-】,【*】,【/】,【**】,【数字】,【括号(包括左括号和右括号)】。
词元类型一般是用常量来定义的,对于我们将要实现的简单四则运算器,词元类型定义如下:
//token.go
type TokenType int //定义词元类型为一个整形
//定义Token类型
const (
TOKEN_ILLEGAL TokenType = (iota - 1) // 非法词元
TOKEN_EOF // 文件(或输入)结束Token(EOF:End of file)
TOKEN_PLUS // +
TOKEN_MINUS // -
TOKEN_MULTIPLY // *
TOKEN_DIVIDE // '/'
TOKEN_MOD // '%'(取模)
TOKEN_POWER // ** (开方)
TOKEN_LPAREN // (
TOKEN_RPAREN // )
TOKEN_NUMBER //数字,例如10、10.1等
)
词元位置(Token Position)
【位置】信息需要有诸如行号,列号等信息,下面是其定义:
//token.go
//词元的【位置】
type Position struct {
Filename string //文件名(当脚本的输入为字符串的时候,为空)
Offset int //当前词元相对于整个文件或者输入的偏移量(暂时作用不大)
Line int //当前词元所在的行号
Col int //当前词元所在的列号
}
//调试用
//可以使用类似`fmt.Printf("Position=%s\n", position)`的语句来打印Position结构
func (p Position) String() string {
var msg string
if p.Filename == "" {
msg = fmt.Sprint(" <", p.Line, ":", p.Col, "> ")
} else {
msg = fmt.Sprint(" <", p.Filename, ":", p.Line, ":", p.Col, "> ")
}
return msg
}
//位置的字符串表示。调试用(脚本错误的时候会报告相应的行号)
//这里我们不能使用`Line()`作为函数名, 因为`Line`是Position结构的字段名
func (p Position) Sline() string { //Sline: String line
var msg string
if p.Filename == "" {
msg = fmt.Sprint(p.Line)
} else {
msg = fmt.Sprint(" <", p.Filename, ":", p.Line, "> ")
}
return msg
}
这里的Position结构浅显易懂,应该没有太多需要解释的地方。
词元结构(Token Structure)
有了上面的说明,下面来定义我们的词元结构。大致如下:
//token.go
//词元结构
type Token struct {
Pos Position //词元的位置信息
Type TokenType //词元的类型
Literal string //词元的字面量
}
//词元类型的字符串表示(调试或者测试用)
func (tt TokenType) String() string {
switch tt {
case TOKEN_ILLEGAL:
return "ILLEGAL"
case TOKEN_EOF:
return "EOF"
case TOKEN_PLUS:
return "+"
case TOKEN_MINUS:
return "-"
case TOKEN_MULTIPLY:
return "*"
case TOKEN_DIVIDE:
return "/"
case TOKEN_MOD:
return "%"
case TOKEN_POWER:
return "**"
case TOKEN_LPAREN:
return "("
case TOKEN_RPAREN:
return ")"
case TOKEN_NUMBER:
return "NUMBER"
default:
return "UNKNOWN"
}
}
//词元的字符串表示。调试用
//可以使用类似`fmt.Printf("token=%s\n", tok)`的语句来打印词元结构
func (t Token) String() string {
return fmt.Sprintf("Position: %s, Type: %s, Literal: %s", t.Pos, t.Type, t.Literal)
}
为了让读者加深理解,这里再次举个例子:
2 + (3 * 5) - 10
对于上面的例子,共有10个词元类型(Token Type):
TOKEN_NUMBER ---> 字面量是【2】
TOKEN_PLUS ---> 字面量是【+】
TOKEN_LPAREN ---> 字面量是【(】
TOKEN_NUMBER ---> 字面量是【3】
TOKEN_MULTIPLY ---> 字面量是【*】
TOKEN_NUMBER ---> 字面量是【5】
TOKEN_RPAREN ---> 字面量是【)】
TOKEN_MINUS ---> 字面量是【-】
TOKEN_NUMBER ---> 字面量是【10】
TOKEN_EOF ---> 做为结尾标识
注:词法分析器(Lexer)会忽略空格
对应着上面的计算式,10个词元结构的内容如下:
词法分析器(Lexer)
结合本节开头的图,我们知道词法分析器的作用是将输入进行分析,最后输出词元(Token)。
下面我们来分析一下,词法分析器需要哪些信息呢?
- 文件名(从文件读取脚本输入时使用)
- 输入字节流(从文件读取时,实际是将文件整个内容读到输入字节流中)
- 当前正在处理的字符
- 当前正在处理的字符的位置
- 当前正在处理的字符的下一个位置
- 行号和列号
有了上面的信息后,我们的词法分析器(Lexer)结构如下:
//lexer.go
type Lexer struct {
filename string //文件名
input []rune //输入(如果文件名不为空,会将文件整个文件的内容读入input中)
ch rune //当前正在处理的字符
position int //当前正在处理的字符的位置
readPosition int //当前正在处理的字符的下一个位置
line int //行号
col int //列号
}
这里input使用
[]rune,ch使用rune的目的主要是为了能够处理中文之类的变量名。
读取字符
词法分析器需要一个字符一个字符的读取“输入”:
//lexer.go
func (l *Lexer) readNext() { //读取下一个字符
if l.readPosition >= len(l.input) { //如果读取的位置大于等于输入的长度,则认为读到了结束。
l.ch = 0 //读取结束
} else {
l.ch = l.input[l.readPosition]
if l.ch == '\n' { //当遇到'\n'时
l.col = 0
l.line++
} else {
l.col += 1
}
}
l.position = l.readPosition
l.readPosition++ //将readPosition指向下一个字符
}
对于第4行的代码,有的读者可能就会问了,如果输入中有0字符的话,那么是不是就会有问题?答案是不会,因为如果输入中有0的话,
l.ch中存放的是'0',而不是0。
词法分析器,还要根据输入来初始化Lexer结构:
//lexer.go
func NewLexer(input string) *Lexer {
l := &Lexer{input: []rune(input)} //将input字符串转换为rune数组
l.ch = ' '
l.position = 0
l.readPosition = 0
l.line = 1
l.col = 0
//预先读取一个字符,这样position的值为0,readPostion的值为1。
//就是说下面的语句执行后,当前字符位置为position,下一个字符位置为readPosition。
//这样,初始化后,position和readPosition都指向了正确的位置。
l.readNext()
//0xFEFF: BOM(byte order mark)
if l.ch == 0xFEFF {
l.readNext() //忽略文件头的BOM
}
return l
}
关于BOM(Byte order mark)的相关信息,请读者参照百度的文档baike.baidu.com/item/BOM/27…
有时候,我们还需要词法分析器能够获取下一个字符的值,而并不读取下一个字符。因此我们还需要给词法分析器提供一个peek()函数,来获取(并非读取)下一个字符。
//lexer.go
func (l *Lexer) peek() rune {
if l.readPosition >= len(l.input) {
return 0
}
return l.input[l.readPosition]
}
有的细心的读者可能会有疑问,获取下一个字符(而不读取)的意义是啥?下面我们举个例子:
2 ** 3
这里的
**表示开方。 2 ** 3 = 2^3^ = 8
当我们遇到第一个 *的时候,我们需要预判断下一个字符是否为*。如果下一个字符是*,那么我们就能够判断需要处理的词元为**。如果不是,那么我们就知道现在处理的词元为*。如果读者还是不能够很明白其中的道理,不用担心,请继续往下读。
对于词法分析器来说,需要忽略输入中的空格(这里的空格包括回车换行,TAB键,空白)。我们的词法分析器还需要提供一个忽略空格的函数:
//lexer.go
func (l *Lexer) skipWhitespace() {
for unicode.IsSpace(l.ch) {
l.readNext()
}
}
我们的简单四则运算器主要处理的是数字的运算,所以我们还需要提供一个判断数字的函数:
//lexer.go
func isDigit(ch rune) bool {
return '0' <= ch && ch <= '9'
}
这个函数是一个工具(utility)函数。它接受一个类型为
rune的ch参数,然后判断这个参数是否为数字。它并不依赖于Lexer结构中的任何信息,所以并不是Lexer的成员函数。
我们的词法分析器(Lexer)还需要一个简单的生成词元(Token)的工具函数(utility function):
//lexer.go
func newToken(tokenType token.TokenType, ch rune) token.Token {
return token.Token{Type: tokenType, Literal: string(ch)}
}
读取数字
我们的简单四则运算器需要处理类似12.75之类的浮点数。有了isDigit()这个工具函数(utility function)后,我们就可以写一个处理数字的函数:
//lexer.go
func (l *Lexer) readNumber() string {
var ret []rune
ch := l.ch
ret = append(ret, ch)
l.readNext()
for isDigit(l.ch) || l.ch == '.' { //当前字符是数字或者'.'的时候,就继续读取
ret = append(ret, l.ch)
l.readNext()
}
return string(ret)
}
注:这个处理数字的函数,其实是有些问题的。第9行判断如果字符为数字或者点,就继续。那么如果是类似
99...2345这样的输入,那么这里的处理就会有问题。
词法分析器(Lexer)还需要获取当前词元(Token)的位置信息。所以这里我们提供了获取位置信息的函数:
//lexer.go
func (l *Lexer) getPos() token.Position {
return token.Position{
Filename: l.filename,
Offset: l.position,
Line: l.line,
Col: l.col,
}
}
这个函数应该也是比较浅显易懂,无需太多解释吧。
获取下一个词元(Token)
说了这么多,读者可能已经有点不耐烦了,词法分析器(Lexer)的输出是词元(Token)。怎么还没有获取词元(Token)的函数呢?好吧,我承认这里有些啰嗦了。下面是我们真正的主角开始登场了:
//lexer.go
//获取下一个词元
func (l *Lexer) NextToken() token.Token {
var tok token.Token
l.skipWhitespace() //忽略所有的空格
pos := l.getPos() //获取Token的位置信息
switch l.ch {
case '+':
tok = newToken(token.TOKEN_PLUS, l.ch)
case '-':
tok = newToken(token.TOKEN_MINUS, l.ch)
case '*':
//这里需要获取下一个字符,判断是否是`**`,还是一个简单的`*`
if l.peek() == '*' {
tok = token.Token{Type: token.TOKEN_POWER, Literal: string(l.ch) +
string(l.peek())}
l.readNext()
} else { //下一个字符不是`*`
tok = newToken(token.TOKEN_MULTIPLY, l.ch)
}
case '/':
tok = newToken(token.TOKEN_DIVIDE, l.ch)
case '%':
tok = newToken(token.TOKEN_MOD, l.ch)
case '(':
tok = newToken(token.TOKEN_LPAREN, l.ch)
case ')':
tok = newToken(token.TOKEN_RPAREN, l.ch)
case 0:
tok.Literal = "<EOF>"
tok.Type = token.TOKEN_EOF
default:
if isDigit(l.ch) { //如果当前字符为数字
tok.Literal = l.readNumber()
tok.Type = token.TOKEN_NUMBER
tok.Pos = pos
return tok
} else {
tok = newToken(token.TOKEN_ILLEGAL, l.ch)
}
}
tok.Pos = pos
l.readNext()
return tok
}
这个NextToken()函数就是词法分析器(Lexer)的灵魂,语法解析器(Parser)就是通过不断的调用这个NextToken()函数来分析词元(Token),生成相应的抽象语法树(AST)的。所以请初学者认真体会一下这个函数。
测试词法分析器
下面我们写一个简单的测试程序来测试我们的词法分析器的正确性:
//main.go
func TestLexer() {
input := "2 + (3 * 4) / ( 5 - 3 ) + 10 - a ** 2"
fmt.Printf("Input = %s\n", input)
l := lexer.NewLexer(input)
for {
tok := l.NextToken()
fmt.Printf("%s\n", tok)
if tok.Type == token.TOKEN_EOF {
break
}
}
}
func main() {
TestLexer()
}
运行后,输出信息如下:
Input = 2 + (3 * 4) / ( 5 - 3 ) + 10 - a ** 2
Position: <1:1> , Type: NUMBER , Literal: 2
Position: <1:3> , Type: + , Literal: +
Position: <1:5> , Type: ( , Literal: (
Position: <1:6> , Type: NUMBER , Literal: 3
Position: <1:8> , Type: * , Literal: *
Position: <1:10>, Type: NUMBER , Literal: 4
Position: <1:11>, Type: ) , Literal: )
Position: <1:13>, Type: / , Literal: /
Position: <1:15>, Type: ( , Literal: (
Position: <1:17>, Type: NUMBER , Literal: 5
Position: <1:19>, Type: - , Literal: -
Position: <1:21>, Type: NUMBER , Literal: 3
Position: <1:23>, Type: ) , Literal: )
Position: <1:25>, Type: + , Literal: +
Position: <1:27>, Type: NUMBER , Literal: 10
Position: <1:30>, Type: - , Literal: -
Position: <1:32>, Type: UNKNOWN, Literal: a
Position: <1:34>, Type: ** , Literal: **
Position: <1:37>, Type: NUMBER , Literal: 2
Position: <1:37>, Type: EOF , Literal: <EOF>
上面的输出,我做了相应的格式化,以方便读者阅读。
请注意最后的,它的Position实际上已经没有太大的意义。对于,我们关心的只是词元的类型,而并不关心词元的位置。
恭喜!词法分析器的工作已经完成了。对于初学者,请仔细阅读上面所讲的内容,争取能够完全吸收。
抽象语法树(AST)与语法解析器(Parser)
什么是语法?简单来说就类似人类语言,我们说话有主谓宾,而计算机语言也需要自己的语法,比如一个if表达式,使用go语言的话,类似下面这样:
if a == 10 {
//...
}
而对于c语言,则类似下面这样:
if (a == 10) {
//...
}
这个c语言版本的if条件判断,要求后面的判断必须包在括号中,而go语言版本不需要使用括号。这就是两种语言所要求的语法不同所导致的。
语法解析器(Parser)的作用是将输入(Token)转换为代表语法的数据结构,这个数据结构通常就是我们说的抽象语法树(AST)。下面就来看看如何用代码表示四则运算。
四则运算的表示(抽象语法树)
首先让我们假设,我们需要处理的算术运算如下:
1 + 2
1 - 2
1 * 2
1 / 2
5 % 2
我们怎么用代码表示这个【算术运算】呢?我们可以把这个【算术运算】想象成下面这种:
<Expression> operator <Expression>
//<表达式> 操作符 <表达式>
有的读者就会有疑问:什么是表达式(expression)?简单来说表达式就是能够产生【值】的式子。
语言中还有称为
语句(statement)的式子。它和表达式的区别是:语句不产生【值】,表达式产生【值】。例如:let sum = 1 + 2这里的
let就是一个语句,而1 + 2就是表达式。let语句的形式如下://let 标识符 = 表达式 let <identifier> = <expression>这里说的有点偏离现在的主题了,读者只需要简单理解一下即可。之后我们扩展的时候会用到
语句。对于现在的四则运算,则只用到表达式。
如何在语言中表示这个表达式(expression)呢?这里就需要提到抽象语法树(AST)了。在抽象语法树中,每个表达式(包括将来扩展要用到的语句)都是一个节点(Node)。那么节点(Node)数据结构中需要什么样的信息呢?
- 每个节点的开始和结束位置。
- 节点对应的词元(Token)的字面量(Token Literal)。
- 节点的字符串表示(调试用)。
既然是抽象语法树(一棵树),就必然会用到节点(Node),这个应该不难理解。
看起来非常简单,对吧。下面是节点(Node)的定义:
//ast.go
type Node interface {
Pos() token.Position // 节点的开始位置
End() token.Position // 节点的结束位置
TokenLiteral() string // 节点对应的词元(Token)字面量(Token Literal)
String() string // 节点的字符串表示(调试用)
}
这个是节点(Node)的定义。所有的表达式和将来要使用到的语句都必须要实现这个节点(Node)接口。
我们将要构建的抽象语法树( AST)就是由彼此相关联的
节点(Node)组成的。
那么怎么用代码表示我们的表达式呢?刚才提到了表达式是一个节点。所以表达式的接口如下:
type Expression interface {
Node //`表达式`是一个节点(Node)
//一个空的dummy方法,表示此节点是一个`表达式`节点,用来区别于将来的`语句`节点
expressionNode()
}
有了节点(Node)和表达式(Expression)的说明,让我们再来看看【算术表达式】:
1 + 2
这其实就是一个中缀表达式(Infix Expression)。中缀表达式就是类似如下的表达式:
<expression> operator <oexpression>
//<表达式> 操作符 <表达式>
那么对于1+2这个算术表达式,1就是左表达式,+是操作符,而2是右表达式。
可以看到中缀表达式需要下面的信息:
- 中缀表达式对应的词元(Token)
- 操作符(operator)
- 操作符左边的表达式和右边的表达式
//ast.go
//中缀表达式
type InfixExpression struct {
Token token.Token //词元信息
Operator string //操作符
Right Expression //右表达式
Left Expression //左表达式
}
这个中缀表达式是一个上面我们提到的表达式,因此需要实现表达式接口的所有方法:
//ast.go
//开始位置
func (ie *InfixExpression) Pos() token.Position { return ie.Token.Pos }
//结束位置(即右表达式的结束位置)
func (ie *InfixExpression) End() token.Position { return ie.Right.End() }
//表示此中缀表达式是一个`表达式(expression)`节点,以区别于将来要扩展的`语句(statement)`节点
func (ie *InfixExpression) expressionNode() {}
//节点的字面量
func (ie *InfixExpression) TokenLiteral() string { return ie.Token.Literal }
//中缀表达式节点的字符串表示
func (ie *InfixExpression) String() string {
var out bytes.Buffer
out.WriteString("(")
out.WriteString(ie.Left.String()) //左边表达式的字符串表示
out.WriteString(" " + ie.Operator + " ") //操作符
out.WriteString(ie.Right.String()) //右边表达式的字符串表示
out.WriteString(")")
return out.String()
}
不算很复杂,是吧。那么怎么用代码表示【算术表达式】中的【数字】呢?其实【数字】也是一个表达式,这个表达式非常简单,请看下面的代码:
//ast.go
//数字字面量
type NumberLiteral struct {
Token token.Token //词元信息
Value float64 //数字表示的值,这里只支持float类型(就是说对于整型,内部实际是使用float来存放的)
}
//开始位置
func (nl *NumberLiteral) Pos() token.Position { return nl.Token.Pos }
//结束位置 = 节点开始位置 + 数字字面量的长度。
func (nl *NumberLiteral) End() token.Position {
pos := nl.Token.Pos //节点开始位置
length := utf8.RuneCountInString(nl.Token.Literal) //数字字面量的长度
return token.Position{Filename: pos.Filename, Line: pos.Line, Col: pos.Col + length}
}
//数字字面量是一个`表达式(expression)`节点
func (nl *NumberLiteral) expressionNode() {}
func (nl *NumberLiteral) TokenLiteral() string { return nl.Token.Literal }
func (nl *NumberLiteral) String() string { return nl.Token.Literal }
脚本是由程序(Program)组成的,程序(Program)也是一个节点(Node)。对于这里介绍的简单计算器而言,程序(Program)仅仅包含一个四则运算表达式:
//ast.go
type Program struct {
Expression Expression //仅包含一个四则运算表达式,实际上就是上面介绍的`InfixExpression`
}
//开始位置
func (p *Program) Pos() token.Position {
return p.Expression.Pos()
}
//结束位置
func (p *Program) End() token.Position {
return p.Expression.End()
}
//字面量
func (p *Program) TokenLiteral() string {
return p.Expression.TokenLiteral()
}
//程序(Program)节点的字符串表示
func (p *Program) String() string {
var out bytes.Buffer
out.WriteString(p.Expression.String())
return out.String()
}
最后,我们的四则运算表达式,还需要支持类似下面的运算:
-2
+2
这里的例子就是前缀表达式(prefix expression)的例子,很好理解。前缀表达式的形式如下:
//-2, +2
operator <expression>
前缀表达式的抽象语法树(AST)表示和中缀表达式差不多,甚至更简单。因为没有了左表达式,而只包含右表达式:
//ast.go
//前缀表达式
type PrefixExpression struct {
Token token.Token //词元信息
Operator string //操作符
Right Expression //右表达式
}
//开始位置
func (pe *PrefixExpression) Pos() token.Position { return pe.Token.Pos }
//结束位置
func (pe *PrefixExpression) End() token.Position { return pe.Right.End() }
//前缀表达式是一个表达式(Expression)节点
func (pe *PrefixExpression) expressionNode() {}
func (pe *PrefixExpression) TokenLiteral() string { return pe.Token.Literal }
//前缀表达式的字符串表示
func (pe *PrefixExpression) String() string {
var out bytes.Buffer
out.WriteString("(")
out.WriteString(pe.Operator)
out.WriteString(pe.Right.String())
out.WriteString(")")
return out.String()
}
细心的读者会注意到,前述所说的所有的表达式结构都有一个词元(Token)信息字段,这个字段主要是用来解析、调试和报错用的。
这里强调一下,将来所有的节点结构,都包含这个
Token字段。
四则运算的解析
对于语法解析器(Parser),实际上有几种软件行业采用的方法(这里说的是主要方法):
- EBNF(扩展巴克斯范式)
- Pratt解析器(Pratt parser)
- Split & Merge
上面列出的前两种比较常用,第三种方法不是很常用。有兴趣的读者可以参考相关的文档学习。我们这里使用的是第二种Pratt解析器(Pratt parser)。
Pratt解析器(Pratt parser)
在序言部分,我说过我不会讲一些深奥的理论知识。为了不把初学的读者吓退,所以关于Pratt解析的具体内容就不展开来讲了,当然有兴趣可以自己阅读。它的具体内容就是:对于每一个词元类型(Token Type),我们可以有两个函数去处理它infix(中缀)或者prefix(前缀)。选择哪个函数取决于词元(Token)在哪个位置。
举个例子,对于-(减号)这个操作符,如果用在2-1这种表达式中,它就是个中缀表达式,而如果用在-2这样的表达式中,它就是个前缀表达式。对于这两种位置,我们可以用两种不同的方法来处理它。
之前讲过,我们的语法解析器(Parser)的输入是词元(Token),它由词法分析器(Lexer)分析脚本后生成。
语法解析器(Parser)需要什么样的信息呢?
- 词法分析器(Lexer),用来获取词法分析器产生词元(Token)
- 语法错误的时候,需要尽可能多的捕获错误信息,即错误信息
- 当前词元(Token)和下一个词元(Token)
- Pratt解析器需要用到的前缀和中缀函数
//定义Pratt解析器用到的函数
type (
//处理前缀表达式的函数
prefixParseFn func() ast.Expression
//处理中缀表达式的函数(参数为中缀表达式的左表达式)
infixParseFn func(ast.Expression) ast.Expression
)
//解析器
type Parser struct {
l *lexer.Lexer //词法分析器,用来通过其读取词元(Token)信息
errors []string //存放错误处理信息
//存放错误处理行号(这个主要是用在和网页交互上使用,将来会用到,暂时请忽略)
errorLines []string
curToken token.Token //当前词元
peekToken token.Token //当前词元的下一个词元
//前缀表达式map,key是一个词元类型,值是需要处理的前缀表达式函数
prefixParseFns map[token.TokenType]prefixParseFn
//中缀表达式map,key是一个词元类型,值是需要处理的中缀表达式函数
infixParseFns map[token.TokenType]infixParseFn
}
这里面比较难理解的是前缀表达式map和中缀表达式map。简单来讲的话,就是对于语法解析过程中遇到的词元类型(Token Type),会调用相对应的函数去解析。如果还是不好理解,那么没关系,我们先把这个暂且放一边,接着看下面的分析,一会你就会慢慢理解这个。
对于前缀表达式函数和中缀表达式函数,我们给语法解析器(Parser)提供两个简单的函数:
//parser.go
//注册前缀表达式函数
func (p *Parser) registerPrefix(tokenType token.TokenType, fn prefixParseFn) {
p.prefixParseFns[tokenType] = fn
}
//注册中缀表达式函数
func (p *Parser) registerInfix(tokenType token.TokenType, fn infixParseFn) {
p.infixParseFns[tokenType] = fn
}
这两个函数应该很好理解,根据传入的词元类型和回调函数来填充map。
语法解析器(Parser)要不断的读取词元(Token),根据得到的词元(Token)信息来生成相应的抽象语法树。所以这里需要给语法解析器提供一个获取下一个词元(Token)的函数:
//parser.go
//获取下一个词元(Token),将其储存在语法分析器的curToken和peekToken变量中
func (p *Parser) nextToken() {
p.curToken = p.peekToken
p.peekToken = p.l.NextToken()
}
接下来,看一下我们如何初始化语法解析器(Parser):
//parser.go
//解析器的参数为词法分析器(Lexer)
func NewParser(l *lexer.Lexer) *Parser {
p := &Parser{
l: l,
errors: []string{},
errorLines: []string{},
}
p.registerAction()
//通过两次调用nextToken,语法分析器的curToken变量中保存的就是输入的第一个词元(Token),
//peekToken保存的就是第二个词元(Token)。
p.nextToken()
p.nextToken()
return p
}
这个初始化里面,我们没有介绍到registerAction()这个函数。在介绍这个函数之前,我还是要再次强调之前说的话:对于前缀表达式map和中缀表达式map,简单来讲的话,就是对于语法解析过程中遇到的词元类型(Token Type),会调用相对应的函数去解析。这个是Pratt解析器(Pratt Parser)的核心功能之一。
Pratt解析器(Pratt Parser)另外一个核心功能是对于优先级的处理,之后会讲到。
如果仔细理解上面的这句话,那么我们的registerAction()函数就是显而易懂的了:
//parser.go
//注册前缀表达式方法和中缀表达式函数
func (p *Parser) registerAction() {
//前缀表达式
p.prefixParseFns = make(map[token.TokenType]prefixParseFn)
//遇到数字,会调用parseNumber函数来处理
p.registerPrefix(token.TOKEN_NUMBER, p.parseNumber)
//遇到类似`+2`这样的表达式中的`+`操作符,调用parsePrefixExpression函数来处理
p.registerPrefix(token.TOKEN_PLUS, p.parsePrefixExpression)
//遇到类似`-2`这样的表达式中的`-`操作符,调用parsePrefixExpression函数来处理
p.registerPrefix(token.TOKEN_MINUS, p.parsePrefixExpression)
//遇到'(',调用parseGroupedExpression来处理
p.registerPrefix(token.TOKEN_LPAREN, p.parseGroupedExpression)
//中缀表达式
p.infixParseFns = make(map[token.TokenType]infixParseFn)
//遇到类似`3+2`这样的表达式中的`+`操作符,调用parseInfixExpression函数来处理
p.registerInfix(token.TOKEN_PLUS, p.parseInfixExpression)
p.registerInfix(token.TOKEN_MINUS, p.parseInfixExpression)
p.registerInfix(token.TOKEN_MULTIPLY, p.parseInfixExpression)
p.registerInfix(token.TOKEN_DIVIDE, p.parseInfixExpression)
p.registerInfix(token.TOKEN_MOD, p.parseInfixExpression)
p.registerInfix(token.TOKEN_POWER, p.parseInfixExpression)
}
上面的registerAction()函数,主要就是注册一堆词元类型(Token Type)和与其对应的前缀表达式和中缀表达式回调函数。如何理解呢?举个例子:
-2 + 3 * 4 - (6 / 3)
对于这样的输入,我们看一下registerAction函数所做的工作:
字面量 词元类型 注册的回调函数
---------------------------------------------------------------------
- ---> TOKEN_MINUS ---> parsePrefixExpression
2 ---> TOKEN_NUMBER ---> parseNumber
+ ---> TOKEN_PLUS ---> parseInfixExpression
3 ---> TOKEN_NUMBER ---> parseNumber
* ---> TOKEN_MULTIPLY ---> parseInfixExpression
4 ---> TOKEN_NUMBER ---> parseInfixExpression
- ---> TOKEN_MINUS ---> parseInfixExpression
( ---> TOKEN_LPAREN ---> parseGroupedExpression
6 ---> TOKEN_NUMBER ---> parseInfixExpression
/ ---> TOKEN_DIVIDE ---> parseInfixExpression
3 ---> TOKEN_NUMBER ---> parseNumber
) ---> TOKEN_RPAREN ---> --
结束标志 ---> TOKEN_EOF ---> --
使用更通俗的说法,就是当语法解析器(Parser)遇到相应的词元类型(Token Type),会调用注册过的回调函数来执行相应的动作。
使用一幅图,可能更形象一些:
下面让我们来一步一步实现语法解析的相关代码。
解析程序(Parse Program)
脚本是由程序(Program)组成的,我们先看一下之前定义的的Program抽象语法树表示:
//ast.go
type Program struct {
Expression Expression //仅包含一个四则运算表达式
}
解析Program节点的代码也比较简单:
func (p *Parser) ParseProgram() *ast.Program {
program := &ast.Program{} //生成一个Program节点
program.Expression = p.parseExpression(LOWEST) //解析表达式
return program
}
这里需要注意的是第四行的parseExpression(暂时还没有讲到)函数的参数LOWEST,这个就涉及到Pratt解析器的第二个核心功能【优先级】。
我们小时候学数学的时候,老师告诉我们乘除法的优先级高于加减法。那么Pratt解析器如何去处理这个概念的呢?
和之前的registerAction()函数类似,在这个registerAction()函数里,每个词元类型(Token Type)对应一个回调函数。同样,Pratt解析器为了处理优先级这个概念,它规定对于每个词元类型(Token Type)(当然这里主要是指前缀和中缀操作符),也可以有一个相应的优先级。
我们使用代码具体来说:
//parser.go
const (
_ int = iota
LOWEST //值为1
SUM //+, -
PRODUCT //*, /, %, **
PREFIX //-X, +X
)
var precedences = map[token.TokenType]int{
token.TOKEN_PLUS: SUM,
token.TOKEN_MINUS: SUM,
token.TOKEN_MULTIPLY: PRODUCT,
token.TOKEN_DIVIDE: PRODUCT,
token.TOKEN_MOD: PRODUCT,
token.TOKEN_POWER: PRODUCT,
}
从上面的代码中我们可以看到,我们给+(TOKEN_PLUS)和-(TOKEN_MINUS)定义的优先级为SUM(值为2),给
*(TOKEN_MULTIPLY)、/(TOKEN_DIVIDE)、%(TOKEN_MOD)和**(TOKEN_POWER)定义的优先级为PRODUCT(值为3)。简单来讲,就是我们给乘除法、取模、乘方定义的优先级比加减法高。而前缀+和前缀-的优先级为PREFIX(值为4),就是最高的优先级。
细心的读者可能会觉得奇怪,precedences变量中为啥没有-X或者+X对应的优先级PREFIX。这是因为我们会在parsePrefixExpression方法里使用这个优先级,稍后读者会看到。
解析表达式(expression)
在parseProgram方法中,调用了parseExpression函数,这个函数就是语法解析器(Parser)中最核心的函数。它不仅处理优先级,还同时处理之前介绍的回调函数。请看下面的代码:
//parser.go
//处理表达式:传入的是当前词元的【优先级】
func (p *Parser) parseExpression(precedence int) ast.Expression {
//对当前的词元类型,调用【前缀回调函数】
prefix := p.prefixParseFns[p.curToken.Type]
if prefix == nil { //如果没有找到,则报错
p.noPrefixParseFnError(p.curToken.Type)
return nil
}
leftExp := prefix() //调用前缀回调函数
// 调用中缀回调函数,直到下一个词元(Token)有更高的优先级,就退出循环
for precedence < p.peekPrecedence() {
infix := p.infixParseFns[p.peekToken.Type]
if infix == nil {
return leftExp
}
p.nextToken()
leftExp = infix(leftExp) //调用中缀回调函数,将前缀回调函数的返回值作为参数传递给中缀表达式
}
return leftExp
}
从代码中可以看到,parseExpression首先会调用之前注册的前缀表达式的回调函数,如果没有找到,就会报错。如果找到了,接着就会循环处理中缀表达式的回调函数,直到遇到下一个词元(Token)有更高的优先级,就退出循环。
关于这个优先级的描述,由于这里面涉及到函数的递归调用,所以比较晦涩难懂。这就是使用Pratt解析器的好处,我们只需要按照Pratt解析器方法来实现就可以了(这就是【站在巨人的肩膀上】)。
当然,如果读者对Pratt解析的细节感兴趣的话,可以在百度、必应、谷歌里面搜索【Pratt Parsing】。
对于这个parseExpression函数,如果还是感觉不太理解的话, 你只需要知道它就是Pratt Parsing的处理逻辑。读者之后会发现,我们后续的章节加入了很多更高级的语言特性,这个函数也从未有任何改变。它老老实实的待在那里,默默无闻的做着最核心的事情(调用前缀回调函数,调用中缀回调函数,处理优先级),从来不抱怨:smile:。
假设我们的四则运算表达式如下:
5 + 2 * 3
对于这个输入,调用parseExpression函数后,生成的抽象语法树(AST)如下:
parseExpression函数里调用了一个peekPrecedence函数,顾名思义就是取得下一个词元(Token)的优先级:
//parser.go
func (p *Parser) peekPrecedence() int {
//从变量为precedences的map中取出下一个词元(Token)对应的优先级
if p, ok := precedences[p.peekToken.Type]; ok {
return p
}
return LOWEST
}
同样的,我们还需要一个获取当前词元(Token)优先级的函数:
//parser.go
func (p *Parser) curPrecedence() int {
//从变量为precedences的map中取出当前词元(Token)对应的优先级
if p, ok := precedences[p.curToken.Type]; ok {
return p
}
return LOWEST
}
parseExpression函数里调用了一个noPrefixParseFnError函数,我们还没有涉及到。当语法解析器(Parser)找不到当前词元对应的前缀函数的时候,会调用这个noPrefixParseFnError函数报告语法错误:
//parser.go
//没有找到当前词元(Token)对应的前缀函数的时候,会报告语法错误
func (p *Parser) noPrefixParseFnError(t token.TokenType) {
if t != token.TOKEN_EOF {
msg := fmt.Sprintf("Syntax Error:%v - no prefix parse functions for '%s' found",
p.curToken.Pos, t)
//将语法错误信息加入到errors数组
p.errors = append(p.errors, msg)
//将语法错误的当前行号加入到errorLines数组
p.errorLines = append(p.errorLines, p.curToken.Pos.Sline())
}
}
我们还需要给语法解析器(Parser)提供一些常用的函数:
- 当我们期待下一个词元(Token)必须是某个类型的
expectPeek()函数。 - 判断当前词元(Token)的类型是否为某个期望的类型的
curTokenIs()函数。 - 判断下一个词元(Token)的类型是否为某个期望的类型的
peekTokenIs()函数。 - 当下一个词元(Token)的类型不是期望的类型的时候的报错函数
peekError()。 - 获取语法错误信息的函数
Errors()。 - 获取语法错误行号的函数
ErrorLines()。
//parser.go
//判断当前词元(Token)类型是否为指定的词元(Token)类型
func (p *Parser) curTokenIs(t token.TokenType) bool {
return p.curToken.Type == t
}
//判断下一个词元(Token)类型是否为指定的词元(Token)类型
func (p *Parser) peekTokenIs(t token.TokenType) bool {
return p.peekToken.Type == t
}
//期待下一个词元(Token)类型必须为某个类型。如果是,就读取下一个词元并返回true。
//否则,报告语法错误并返回false。
func (p *Parser) expectPeek(t token.TokenType) bool {
if p.peekTokenIs(t) {
p.nextToken()
return true
}
peekError(t)
return false
}
func (p *Parser) peekError(t token.TokenType) {
newPos := p.curToken.Pos
//注:这里不要使用'len(p.curToken.Literal)'。因为标识符中可以包含中文等多字节字符。
newPos.Col = newPos.Col + utf8.RuneCountInString(p.curToken.Literal)
msg := fmt.Sprintf("Syntax Error:%v- expected next token to be %s, got %s instead",
newPos, t, p.peekToken.Type)
p.errors = append(p.errors, msg)
p.errorLines = append(p.errorLines, p.curToken.Pos.Sline())
}
//获取语法错误信息
func (p *Parser) Errors() []string {
return p.errors
}
//获取语法错误行号
func (p *Parser) ErrorLines() []string {
return p.errorLines
}
上面的几个语法解析器(Parser)函数理解起来应该都比较简单。需要注意的就是peekError函数,我们汇报错误的时候,行号应该是报告当前词元(Token)的位置,而不是下一个词元(peek Token)的位置。如果我们将其更改为如下的代码(不正确的版本):
func (p *Parser) peekError(t token.TokenType) {
newPos := p.peekToken.Pos
newPos.Col = newPos.Col + len(p.peekToken.Literal) //当前词元的位置+当前词元的长度
msg := fmt.Sprintf("Syntax Error:%v- expected next token to be %s, got %s instead",
newPos, t, p.peekToken.Type)
p.errors = append(p.errors, msg)
p.errorLines = append(p.errorLines, p.peekToken.Pos.Sline())
}
报告的错误行号,反而会让人感到困惑。这是为什么呢?来看一个例子,假设我们有如下的脚本代码(这里的代码是将来会实现的脚本代码,用在这里只是为了说明问题):
println("hello world!"
println()
注意:第1行,我故意少写了一个)。如果peekError函数使用的是不正确的版本,那么会报告如下错误:
Syntax Error: <examples/xxx.mp:3:1> - expected next token to be ), got IDENTIFIER instead
可以看到,它报告的是下一个词元(peek Token)即第3行的println所在的位置(因为第2行是个空行)。这会让人感到非常困惑。
如果peekError函数使用的是正确的版本,那么报告如下错误:
Syntax Error: <examples/xxx.mp:1:21> - expected next token to be ), got IDENTIFIER instead
可以看到,语法解析器正确的汇报出了错误的行号。我们希望获取的是当前词元的位置+当前词元的长度这个错误位置,而不是下一个词元的位置(因为如果有多个空行或者多个注释行的情况下,下一个词元的行号可能离现在词元的行号位置相差很远了)。
现在我们的语法解析器(Parser)还剩下registerAction()函数里的回调函数没有实现,分别是:
- 解析数字的函数
parserNumber。 - 解析括号的函数
parseGroupedExpression。 - 解析中缀表达式的函数
parseInfixExpression。 - 解析前缀表达式的函数
parsePrefixExpression。
下面我们来逐步实现这几个函数:
首先是parseNumber函数,用来解析数字:
//parser.go
//解析数字
func (p *Parser) parseNumber() ast.Expression {
lit := &ast.NumberLiteral{Token: p.curToken} //生成NumberLiteral节点
//取出当前词元包含的字面量值,并将其转换为浮点数(float64)。
value, err := strconv.ParseFloat(p.curToken.Literal, 64)
if err != nil { //如果无法解析,则报错
msg := fmt.Sprintf("Syntax Error:%v - could not parse %q as float",
p.curToken.Pos, p.curToken.Literal)
p.errors = append(p.errors, msg)
p.errorLines = append(p.errorLines, p.curToken.Pos.Sline())
return nil
}
lit.Value = value
return lit
}
这个parseNumber函数返回的是NumberLiteral这个抽象语法树结构。
接下来是解析括号的函数parseGroupedExpression:
//parser.go
//解析括号
func (p *Parser) parseGroupedExpression() ast.Expression {
p.nextToken()
exp := p.parseExpression(LOWEST)
//解析完成后,判断下一个词元类型是否为右括号。如果不是的话,报错
if !p.expectPeek(token.TOKEN_RPAREN) {
return nil
}
return exp
}
再接下来是解析中缀表达式的函数parseInfixExpression:
//parser.go
// 解析中缀表达式: <left-expression> operator <right-expression>
func (p *Parser) parseInfixExpression(left ast.Expression) ast.Expression {
//生成中缀表达式节点
expression := &ast.InfixExpression{
Token: p.curToken,
Operator: p.curToken.Literal, //当前节点的操作符
Left: left, //左表达式
}
precedence := p.curPrecedence()
// 如果当前的词元类型是'**', 我们希望它是右结合的。例如: 3 ** 2 ** 3 = 3 ** (2 ** 3)
// 这里的做法非常简单,只需要将其优先级减去【1】即可。
// 当然如果希望'**'是左结合的,那么下面的判断是不需要的。
if p.curTokenIs(token.TOKEN_POWER) {
precedence--
}
p.nextToken()
expression.Right = p.parseExpression(precedence) //处理右表达式
return expression
}
这里唯一需要注意的就是词元类型为**的时候的处理。我已经在代码的注释中写的比较清楚了,理解起来也应该不算困难。
最后是处理前缀表达式的函数parsePrefixExpression:
//parser.go
// 解析前缀表达式: <prefix-operator><right-expression>
func (p *Parser) parsePrefixExpression() ast.Expression {
expression := &ast.PrefixExpression{Token: p.curToken, Operator: p.curToken.Literal}
p.nextToken()
//注意:这里给parseExpression函数传入的是'PREFIX'的优先级,这个的优先级是最高的。所以诸如:
// 2 + -2, 2 - -2
// 都是合法的表达式。
// 它们会被处理成:
// 2 + (-2), 2 - (-2)
expression.Right = p.parseExpression(PREFIX)
return expression
}
还有一点不能忘记了,当遇到不合法的词元类型(TOKEN_ILLEGAL)的时候,我们也需要处理这种情况,因为这个所谓的Illegal词元类型可能会出现在程序的任何位置,所以我们必须为其注册前缀和中缀表达式回调函数:
//parser.go
func (p *Parser) registerAction() {
p.prefixParseFns = make(map[token.TokenType]prefixParseFn)
p.registerPrefix(token.TOKEN_ILLEGAL, p.parsePrefixIllegalExpression)
//...
p.infixParseFns = make(map[token.TokenType]infixParseFn)
p.registerPrefix(token.TOKEN_ILLEGAL, p.parseInfixIllegalExpression)
//...
}
func (p *Parser) parsePrefixIllegalExpression() ast.Expression {
msg := fmt.Sprintf("Syntax Error:%v - Illegal token found. Literal: '%s'",
p.curToken.Pos, p.curToken.Literal)
p.errors = append(p.errors, msg)
p.errorLines = append(p.errorLines, p.curToken.Pos.Sline())
return nil
}
func (p *Parser) parseInfixIllegalExpression() ast.Expression {
msg := fmt.Sprintf("Syntax Error:%v - Illegal token found. Literal: '%s'",
p.curToken.Pos, p.curToken.Literal)
p.errors = append(p.errors, msg)
p.errorLines = append(p.errorLines, p.curToken.Pos.Sline())
return nil
}
parsePrefixIllegalExpression()函数和parseInfixIllegalExpression函数的内容是一样的,都是简单的报告一个语法错误。当然如果你觉得代码重复了的话,你可以使其中一个函数来调用另一个函数,像下面这样:
//parser.go
func (p *Parser) parsePrefixIllegalExpression() ast.Expression {
msg := fmt.Sprintf("Syntax Error:%v - Illegal token found. Literal: '%s'",
p.curToken.Pos, p.curToken.Literal)
p.errors = append(p.errors, msg)
p.errorLines = append(p.errorLines, p.curToken.Pos.Sline())
return nil
}
func (p *Parser) parseInfixIllegalExpression() ast.Expression {
return parsePrefixIllegalExpression()
}
恭喜恭喜!我们的语法解析器(Parser)终于大功告成了。
测试语法解析器
现在我们写一个简单的测试程序,来验证一下我们的语法解析器(Parser):
//main.go
func TestParser() {
input := " 1 + 2 * (5 - 3) * 3" //输入
expected := "(1 + ((2 * (5 - 3)) * 3))" //期待值
l := lexer.NewLexer(input)
p := parser.NewParser(l)
program := p.ParseProgram()
if len(p.Errors()) != 0 { //如果语法有错误,则报告错误
for _, err := range p.Errors() {
fmt.Println(err)
}
os.Exit(1)
}
if program.String() != expected {
fmt.Printf("Syntax error: expected %s, got %s\n", expected, program.String())
os.Exit(1)
}
fmt.Printf("input = %s\n", input)
fmt.Printf("output = %s\n", program.String())
}
func main() {
TestParser()
}
有的读者可能会有个疑问,对于带有括号的算术表达式,我们好像并没有处理括号的优先级,实际上这个括号的优先级是在parseGroupedExpression函数中处理的。但是我们在parseGroupedExpression函数里并没有看到处理任何有关优先级的代码啊,这是怎么回事?这就是Pratt解析器的神奇之处。是的,就是这么神奇!上面的例子能够正常运行,且返回正确的结果。
读者可以多写一些测试用例,来深入理解一下语法解析器(Parser)是如何工作的。
解释器(Evaluator)
通过上面的学习,读者应该了解了语法解析器(Parser)的实现步骤。现在到了最激动人心的时刻了。请大家稍安勿躁,容我慢慢讲来。
我们的解释器(Evaluator)的输入是抽象语法树(AST),解释器(Evaluator)通过解释抽象语法树(AST)来实现相应的脚本动作。
在实现之前,我们先想一想,我们需要定义什么信息,来表示解释器(Evaluator)的返回值呢?这就引出了我们将要讲的对象(Object)。
有的书籍或者文档中,使用Value这个概念,和我们这里讲的Object差不多是一个概念,只不过使用的是不同的术语罢了。
对象表示 (Representing Objects )
对于我们的简单计算器,当解释器(Evaluator)解释抽象语法树(AST)的时候,我们需要一个对象结构,来表示抽象语法树的计算结果(或者说中间结果)。举个例子:
1 + 2 + 3
当语法解析器遇到这样的表达式之后,它生成的抽象语法树如下图所示:
解释器(Evaluator)遇到上面的抽象语法树(AST)后,它需要将1+2的结果【3】先保存起来,然后再和3相加。
那么解释器(Evaluator)如何保存1+2的结果呢?这就需要用到解释器(Evaluator)的对象系统(Object system)了。
那么这个对象(Object)需要什么样的信息呢?
- 对象的类型(Object Type)
- 对象的字符串表示(输出或者调试用)
下面来看看对象(Object)的代码表示:
//object.go
//对象类型,这里为了便于理解,使用的是字符串类型,实际上还可以是整形。
type ObjectType string
//对象接口
type Object interface {
Type() ObjectType // 对象的类型
Inspect() string // 对象的字符串表示(输出或者调试用)
}
对于简单的计算器,我们如何表示我们的数字对象(Number Object)呢?其实对于 数字对象(Number Object),它唯一需要保存的就是数字,这个其实很好理解,下面是数字对象(Number Object)的代码表示:
//object.go
const (
NUMBER_OBJ = "NUMBER"
)
// 数字对象(实现了Object接口)
type Number struct {
Value float64 //数字对象存储的数字值
}
func (n *Number) Inspect() string {
return fmt.Sprintf("%g", n.Value) //将对象中存储的数字,转换为字符串返回
}
func (n *Number) Type() ObjectType { return NUMBER_OBJ }
// 这里提供一个工具函数(utility function)
// 根据传入的浮点型参数,生成一个新的数字对象。
func NewNumber(f float64) *Number {
return &Number{Value: f}
}
是不是觉得不可思议,简单到难以置信!
不要把这里的对象(Object)和面向对象系统中的对象搞混了,它们完全不是一个概念。
解释抽象语法树(AST)
有了这个对象(Object)系统后,接下来让我们看一下解释器(Evaluator)如何解释抽象语法树(AST):
//eval.go
//解释AST
func Eval(node ast.Node) Object {
switch node := node.(type) {
case *ast.Program: //遇到程序节点
return evalProgram(node)
case *ast.NumberLiteral: //遇到数字节点
return evalNumber(node)
case *ast.PrefixExpression: //遇到前缀表达式节点
right := Eval(node.Right) //解释右表达式
return evalPrefixExpression(node, right)
case *ast.InfixExpression: //遇到中缀表达式节点
left := Eval(node.Left) //解释左表达式
right := Eval(node.Right) //解释右表达式
return evalInfixExpression(node, left, right)
}
return nil
}
从上面的代码中可以看到,Eval()函数的内部是由一个巨大的swtich语句组成的。遇到特定的节点(Node)就解释(Evaluating)相应的节点,这个还是比较好理解的,而Eval()函数的返回值就是我们前面提到的对象(Object)。
下面我们分别来实现上面switch分支中的函数,这些函数的实现可能比你们想象的简单很多。
解释程序(Program)节点
//eval.go
func evalProgram(program *ast.Program) (results Object) {
results = Eval(program.Expression) //解释程序(Program)节点中包含的表达式
return results
}
对于程序(Program)节点的解释,仅仅是解释程序(Program)节点包含的表达式,这个应该也是比较好理解的。
下面我们再来看看如何解释数字节点。
解释数字节点
//eval.go
//解释数字节点: 3.23, 5,...
func evalNumber(n *ast.NumberLiteral) Object {
return NewNumber(n.Value) //生成一个数字对象并返回
}
当遇到数字节点的时候,我们只是简单的取出数字节点(*ast.NumberLiteral)中的值,然后调用NewNumber来生成一个之前介绍的数字对象(Number Object),然后返回这个数字对象。
解释前缀表达式(prefix-expression)节点
//eval.go
//解释前缀表达式节点: -1.5, +2.3,...
func evalPrefixExpression(node *ast.PrefixExpression, right Object) Object {
switch node.Operator {
case "+":
return evalPlusPrefixOperatorExpression(node, right)
case "-":
return evalMinusPrefixOperatorExpression(node, right)
default:
return nil
}
}
func evalPlusPrefixOperatorExpression(node *ast.PrefixExpression, right Object) Object {
if right.Type() != NUMBER_OBJ {
return nil
}
return right
}
func evalMinusPrefixOperatorExpression(node *ast.PrefixExpression, right Object) Object {
if right.Type() != NUMBER_OBJ {
return nil
}
value := right.(*Number).Value
return NewNumber(-value)
}
对于前缀表达式节点,如果遇到诸如+1.3之类的节点的时候,我们只是判断一下对象的类型是否为数字类型,如果是的话,就简单返回这个数字对象(Number object)。当遇到诸如-2.5之类的节点的时候,同理,我们也只是判断一下对象的类型是否为数字类型,如果是的话,就将对象的值取负数,然后返回一个新的数字对象(Number Object)。
细心的读者可能会注意到,我们的前缀表达式节点中,其实是不可能存在非数字节点的,所以上面的判断不是必须的。没错,对于简单的四则运算,我们确实不需要这个判断。但是我们将来会扩展这个方法,使其支持类似
!true,!false等语法,即支持!这种前缀。
解释中缀表达式(infix-expression)节点
//eval.go
//解释中缀表达式节点: 1.5 + 2.3, 3 * 5, ...
func evalInfixExpression(node *ast.InfixExpression, left, right Object) Object {
switch {
case left.Type() == NUMBER_OBJ && right.Type() == NUMBER_OBJ:
return evalNumberInfixExpression(node, left, right)
default:
return nil
}
}
func evalNumberInfixExpression(node *ast.InfixExpression, left, right Object) Object {
leftVal := left.(*Number).Value //取出左边的数字对象(Number Object)中保存的值
rightVal := right.(*Number).Value //取出右边的数字对象(Number Object)中保存的值
switch node.Operator {
case "+":
return &Number{Value: leftVal + rightVal}
//还可以使用: return NewNumber(leftVal + rightVal)
case "-":
return &Number{Value: leftVal - rightVal}
case "*":
return &Number{Value: leftVal * rightVal}
case "/":
return &Number{Value: leftVal / rightVal}
case "%":
v := math.Mod(leftVal, rightVal)
return &Number{Value: v}
case "**":
return &Number{Value: math.Pow(leftVal, rightVal)}
default:
return nil
}
}
这个代码实际上没有太多可说的。取出数字对象中存储的值,然后利用go语言进行四则运算后,生成新的数字对象(Number Object)返回。
注意:这里并没有处理除法的除数为【0】的情况,只是为了使程序更简单罢了。真正的脚本语言是必须要考虑到这层因素的。当然加上这个逻辑也是比较简单的,相信读者应该能够自行完成。
实际上,如果不处理这种异常情况,
go语言自身也会抛出异常,从而导致我们的脚本语言解释器异常终了。之后我们扩展的时候,会加入异常的捕获。
It's Amazing!不是吗?这就是所有的解释代码。好了,又到了测试解释器的时候了。
测试解释器
//main.go
func TestEval() {
tests := []struct {
input string //输入
expected string //期待值
}{
{"-1 - 2.333", "-3.333"},
{"+1 + 2", "3"},
{"2 + (3 * 4) / ( 6 - 3 ) + 10", "16"},
{"2 + 3 * 4 / 6 - 3 + 10", "11"},
{"(5 + 2) * (4 - 2) + 6", "20"},
{"5 + 2 * 4 - 2 + 6", "17"},
{"5 + 2.1 * 4 - 2 + 6.2", "17.6"},
{"2 + 2 ** 2 ** 3", "258"},
{"10", "10"},
}
for _, tt := range tests {
l := lexer.NewLexer(tt.input) //创建新的词法分析器
p := parser.NewParser(l) //将词法分析器(Lexer)作为输入传给语法解析器(Parser)
program := p.ParseProgram() //解析程序(Program)节点
evaluated := eval.Eval(program) //解释程序
if evaluated != nil {
if evaluated.Inspect() != tt.expected {
fmt.Printf("%s", evaluated.Inspect())
} else {
fmt.Printf("%s = %s\n", tt.input, tt.expected)
}
}
}
}
func main() {
TestEval()
}
总结
我们从介绍词元(Token)开始,到词法分析器(Lexer)输出词元(Token),再到语法解析器(Parser)根据输入的词元(Token),解析后生成抽象语法树(AST),最后到解释器(Evaluator)解释抽象语法树(AST),返回对象系统(Object System)中的对象。一步一个脚印的实现了一个简单的能够处理四则运算的计算器。有没有点非常激动人心的感觉?
很多读者可能会说,这么简单的四则运算的实现,你这也搞得太复杂了吧。这个我必须得承认,确实搞得有点太复杂了。使用堆栈的方式(或者EBNF的方式)很容易就能够实现一个这样的四则运算解释器了。但是这个设计,为以后的程序扩展带来了很多的便利。之后的文章,我们会在现有的代码的基础上,实现if-else判断,for/while循环(break, continue),数组/元组/哈希支持,函数(支持多值返回),内置函数,导入go语言的package和变量,导入(import)别人写的代码,结构(struct)支持,甚至让我们的解释器在浏览器中运行等等功能。
鉴于本篇的内容比较多,而且是后续文章的基础,所以这里建议初学者认真体会。最好多读几遍,以达到最终完全掌握的目的。
如果读者希望进一步学习的话,请继续关注我的下一篇文章,我们下次见!