首页 > 代码库 > 表达式求值:从“加减”到“带括号的加减乘除”的实践过程
表达式求值:从“加减”到“带括号的加减乘除”的实践过程
本文乃Siliphen原创,转载请注明出处:http://blog.csdn.net/stevenkylelee
● 为什么想做一个表达式求值的程序
最近有一个需求,策划想设置游戏关卡的某些数值,这个数值不是一个常量,而是根据关卡的某些环境数据套上一个计算表达式算出来的。这个需求无法用excel拖表预计算出,因为关卡的环境数据只有在游戏中才能产生,在excel制表时,这些都是未知的。作为程序员,我可以把计算表达式硬编码在代码中,但这个做法有缺陷,如果策划要修改计算表达式的话,只能通过我修改程序并重新编译的方式来解决。一个好的做法是,策划可以修改计算表达式,但尽量不要修改代码重新编译程序。要做到这点的话,问题就变成了:对字符串形式的表达式求值。在excel中的关卡表相应的数值单元格中,填写一个表达式,程序在运行时解释出这个表达式的值。策划要修改计算的话,就修改excel表中相应的表达式字符串即可,不用修改程序。
在游戏的制作过程中,遇到表达式求值的问题,可以通过脚本来解决,比如:lua,js等,借助这些脚本语言的解析器来计算。目前我没在项目中用过脚本,所以,我决定做一个表达式求值的程序。其实表达式求值几年前学习数据结构时就做过了。现在再做一遍当复习,同时也把我的思考过程和大家分享一下,给没做过表达式求值的同学一些帮助。:)
本文的实现环境是VS2013,C++语言。
本文记录了我做表达式求值的实践过程,
从最简单的,
像“1+2”这样的表达式,
到
像“10 + 2.55 * ( ( 10 * ( 2.1 + 2 ) + 1.1 ) / 10”这样的表达式,
的实践过程。
● 计算不带括号的只有加减的表达式
先定义一下术语。像“232”,“653”这样的数字,称作操作数。像“+“,“-”,“×”,“÷”这样的运算符号,称作操作符。操作符会连接左右2个操作数进行运算。操作符左边的操作数称为左操作数,或者操作数1。操作符右边的操作数称为右操作数,或者操作数2。括号也算是一种操作符。
表达式由无限个操作数和无限个操作符按照规则组成。运算优先级:乘法优先于加减计算,括号优先于乘法计算,括号可以无限嵌套。
一口气吃成个胖子,不科学。所以,一开始先考虑最简单的情况,比如:只有加减,没有乘除,没有括号,有无限个运算数和无限个加减运算符组成的表达式。先尝试实现一个相对简单的情况的计算,完成后,再考虑更复杂的情况,这样一步步循环渐进,是学习和做工程的最佳实践。
观察一下最简单的表达式,例如“12 + 85 - 34”。
程序一般会从左边向右边扫描这个表达式字符串,当读到第一个操作数12的1时,是在读取操作数,当读到第一个操作符“+”时,加法左边的操作数读取完毕。这时,虽然知道是要执行加法,但因为还缺少操作符右边的一个操作数,所以,暂时还不能执行计算。当加法操作符右边的操作数读取完毕时才能执行加法运算。如何判断“+”右边的操作数读取完毕呢,是在读到第二个运算符“-”时,读到“-”就意味着之前的“+”的右操作数已经结束,全部读完了。
可以设计一个算法。用3个变量,分别记录操作数1,操作数2和运算符。一个循环从左向右读取字符。当读到第一个操作符时,意味着操作数1已经读取完毕,记录下读到的这个操作符,设置往后读的是操作数2的内容。当读到第二个操作符时,意味着操作数2已经读取完毕,这时满足了之前运算符的计算条件:已经有了操作数1,运算符,操作符2。进行计算,并且把计算的结果赋予操作数1变量。从此后,操作数1变量就永远只是存放计算结果了,只有操作数2是从表达式中读取的,不断循环反复,最终计算出表达式的值。
用“12 + 85 - 34”这个表达式来模拟一下上面说的算法。读到“+”时,操作数1变量保存了12。记录下“+”这个操作符。当读到“-”时,操作数2变量保存了85,计算12 + 85的结果,把计算结果97赋值给操作数1变量,这时,“-”操作符的左操作数就已知了,是之前加法的计算结果。我们只要再把后面的34读取完,就满足了减法的计算要求,最终计算出表达式的值。
本小节实现代码,如下:
#include "stdafx.h" #include <string> #include <iostream> using namespace std ; // 计算2个操作数 加减 的结果。 float Calculate( float Operand1 , float Operand2 , char Operator ) { float Ret = 0 ; if ( Operator == '+' ) { Ret = Operand1 + Operand2 ; } else if ( Operator == '-' ) { Ret = Operand1 - Operand2 ; } return Ret ; } // 计算 加减,不带括号的表达式 float EvaluateExpression( const string& str ) { float Operand1 = 0 ; // 操作符左边的操作数 float Operand2 = 0 ; // 操作符右边的操作数 char Operator = 0 ; // 操作符 for ( size_t i = 0 , size = str.size( ) ; i < size ; ++i ) { const char& ch = str[ i ] ; if ( '0' <= ch && ch <= '9' ) { if ( Operator == 0 ) { // 操作符为空时,表示正在解析操作符左操作数 Operand1 = Operand1 * 10 + ch - '0' ; } else { // 操作符不为空,表示正在解析操作符右边的操作数 Operand2 = Operand2 * 10 + ch - '0' ; } } else if ( ch == '+' || ch == '-' ) { if ( Operator == 0 ) { // 如果操作符为空,先保存操作符,当遇到下一个操作符时,才进行计算。 Operator = ch ; } else { // 如果之前存在了操作符,当前遇到第二个操作符,意味着先计算之前的 加减。 Operand1 = Calculate( Operand1 , Operand2 , Operator ) ; // 操作数1 和 操作数2 进行计算 Operand2 = 0 ; // 操作数2 置为空,为了保存下一个操作符右边的操作数。 Operator = ch ; // 保存本次遇到的操作符,当前不会计算这个操作符,因为操作符右边的操作数还没有读取到。 } } } // end for // 表达式遍历完后,会遗留下最后一个操作数,进行最后一项的计算。 Operand1 = Calculate( Operand1 , Operand2 , Operator ) ; return Operand1 ; } int _tmain(int argc, _TCHAR* argv[]) { string str = "12 + 85 - 34 + 11 + 222 + 234 - 500" ; float Ret = EvaluateExpression( str ) ; cout << "算术表达式: " + str << " 的结果是: " << Ret << endl ; return 0; }
以上代码运行后的结果是:
对于表达式“12 + 85 - 34 + 11 + 222 + 234 - 500”的计算,输出结果是:30。正确!Oh yeah!
嘿嘿,60行左右就完成了”不带加减不带括号的表达式“的计算。
● 计算不带括号的加减乘除的表达式
OK,循环渐进,加大难度。现在再来考虑更复杂一点的情况,加入乘除运算符。乘除的优先级高于加减,在有加减乘除的混合运算中优先计算乘除。从现在开始,表达式的计算开始有了优先级的概念,我们定义,左边的加减运算符的优先级高于右边的加减运算符,左边的乘除运算符的优先级高于右边的乘除运算符,所有乘除运算符的优先级高于所有加减运算符。
我们依旧是从左向右扫描表达式字符串。看这个表达式“5 + 2 * 3 - 4”,当扫描到运算符“*”时,我们是不能计算之前遇到的“+”运算符的,因为乘法优先级高于加法,要先计算出乘法的结果,然后把乘法结果作为加法的右运算数,加法才能计算。加法依赖于乘法的计算结果,加法的运算被延迟了,所以,在遍历表达式时,要一路保存遇到的运算数和运算符。
上面的情况,加法依赖于乘法,那么乘法什么时候计算呢?在遇到“-”减法运算符时。减法的运算符优先级低于乘法,理所当然地要先计算乘法才能计算加法。这时,从过去的记录中,取出乘法的2个操作数和乘法运算符进行计算。计算完成后,表达式变成“5 + 6 - 4”,这时减法又和过去记录的操作符进行比较,发现加法的优先级高于减法,那么先计算5 + 6 的结果,最后计算 11 - 4的结果。
当前遇到的运算符一定是和上一次最近遇到的运算符进行优先级比较的,上一次遇到的运算符一定是和上一次的上一次遇到的运算符进行优先级比较。而运算符的读入,是从左到右,其实这是一个栈的访问顺序,读入运算符时是运算符入栈,进行运算符优先级比较决定当前是否能够计算时,是运算符出栈。同理,运算符如此了,那么对应的操作数也是如此。
现在,我们可以设计下基础算法了。从左向右遍历表达式,遇到运算数,先入操作数栈保存起来供以后使用。遇到运算符也入运算符栈保存起来供以后使用。
如果,当前遍历遇到了运算符,并且运算符栈不为空的话,就和栈顶运算符,也就是最近上一次遇到的运算符进行优先级比较,如果上一次遇到的运算符优先级高,那么,拿出之前遇到的2个运算数和上一次遇到的运算符进行运算,运算结果入栈保存起来。当前遇到的运算符再和运算符栈里面的栈顶运算符进行比较,反复如此。当当前运算符的优先级大于栈里面保存的运算符时,意味着,之前遇到的运算符也不能立马计算了,把当前运算符入栈保存。继续读表达式。
这里当前遇到的运算符有迫使之前遇到的运算符进行计算的作用(能够迫使计算的规则是当前运算符优先级低于上一次遇到的运算符),当它迫使不了的时候,就入栈保存当前运算符它自己,让下一个读入的运算符来迫使它进行计算。
本小节实现代码,如下:
#include <string> #include <iostream> #include <unordered_map> using namespace std ; // 运算符优先级表 unordered_map< char , unordered_map< char , char > > Priorities ; // 初始化运算符优先级数据 void InitPriorities( ) { Priorities[ '+' ][ '-' ] = '>' ; Priorities[ '+' ][ '+' ] = '>' ; Priorities[ '+' ][ '*' ] = '<' ; Priorities[ '+' ][ '/' ] = '<' ; Priorities[ '-' ][ '-' ] = '>' ; Priorities[ '-' ][ '+' ] = '>' ; Priorities[ '-' ][ '*' ] = '<' ; Priorities[ '-' ][ '/' ] = '<' ; Priorities[ '*' ][ '-' ] = '>' ; Priorities[ '*' ][ '+' ] = '>' ; Priorities[ '*' ][ '*' ] = '>' ; Priorities[ '*' ][ '/' ] = '>' ; Priorities[ '/' ][ '-' ] = '>' ; Priorities[ '/' ][ '+' ] = '>' ; Priorities[ '/' ][ '*' ] = '>' ; Priorities[ '/' ][ '/' ] = '>' ; } // 计算2个操作数 加减乘除 的结果。 float Calculate( float Operand1 , float Operand2 , char Operator ) { float Ret = 0 ; if ( Operator == '+' ) { Ret = Operand1 + Operand2 ; } else if ( Operator == '-' ) { Ret = Operand1 - Operand2 ; } else if ( Operator == '*' ) { Ret = Operand1 * Operand2 ; } else if ( Operator == '/' ) { Ret = Operand1 / Operand2 ; } return Ret ; } // 计算 加减乘除,不带括号的表达式 float EvaluateExpression( const string& str ) { vector< float > Operands ; // 操作数栈,也可以用 stack< float > vector< char > Operators ; // 操作符栈,也可以用 stack< char > float OperandTemp = 0 ; for ( size_t i = 0 , size = str.size( ) ; i < size ; ++i ) { const char& ch = str[ i ] ; if ( '0' <= ch && ch <= '9' ) { // 读取一个操作数 OperandTemp = OperandTemp * 10 + ch - '0' ; } else if ( ch == '+' || ch == '-' || ch == '*' || ch == '/' ) { // 遇到一个操作符后,意味着之前读取的操作数已经结束。保存操作数。 Operands.push_back( OperandTemp ) ; // 清空,为读取下一个操作符做准备。 OperandTemp = 0 ; // 当前遇到的操作符作为操作符2,将和之前遇到的操作符(作为操作符1)进行优先级比较 const char& Opt2 = ch ; for ( ; Operators.size( ) > 0 ; ) { // 比较当前遇到的操作符和上一次遇到的操作符的优先级 const char& Opt1 = Operators.back( ) ; char CompareRet = Priorities[ Opt1 ][ Opt2 ] ; if ( CompareRet == '>' ) { // 如果操作符1 大于 操作符2 那么,操作符1应该先计算 // 取出之前保存的操作数2 float Operand2 = Operands.back( ) ; Operands.pop_back( ) ; // 取出之前保存的操作数1 float Operand1 = Operands.back( ) ; Operands.pop_back( ) ; // 取出之前保存的操作符。当前计算这个操作符,计算完成后,消除该操作符,就没必要保存了。 Operators.pop_back( ) ; // 二元操作符计算。并把计算结果保存。 float Ret = Calculate( Operand1 , Operand2 , Opt1 ) ; Operands.push_back( Ret ) ; } else if ( CompareRet == '<' ) { // 如果操作符1 小于 操作符2,说明 操作符1 和 操作符2 当前都不能进行计算, // 退出循环,记录操作符。 break; } } // end for // 保存当前遇到操作符,当前操作符还缺少右操作数,要读完右操作数才能计算。 Operators.push_back( Opt2 ) ; } } // end for /* 上面的 for 会一面遍历表达式一面计算,如果可以计算的话。 当遍历完成后,并不代表整个表达式计算完成了。而会有2种情况: 1.剩余1个运算符。 2.剩余2个运算符,且运算符1 小于 运算符2。这种情况,在上面的遍历过程中是不能进行计算的,所以才会被遗留下来。 到这里,已经不需要进行优先级比较了。情况1和情况2,都是循环取出最后读入的操作符进行运算。 */ Operands.push_back( OperandTemp ) ; for ( ; Operators.size( ) > 0 ; ) { // 取出之前保存的操作数2 float Operand2 = Operands.back( ) ; Operands.pop_back( ) ; // 取出之前保存的操作数1 float Operand1 = Operands.back( ) ; Operands.pop_back( ) ; // 取出末端一个操作符 char Opt = Operators.back( ) ; Operators.pop_back( ) ; // 二元操作符计算。 float Ret = Calculate( Operand1 , Operand2 , Opt ) ; Operands.push_back( Ret ) ; } return Operands[ 0 ] ; } int _tmain( int argc , _TCHAR* argv[ ] ) { // 初始化运算符的优先级 InitPriorities( ) ; string str = "11 - 6 + 2 * 3 + 50 / 10 * 2 + 12" ; float Ret = EvaluateExpression( str ) ; cout << "算术表达式: " + str << " 的结果是: " << Ret << endl ; return 0; }
以上代码运行后的结果是:
对表达式“11 - 6 + 2 * 3 + 50 / 10 * 2 + 12”的计算,输出结果是:33 。正确!Oh yeah!
● 计算带括号的加减乘除的表达式
OK,循环渐进,加大难度。现在,要考虑带括号的表达式。不带括号的加减乘除表达式的做法和带括号的表达式的做法是很接近的,因为括号也可以认为是一种运算符,只是括号运算符不是用来参与计算的,括号的作用是干预加减乘除计算的优先级。
“1 * ( 2 + 3)”这个表达式中,“(”会阻止它左边的乘法“*”立即计算。要达到阻止的目的,“(”运算符的优先级需要高于乘除,只有当前遇到操作符优先级高于上一次遇到的运算符的优先级,才能阻止之前遇到的运算符立即计算。而“)”会迫使之前遇到的运算符立即运算,要达到迫使的目的,“)”运算符要的优先级要低于加减乘除,只有之前遇到的运算符优先级高时,之前的遇到的运算符才应该进行计算。
“)”不断迫使之前的运算符进行计算,如果,它遇到了它的镜像“(”,就是括号被消除了,“(”和”)”湮灭,就像物质遇到反物质一样。
“(”出现的地方都会紧跟在一个运算符之后,“)”出现的地方后面都会紧跟一个运算符。括号运算符的作用是干预其他运算符的计算优先级,所以,括号不会在画面上分割2个运算数,只会紧贴运算符。这让运算数的解析和之前程序相比多了一些规则。
现在来梳理一下带括号的表达式的计算算法:
先定义运算符比较优先级:
加减比较时,左边的运算符优先级高。
乘除比较时,左边的运算符优先级高。
乘除优先级高于加减。
“(“左括号的优先级高于乘除,是最高优先级的运算符。
“)”右括号的优先级低于加减,是最低优先级的运算符。
“(”和“)”优先级相等。相等的含义是,括号被消除。
for 从左向右扫描表达式 if( 遇到一个运算符 ) 入栈保存操作数 ; while( 操作符栈不为空 ) if( 当前遇到的运算符优先级低于操作符栈栈顶运算符的优先级 ) 操作数栈弹出2个操作数 ; 操作符栈弹出一个操作符 ; 用弹出的操作符计算弹出的2个操作数的结果 ; 结果入栈操作数栈 ; else if(当前遇到的运算符优先级高于操作符栈栈顶运算符的优先级 ) 退出循环 ; else if(当前遇到的运算符优先级等于操作符栈栈顶运算符的优先级 ) )遇到(,括号消除 ; 入栈当前遇到的运算符 ; 表达式扫描完成后,会遗留一些操作符未能计算,此时已经不存在括号了。 不断从栈中弹出操作符和操作数进行计算,直到操作符栈为空。
本节的实现代码如下:
#include <string> #include <iostream> #include <unordered_map> using namespace std ; // 运算符优先级表 unordered_map< char , unordered_map< char , char > > Priorities ; // 初始化运算符优先级定义数据 void InitPriorities( ) { Priorities[ '+' ][ '-' ] = '>' ; Priorities[ '+' ][ '+' ] = '>' ; Priorities[ '+' ][ '*' ] = '<' ; Priorities[ '+' ][ '/' ] = '<' ; Priorities[ '+' ][ '(' ] = '<' ; Priorities[ '+' ][ ')' ] = '>' ; Priorities[ '-' ][ '-' ] = '>' ; Priorities[ '-' ][ '+' ] = '>' ; Priorities[ '-' ][ '*' ] = '<' ; Priorities[ '-' ][ '/' ] = '<' ; Priorities[ '-' ][ '(' ] = '<' ; Priorities[ '-' ][ ')' ] = '>' ; Priorities[ '*' ][ '-' ] = '>' ; Priorities[ '*' ][ '+' ] = '>' ; Priorities[ '*' ][ '*' ] = '>' ; Priorities[ '*' ][ '/' ] = '>' ; Priorities[ '*' ][ '(' ] = '<' ; Priorities[ '*' ][ ')' ] = '>' ; Priorities[ '/' ][ '-' ] = '>' ; Priorities[ '/' ][ '+' ] = '>' ; Priorities[ '/' ][ '*' ] = '>' ; Priorities[ '/' ][ '/' ] = '>' ; Priorities[ '/' ][ '(' ] = '<' ; Priorities[ '/' ][ ')' ] = '>' ; Priorities[ '(' ][ '+' ] = '<' ; Priorities[ '(' ][ '-' ] = '<' ; Priorities[ '(' ][ '*' ] = '<' ; Priorities[ '(' ][ '/' ] = '<' ; Priorities[ '(' ][ '(' ] = '<' ; Priorities[ '(' ][ ')' ] = '=' ; // 不存在操作符1是")"和 操作符2 比较的情况 // 因为 ) 会迫使之前的操作符进行运算。 // 直到遇到匹配的“(”操作符,双双被消除掉 // 所以下面的数据无意义。 Priorities[ ')' ][ '+' ] = '?' ; Priorities[ ')' ][ '-' ] = '?' ; Priorities[ ')' ][ '*' ] = '?' ; Priorities[ ')' ][ '/' ] = '?' ; Priorities[ ')' ][ '(' ] = '?' ; Priorities[ ')' ][ ')' ] = '?' ; } // 计算2个操作数 加减乘除 的结果。 float Calculate( float Operand1 , float Operand2 , char Operator ) { float Ret = 0 ; if ( Operator == '+' ) { Ret = Operand1 + Operand2 ; } else if ( Operator == '-' ) { Ret = Operand1 - Operand2 ; } else if ( Operator == '*' ) { Ret = Operand1 * Operand2 ; } else if ( Operator == '/' ) { Ret = Operand1 / Operand2 ; } return Ret ; } // 计算 加减,不带括号的表达式 float EvaluateExpression( const string& str ) { vector< float > Operands ; // 操作数栈,也可以用 stack< float > vector< char > Operators ; // 操作符栈,也可以用 stack< char > float OperandTemp = 0 ; char LastOperator = 0 ; // 记录最后遇到的操作符 for ( size_t i = 0 , size = str.size( ) ; i < size ; ++i ) { const char& ch = str[ i ] ; if ( '0' <= ch && ch <= '9' ) { // 读取一个操作数 OperandTemp = OperandTemp * 10 + ch - '0' ; } else if ( ch == '+' || ch == '-' || ch == '*' || ch == '/' || ch == '(' || ch == ')' ) { // 有2种情况 是没有操作数需要入栈保存的。 // 1 当前操作符是 “(”。(的左边的操作符已经负责操作数入栈了。 // 2 上一次遇到的操作符是“)”。)本身会负责操作数入栈,)后面紧跟的操作符不需要再负责操作数入栈。 if ( ch != '(' && LastOperator != ')' ) { // 遇到一个操作符后,意味着之前读取的操作数已经结束。保存操作数。 Operands.push_back( OperandTemp ) ; // 清空,为读取下一个操作符做准备。 OperandTemp = 0 ; } // 当前遇到的操作符作为操作符2,将和之前遇到的操作符(作为操作符1)进行优先级比较 const char& Opt2 = ch ; for ( ; Operators.size( ) > 0 ; ) { // 比较当前遇到的操作符和上一次遇到的操作符的优先级 const char& Opt1 = Operators.back( ) ; char CompareRet = Priorities[ Opt1 ][ Opt2 ] ; if ( CompareRet == '>' ) { // 如果操作符1 大于 操作符2 那么,操作符1应该先计算 // 取出之前保存的操作数2 float Operand2 = Operands.back( ) ; Operands.pop_back( ) ; // 取出之前保存的操作数1 float Operand1 = Operands.back( ) ; Operands.pop_back( ) ; // 取出之前保存的操作符。当前计算这个操作符,计算完成后,消除该操作符,就没必要保存了。 Operators.pop_back( ) ; // 二元操作符计算。并把计算结果保存。 float Ret = Calculate( Operand1 , Operand2 , Opt1 ) ; Operands.push_back( Ret ) ; } else if ( CompareRet == '<' ) { // 如果操作符1 小于 操作符2,说明 操作符1 和 操作符2 当前都不能进行计算, // 退出循环,记录操作符。 break; } else if ( CompareRet == '=' ) { // 操作符相等的情况,只有操作符2是“)”,操作数1是“(”的情况, // 弹出原先保存的操作符“(”,意味着“(”,“)”已经互相消掉,括号内容已经计算完毕 Operators.pop_back( ) ; break; } } // end for // 保存当前遇到操作符,当前操作符还缺少右操作数,要读完右操作数才能计算。 if ( Opt2 != ')' ) { Operators.push_back( Opt2 ) ; } LastOperator = Opt2 ; } } // end for /* 上面的 for 会一面遍历表达式一面计算,如果可以计算的话。 当遍历完成后,并不代表整个表达式计算完成了。而会有2种情况: 1.剩余1个运算符。 2.剩余2个运算符,且运算符1 小于 运算符2。这种情况,在上面的遍历过程中是不能进行计算的,所以才会被遗留下来。 到这里,已经不需要进行优先级比较了。情况1和情况2,都是循环取出最后读入的操作符进行运算。 */ if ( LastOperator != ')' ) { Operands.push_back( OperandTemp ) ; } for ( ; Operators.size( ) > 0 ; ) { // 取出之前保存的操作数2 float Operand2 = Operands.back( ) ; Operands.pop_back( ) ; // 取出之前保存的操作数1 float Operand1 = Operands.back( ) ; Operands.pop_back( ) ; // 取出末端一个操作符 char Opt = Operators.back( ) ; Operators.pop_back( ) ; // 二元操作符计算。 float Ret = Calculate( Operand1 , Operand2 , Opt ) ; Operands.push_back( Ret ) ; } return Operands[ 0 ] ; } int _tmain( int argc , _TCHAR* argv[ ] ) { // 初始化运算符的优先级 InitPriorities( ) ; string str = "1 + ( 1 + 2 ) * ( ( 54 - 51 ) + 8 ) / 3" ; float Ret = EvaluateExpression( str ) ; cout << "算术表达式: " + str << " 的结果是: " << Ret << endl ; return 0; }
以上代码运行后的结果是:
对表达式“1 + ( 1 + 2 ) * ( ( 54 - 51 ) + 8 ) / 3” 的计算,输出结果是:12。正确!Oh yeah!
● 表达式预处理:词法分析,分离出表达式中的词素。
到此为止,已经做到了支持带括号的四则混合运算,看似已完成任务。实际上,还有一些问题没有解决。仔细观察以上的实现会发现,这些实现虽然用的是float型变量来保存计算结果,但对表达式中的运算数的解析实现得很简单,解析运算数的算法认为运算数的所有组成部分都是整数,这导致了虽然能计算出 5 / 2 = 2.5 但不能反过来计算 2.5 * 2 = 5。
为了支持获取浮点型的运算数,需要修改对字符串的解析算法。如果直接在原先的代码上增加对浮点型数字的解析,会让原先的代码变得很复杂,解析的实现和运算规则的实现混在一起,最后会让整个程序变得难以维护、难以扩展、难以改变。
考虑这样一种情况,如果要支持带有指数表示的运算数,类似:”2.55e5” , “0.1e100”等,支持表达式的表示形式越多,解析算法就会越复杂。有可能实现解析的代码量会超过实现运算规则的。
敏捷开发的原则之一:SRP。解析表达式是一个单一的职责,运算规则的实现是一个单一的职责,两者应该分开,这样更有利于程序的维护,扩展和复用。编译器的实现也正是这样,对表达式的解析对应词法分析,对运算规则的实现可以对应到语法分析。
因为,我们之前的主要精力是放在运算规则的实现上,所以在对解析要求不高的情况下,我把解析和运算实现混在了一起。现在,为了支持带小数表示运算数,我们先做一个简单的词法分析器。
针对表达式的词法分析器,具备如下功能:
输入表达式字符串“23.11 * ( 22e2 + 10 )”,输出每个词法单元。词法单元是在表达式中有完整含义的最小单位。比如:“23.11”是一个完整的运算数词素,“*”是一个完整的运算符词素。词法分析器对这个表达式会输出 < 23.11 > , < * > , < ( > , < 22e2 > , < + > , < 10 > , < ) > 词素序列。
OK,现在来实现我们的简单词法分析器。因为本人水平和时间有限,就用状态机粗略做下。用状态机做有一个好处,可以发现表达式中的某些语法错误。比如,像“2.1.1”这样的连续的2个小数点的运算数,可以发现其语法有错且知道错在哪里。但词法分析阶段能发现的错误仅限于词法,各种词素组合在一起形成的句子,这条句子有没有语法和语义上的错误,需要到语法分析时才能被发现。
本小节的实现代码如下:
/* 作者:Siliphen */ #include <string> #include <vector> #include <iostream> #include <unordered_map> using namespace std ; // 词法单元结构体 struct Token { // 类型 enum class Type { // 数字常量 Num , // 运算符 Operator , }; Type TokenType ; // 内容 string Value ; Token( Type TokenType = Type::Num , const string& Value = http://www.mamicode.com/"" )>
以上代码运行后的结果是:
我写了一个测试函数,用来测试对不同的表达式输出的结果,验证实现是否正确。OK,看起来没问题,现在我们的对运算规则的实现,再也不用关心对表达式的解析咯,Oh,Yeah!
● 增加对带小数的运算数的支持
之前实现的词法分析器,为了更好地复用,先把以上代码封装成一个LexicalAnalyser类。这个类的作用就是用来对表达式进行分词的。
把上节代码整理成如下形式:
同时,表达式求值的算法也整理到一个叫ExpressionEvaluator的类中,如下图:
重构表达式求值的算法,把原先直接对表达式字符串的处理改成对词法单元的处理。
重构的计算函数代码如下:
std::string ExpressionEvaluator::Evaluate( const vector< Token >& Tokens , float& ExprRet ) { string strRet = "OK" ; vector< float > Operands ; // 操作数栈,也可以用 stack< float > vector< char > Operators ; // 操作符栈,也可以用 stack< char > for ( size_t i = 0 , size = Tokens.size( ) ; i < size ; ++i ) { const Token& theToken = Tokens[ i ] ; if ( theToken.TokenType == Token::Type::Num ) { Operands.push_back( atof( theToken.Value.c_str() ) ) ; } else if ( theToken.TokenType == Token::Type::Operator ) { // 当前遇到的操作符作为操作符2,将和之前遇到的操作符(作为操作符1)进行优先级比较 const char& Opt2 = theToken.Value[ 0 ] ; for ( ; Operators.size( ) > 0 ; ) { // 比较当前遇到的操作符和上一次遇到的操作符的优先级 const char& Opt1 = Operators.back( ) ; char CompareRet = m_Priorities[ Opt1 ][ Opt2 ] ; if ( CompareRet == '>' ) { // 如果操作符1 大于 操作符2 那么,操作符1应该先计算 // 取出之前保存的操作数2 float Operand2 = Operands.back( ) ; Operands.pop_back( ) ; // 取出之前保存的操作数1 float Operand1 = Operands.back( ) ; Operands.pop_back( ) ; // 取出之前保存的操作符。即将搞定这个操作符的计算。 Operators.pop_back( ) ; // 二元操作符计算。并把计算结果保存。 float Ret = Calculate( Operand1 , Operand2 , Opt1 ) ; Operands.push_back( Ret ) ; } else if ( CompareRet == '<' ) { // 如果操作符1 小于 操作符2,说明 操作符1 和 操作符2 当前都不能进行计算, // 退出循环,记录操作符。 break; } else if ( CompareRet == '=' ) { // 操作符相等的情况,只有操作符2是“)”,操作数1是“(”的情况, // 弹出原先保存的操作符“(”,意味着“(”,“)”已经互相消掉,括号内容已经计算完毕 Operators.pop_back( ) ; break; } } // end for // 保存当前遇到操作符,当前操作符还缺少右操作数,要读完右操作数才能计算。 // 只有“)”操作符不要被保存 if ( Opt2 != ')' ) { Operators.push_back( Opt2 ) ; } } } // end for /* 上面的 for 会一面遍历表达式一面计算,如果可以计算的话。 当遍历完成后,并不代表整个表达式计算完成了。而会有2种情况: 1.剩余1个运算符。 2.剩余2个运算符,且运算符1 小于 运算符2。这种情况,在上面的遍历过程中是不能进行计算的,所以才会被遗留下来。 到这里,已经不需要进行优先级比较了。情况1和情况2,都是循环取出最后读入的操作符进行运算。 */ for ( ; Operators.size( ) > 0 ; ) { // 取出之前保存的操作数2 float Operand2 = Operands.back( ) ; Operands.pop_back( ) ; // 取出之前保存的操作数1 float Operand1 = Operands.back( ) ; Operands.pop_back( ) ; // 取出末端一个操作符 char Opt = Operators.back( ) ; Operators.pop_back( ) ; // 二元操作符计算。 float Ret = Calculate( Operand1 , Operand2 , Opt ) ; Operands.push_back( Ret ) ; } // end for ExprRet = Operands[ 0 ] ; return strRet ; }
可以看到,重构后的表达式求值算法变得更短(不到100行),更容易理解,更强(支持对运算数是浮点数的运算)。
写一个测试来测试以上算法的正确性。测试代码如下:
#include "ExpressionEvaluator.h" #include <iostream> using namespace std ; // 测试表达式求值的正确性 void TestExprEval( const string& Expr , float ExpectedRet ) { float ExprRet = 0 ; ExpressionEvaluator ExprEval ; string strRet = ExprEval.Evaluate( Expr , ExprRet ) ; if ( strRet != "OK" ) { cout << "表达式计算出错,信息:" << strRet<<endl ; return ; } cout << "表达式:" + Expr << endl ; cout << "计算结果是:" << ExprRet<<endl ; if ( fabs( ExpectedRet - ExprRet ) < 0.00001 ) { cout << "与预期相等,计算正确。" << endl ; } else { cout << "与预期不等,计算错误!预期结果:" << ExpectedRet << endl ; } } int _tmain(int argc, _TCHAR* argv[]) { typedef pair< string , float > Item ; vector< Item > TestList = { Item( "2.1 * ( 2.11 + 3.5557 )" , 11.89797 ) , Item( "3 * ( 2.4 + 3 ) + 1.1" , 17.3 ) , Item( "3 * ( 10 + 3 ) + ( 1.1 - 0.1) * 2" , 41 ) , Item( "1.4 - 0.4 + 2 " , 3 ) , Item( "10 + 10 / ( ( 1 - 2 ) * 4 + 2 )" , 5 ) , Item( "( 2 + 11.5 ) * 20" , 270 ) , Item( "(( 2 + 5 ) / 7 )" , 1 ) , Item( "((((33 - 66 / 3 ))))" , 11 ) , Item( "10 * ( ( 2 + 1 ) * ( 3 + 4 ) + 1 ) - ( 50 - ( 23 - 20 / 10 ) ) / 2" , 205.5 ) , Item( "0" , 0 ) , } ; for ( auto& it : TestList ) { TestExprEval( it.first , it.second ) ; cout << endl ; } return 0; }
以上代码运行后的结果是:
OK,通过了全部正确性验证测试!Oh,yeah!
● 总结:
表达式求值的问题,到此结束了么?当然没!以上实现的表达式求值还不完善,比如:
1.还不支持带符号的运算数的计算。对于带符号的运算数会认为是运算符,导致计算出错。处理运算数的正负号,可以放在词法分析阶段处理,也可以放到语法分析阶段,我觉得放在后者中可能更合适。
2.不支持指数形式的运算数的运算,C语言标准库的atof函数不能对像“23.2e5”这种形式的浮点数表示进行正确转换。我们需要写自己的转换函数。
表达式求值的问题没结束,但我得先结束了。关于正负号的处理,逆波兰式的生成与计算,表达式树的生成与计算等实践,下回再搞吧。
表达式求值:从“加减”到“带括号的加减乘除”的实践过程