桌面计算器解析
1.1自定义类型分析
1.1.1 枚举类型
enum Token_value {
NAME, NUMBER, END,
PLUS='+', MINUS='-', MUL='*', DIV='/',
PRINT=';', ASSIGN='=', LP='(', RP=')'
};
首先我们可以看到程序维护了一个枚举类型Token_value
,并且实例化了一个变量:curr_tok
,用来存储由控制台读入的用户输入的类型。大部分名字都采取见名知意的风格,可直观了解到符号要表达的意思,下面做一些细节的解释:
$$
\begin{cases}
NAME:代表常量,PI和自然对数e或者用户自己输入的变量的名称\
NUMBER:浮点数\
END:字符串的结束标志:\text{\0}\
\end{cases}
$$
其他的符号意思都很明显,不做赘述。
1.1.2 全局变量
extern Token_value curr_tok;
extern map<string, double> table; // var table
extern int no_of_errors;
整个程序中存在三个全局变量:
-
curr_tok
作用已在上一小节中予以描述,被初始化为PRINT状态。
-
table
table是一个map集合,将从控制台读入的pi,e字符(串)转化称为对应的值,同时假如读入的是table中的元素,那么
curr_tok
也会将状态标记为NAME,及上述的代表常量的符号。 -
no_of_errors
报错的个数
1.2 输入分析
int main(int argc, char * argv[])
{
switch (argc) {
case 1:
input = &cin;
break;
case 2:
input = new istringstream(argv[1]);
break;
default:
error("too many arguments");
return 1;
}
......
}
这部分不是我们要分析的重点,所以只做简单说明。
程序的输入主要时针对了两种情况:
- 一种是直接有输入dc和要执行的指令的,比如:
dc 1+3;
,这种在我们直接在IDE中run的情况下不存在,在命令行输入才需要输入dc。 - 一种时只有输入
1+3
,即是我们在IDE中run的过程。
我们针对这两种情况,根据参数的个数进行判别,以便使指针指向正确的流的位置。
1.3 程序流程分析
在这部分,我们将以完整的输入:3+2*3
;为例进行一个流程的简短说明,以说明函数之间的调用关系和流程。随后再分别对各个调用的函数的功能和相互关系进行分析。
1.3.1 举例分析流程
我们首先进入main函数,忽略掉前面的输入部分,函数体写得很精简:
while (*input) {
get_token();
if (curr_tok == END) break;
if (curr_tok == PRINT) continue;
cout << expr(false) << '\n';
}
循环体的循环条件是输入的指针无异常,要是输入异常则会跳出循环。
首先进行的get_token函数的调用:
通读代码,大致可以得到结论:get_token调用目的是不断更新curr_tok变量的状态,也就是当前的输入所代表的状态,并且通过这个状态来判断下一步应该执行什么样的操作。
Token_value get_token()
{
char ch = 0;
// --BEGIN-- ignore blanks except '\n'
// do {
// if (!cin.get(ch)) return curr_tok = END;
// } while (ch != '\n' && isspace(ch));
cin >> ch;
// ---END--- ignore blanks except '\n'
switch (ch) {
case 0:
return curr_tok = END; // assign and return
case ';':
case '*':
case '/':
case '+':
case '-':
case '(':
case ')':
case '=':
return curr_tok = Token_value(ch);
case '0': case '1': case '2': case '3': case '4':
case '5': case '6': case '7': case '8': case '9':
case '.':
cin.putback(ch);
cin >> number_value;
return curr_tok = NUMBER;
default:
if (isalpha(ch)) {
// --BEGIN-- Optimize to avoid meeting blank issue
// cin.putback(ch);
// cin >> string_value;
string_value = ch;
while (cin.get(ch) && isalnum(ch)) string_value.push_back(ch);
cin.putback(ch);
// ---END_-- Optimize to avoid meeting blank issue
return curr_tok = NAME;
}
error("bad token");
return curr_tok = PRINT;
}
}
字符ch是一个临时变量,用来记录从控制台输入的数字,字符等。这个变量在switch中会因变量的类型不同而进行不同而进入到不同的归宿:
-
若读入结束标志,那么更新
curr_tok
标志为END
,整个程序会返回到mian函数跳出循环体并结束 -
若读入的是运算符,那么在枚举类型中找到对应的枚举量,用枚举类型值更新
curr_tok
-
若读入的是数字,则更新
curr_tok
,即当前输入状态为NUMBER并且这里作者有一个特别的操作,为了避免输入123,在一个字符一个字符读入的情况下只拿出来一个ch=1,这里将字符putback,放回流中,再一把将读入的数拿出来,放到number_value中。
-
如读入的是字符,那么会进行是否是常量e和pi的判断或者进一步判断是否是用户自己定义的变量的名称:进行类似于上面读取整个number_value的操作,将后续要读入的字符也都进来。最后更新
curr_tok
状态为NAME。 -
假如都不满足上面的情况,那么只能说明输入出现了错误,通过标准错误流报错,并且将状态更新为PRINT.
对于我们举出的例子,自然满足第三种情况,我们保存了现在的curr_tok
的状态:NUMBER,返回到main函数中,进入expr函数的调用。我们先给出上述状态的流程图(由于main函数其实大程度上起到的是调节下一次的输入和非法输入的状态,在我们举例说明的时候并没有反复使用,此外由于调用的过程较为复杂,所以这之后的流程图,main和星形的get_token将不会画出):
1.3.1.1进行运算的函数expr(),trem(),prim()
expr()
函数主要的功能是及逆行加减运算和将最后结果返回到main中进行打印到控制台的操作。
可以看到:double left = term(get);
我们传入的参数为false,程序首先会进行又一层的函数调用:调用term来进行乘法的操作。而进入term函数之后,我们发现函数的第一行又是对下一个函数的调用:我们接着不得不进入prim函数。层层递归以达到取得最后的left值的结果。
所以这里我们先进入递归的最底层:prim()函数,对其进行说明,稍后再以返回的顺序对expr()函数进行说明。
prim()函数处理最基础的,也是级别最高的状态:
double prim(bool get)
{
if (get) get_token();
switch (curr_tok) {
case NUMBER:
{ double v = number_value;
get_token();
return v;
}
case NAME:
{ double& v = table[string_value];
if (get_token() == ASSIGN) v = expr(true);
return v;
}
case MINUS:
return -prim(true);
case LP:
{ double e = expr(true);
if (curr_tok != RP) return error(") expected");
get_token();
return e;
}
default:
return error("primary expected");
}
}
调用到这层时,由于传入的get值时false,所以这里不会在对get_token函数进行调用,直接进入switch分支中,根据当前的NUMBER状态,进入相应的分支中。首先用临时变量记下当前的number_value值,即之前输入的3。接着prim再进行一次get_token的调用,此时在get_token函数中读入新的字符:+,并且此时把我们的输入状态改变为PLUS,最后这次get_token调用结束,返回到prim函数中。此时prim调用也随即结束,返回到调用的term函数处,并带回临时变量的值v:赋予term中的变量left。
至此我们进行的流程为:
我们来到term函数:
double term(bool get)
{
double left = prim(get);
for (;;)
{
switch (curr_tok) {
case MUL:
left *= prim(true);
break;
case DIV:
if (double d = prim(true)) {
left /= d;
break;
}
return error("divide by 0");
default:
return left;
}
}
在term中,我们进入循环,根据现在的curr_tok的状态,进入switch分支:default 。下面也将得到结论,这个函数只是处理乘除法,并不对加法进行处理。不管怎样,left值:3,被带回到了expr函数中。
带着left = 3的结果,我们来着这个整个函数体类似trem的函数expr中。至此,我们终于结束了上图中描述的调用,进入了循环中,根据当前的PLUS状态,我们发现函数继续向下调用了term函数,而trem函数向下调用prim,prim从get_token中读入数字2,更新curr_tok状态为NUMBER,接着不断地返回到上次的调用处……在term函数中接着调用get_token,得到 乘法的状态,饥渴的term函数终于得到了一个属于他自己专属的乘法运算,他迫不及待想知道要相乘的对象是什么,故而再次call了prim函数,希望得到一个number与left值2相乘,prim带着3与2相乘,最后得到left值:6。term函数break,返回到上次调用它的地方。这次我们调用的完整过程为:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7mDKmEnq-1620315125756)(C:/Users/%E5%AD%99%E8%95%B4%E7%90%A6/AppData/Roaming/Typora/typora-user-images/image-20210329190029463.png)]
double expr(bool get)
{
double left = term(get);
for (;;)
{
switch (curr_tok) {
case PLUS:
left += term(true);
break;
case MINUS:
left -= term(true);
break;
default:
return left;
}
}
}
经过这次的递归下降,读者可能已经不记得了上次我们调用term是在哪里了,不妨看一下上上个段落——上次调用是在expr函数中,我们想要知道PLUS的操作对象,所以向下调用的term,而term最后是获得了一个left值的:6,把它返回expr函数中,经过PLUS操作,最终left值更新为3+6,即9。
跳出expr,根据上面的流程图,我们是回到了main函数。最终将结果6进行了输出,并等待下一次的循环……
1.3.1.2 完整流程图
为了方便梳理整个流程,我们再次做出一个更完整的流程图,方便对着流程图进行梳理。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yeVq3MDy-1620315125761)(C:/Users/%E5%AD%99%E8%95%B4%E7%90%A6/AppData/Roaming/Typora/typora-user-images/image-20210329185242321.png)]
1.3.2 函数功能及关系
1.3.2.1 函数功能
经过上面流程的大概分析,我们已经差不多把主要的代码阅读了一遍。可以对相应的函数进行细节功能进行细节性的分析了:
- get_token()
- 整个程序的获得输入的唯一入口就是get_token函数,不论从何处要获得下一个输入的字符,就需要调用curr_token函数
- get_token整个程序的状态解析器也是该函数。get_token会根据输入对curr_tok进行修改,从该全局变量的状态会直接决定程序下一步应该调用哪个函数,进行哪个运算。
- 其实整个递归下降的编程风格中,每一次调用向下的过程不论是小的分支,还是大的流程的调用,get_token函数都是我们递归的的终点,开始返回调用处的起点。
- expr()
- expr函数所能处理就是基本的加减运算,这也说明这是一个优先级最低的运算。
- 优先级最低决定了该函数需要不断地向下调用别的函数,获得下一个运算法,以判断有无更高级的运算。在高级的运算算完之后返回结果,再对结果进行加减运算。所以expr函数的出现常常的递归的起点。
- term()
- term函数常是由expr函数调用的,他会根据现在的状态来判断是否要进行乘法和除法运算。
- term函数不是运算优先级最高的函数,所以常常会继续向下调用最基础的prim()函数。
- prim()
- prim()是最基础的字符和常量的处理,也是整个函数中直接调用get_token的唯二函数(main也调用)。
- 常常是通过prim()对当前状态的分析来获得下一次的输入或者是返回负数的值等。
1.3.2.3 函数间的关系
在上面的论述中我们其实已经对各个函数之间的协作关系进行了详细的分析和总结,所以这里只以树状图的形式进行图示说明:
此外,由于计算器的功能比较强大,调用关系也很复杂,所以仅仅根据我们举出的3+2*3
的例子只能大概了解函数的功能,更加复杂的调用和函数间关系根据这幅图也能得到一些启示。
1.4 程序中的错误管理
这是一个需要直接跟用户进行输入上交互的程序,所以面对用户的输入状态,不合法的输入必然要进行适当的处理。所以程序实际上维护一个错误管理函数,对可能出现的错误,比如除数是0,输入的符号不合法等进行处理。一些函数会对其进行调用,将错误的情况打印在控制台,供用户检查。
double error(const string& s)
{
no_of_errors++;
cerr << "error: " << s << '\n';
return 1;
}
2.1 小结
较之前阅读和自己写的的面向过程的程序或者一些大项目来说,该桌面计算器无疑是调用关系最为复杂的一个,同时也是最为精巧的一个:每个函数都像面向对象的开发中的一个小组件,各个函数之间任务不重叠,只是实现自己的一部分功能,复杂的调用关系也反映了每个部分的复用性是非常之高的,任务分配也是十分准确的。
这样的程序可能在接触面向对象的思想之前会觉得没有比这更好的程序了,接触过Java后觉得这样递归下降的程序风格让人又爱又恨了,写的精妙,阅读起来确实也是难度攀升。经过debug调试和一些资料查找才明白了其中的一些细节的设计和整个程序在给出样例的情况下的流程。
桌面计算器面对更加复杂的输入必然会有更加复杂的调用,但函数之间的关系却是万变不离其宗。(希望下一次阅读这个程序的面向对象的版本时能轻松些(逃))
精巧的一个:每个函数都像面向对象的开发中的一个小组件,各个函数之间任务不重叠,只是实现自己的一部分功能,复杂的调用关系也反映了每个部分的复用性是非常之高的,任务分配也是十分准确的。
这样的程序可能在接触面向对象的思想之前会觉得没有比这更好的程序了,接触过Java后觉得这样递归下降的程序风格让人又爱又恨了,写的精妙,阅读起来确实也是难度攀升。经过debug调试和一些资料查找才明白了其中的一些细节的设计和整个程序在给出样例的情况下的流程。
桌面计算器面对更加复杂的输入必然会有更加复杂的调用,但函数之间的关系却是万变不离其宗。(希望下一次阅读这个程序的面向对象的版本时能轻松些(逃))