这学期的一门课上,老师布置了个小项目:设计一个计算器。题目全称很随意地写着 caculator(拼写就不吐槽了),但要求并不随意——支持加减乘除、小数、括号,能处理异常输入,代码结构要清楚,有余力的可以加自己的功能。当时不少同学觉得这就是"用 if 把四种运算拼起来"的事,但当我真的动手写之后,才发现这个项目几乎把课程里最核心的栈、字符串解析、异常处理全串起来了。这篇文章就是我关于这个课上小项目的完整复盘,包含需求拆解、中缀转后缀的实现、完整 C 语言代码、实测踩坑和扩展思路,适合正在做计算器课程设计、或者想搞懂表达式求值原理的同学参考。
1. 拿到题目后,我没有先写代码,而是先拆需求
1.1 计算器题目背后的隐藏考点
"实现一个计算器"这句话听起来像一道课后习题,但如果只看字面意思就动手,很容易把项目做成一个奇奇怪怪的 if-else 大杂烩。我当时给自己列了一个需求清单,发现它比想象中要细得多:
- 数字部分:你要能做整数,也要能做小数,还要处理正负号
- 运算符部分:加减乘除,涉及优先级
- 括号部分:允许多层嵌套,比如 ((1+2)*(3+4))
- 输入部分:用户可能输 1+2,也可能输 1 + 2,空格要不要管?输 1#2 这种乱字符要不要报错?
- 异常部分:除零怎么办?括号不匹配怎么办?空输入怎么办?表达式少了操作数怎么办?
- 交互部分:算完一次还能不能继续输入?想退出怎么办?
把这些需求整理成一张评分维度的表,课程设计老师心里的标准也基本是这个框架:
| 评分维度 | 具体表现 | 常见扣分点 |
|---|---|---|
| 功能完整性 | 四则运算、括号、小数、负数都能算 | 只支持整数、不支持括号 |
| 容错能力 | 输入非法表达式时不崩溃,友好提示 | 一遇除零就退程序 |
| 代码结构 | 函数职责清晰、不堆在一堆 | 全部写在 main 里 |
| 可扩展性 | 新增一种运算或功能时改动小 | 改一行逻辑牵连全局 |
所以真正要面对的,不是"怎么算出 1+2=3",而是"怎么让程序可靠地处理任意合法的算术表达式,同时优雅地拒绝非法输入"。这个目标背后需要的是词法分析(把字符串拆成有意义的 token)、语法分析(理解 token 的顺序和优先级)和求值(真正计算)这样一套完整链路。计算器虽小,五脏俱全,它其实就是个简化版的语言解释器。
1.2 技术选型:为什么选了 C 语言 + 后缀表达式
我在动手前对比了几种方案,因为我见过不少同学的实现,并不是只有一条路:
| 方案 | 优点 | 缺点 |
|---|---|---|
| Python 直接 eval() | 一行代码就出结果 | 课上明确不允许用内置求值函数,而且失去练习意义 |
| Python 手写解析 | 语法糖多,写起来快 | 环境打包麻烦,对栈和内存的理解帮助有限 |
| C 语言控制台版 + 递归下降解析 | 接近真实编译器前端,扩展性强 | 对新生来说代码量偏大,优先级和错误恢复容易写乱 |
| C 语言控制台版 + 中缀转后缀 | 逻辑清晰、调试直观、和课程栈的知识呼应 | 需要多写一次转换流程,但这本来就是考点 |
我最后选了"中缀转后缀 + 后缀求值"这条路。原因很实在:第一,课程刚讲过栈,这个方案能把栈用得非常透彻;第二,它把"分析表达式"和"计算表达式"拆成两个阶段,出 bug 时可以单独调试某一段;第三,代码量控制在一个晚上的工作量之内,不用为了一个课设把自己逼到写递归下降。等真把后缀思路跑通之后,再去理解递归下降解析也会容易很多。
2. 表达式求值的关键思路:把中缀翻译成后缀
2.1 为什么人看得懂的中缀,机器不直接算
我们平时写的 1 + 2 * 3 叫中缀表达式,运算符在两个操作数中间。人能看懂是因为我们从小练到大的优先级和括号规则已经长在脑子里了,但计算机从左往右读取时,看到 1,看到 +,它并不知道后面的 * 优先级更高。如果直接按顺序算成 (1+2)*3=9,那就错了。
解决思路之一,是把表达式改写成一种"顺序即优先级"的形式——后缀表达式,也叫逆波兰式。中缀的 1 + 2 * 3,转成后缀是 1 2 3 * +。计算后缀时从左到右扫,遇到 * 就把前面的 2 和 3 相乘,遇到 + 就把 1 和 6 相加,整个过程完全没有歧义,也不需要回头看优先级。
有个类比我觉得挺贴切:中缀表达式像是自然语言的一句话,可能是倒装句,可能是嵌套从句,你得品;后缀表达式则像写好的执行清单,先做什么、后做什么全都排好了。计算机最喜欢这种没有二义性的东西,所以把这个翻译过程交给程序,后面就顺了。
2.2 调度场算法:中缀转后缀的操作步骤
中缀转后缀最经典的算法叫调度场算法(Shunting-yard),思路是用一个运算符栈来临时存放那些"还没法决定先后"的运算符。核心规则我整理成了五条:
- 从左到右读取 token
- 遇到数字,直接输出到后缀结果序列
- 遇到运算符,先看栈顶:只要栈顶运算符的优先级不低于当前运算符,就把栈顶弹出并输出,反复进行,直到栈空、栈顶是左括号、或者栈顶优先级更低,然后把当前运算符压入栈
- 遇到左括号,直接压入栈;遇到右括号,不断弹出运算符并输出,直到遇到左括号,把左括号弹出丢弃
- 输入全部读完,把栈里剩下的运算符依次弹出并输出
第 3 条里那句"不低于"很关键。四则运算都是左结合的,也就是从左往右算,所以遇到同级的运算符时,先入栈的那个要先算。比如 1 - 2 + 3,读到 + 时,栈顶是 -,和 + 同级,必须先把 - 弹出,让 1-2 先算,结果是 1-2+3=2 的顺序被保留下来。如果这里写成"高于才弹出",同级运算符就变成了右结合,1-2+3 会被算成 1-(2+3)=-4,直接翻车。
我手动模拟一个例子,中缀 1 + 2 * 3 - 4:
- 读 1,输出列表:1
- 读 +,栈空,压栈。栈:+,输出:1
- 读 2,输出:1 2
- 读 *,栈顶是 +,优先级低于 *,所以 * 直接压栈。栈:+ *,输出:1 2
- 读 3,输出:1 2 3
- 读 -,栈顶是 *,优先级高于 -,弹出 * 输出;此时栈顶是 +,优先级不低于 -,再弹出 + 输出;栈空,压入 -。栈:-,输出:1 2 3 * +
- 读 4,输出:1 2 3 * + 4
- 结束,弹出 -,输出:1 2 3 * + 4 -
得到后缀 1 2 3 * + 4 -,用它计算:2*3=6,1+6=7,7-4=3,完全正确。
2.3 后缀求值:栈的第二次登场
后缀表达式求值又是一个栈,这次的栈存数字,规则更简单:
- 从左到右扫描后缀 token
- 遇到数字就压入数字栈
- 遇到运算符就弹出两个数:第一次弹出的是右操作数,第二次弹出的是左操作数
- 运算后把结果压回栈
- 全部扫完,栈顶就是最终结果
第 3 条非常容易写错。减法除法不满足交换律,先弹出的一定是右操作数。拿后缀 4 2 / 举例,扫描时 4 和 2 依次入栈,遇到 / 后先弹出 2 作为右操作数,再弹出 4 作为左操作数,计算 4 / 2 = 2。如果搞反,得到的就是 2 / 4 = 0.5。这个细节我曾经在课设测试时栽过一次,后面会再聊。
括号在这种方案里不需要参与最终求值,它只在转后缀阶段起作用。因为左括号压栈后像一个屏障,把括号内部的运算符隔离处理,直到右括号出现才把屏障打破。这样括号内的子表达式作为一个整体输出,优先级天然就被保证了。
3. 核心代码逐段拆解:一个可编译的 C 语言版本
方案定了,代码怎么写是关键。我按"先拆 token,再做中缀转后缀,最后后缀求值"三个步骤来组织 C 代码,每一段都可以单独理解和测试。
3.1 token 扫描:把字符串切成有意义的部件
第一步要把用户输入的字符串切成长度不一但有确定含义的 token。比如 "1+2.5*(-3)" 应该被切成:数字 1、加号、数字 2.5、乘号、左括号、数字 -3、右括号。我定义了一个 token 结构:
typedef enum { TOK_NUM, TOK_ADD, TOK_SUB, TOK_MUL, TOK_DIV, TOK_LPAREN, TOK_RPAREN, TOK_END } TokenType; typedef struct { TokenType type; double value; } Token;然后需要一个辅助函数跳过空格,以及判断某个位置之前最近的非空格字符,这个函数在识别负号时会用到:
int last_non_space(const char *s, int pos) { for (int i = pos - 1; i >= 0; i--) { if (s[i] != ' ') return s[i]; } return '\0'; }在正式的 token 扫描中,只读取字符的位置,并返回解析出的 token。遇到非法字符时返回 0,由调用方报错。
int next_token(const char *s, int *pos, Token *tok) { while (s[*pos] == ' ') (*pos)++; char c = s[*pos]; if (c == '\0') { tok->type = TOK_END; return 1; } if ((c >= '0' && c <= '9') || (c == '.' && s[*pos + 1] >= '0' && s[*pos + 1] <= '9')) { int i = 0; char buf[128]; int dot_count = 0; while (s[*pos] != '\0') { char ch = s[*pos]; if (ch >= '0' && ch <= '9') { buf[i++] = ch; (*pos)++; } else if (ch == '.') { dot_count++; buf[i++] = ch; (*pos)++; if (dot_count > 1) return 0; } else { break; } } buf[i] = '\0'; tok->type = TOK_NUM; tok->value = atof(buf); return 1; } if (c == '+' || c == '-') { char prev = last_non_space(s, *pos); if (prev == '\0' || prev == '(' || prev == '+' || prev == '-' || prev == '*' || prev == '/') { int sign = (c == '-') ? -1 : 1; (*pos)++; while (s[*pos] == ' ') (*pos)++; if (!(s[*pos] >= '0' && s[*pos] <= '9') && s[*pos] != '.') return 0; int i = 0; char buf[128]; int dot_count = 0; while (s[*pos] != '\0') { char ch = s[*pos]; if (ch >= '0' && ch <= '9') { buf[i++] = ch; (*pos)++; } else if (ch == '.') { dot_count++; buf[i++] = ch; (*pos)++; if (dot_count > 1) return 0; } else { break; } } buf[i] = '\0'; tok->type = TOK_NUM; tok->value = sign * atof(buf); return 1; } tok->type = (c == '+') ? TOK_ADD : TOK_SUB; (*pos)++; return 1; } if (c == '*' || c == '/' || c == '(' || c == ')') { static const TokenType map[256] = {0}; static int inited = 0; if (!inited) { map['*'] = TOK_MUL; map['/'] = TOK_DIV; map['('] = TOK_LPAREN; map[')'] = TOK_RPAREN; inited = 1; } tok->type = map[(unsigned char)c]; (*pos)++; return 1; } return 0; }这段代码里有两个容易被忽略的点:一是遇到 '.' 开头的数字(比如 .5)也允许,这是完整功能的一部分;二是数字格式里如果出现两个以上小数点,比如 3.5.6,直接判非法。
3.2 一元负号的识别:最容易写错的一段逻辑
计算器课设里最普遍的 bug 就是负号。用户输入 -3+5,第一个 - 是负号;输入 2*(-3),左边是左括号,这个 - 也是负号;输入 1--2,第二个 - 前面是运算符,它还是负号。但如果输入 1-2,中间的 - 就是减号运算符。
判断依据其实很简单:一个 '+' 或 '-' 符号,如果它前面是表达式开头、左括号或另一个运算符,那它就不是二元运算符,而是一元正负号,应该并进后面的数字里。我上面代码里用 last_non_space 取出前一个有效字符来判断,就是这个目的。这个写法比先切完 token 再回头修补要干净得多,因为它在词法阶段就把 "-3" 当成一个整体的数字了。
有一个顺带的收获:这样实现之后,类似 1+-2 这种输入,会被解析成 1 + (-2),结果是 -1,符合数学习惯。而 1+ -2 带空格的写法也能正确处理,因为跳过空格后判断前一个有效字符是 +,依然把 -2 识别为负数。这确实省去了后面很多麻烦。
3.3 中缀转后缀、后缀求值以及主程序
有了 token 流,中缀转后缀就可以直接用栈来操作。我单独写一个函数,把 token 数组作为输出缓冲区,返回转换后的长度;返回 0 表示括号不匹配。
int precedence(TokenType t) { if (t == TOK_ADD || t == TOK_SUB) return 1; if (t == TOK_MUL || t == TOK_DIV) return 2; return 0; } int infix_to_postfix(Token *tokens, int n, Token *output) { Token stack[512]; int top = -1; int out_len = 0; for (int i = 0; i < n; i++) { Token t = tokens[i]; if (t.type == TOK_NUM) { output[out_len++] = t; } else if (t.type == TOK_LPAREN) { stack[++top] = t; } else if (t.type == TOK_RPAREN) { while (top >= 0 && stack[top].type != TOK_LPAREN) { output[out_len++] = stack[top--]; } if (top < 0) return 0; top--; } else { while (top >= 0 && stack[top].type != TOK_LPAREN && precedence(stack[top].type) >= precedence(t.type)) { output[out_len++] = stack[top--]; } stack[++top] = t; } } while (top >= 0) { if (stack[top].type == TOK_LPAREN) return 0; output[out_len++] = stack[top--]; } return out_len; }紧接着是后缀求值函数:
int eval_postfix(Token *postfix, int n, double *result) { double stack[512]; int top = -1; for (int i = 0; i < n; i++) { Token t = postfix[i]; if (t.type == TOK_NUM) { stack[++top] = t.value; } else { if (top < 1) return 0; double r = stack[top--]; double l = stack[top--]; switch (t.type) { case TOK_ADD: stack[++top] = l + r; break; case TOK_SUB: stack[++top] = l - r; break; case TOK_MUL: stack[++top] = l * r; break; case TOK_DIV: if (r == 0) { printf("错误:除数为零\n"); return 0; } stack[++top] = l / r; break; default: return 0; } } } if (top != 0) return 0; *result = stack[top]; return 1; }主程序里用 fgets 读输入,因为 scanf 遇到空格会断开,fgets 可以完整读到一行。读进来之后先去掉末尾换行,再调用 next_token 循环收集所有 token,然后依次调用中缀转后缀和后缀求值。遇到非法字符、括号不匹配、除零等情况,都给出明确的中文提示,而不是直接让程序崩溃。
我在实际代码里还做了边界防护:操作数栈和运算符栈都设了固定大小 512,这在课设场景下足够。如果要交作业,建议在代码注释里写清楚这个上限的考虑,老师会给你加分。
4. 我在调试中踩到的隐藏雷区
4.1 scanf 的陷阱和空输入
一开始我用的是 scanf("%s", expr),然后测试 1 + 2 的时候程序只算出了 1。原因很简单:scanf 遇空格就停。换成 fgets 之后,整行都进来了,这才算解决。这个坑特别隐蔽,因为很多同学只用紧凑的无空格表达式测试,根本不会发现 scanf 的问题;但老师演示时很可能会随手输入带空格的表达式。
另一个我一开始忽略的问题是用户直接按下回车,也就是空输入。fgets 会读到一个只有换行符的字符串,去掉换行之后就变成空串。如果程序不去判断,后面解析阶段就会越界或者返回奇怪的结果。我的做法是在 main 里先判断去掉换行后的长度,等于 0 就提示"输入为空"并 continue。看起来是小问题,但真到课设演示时,空输入崩溃绝对是最尴尬的场景之一。
4.2 括号、除零和连续运算符
我准备了一张测试用例表,对着这张表测一遍,能帮你提前发现 90% 的崩溃点:
| 输入 | 预期输出/行为 | 容易出错的原因 |
|---|---|---|
| 1+2*3 | 7 | 优先级 |
| (1+2)*3 | 9 | 括号优先 |
| 2*(-3) | -6 | 负号识别 |
| 1+-2 | -1 | 一元符号判断 |
| 5/0 | 提示"除数为零" | 运行时异常 |
| 1+2) | 提示"括号不匹配" | 右括号多余 |
| (1+2 | 提示"括号不匹配" | 左括号未闭合 |
| 3..14 | 提示"非法字符或格式错误" | 数字格式 |
| 1#2 | 提示"非法字符或格式错误" | 非法字符 |
| 1.2*(3.4-2) | 1.68 | 浮点精度 |
连续运算符也是一个值得注意的输入。比如用户输入 1++2,第一个 + 是运算符,第二个 + 因为前面是运算符,会被识别成一元正号,于是整个表达式合法,结果是 3。这是不是你想要的行为取决于需求。如果你希望严格禁止这种写法,那需要在扫描时额外加规则,要求运算符后面必须跟数字或左括号。我当时选择允许,因为数学上 1++2 可以看作 1+(+2),不算错,而且处理起来成本低。
4.3 浮点精度:为什么 1.2*(3.4-2) 不等于 1.68
用 double 做浮点运算,会出现精度误差。1.2*(3.4-2) 手算是 1.68,但程序算出来的可能是 1.6800000000000002,或者 1.6799999999999997。如果我在测试代码里写 "if (result == 1.68)",它一定会失败。
解决方式有两个。第一,输出时用 printf("%.6g", result),它会自动把尾巴修整成方便阅读的形式;第二,如果需要和期望值比较,不要用 ==,用 fabs(result - expected) < 1e-9 这种误差范围判断。这一点在课设报告里明确写出来,老师会认为你理解了浮点计算的本质,比功能本身还加分。
另外还有个老梗级的错误:有位同学把数字栈定义成了 int 数组,结果 1.2*3 输出 3,因为小数点后面的部分被直接截断了。如果你的课设明确要求支持小数,记得把求值栈声明成 double,这种错误一旦出现真的很难排查,因为大部分测试用例都集中在整数上。
5. 及格之后怎么卷:给计算器加这些功能
5.1 错误定位:告诉用户具体错在哪
基础版的错误提示是"表达式有误",但这不够友好。我做了个小改进:在 next_token 里返回的不仅是成功或失败,还能告诉调用方是在第几个字符出错。比如字符串 "1+2#3",扫描到 # 时返回失败,同时把当前位置传出来,主程序就可以输出"第 4 个字符非法"。
实现不复杂:把 next_token 的函数签名改成 int next_token(const char *s, int *pos, Token *tok),发生错误时保持 *pos 不动,主程序记录当前 *pos 值再打印。这样用户就能知道自己在哪输错了,而不是面对一句冷冰冰的"错误"。这个功能在课设答辩时特别容易演示,因为不是每个计算器都能精确报错位置。
5.2 从控制台到 GUI:怎么选
如果学有余力,把控制台程序包装成带界面的版本是很自然的扩展。几种做法:
| 方案 | 难度 | 适合场景 |
|---|---|---|
| Windows 对话框资源 | 中 | 想体会系统原生界面 |
| Qt / GTK | 中高 | 需要跨平台 |
| Web 页面(HTML+JS) | 低 | 有前端基础的同学 |
| 控制台菜单交互 | 很低 | 只是想模拟按键式计算器 |
我没选图形界面,而是加了一个"历史记录"功能:用一个数组保存最近 20 条计算结果,每次算完后自动追加,输入 history 可以查看,输入 clear 清空。这个扩展依然在控制台层面,但完整展示了一个"功能需求怎么变成代码逻辑"的过程,对课设来说性价比很高。图形界面最大的问题是你还要处理窗口事件循环、按钮布局,这些东西和表达式解析没有关系,反而会夺走你对核心算法的思考时间。
5.3 课设答辩前应该准备什么
经历过几次课设之后,我发现老师评审一个计算器项目时,看的往往不是你功能多炫,而是这几个问题:
- 为什么选这个方案?能不能说出两个备选方案并解释取舍?
- 测试用例是怎么设计的?老师随手输入一个复杂表达式,你的程序能不能稳住?
- 代码里有没有清晰的函数划分?错误处理在哪里?
- 如果让你加一个开方运算,你大概会在哪个位置改动?
最后一个问题尤其关键。如果你用的是"中缀转后缀 + 后缀求值"这套结构,加一个平方根函数只需要:在 token 扫描里识别 sqrt 关键字,把函数调用当作一元运算符处理,优先级设在最高,后缀求值里加一个 case 分支。这个清晰的扩展路径本身就是你架构能力的证明。
我在实际测试中还有一个体会:写代码前先写测试用例,效率会高很多。把上面那张表先列出来,再逐个让程序跑通,比漫无目的地乱敲表达式要强得多。毕竟计算器的正确性不是靠感觉验证的,是靠一组边界用例验证的。
最后再分享一个小技巧:主程序里计算结果用 printf("%.6g", result) 输出,它会把 1.6800000000000002 整理成 1.68。这个格式化细节看似不起眼,但老师演示时看到一长串精度尾巴和看到一个干净清爽的结果,对你的代码印象是完全不同的。计算器项目虽小,把每一处细节都做好,它就是你面试时能直接拿出来讲的完整案例。