前言,一下内容引用作者博客。

Go实现的数学表达式解析计算引擎插图

math-engine-demo

导读

这篇文章将从头开始,使用Go语言来实现一个完整的数学表达式计算引擎。本文采用的是抽象语法树(Abstract Syntax Tree,AST)实现方式。虽然本文的实现代码为Go,但不用纠结于此,语言只是实现方式的一种选择,作为开发工程师,相信你读起来它并不会感到费力。你完全可以在读完文章后,用C/C++Rust或者任意你喜欢的语言来实现一遍,甚至,这是检验你学习成果的佳方式。文章的所有代码和编译后的二进制可执行文件都会放在 Github, 文章末尾附有链接。那么,让我们开始吧。

概述

我们来先做一道题数学题,计算下面的式子:92 + 5 + 5 * 27 – (92 – 12) / 4 + 26

什么?没了??这么简单???( 是的,这么简单

凭借个人实力,我觉得大家完全可以口算给出这道题的答案。但是,你并不会这么做,因为你觉得你是一名工程师,你要用你所擅长的代码来解决这个问题,以至于一发不可收,后解决了这类问题。(我没有、我不想、别瞎说.jpg)

如果不往下看,停顿思考,你的实现思路是什么样的?每个人都会有一个自己的实现思路,事实上,解决这个问题的方法很多,没有必要拘泥于某一种算法。本文为了保证代码实现质量的同时,还能够阐述基础的语言处理结构,这里使用的是抽象语法树(Abstract Syntax Tree,AST,下称:AST)实现方式。使用抽象语法树还有一个目的,是窥一斑而知全豹,让大家能了解脚本语言(典型的如PHP、Javascript)是如何动态解析执行的。

所以,看完本章,你会了解到:

词法分析,如何手写一个词法分析器语法分析,如何手写一个语法分析器AST,如何构建一个AST运算优先级算法的实现执行一个 AST

题目分析思路

首先,我们先来分析一下这个题目。92 + 5 + 5 * 27 – (92 – 12) / 4 + 26根据小学学过的理论知识,我们知道:先乘除后加减,有括号优先。所以在我们口算这道题的时候,我们的计算顺序应该是:X = ( 92 – 12 ) = 80Y = 5 * 27 = 135Z = X / 4 = 80 / 4 = 20原题 =92 + 5 + Y – Z + 26=92 + 5 + 135 – 20 + 26=238

为了能够用代码实现这个算法,我们需要提炼出口算时的计算模式,或者说,计算的规律。有了规律,转换成代码实现会很容易。

不知道你有没有注意到,我们口算解这道题的时候,并没有说一下子把题目解出来,而是对题目先做了分解,比如(92-12)是步分解出来的,我们可以先计算这个小的子题,得出80这个结果后,再代入到原式中。这个思路正是上面说的先乘除后加减,有括号优先的具体实践,也是说,运算符是有优先级的,需要根据算式中优先级的高低,先分解优先级高的计算出来,然后计算优先级低的,而不是简单的从题目的起始一直计算到结尾。

很明显,在这道题中,()、的优先级高,*/的优先级次之,+的优先级低。同一优先级可以从左到右顺序计算。确立了优先级,我们能根据上面的口算步骤,模拟一个算法流程:

1、找出较高优先级的子算式92-12,得出结果80,代替92-12的位置;2、找出较高优先级的子算式5*7,得出结果35,代替5*7的位置;3、找出较高优先级的子算式80/4,得出结果20,代替80/4的位置;4、发现优先级都是一样的,可以顺序计算,依次计算得出结果238

图解如下:

Go实现的数学表达式解析计算引擎插图1

图解

模拟的算法流程有了,但是,emmm… 好像还是不知道该从哪里开始码代码,难道要一遍遍的遍历查找计算吗?不。

那进一步分析这个流程,可以发现,在每一步的后一个操作都是做一个简单的运算,然后得出结果,这个简单运算遵循number opeator number,可以很明确的知道,这是一个二元运算,我们记作(lhs op rhs)

lhs, left hand side 的缩写,意为左侧。rhs同理。

所以,回过头,再来规划一下上面的题目和流程,可以总结出:计算一个数学表达式,本质上是把表达式按照运算优先级分出二元运算、运算合并的过程。有了这个认知后,我们可以把这个问题跟我们的数据结构结合在一起,用某种或者多种数据结构来描述这个题目。在计算机中,该如何描述呢?我们可以先改进一下lhs op rhs, 把op提到前面, 转化成op lhs rhs, 因为它们都是一个个二元运算,而每个二元运算都可以看做是一个RootopLeftNodelhsRightNoderhs的二叉树(Root=op LeftNode=lhs RightNode=rhs),那么按照算法流程来构建一个二叉树可以很好的表示这个题目。

终,我们可以得出这张图:

Go实现的数学表达式解析计算引擎插图2

二叉树的表示

整个待计算的表达式可以用二叉树来完美的表示。这是一个令人兴奋的事情,因为我们把一道数学问题成功的转化成了一道数据结构的算法问题,也是说,我们只需要一种算法对这个二叉树求解,能得出原算式的答案。事实上,这是解决这道题的核心所在(不仅仅是这道数学运算题,把范围扩展到整个计算机领域都会适用),一旦我们能够通过数据结构和算法描述现实问题,那么我们可以利用计算机完成问题的映射,并且通过计算得出答案。

解决问题的思路有了,那我们可以开始准备 Coding,实现我们说的数据结构。

词法分析

词法分析:将字符序列转换为单词(Token)序列的过程。

为了能让计算机理解我们输入的字符串,我们需要对这个字符串做解析,那个阶段是词法分析。不要惊慌!即使你从来没接触过这类知识,这个小节也并不会出现让你感到晦涩难懂的地方。相信我!(其实,看完你会觉得很简单)

92 + 5 + 5 * 27 – (92 – 12) / 4 + 26这个算式,对我们而言,有意义的字符/词是什么?我们来逐个拆解一下:

92有意义,因为它是数字,参与运算;+有意义,因为它是操作符,参与运算;5,+,5,*,27,,92,,12,/,4,+,26都有意义,原因同上;(有意义,它表示一个优先级开始;)有意思,它表示一个优先级结束;

当我们把所有有意义的词拆解出来,那剩下的自然是无意义的了。在这个式子中是空格。

上面的这个拆解过程,我们称之为词法分析。通俗的来讲,词法分析是将输入的字符串逐个分析,通过某种方式(一般是有限状态机)来识别有意义的字符,转化成相应的标记Token,无意义的字符直接忽略丢弃,终得到一个全部是有意义的Token序列的过程。上面我们提到了两种类型,一个是数字,一个是操作符(()我们也当做是操作符),在代码中可以定义成常量,供接下来使用:

const(// 字面量,e.g. 50Literal =iota// 操作符, e.g. + – * /Operator )

定义好类型,我们接下来可以定义Token

type Tokenstruct{// 原始字符Tokstring// 类型,有 Literal、Operator 两种Typeint}

只要能够通过词法分析得到的Token,都是有意义的,对于本文来讲,Token只有Literal,Operator两种类型,所以,接下来的扫描过程只需要处理这两种类型行了。词法分析的具体实现代码:

// 定义一个结构体,描述一个词法分析器typeParserstruct{// 输入的字符串Sourcestring// 扫描器当前所在的字符chbyte// 扫描器当前所在的位置offsetint// 扫描过程出现的错误收集err error }// 逐个字符扫描,得到一串 Token 序列func(p *Parser)parse()[]*Token{ toks :=make([]*Token,0)// 一直获取下一个 Tokenfor{ tok := p.nextTok()iftok ==nil{// 已经到达末尾或者出现错误时,停止break}// 收集 Tokentoks =append(toks, tok) }returntoks }// 获取下一个 Tokenfunc(p *Parser)nextTok()*Token{// 已经到达末尾或者出现错误ifp.offset >=len(p.Source) || p.err !=nil{returnnil}varerr error// 跳过所有无意义的空白符forp.isWhitespace(p.ch) && err ==nil{ err = p.nextCh() } start := p.offsetvartok *Tokenswitchp.ch {// 操作符case(,),+,,*,/,^,%: tok = &Token{ Tok:string(p.ch), Type: Operator, } tok.Offset = start// 前进到下一个字符err = p.nextCh()// 字面量(数字)case0,1,2,3,4,5,6,7,8,9:forp.isDigitNum(p.ch) && p.nextCh() ==nil{ } tok = &Token{ Tok: strings.ReplaceAll(p.Source[start:p.offset],“_”,“”), Type: Literal, } tok.Offset = start// 捕获错误default:ifp.ch !={ s := fmt.Sprintf(“symbol error: unkown %v, pos [%v:]\n%s”,string(p.ch), start, ErrPos(p.Source, start)) p.err = errors.New(s) } }returntok }// 前进到下一个字符func(p *Parser)nextCh()error{ p.offset++ifp.offset <len(p.Source) { p.ch = p.Source[p.offset]returnnil}// 到达字符串末尾returnerrors.New(“EOF”) }// 空白符func(p *Parser)isWhitespace(cbyte)bool{returnc ==|| c ==\t|| c ==\n|| c ==\v|| c ==\f|| c ==\r}// 数字func(p *Parser)isDigitNum(cbyte)bool{return0<= c && c <=9|| c ==.|| c ==_|| c ==e}// 对错误包装,进行可视化展示funcErrPos(sstring, posint)string{ r := strings.Repeat(“-“,len(s)) +“\n”s +=“\n”fori :=0; i < pos; i++ { s +=” “} s +=“^\n”returnr + s + r }

为了方便调用,我们封装一下这个Parser结构体,通过一个函数来把它变成一个黑箱,简化流程:

// 封装词法分析过程,直接调用该函数即可解析字符串为[]TokenfuncParse(sstring)([]*Token, error){// 初始化 Parserp := &Parser{ Source: s, err:nil, ch: s[0], }// 调用 parse 方法toks := p.parse()ifp.err !=nil{returnnil, p.err }returntoks,nil}

现在我们只需要调用Parse函数,能够对任意字符串进行分析,得到一串Token序列。

至此,我们所有的词法分析的代码全部完成了。这短短不到100行代码,我们得到了一个可以工作的很好的词法分析器,它能够处理目前我们需要计算数学表达式的输入字符串。(放大一下视角,在编译器领域,对于源代码的阶段的词法分析,和我们现在写的代码并没什么两样,只不过是它们的类型更多,逻辑更复杂而已。这种通过有限状态机的方式来写,虽然会比较繁琐、代码量多,但是贵在逻辑很清楚,大多数语言会用这种算法来实现)

有了Token序列是没有什么卵用的,我们的目的是求解。接下来我们要用它来进行下一阶段:语法分析和构建一个 AST。

语法分析、构建 AST

语法分析:在词法分析的基础上将单词Token序列组合成各类语法短语, 判断源代码在结构上是否正确。

拿到一串Token序列,把它当做一个输入,由一个函数去执行,然后得到一个检查结果,这个是语法分析阶段要做的事情,这个函数是语法分析器。语法分析器得出的结果是对我们输入的整个字符串在语法上的检查结果,它能够告诉我们,这个输入的字符串内容是否能够被识别格式化为下一个阶段目标(这里的目标是构建一个 AST ),举个例子:

1+2+3+4+5>>>>词法分析>>>>语法分析>>>>无错误>>>>AST

1+2+(3-4>>>>词法分析>>>>语法分析>>>>出错,4之后应该要有一个操作符,可能是)或者+//*等等

可以看出,语法分析阶段是我们能够确定是否可以继续进行求解很重要的一步。有了它,原始输入的算式字符串才有转成计算机所能理解的数据结构的可能。一般来讲,语法分析不仅仅孤立存在于本身的语法语义检查,他还会产生一个数据结构,这个数据结构通常是一个语法树。要注意的是,并不是只有完全的语法分析通过才会产生这个数据结构,部分语法通过也可以有这个数据结构,具体要看你的语法分析器的宽容度,也是纠错能力。说的比较抽象,我们来看个例子:

输入1+2+3 0 5 06 9*4,词法分析通过,然后到语法分析,很明显整个算式是无法通过语法检查的,但是语法分析器可以只解析到正确的部分1+2+3,然后返回一个1+2+3的语法树结构,这样即使语法没通过,但是也能得到一个语法树结构。

目前的问题而言,我们可以在语法分析阶段生成一个 AST, 用这个 AST 描述数学表达式,像做思路分析里的这张图一样:

Go实现的数学表达式解析计算引擎插图3

二叉树的表示

根据这张图,先定义用到的两个 AST 节点:

// 基础表达式节点接口typeExprASTinterface{ toStr()string}// 数字表达式节点typeNumberExprASTstruct{// 具体的值Valfloat64}// 操作表达式节点typeBinaryExprASTstruct{// 操作符Opstring// 左右节点,可能是 数字表达式节点/操作表达式节点/nilLhs, Rhs ExprAST }// 实现接口func(n NumberExprAST)toStr()string{returnfmt.Sprintf(“NumberExprAST:%s”, strconv.FormatFloat(n.Val,f,0,64), ) }// 实现接口func(b BinaryExprAST)toStr()string{returnfmt.Sprintf(“BinaryExprAST: (%s %s %s)”, b.Op, b.Lhs.toStr(), b.Rhs.toStr(), ) }

解析数学表达式这类问题,只需要NumberExprASTBinaryExprAST这两个节点可以了。原因词法分析阶段已经说过的,只有LiteralOperator两种类型。

定义基础的 AST 结构:

// AST 生成器结构体typeASTstruct{// 词法分析的结果Tokens []*Token// 源字符串source string// 当前分析器分析的 TokencurrTok *Token// 当前分析器的位置currIndex int// 错误收集Errerror }

下面开始实现语法分析和构建 AST 的核心代码:

// 定义操作符优先级,value 越高,优先级越高varprecedence =map[string]int{“+”:20,“-“:20,“*”:40,“/”:40,“%”:40,“^”:60}// 语法分析器入口func(a *AST)ParseExpression()ExprAST{ lhs := a.parsePrimary()returna.parseBinOpRHS(0, lhs) }// 获取下一个 Tokenfunc(a *AST)getNextToken()*Token{ a.currIndex++ifa.currIndex <len(a.Tokens) { a.currTok = a.Tokens[a.currIndex]returna.currTok }returnnil}// 获取操作优先级func(a *AST)getTokPrecedence()int{ifp, ok := precedence[a.currTok.Tok]; ok {returnp }return-1}// 解析数字,并生成一个 NumberExprAST 节点func(a *AST)parseNumber()NumberExprAST{ f64, err := strconv.ParseFloat(a.currTok.Tok,64)iferr !=nil{ a.Err = errors.New( fmt.Sprintf(“%v\nwant ( or 0-9 but get %s\n%s”, err.Error(), a.currTok.Tok, ErrPos(a.source, a.currTok.Offset)))returnNumberExprAST{} } n := NumberExprAST{ Val: f64, } a.getNextToken()returnn }// 获取一个节点,返回 ExprAST// 这里会处理所有可能出现的类型,并对相应类型做解析func(a *AST)parsePrimary()ExprAST{switcha.currTok.Type {caseLiteral:returna.parseNumber()caseOperator:// 对 () 语法处理ifa.currTok.Tok ==“(“{ a.getNextToken() e := a.ParseExpression()ife ==nil{returnnil}ifa.currTok.Tok !=“)”{ a.Err = errors.New( fmt.Sprintf(“want ) but get %s\n%s”, a.currTok.Tok, ErrPos(a.source, a.currTok.Offset)))returnnil} a.getNextToken()returne }else{returna.parseNumber() }default:returnnil} }// 循环获取操作符的优先级,将高优先级的递归成较深的节点// 这是生成正确的 AST 结构最重要的一个算法,一定要仔细阅读、理解func(a *AST)parseBinOpRHS(execPrecint, lhs ExprAST)ExprAST{for{ tokPrec := a.getTokPrecedence()iftokPrec < execPrec {returnlhs } binOp := a.currTok.Tokifa.getNextToken() ==nil{returnlhs } rhs := a.parsePrimary()ifrhs ==nil{returnnil} nextPrec := a.getTokPrecedence()iftokPrec < nextPrec {// 递归,将当前优先级+1rhs = a.parseBinOpRHS(tokPrec+1, rhs)ifrhs ==nil{returnnil} } lhs = BinaryExprAST{ Op: binOp, Lhs: lhs, Rhs: rhs, } } }

parseBinOpRHS是一个非常重要的算法,他根据定义的操作符优先级进行高优先级递归,从而使高优先级的节点具有更深的 AST 节点,这样,我们遍历 AST 的时候会天然的进行高优先级的计算。(这是一个成熟的算法,更多的细节可以阅读这篇 WikiOperator-precedence_parser)。

同样,为了便于调用,我们封装一下生成 AST 的方法:

// 生成一个 AST 结构指针funcNewAST(toks []*Token, sstring)*AST{ a := &AST{ Tokens: toks, source: s, }ifa.Tokens ==nil||len(a.Tokens) ==0{ a.Err = errors.New(“empty token”) }else{ a.currIndex =0a.currTok = a.Tokens[0] }returna }

OK!所有的语法分析和 AST 构建的代码都已经完成了。你现在可以通过下面的代码来尝试查看生成的 AST 结构:

funcmain(){ exp :=“92 + 5 + 5 * 27 – (92 – 12) / 4 + 26”// input text -> []tokentoks, err := Parse(exp)iferr !=nil{ fmt.Println(“ERROR: “+ err.Error())return}// []token -> AST Treeast := NewAST(toks, exp)ifast.Err !=nil{ fmt.Println(“ERROR: “+ ast.Err.Error())return}// AST builderar := ast.ParseExpression()ifast.Err !=nil{ fmt.Println(“ERROR: “+ ast.Err.Error())return} fmt.Printf(“ExprAST: %+v\n”, ar) }

运行这段代码,应该可以看到如下输出:

ExprAST: {Op:+ Lhs:{Op:- Lhs:{Op:+ Lhs:{Op:+ Lhs:{Val:92}Rhs:{Val:5}}Rhs:{Op:* Lhs:{Val:5}Rhs:{Val:27}}}Rhs:{Op:/ Lhs:{Op:- Lhs:{Val:92}Rhs:{Val:12}}Rhs:{Val:4}}}Rhs:{Val:26}}

成功的将一段源字符串转化成了一个 ExprAST 结构!

AST 求解

现在离我们成功只差一步之遥,我们只需要对这个 ExprAST 进行某种算法运算,可以得出答案。事实上,我们得到的这个 ExprAST 是一个二叉树结构,根据经验,写一个后序遍历算法(LRD)进行求解即可。

代码如下:

// 一个典型的后序遍历求解算法funcExprASTResult(expr ExprAST)float64{// 左右值varl, rfloat64switchexpr.(type) {// 传入的根节点是 BinaryExprASTcaseBinaryExprAST: ast := expr.(BinaryExprAST)// 递归左节点l = ExprASTResult(ast.Lhs)// 递归右节点r = ExprASTResult(ast.Rhs)// 现在 l,r 都有具体的值了,可以根据运算符运算switchast.Op {case“+”:returnl + rcase“-“:returnl – rcase“*”:returnl * rcase“/”:ifr ==0{panic(errors.New( fmt.Sprintf(“violation of arithmetic specification: a division by zero in ExprASTResult: [%g/%g]”, l, r))) }returnl / rcase“%”:returnfloat64(int(l) %int(r))default: }// 传入的根节点是 NumberExprAST,无需做任何事情,直接返回 Val 值caseNumberExprAST:returnexpr.(NumberExprAST).Valreturn0.0}

改造一下之前的main()方法:

funcmain(){ exp :=“92 + 5 + 5 * 27 – (92 – 12) / 4 + 26”// input text -> []tokentoks, err := Parse(exp)iferr !=nil{ fmt.Println(“ERROR: “+ err.Error())return}// []token -> AST Treeast := NewAST(toks, exp)ifast.Err !=nil{ fmt.Println(“ERROR: “+ ast.Err.Error())return}// AST builderar := ast.ParseExpression()ifast.Err !=nil{ fmt.Println(“ERROR: “+ ast.Err.Error())return} fmt.Printf(“ExprAST: %+v\n”, ar)// 加入下面的代码// AST traversal -> resultr := ExprASTResult(ar) fmt.Println(“progressing …\t”, r) fmt.Printf(“%s = %v\n”, exp, r) }

再次运行,可以看到如下输出:

ExprAST:{Op:+Lhs:{Op:-Lhs:{Op:+Lhs:{Op:+Lhs:{Val:92}Rhs:{Val:5}}Rhs:{Op:*Lhs:{Val:5}Rhs:{Val:27}}}Rhs:{Op:/Lhs:{Op:-Lhs:{Val:92}Rhs:{Val:12}}Rhs:{Val:4}}}Rhs:{Val:26}}progressing23892+5+5*27(9212)/4+26=238

看到了吗,92 + 5 + 5 * 27 – (92 – 12) / 4 + 26 = 238,我们成功的计算出来了这个算式的结果,是不是跟你一开始口算的结果是一样的呢?!(不要怀疑,这个运算的结果是百分百正确的 )

所有的代码到这里全部写完了,你现在可以尝试对这个算法输入任意的数学表达式算式,他都能帮你算出结果!很快!!!

结尾

我们回顾一下刚刚实现的引擎:它读取一段表达式源字符串,经过词法分析、语法分析、生成 AST ,后遍历执行 AST 得到结果。这些步骤,会不会让你觉得它跟脚本语言的解释引擎很像?

是的,典型的如PHP,如果你有看过Zend引擎的底层实现,你应该不会对此感到陌生。我们写的这个引擎当然没办法跟Zend这种解释引擎去比较,但是完全可以窥一斑而知全豹,通过短短一两百行的代码来模拟实现一个小型的引擎,理解底层系统是如何对输入的源代码进行解析执行的,这对你加深底层编译器的了解有着非常重要的帮助。所以,本文并不是仅仅作为一篇代码讲解或者算法实现的教科文,而是给你一种思路和方式去了解离我们业务较远但是却非常重要的底层系统原理。如果有兴趣,你甚至可以借助 LLVM 来实现一个你自己的编程语言(老实讲,只要你愿意,这并不是一件很难的事情)。

本文并没有提及一些专业的编译器术语,这是故意而为之。一来是可能看官们并没有这方面的知识储备,会让大家觉得晦涩难懂,二来是我本身对这些概念的理解也不是特别的深刻,怕提及解释不到位误导各位。如果你有兴趣,可以在下面的 Github issue 区跟我探讨交流,期待你的高见!

https://github.com/dengsgo/math-engine

作者 nasiapp

在线客服
官方客服
我们将24小时内回复。
12:01
您好,有任何疑问请与我们联系!

选择聊天工具: