☰
C++实现四则运算器(带括号)
2026/10/20 4:48:37 网站建设 项目流程

前言


「四则运算器」是练习递归与栈的经典题目,但很多人第一次实现时会写成「从左到右扫描,遇到运算符就立刻计算」。这种写法在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+

这套规则表达了三件事:



  1. +、-的优先级最低,由expression处理;

  2. *、/的优先级较高,由term处理;

  3. 括号和数字是原子单位,由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的默认精度,属于实现细节。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询