前言
PA1.2的细节非常非常多,导致这几天花了大量的时间去调试bug,4.3晚上终于过了最后一关“如何测试你的代码”(花了两整天时间才调成功)。虽然耗时巨大,但确实学到了不少东西、训练了能力,于是抽几天时间来此记录一下遇到的问题以及解决和一些心得体会
本文仅提供详细的思路,因为时间有限和比较难整理,所以暂时不展示完整源码,谢谢理解
词法分析
词法分析的目的是把用户输入的表达式分解成一个一个的tokens保存起来,用于后续的运算,这个过程看似直接,但其实会遇到很多细节问题,比如连续的好几个数字字符怎么识别成一个整体的数字?连续的空格字符怎么全部忽略?如果直接用C语言编写,将会十分的复杂,所以使用一个强大的工具——正则表达式
将不同类型的正则表达式映射到不同类型的数字上,组成一个二元组集合,用结构体表示,比如:
struct rule {
char *regex;
int type;
}rules[]={
{" +", TK_NOTYPE},
//others...
}
前面的" +"表示空格这个字符可以重复1或多次,需要注意的是前面的匹配规则要考虑C语言的转义符使用,比如要匹配除号,要写"\\/",第一个\是为了在C语言中转义/,第二个\是在正则表达式本身转义/。
后面的TK_NOTYPE表示空格,可以在结构体的上面用enum对每一个这种表示起一个编号,如:
enum {
TK_NOTYPE = 256,
//other...
}
想要使用上面的正则表达式匹配,需要重点关注两个C语言库函数:regcomp和regexec
regcomp可以放在init_regex函数中用于对正则表达式相关的规则初始化,然后init_regex可以放在init_sdb函数中,进行初始化
下面说make_token函数的思路:
make_token()函数的工作方式十分直接, 它用position变量来指示当前处理到的位置, 并且按顺序尝试用不同的规则来匹配当前位置的字符串. 当一条规则匹配成功, 并且匹配出的子串正好是position所在位置的时候, 我们就成功地识别出一个token,把识别到的token放入tokens数组中(具体可以查阅其它博客)
效果举例:
输入:1 + 666* ( 6+5)
识别为:
tokens[0].type==NUM,tokens[0].str=="1"
tokens[1].type==TK_NOTYPE
tokens[2].type==ADD
tokens[3].type==TK_NOTYPE
tokens[4].type==NUM,tokens[4].str=="666"
tokens[5].type=='*'
tokens[6].type=='('
tokens[7].type==TK_NOTYPE
tokens[8].type==NUM,tokens[8].str=='6'
tokens[9].type==ADD
tokens[10].type==NUM,tokens[10].str=='5'
tokens[11].type==')'
学会调试
通过PA1.2的学习,我的调试能力有了一定提高,正如讲义所说,调试常见三法宝是:printf、assert、GDB
printf
在某些地方写printf,输入此时某些变量的值进行分析,这也是算法题中常用的技巧
assert
在写下面的eval函数时,assert让我高兴又痛苦,高兴的是每次编译的时候都能找到出错的地方,最常见的两个是:1.括号匹配函数没写好导致的报错 2.除数为0导致的报错(因为这个调bug调了一整天),所以说在合适的位置插入assert(0)可以帮助提高代码的健壮性
以防止除数为0为例,可以写:
case '/':
if(val2 == 0){
printf(" val1 = %d p = %d q = %d\n",val1,p,q);
assert(0);
return 0;
}
return val1 / val2;
GDB调试器
如讲义所说,要掌握gdb常见命令,如next、step、until、info、file、run、list...写PA期间大量使用了GDB,帮了大忙
递归求值
括号检测函数
我觉得这个函数的目的以下几个:
1.判断一个表达式的括号是否合法,比如(1+3))或(1+((23-6))))就不合法,针对这种不合法的情况,应该直接终止计算
2.去掉两侧多余的函数,比如((1+1)+(2+3)),但(1+1)+(2+3)这种两边的括号当然是不能去掉的
综上,设置三种返回值类型:
return -1:表达式格式有误、终止计算
return 0 :表达式格式正确,且不需要进行去括号处理
return 1 :表达式格式正确,需要去掉两边的括号,进入eval(p+1,q-1)
这里有个问题,多余的括号非去不可吗?是的,在后续“主运算符的寻找”中,如果多这种多余的括号,将会有bug(至少是我的思路会出bug)
继续说这个函数,我们可以分为三个部分:
1.检测
遍历表达式判断格式是否正确,若true,则继续进行下面操作,否则,return -1
2.初步筛选
判断表达式第一个token是否为'('且最后一个token是否为')',若不是,那么一定不用去括号啦,直接return 0
3.递归筛选
直接进入check_parentheses(p+1,q-1),根据返回值分类讨论:
1.若返回-1,说明,(p,q)不能去括号,return 0
2.若返回0或者1,要去括号,return 1
综上,此函数源码为:
static int check_parentheses(int p,int q){
//return -1:stop calculate
//return 0 :no need to remove parentheses
//return 1 :need to remove parentheses
//part1
int cnt = 0;
for(int i = p; i <= q;i ++)
{
if(tokens[i].type == '(')cnt ++;
else if(tokens[i].type == ')')cnt --;
if(cnt < 0)return -1;
}
if(cnt != 0) return -1;
//part2
if(tokens[p].type != '(' || tokens[q].type != ')')return 0;
//part3
int ret = check_parentheses(p+1,q-1);
if(ret == -1)return 0;
else if(ret == 0||ret == 1)return 1;
return 2;//algorithm_error,提高算法健壮性,防止上面考虑不全而出错
}
主运算符的寻找
解决了括号匹配问题,再看这个核心问题,找主运算符,实际上,满足下面条件即可:
1.一定不在括号里面
2.优先级很小
3.如果几个运算符优先级相同,那么主运算符是最后面那个
所以,整体思路为:
step1:跳过括号
比如(1+1)+(2+3),在遍历token时,遇见第一个'('直接跳到')',但如果没有上面去多余括号的一步,就有:((1+1)+(1+1)),遇见(直接会跳到最右边的')',那么都遍历结束了,如何找主运算符?
step2:遍历token,根据优先级锁定主运算符
这个不多说了,直接上源码、请自行理解 (注意,越往下的优先级越高,可以照着C语言优先级等级表写这个flag大小顺序!)
int op = -1;
int flag = -1 ;
for(int i = p; i <= q;i ++)
{
if(tokens[i].type == '(')
{
int cnt = 1;
while(cnt != 0)
{
i ++;
if(tokens[i].type == '(')cnt ++;
else if(tokens[i].type == ')')cnt--;
}
}
if(flag<=7 && tokens[i].type == OR){
flag = 7;
op = max(op,i);
}
if(flag<=6 && tokens[i].type == AND){
flag = 6;
op = max(op,i);
}
if(flag<=5 && tokens[i].type == NOTEQ){
flag = 5;
op = max(op,i);
}
if(flag<=4 && tokens[i].type == EQ){
flag = 4;
op = max(op,i);
}
if(flag<=3 && tokens[i].type == LEQ){
flag = 3;
op = max(op,i);
}
if(flag<=2 && (tokens[i].type == '+' || tokens[i].type == '-')){
flag = 2;
op = max(op,i);
}
if(flag<=1 && (tokens[i].type == '*' || tokens[i].type == '/') ){
flag =1;
op = max(op,i);
}
}
int op_type = tokens[op].type;
负号和减号的区分
对于更复杂的表达式,比如1 + -2 如何知道后面的"-"是一个负号而不是减号呢?
可以在正式计算的函数(word_t expr(char *e, bool *success))中,在调用eval函数之前,做一些预处理,就以负号判断为例,可以写:
for(int i = 0;i < tokens_len; i ++)
{
if((tokens[i].type == '-' && i == 0)||(tokens[i].type == '-' && i > 0 &&tokens[i-1].type != NUM && tokens[i+1].type == NUM &&tokens[i-1].type != ')'))
{
tokens[i].type = TK_NOTYPE;
for(int j = 31;j >= 0;j --)
{
tokens[i+1].str[j] = tokens[i+1].str[j-1];
}
tokens[i+1].str[0] = '-';
for(int j = 0;j < tokens_len; j ++){
if(tokens[j].type == TK_NOTYPE)
{
for(int k = j+1; k < tokens_len;k ++){
tokens[k - 1] = tokens[k];
}
tokens_len --;
}
}
}
}
即如果这个'-'在开头或者不在开头但是前面的一个token不是数字、右括号、右边的token是数字,就可以让这个'-'先和后面的数字直接结合,然后调整tokens的位置('-'与后面的数字结合之后就会变成空格)
如何测试你的代码
生成测试用例
这一部分将采用“随机测试”的方法,生成大量的随机表达式,然后放入上面自己写的expr函数中进行测试,以前感觉OJ题目的评判很神奇,能够直接知道自己的哪个数据过不了,做完这个之后,感觉可能和这个测试代码的思路有点相似吧。
生成随机表达式的框架代码如下
static void gen_rand_expr(){
// buf[0] = '\0' ;
if(index_buf > 655){
// printf("overSize\n");
index_buf = 0;}
switch(choose(3)){
case 0:
gen_num();
break;
case 1:
gen('(');
gen_rand_expr();
gen(')');
break;
default:
gen_rand_expr();
gen_rand_op();
gen_rand_expr();
break;
}
}
使用测试用例计算调试器运算准确率
补充gen、choose等函数的代码之后,还需要考虑一些问题,如果生成的表达式格式不对怎么办?如果里面有除数为0的情况又怎么办,我的方法是把报错信息重定向到一个文件中,如果文件为空,则没问题,或者直接根据system函数的返回值确定,如果有问题,则重新生成一次表达式,main源码如下:
int main(int argc, char *argv[]) {
int seed = time(0);
srand(seed);
int loop = 1;
if (argc > 1) {
sscanf(argv[1], "%d", &loop);
}
int i;
for (i = 0; i < loop; i ++) {
index_buf = 0;
gen_rand_expr();
buf[index_buf] = '\0';
long size = 0;
sprintf(code_buf, code_format, buf);
FILE *fp = fopen("/tmp/.code.c", "w");
assert(fp != NULL);
fputs(code_buf, fp);
fclose(fp);
FILE *fp_err = fopen("/tmp/.err_message","w");
assert(fp_err != NULL);
int ret = system("gcc /tmp/.code.c -o /tmp/.expr 2>/tmp/.err_message");
fseek(fp_err, 0, SEEK_END);
// 获取文件大小
size = ftell(fp_err);
fclose(fp_err);
if (ret != 0 ||size != 0){index_buf = 0; i--; continue;}
fp = popen("/tmp/.expr", "r");
assert(fp != NULL);
uint32_t result;
ret = fscanf(fp, "%u", &result);
pclose(fp);
printf("%u %s\n", result, buf);
index_buf = 0;
}
return 0;
}
然后在命令行用gcc编译、运行gen-expr.c把输出重定向到input中,用于后序测试调试器运算的准确率
gcc -c -g gen-expr.c
gcc gen-expr.o -o gen-expr
./gen-expr 100 > input
在sdb.c中增加一条命令test,用于测试准确率
static int cmd_test(char *args){
int right_ans = 0;
FILE *input_file = fopen("/home/dmz/ics2023/nemu/tools/gen-expr/input", "r");
if (input_file == NULL) {
perror("Error opening input file");
return 1;
}
char record[1024];
unsigned real_val;
char buf[1024];
// 循环读取每一条记录
for (int i = 0; i < 100; i++) {
// 读取一行记录
if (fgets(record, sizeof(record), input_file) == NULL) {
perror("Error reading input file");
break;
}
// 分割记录,获取数字和表达式
char *token = strtok(record, " ");
if (token == NULL) {
printf("Invalid record format\n");
continue;
}
real_val = atoi(token); // 将数字部分转换为整数
// 处理表达式部分,可能跨越多行
strcpy(buf, ""); // 清空buf
while ((token = strtok(NULL, "\n")) != NULL) {
strcat(buf, token);
strcat(buf, " "); // 拼接换行后的部分,注意添加空格以分隔多行内容
}
// 输出结果
printf("Real Value: %u, Expression: %s\n", real_val, buf);
bool flag = false;
unsigned res = expr(buf,&flag);
if(res == real_val)right_ans ++;
}
printf("test 100 expressions,the accuracy is %d/100\n",right_ans);
fclose(input_file);
return 0;
}
然后进入nemu,输入test,发现很多表达式依旧发生报错(除数为0的现象),而且是我写的sdb里面发生了除数为0,但是当我把这个表达式放入devC++直接运算,并没有报错现象,经过一番探索,我尝试了如下代码:
#include <stdio.h>
int main() {
unsigned result = 1000/(-120);
printf("%u", result);
return 0;
}
结果是一个很大的数,但用我自己写的sdb的p命令来算,算出的是0,最终知道了:对于一个表达式在devC++计算的过程中,先是当成有符号数处理了,比如上面的算出来是-8,然后再转换成了无符号数,所以很大,但我写的eval的返回类型就是uint32_t,就会先把-120变成很大的数,然后相除后得0!
当把此返回类型改成int,仅在最后的expr函数中转成uint32_t之后:
进入nemu、输入test,成功!