分析器
这里是该计算器所接受的语言的语法:
program:
END // END表示输入结束
expr_list END
expr_list:
expression PRINT // PRINT是分号
expression PRINT expr_list
expression:
expression + term
expression - term
term
term:
term / primary
term * primary
primary
primary:
NUMBER
NAME
NAME = expression
- primary
(expression)
换句话说,一个程序是由分号分隔的一系列表达式。表达式的基本单元是数、名字和运算符*、/、+、-(包括一元的和二元的)和=。名字在使用之前无需声明。
语法分析采用通常称为递归下降的风格,这是一种很流行的直截了当的自顶向下技术。在像C++这类语言中,函数调用的代价相对比较低,采用这种技术就相当有效。对于语法中的每个产生式存在着一个函数,它还要调用其他的函数。终结符(例如END、NUMBER、+和-)由词法分析程序get_token()识别,而非终结符则由语法分析函数expr()、term()和prim()识别。一旦一个(子)表达式的两个运算对象都已经知道了,就立即对这个表达式求值。而对于真正的编译器,在这一点则可能生成代码。
分析器利用函数get_token()取得输入,最后一次调用get_token()得到的值可以在全局变量curr_tok里找到。curr_tok的类型是枚举Token_value:
enum Token_value
{
NAME, NUMBER, END,
PLUS='+', MINUS='-', MUL='*', DIV='/',
PRINT=';', ASSIGN='=', LP='(', RP=')'
};
Token_value cur_tok = PRINT;
将单词(token)用它们的字符所对应的整数表示,这样做既方便有效,又能帮助使用排错系统的人。只要在所有输入字符中,没有任何字符的值被当做枚举符使用,这种方式就可以工作---在我所知道的字符集中,都不存在可打印字符的值只有一位数字的情况。我选择PRINT作为curr_tok的初始值,因为这正是计算器计算完一个表达式,并显示结果值之后curr_tok应该具有的值。这样,我也就是以一种正常状态“启动这个系统”,这样做最大限度地减少了出错的机会和对特殊启动代码的需求。
每个分析函数都有一个bool(4.2节)参数,指明该函数是否需要调用get_token()去取得下一个单词。每个分析函数都将对“它的”表达式求值并返回这个值。函数expr()处理加和减。它由一个查找被加减的项的循环组成:
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;
}
}
这个函数本身并不做多少事情,它以一个大程序里高层函数的典型方式,调用其他函数去完成具体工作。
switch-statement对照着一集常量,检查在关键字switch之后的括号里所描述的条件值。break语句用于从开关语句中退出。在各个case之后的常量必须互不相同。如果被检查的值与任何case标号都不匹配,那么就选择default。程序员不一定要提供default。
请注意,形如2-3+4这样的表达式将被按(2-3)+4的方式求值,正也是语法所描述的。
奇特的记法形式for(;;)是人们描述无限循环的一种标准方式,你可以将它读作“永远”。这是for-statement(6.3.3节)的一种退化形式;我们也可以用while(true)代替它。这里的开关语句将反复执行,直到遇到的不是+和-,这就会导致在default情况中的返回语句的执行。
运算符+=和-=分别用于处理加和减;可以用left=left+term(true)和left=left-term(true)代替它们而不会影响程序的意义。然而,left+=term(true)和left-=term(true)不仅更短一些,而且也更直接地描述了我们想做的事情。每个赋值运算符是一个单独的词法单词,因此,a+ =1;十个语法错误,因为在+和=之间出现了空格。
下面这些二元运算符都有相应的赋值运算符:
+ - * / % & | ^ << >>
所以下面赋值运算符都可以使用
= += -= *= /= %= &= |= ^= <<= >>=
这里的%是取模,或说是求余运算符;&、|和^分别是按位逻辑“与”、按位逻辑“或”和按位逻辑“异或”运算符;<< 和 >> 是左移和右移运算符。6.2节总结了所有运算符和它们的意义。对于一个应用于内部类型运算对象的二元运算符@,表达式x@=y的意思就是x=x@y,不过在前一种情况中只对x求值一次。
第8章和第9章将讨论如何将程序组织为一组模块。除了一个例外之处,这个计算器例子中的所有声明都可以排列好,使每个东西都只需要在它被使用之前声明一次。这个例子就是expr(),它调用term(),这个函数转而调用prim(),而prim()又调用了expr()。这种循环调用的情况必须打破,只要将一个声明
double expr(bool);
放在prim()的定义之前就行了。
函数term()处理乘和除,采用的方式与expr()处理加和减的方式一样:
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("divie by 0");
default:
return left;
}
}
用0去除的结果无定义,通常将是个灾难。因此我们需要在除之前检查是否为0,如果检查到以0作为除数就调用error()。函数error()将在6.1.4节讨论。
变量d正是在需要它的地方才被引进程序里,而且立即做了初始化。一个在条件中引进的名字,其作用域就是这个条件所控制的语句,其结果值被作为这个条件的值(6.3.2.1节)。这样,只有在d不是0的情况下除法和赋值left/=d才会进行。
函数prim()处理初等项的方式很像expr()和term(),当然,因为我们需要进入调用层次的更下一层,在这里需要多做一点事情,而且不必做循环:
double number_value;
string string_value;
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);
}
}
如果遇到一个NUMBER(也就是说,遇到一个整数或浮点的文字量),prim()就返回它的值。输入例程get_token()将把有关的值存入全局变量number_value。在程序里采用全局变量,一般说明这种结构不是很清晰---应该进行某种优化。目前就出现了这种情况。理想方式是让一个词法单词由两个部分组成:一个刻画单词种类(在这个程序里是Token_value)的值和(如果需要的话)一个单词值。在这里只存在一个单独的变量curr_tok,所以需要用一个全局变量number_value保存最近读到的一个NUMBER值。清除这个不良的全局变量的工作留做练习(6.6[21])。在调用get_token()之前,我们并不需要将number_value的值保存到某个局部变量v里,因为对于合法的输入,该计算器在去读其他形式的输入之前只需要用一个数。当然,将这种值存起来并在出错时显示,也能对用户有所帮助。
按照将最近的一个NUMBER的值保存于number_value的同样方式,代表最近遇到的NAME的字符串将被保存在string_value里。在对一个名字做其他事情之前,计算器必须首先去看是要对它赋值,还是要去读它的值。这两种情况都需要检查符号表,该符号表是一个map(3.7.4节、17.4.1节):
map<string, double> table;
也就是说,如果用一个string去索引table,得到的结果是对应于该string的double值。例如,如果用户输入
radius = 6338.388;
计算器将执行
double& v = talbe["radius"];
// ...expr()计算要赋的值...
v = 6378.388;
在expr()由输入字符序列计算6378.388的值的过程中,将通过引用v去把握住与radius相关联的那个double值。
🔚