前言
「四则运算器」是练习递归与栈的经典题目,但很多人第一次实现时会写成「从左到右扫描,遇到运算符就立刻计算」。这种写法在2 + 3 * 4上就会给出 20 而不是 14,因为乘除的优先级高于加减;加上括号后问题更明显,扫描器必须知道当前处在第几层括号里。
另一类常见误解是认为「括号只是把子表达式先算出来」,于是用std::stoi或std::stod去解析整个表达式,或者在字符串里反复查找最内层括号再替换成结果。前者根本解析不了运算符,后者的字符串拼接既慢又容易在负数、多个括号嵌套时出错。
本文用递归下降(recursive descent)实现一个支持+ - * /、一元正负号、任意层括号和空白的表达式求值器。它把文法直接翻译成函数调用,优先级和结合性由函数层次保证,不需要额外的栈或优先级表。代码基准为 C++17,使用double做浮点运算;文末也说明整数版本的改法。
一、先把文法写出来
递归下降的第一步不是写代码,而是把表达式拆成若干层文法规则。每一层对应一个函数,层与层之间的调用关系就是优先级:
expression := term (('+' | '-') term)*
term := unary (('*' | '/') unary)*
unary := ('+' | '-') unary | primary
primary := number | '(' expression ')'
number := digit+ ('.' digit*)? | '.' digit+这套规则表达了三件事:
+、-的优先级最低,由expression处理;*、/的优先级较高,由term处理;- 括号和数字是原子单位,由
primary处理;一元符号在unary里递归。
(('+' | '-') term)*这种写法表示「零个或多个」,并且每次循环都先把左操作数算出来、再与右操作数结合。因为是循环而不是右递归,所以2 - 3 - 4自然按左结合解析成(2 - 3) - 4,结果是 -5,而不是2 - (3 - 4) = 3。
| 运算符 | 结合性 | 所在函数 | 优先级 |
|---|
() | 不适用 | primary | 最高 |
一元+/- | 右结合 | unary | 高 |
*// | 左结合 | term | 中 |
+/- | 左结合 | expression | 低 |
二、词法:跳过空白与读取数字
完整的词法分析器会把输入切成 token 数组,但对这个规模的表达式,直接在字符流上「按需读取」更简单。两个基础动作:跳过空白、识别一个数字。
std::isspace和std::isdigit的参数是int,且只对unsigned char范围内的值有定义。如果char是有符号的,传入负值(例如某些非 ASCII 字节)是 UB。所以必须先static_cast<unsigned char>:
// 适用于 C++17
void skip_spaces() {
while (pos_ < text_.size() &&
std::isspace(static_cast<unsigned char>(text_[pos_]))) {
++pos_;
}
}读取数字时,要处理「整数部分」「小数点」「小数部分」,并保证至少有一位数字。只写while (isdigit) ++pos;然后std::stod是不够的:输入.或1.时要给出明确错误,而不是让stod抛一个难以理解的异常。
// 适用于 C++17
double number() {
skip_spaces();
std::size_t digits = 0;
while (pos_ < text_.size() &&
std::isdigit(static_cast<unsigned char>(text_[pos_]))) {
++pos_;
++digits;
}
if (pos_ < text_.size() && text_[pos_] == '.') {
++pos_;
while (pos_ < text_.size() &&
std::isdigit(static_cast<unsigned char>(text_[pos_]))) {
++pos_;
++digits;
}
}
if (digits == 0) {
throw std::runtime_error("expected a number");
}
std::string token(text_.substr(start, pos_ - start));
return std::stod(token);
}注意token_start_要在跳过空白之后、读取数字之前记录。std::stod接受std::string,这里显式构造一个,避免依赖string_view到string的隐式转换。stod在超范围时抛std::out_of_range,在无法转换时抛std::invalid_argument,调用方统一按std::exception捕获即可。
三、递归下降的四个函数
3.1expression:处理加减
// 适用于 C++17
double expression() {
double value = term();
for (;;) {
skip_spaces();
if (pos_ < text_.size() &&
(text_[pos_] == '+' || text_[pos_] == '-')) {
char op = text_[pos_++];
double rhs = term();
value = (op == '+') ? value + rhs : value - rhs;
} else {
break;
}
}
return value;
}循环结构保证了左结合。每次读到一个运算符,就递归调用term()取得右操作数。如果写成return term() + expression();,就变成右结合,2 - 3 - 4会算错。
3.2term:处理乘除
// 适用于 C++17
double term() {
double value = unary();
for (;;) {
skip_spaces();
if (pos_ < text_.size() &&
(text_[pos_] == '*' || text_[pos_] == '/')) {
char op = text_[pos_++];
double rhs = unary();
if (op == '/') {
if (rhs == 0.0) {
throw std::runtime_error("division by zero");
}
value /= rhs;
} else {
value *= rhs;
}
} else {
break;
}
}
return value;
}除数为零必须显式检查。对double来说,除以 0.0 会得到无穷大或 NaN,不会崩溃,但结果对用户没有意义;对整数来说,除以零是 UB。统一按错误处理更稳妥。
3.3unary:一元正负号
// 适用于 C++17
double unary() {
skip_spaces();
if (pos_ < text_.size() &&
(text_[pos_] == '+' || text_[pos_] == '-')) {
char op = text_[pos_++];
double value = unary();
return (op == '-') ? -value : value;
}
return primary();
}一元符号放在term和primary之间,既支持-2 * 3,也支持2 * -3,还支持--2这样的连续符号。递归调用unary()而不是primary(),是为了让--2和-(-2)都能正确解析。
3.4primary:括号与数字
// 适用于 C++17
double primary() {
skip_spaces();
if (pos_ >= text_.size()) {
throw std::runtime_error("unexpected end of input");
}
if (text_[pos_] == '(') {
++pos_;
double value = expression();
skip_spaces();
if (pos_ >= text_.size() || text_[pos_] != ')') {
throw std::runtime_error("missing ')'");
}
++pos_;
return value;
}
return number();
}括号的处理是递归的核心:遇到(就调用expression(),它会一直解析到与之匹配的)之前;然后primary检查并消费)。如果缺少右括号,或者右括号出现在不期望的位置,都会抛出异常。
四、入口与错误处理
parse()负责求值并检查「整个输入是否被消费完」:
// 适用于 C++17
double parse() {
double value = expression();
skip_spaces();
if (pos_ != text_.size()) {
throw std::runtime_error("unexpected trailing character");
}
return value;
}最后这一步常常被省略。如果不检查,输入1+2abc会返回 3,abc被静默忽略。对交互式程序来说,这会让用户以为表达式合法。
错误处理有两种风格:抛异常或返回std::optional<double>/ 错误码。本文用异常,因为递归下降的调用层次深,逐层检查返回码会把代码写得很啰嗦。异常在main里统一捕获,输出错误信息后继续读下一行。
完整示例
// 适用于 C++17
#include <cctype>
#include <cstddef>
#include <iostream>
#include <stdexcept>
#include <string>
#include <string_view>
class Parser {
public:
explicit Parser(std::string_view text) : text_(text) {}
double parse() {
double value = expression();
skip_spaces();
if (pos_ != text_.size()) {
throw std::runtime_error("unexpected trailing character");
}
return value;
}
private:
std::string_view text_;
std::size_t pos_ = 0;
void skip_spaces() {
while (pos_ < text_.size() &&
std::isspace(static_cast<unsigned char>(text_[pos_]))) {
++pos_;
}
}
double expression() {
double value = term();
for (;;) {
skip_spaces();
if (pos_ < text_.size() &&
(text_[pos_] == '+' || text_[pos_] == '-')) {
char op = text_[pos_++];
double rhs = term();
value = (op == '+') ? value + rhs : value - rhs;
} else {
break;
}
}
return value;
}
double term() {
double value = unary();
for (;;) {
skip_spaces();
if (pos_ < text_.size() &&
(text_[pos_] == '*' || text_[pos_] == '/')) {
char op = text_[pos_++];
double rhs = unary();
if (op == '/') {
if (rhs == 0.0) {
throw std::runtime_error("division by zero");
}
value /= rhs;
} else {
value *= rhs;
}
} else {
break;
}
}
return value;
}
double unary() {
skip_spaces();
if (pos_ < text_.size() &&
(text_[pos_] == '+' || text_[pos_] == '-')) {
char op = text_[pos_++];
double value = unary();
return (op == '-') ? -value : value;
}
return primary();
}
double primary() {
skip_spaces();
if (pos_ >= text_.size()) {
throw std::runtime_error("unexpected end of input");
}
if (text_[pos_] == '(') {
++pos_;
double value = expression();
skip_spaces();
if (pos_ >= text_.size() || text_[pos_] != ')') {
throw std::runtime_error("missing ')'");
}
++pos_;
return value;
}
return number();
}
double number() {
skip_spaces();
std::size_t start = pos_;
std::size_t digits = 0;
while (pos_ < text_.size() &&
std::isdigit(static_cast<unsigned char>(text_[pos_]))) {
++pos_;
++digits;
}
if (pos_ < text_.size() && text_[pos_] == '.') {
++pos_;
while (pos_ < text_.size() &&
std::isdigit(static_cast<unsigned char>(text_[pos_]))) {
++pos_;
++digits;
}
}
if (digits == 0) {
throw std::runtime_error("expected a number");
}
std::string token(text_.substr(start, pos_ - start));
return std::stod(token);
}
};
int main() {
std::string line;
while (std::getline(std::cin, line)) {
if (line.empty()) {
continue;
}
try {
Parser parser(line);
std::cout << parser.parse() << '\n';
} catch (const std::exception& e) {
std::cout << "error: " << e.what() << '\n';
}
}
}按文法推演几个输入:2 + 3 * 4先由expression取term,term取到 2 后看到*,把 3 乘进去,返回 12,再加 2,得到 14。(2 + 3) * 4的primary遇到(,递归求值得到 5,回到term再乘 4,得到 20。-2 * -3的第一个unary消费-得到 -2,term看到*,第二个unary消费-得到 -3,结果是 6。2 - 3 - 4在循环里先算2-3=-1,再算-1-4=-5。
常见坑点
❌ 用std::stoi/std::stod直接解析整个表达式。
✅ 这两个函数只解析数字前缀,"1+2"会得到 1;必须自己写词法和语法分析。
❌ 把2 - 3 - 4写成右递归,得到 3。
✅ 用循环处理同级运算符,保证左结合,得到 -5。
❌ 先扫描所有*/再扫描+-,遇到括号就乱序。
✅ 用分层文法:expression → term → unary → primary,括号在primary里递归。
❌ 一元负号只在primary里处理,导致-2 * 3把-当成二元减号。
✅ 在term与primary之间加一层unary,递归处理连续符号。
❌ 解析结束后不检查剩余字符,1+2xyz返回 3。
✅parse()最后检查pos_ == text_.size(),否则报错。
❌ 用isdigit(text_[pos_])直接传char。
✅ 先转unsigned char;传负值给ctype函数是 UB。
❌ 整数版本不检查除数为零。
✅ 整数除以零是 UB;在除法前判断,或改用浮点并检查结果。
❌ 认为括号必须「先找最内层再替换字符串」。
✅ 递归下降在读到(时直接递归求值,天然支持任意嵌套,不需要字符串替换。
总结
| 层次 | 负责的语法 | 关键点 |
|---|
expression | +、- | 循环实现左结合 |
term | *、/ | 除零检查 |
unary | 一元+、- | 递归支持连续符号 |
primary | 数字、括号 | 括号内递归调用expression |
parse | 整体 | 检查输入耗尽 |
| 词法 | 空白、数字 | unsigned char转换、至少一位数字 |
递归下降的优点是「文法即代码」:优先级体现在函数调用层次,结合性体现在循环还是递归,括号体现在primary的递归调用。只要文法写对,代码结构就不会跑偏。表达式求值还有调度场算法(shunting-yard)等做法,它用显式栈处理优先级,适合需要一遍扫描或动态添加运算符的场景;对本题目,递归下降更直观。
本文代码按 C++17 语义人工推演,未在本机编译或运行;浮点结果的具体打印格式取决于std::cout的默认精度,属于实现细节。