几乎所有学 C++ 的人都会在某个阶段卡住:语法书翻完了,能写点小工具,但一碰到 STL 源码、模板编程、表达式求值这些话题就开始发怵。我自己也是从这种状态过来的,后来发现一个特别有效的突破方式——别去啃抽象概念,去找几个看似独立、实则环环相扣的小项目动手做。今天这篇就想聊聊我近期完成的几个拓展练习:反向迭代器实现、计算器实现和逆波兰表达式。这三块内容单看没什么稀奇,但放在一起做,恰好覆盖了容器迭代器适配、栈结构应用、表达式解析这几个 C++ 学习路上绕不开的硬骨头,也是从“会写代码”迈向“理解设计”的一道台阶。
这篇内容不是单纯罗列代码,我会把每个环节的思考过程、踩坑记录和设计取舍都摊开来讲,适合已经掌握类、模板、STL 基本用法的读者,尤其适合正在为“迭代器到底是怎么工作”“表达式求值如何落地”这类问题困扰的朋友。如果你还没接触过模板,也可以先看思路,代码部分存下来以后再看。
1. 三者为何要放在一起学:一个完整的 C++ 能力闭环
很多初学者会陷入一个误区,觉得反向迭代器和计算器是两个风马牛不相及的话题。但真正把这三个练习做完之后我才意识到,它们构成了一条完整的认知链:反向迭代器解决的是“容器怎么被通用地遍历”的问题,计算器解决的是“字符串怎么被解析计算”的问题,而逆波兰表达式则是“计算器如何用栈高效实现”的经典答案。
1.1 反向迭代器:理解 STL 架构的钥匙
迭代器是 C++ STL 的神经系统。只要用过vector<int>::iterator遍历容器,就接触过迭代器的基本形态。但反向迭代器有一个非常容易让人困惑的设计:rbegin()返回的迭代器指向容器最后一个元素,rend()指向第一个元素之前的理论位置。而当你对反向迭代器执行++操作时,它实际上是向容器头部方向移动。
这个反直觉的设计背后,隐藏的正是 STL 的适配器模式。反向迭代器不是一个全新的迭代器类型,而是对正向迭代器的一种包装——通过一个ReverseIterator模板类,把正向迭代器的++映射为--,把--映射为++,同时调整解引用的偏移量。理解了这一点,你就不只是“会用反向迭代器”,而是看懂了 STL 是如何用模板和类型萃取搭建出可复用组件的。
1.2 逆波兰表达式:计算器的数学内核
我最早写的计算器程序,是直接在std::string上从左到右扫描,遇到数字就累积,遇到运算符就看优先级决定要不要立即计算。这种写法在只有+ - * /的情况下勉强能用,一旦加入括号,逻辑立刻变得混乱不堪。而逆波兰表达式(后缀表达式)的出现,把“人读的表达式”和“机器算的表达式”彻底分离。
在后缀表达式中,2 + 3 * 4被写成2 3 4 * +。求值时只需要一个操作数栈:遇到数字入栈,遇到运算符弹出两个数字计算结果再入栈。没有括号、没有优先级判断、没有回溯,一切都线性推进。这种简洁性正是栈这个数据结构的精髓——它天然适合处理这种“后进先出”的嵌套结构。
1.3 计算器:把零散知识串成完整项目
有了反向迭代器对容器遍历的深层理解,有了逆波兰表达式对运算规则的剥离,计算器就成了一个完美的整合项目。它需要你处理输入的字符串解析、容器的动态管理、栈的灵活运用,还要考虑异常处理(除零、非法字符、括号不匹配)。这个过程会把前面学到的零散知识点编织成一张网,让你真正体会到“原来写一个能跑起来的工具需要这么多细节”。
2. 反向迭代器的完整实现:从适配器思想到代码落地
反向迭代器的实现,首要任务是搞清楚它和正向迭代器的关系。我花了很长时间才真正明白 STL 源码里那个看似奇怪的operator*实现:它先拷贝一份内部的正向迭代器,然后自减一次,再解引用。
2.1 核心原理:为什么解引用要偏移
假设有一个vector<int> v = {1, 2, 3, 4, 5},它的正向迭代器begin()指向 1,end()指向 5 之后的虚拟位置。反向迭代器的rbegin()逻辑上指向 5,rend()逻辑上指向 1 之前的虚拟位置。但如果我们把rbegin()内部保存的正向迭代器直接指向 5,那当我们需要rend()时,内部正向迭代器就得指向begin()之前——这在 C++ 标准库中是不允许的(一个前向迭代器是不能指向首元素之前的)。
所以标准的做法是反向迭代器内部保存的正向迭代器指向逻辑元素的下一个位置:
rbegin()内部保存end(),逻辑位置是最后一个元素。rend()内部保存begin(),逻辑位置是第一个元素之前。
这就带来一个直接后果:反向迭代器解引用时,必须先把内部迭代器自减(--),才能指向真正的目标元素。如果不做这一步偏移,rbegin()解引用会得到end()指向的虚拟位置,程序直接崩溃。
2.2 代码实现:适配器模板的骨架
我实现版本时,参考了 C++ 标准库的std::reverse_iterator的接口设计,但做了简化,保留主干:
template <typename Iterator> class ReverseIterator { public: using iterator_type = Iterator; using value_type = typename std::iterator_traits<Iterator>::value_type; using pointer = typename std::iterator_traits<Iterator>::pointer; using reference = typename std::iterator_traits<Iterator>::reference; using difference_type = typename std::iterator_traits<Iterator>::difference_type; using iterator_category = typename std::iterator_traits<Iterator>::iterator_category; ReverseIterator() : current() {} explicit ReverseIterator(iterator_type it) : current(it) {} template <typename OtherIterator> ReverseIterator(const ReverseIterator<OtherIterator>& other) : current(other.base()) {} iterator_type base() const { return current; } reference operator*() const { iterator_type tmp = current; --tmp; return *tmp; } pointer operator->() const { iterator_type tmp = current; --tmp; return &(*tmp); } ReverseIterator& operator++() { --current; return *this; } ReverseIterator& operator--() { ++current; return *this; } ReverseIterator operator++(int) { ReverseIterator tmp = *this; --current; return tmp; } ReverseIterator operator--(int) { ReverseIterator tmp = *this; ++current; return tmp; } ReverseIterator& operator+=(difference_type n) { current -= n; return *this; } ReverseIterator& operator-=(difference_type n) { current += n; return *this; } ReverseIterator operator+(difference_type n) const { return ReverseIterator(current - n); } ReverseIterator operator-(difference_type n) const { return ReverseIterator(current + n); } reference operator[](difference_type n) const { return *(*this + n); } bool operator==(const ReverseIterator& other) const { return current == other.current; } bool operator!=(const ReverseIterator& other) const { return current != other.current; } private: Iterator current; };注意:
iterator_category我沿用了内部正向迭代器的类型。这很关键——如果你的容器是随机访问迭代器,反向迭代器也应该支持随机访问;如果是双向迭代器,那么operator+=这类方法就不该暴露出来。用std::iterator_traits萃取类型,是标准库保持一致性的做法。
2.3 operator* 偏移的自我验证
我犯过的最大错误,是以为反向迭代器的operator*直接返回*current就行。写完后跑测试,发现*rbegin()根本取不到最后一个元素,反而得到的是一个未定义的值。当时排查了很久,最后想到打开调试器看内部current的地址,才意识到问题出在rbegin()和end()的关系上。
你可以自己验证一下这个偏移逻辑是否合理:
std::vector<int> v = {10, 20, 30}; auto it = v.rbegin(); std::cout << *it << "\n"; // 期望 30,实际也输出 30 std::cout << *(it.base()) << "\n"; // 这里输出的是 v.end() 指向的垃圾值因为rbegin()的base()是end(),所以解引用反向迭代器时必须先自减base()。这个设计虽然反直觉,但它是让rend()能够安全存在的唯一方式——这个取舍在整个 C++ 标准库中都是统一的。
2.4 接入容器:为自定义容器实现 rbegin/rend
类模板做完了,还得把它接到容器上。以简化版MyVector为例,只需要在容器类内部加上这四行:
using reverse_iterator = ReverseIterator<iterator>; using const_reverse_iterator = ReverseIterator<const_iterator>; reverse_iterator rbegin() { return reverse_iterator(end()); } reverse_iterator rend() { return reverse_iterator(begin()); } const_reverse_iterator rbegin() const { return const_reverse_iterator(end()); } const_reverse_iterator rend() const { return const_reverse_iterator(begin()); }这里再次体现了“偏移”的威力:begin()和end()的原有语义完全没变,反向迭代器只是在外面包了一层。后面你用for (auto rit = v.rbegin(); rit != v.rend(); ++rit)遍历时,实际上就是在用反向的逻辑访问正向的物理位置。
3. 从朴素求值到逆波兰:计算器实现的设计转折
很多教程会直接甩给你一个“中缀转后缀”的算法。但为了让你真正理解计算器实现需要什么,我先从直接处理中缀表达式的方式讲起,再引出逆波兰的思路,最后给出完整实现。
3.1 为什么直接扫描中缀很折腾
直接实现中缀计算器,最笨的办法是:每次遇到一个运算符,就向左找两个数字计算。比如1 + 2 * 3,当你扫描到+时根本没法立刻计算,因为后面的*优先级更高。于是你不得不维护一个运算符优先级表,甚至还要处理括号把运算符优先级“临时提高”。
我第一次写的时候用了两个栈(操作数栈和运算符栈),算法逻辑大致如下:
// 伪代码:中缀直接求值 for each token in expression: if token is number: push to operand_stack else if token is '(': push to operator_stack else if token is ')': while top of operator_stack != '(': compute_one_operation(operand_stack, operator_stack) pop '(' from operator_stack else: // 运算符 while operator_stack not empty and precedence(top) >= precedence(token): compute_one_operation(operand_stack, operator_stack) push token to operator_stack这套逻辑是能跑的,但有两个痛点。其一,代码一旦要考虑一元负号(比如-3 + 5),优先级判断立即复杂化;其二,整个流程把“解析”和“计算”耦合在一起,一旦想给计算器加新功能(比如函数调用sqrt(9)),就要继续往这段代码里塞条件分支,最后变得面目全非。
3.2 中缀转后缀:调度场算法的落地
分离“解析”和“计算”的经典方案,就是先把中缀表达式转成逆波兰后缀表达式。这个转换过程有一个流派叫做调度场算法——关于这个名字我们不需要过度纠缠,你只需要关注它的核心规则。
规则可以从三个角度来记忆:
- 遇到数字,直接输出到后缀结果。
- 遇到运算符,把运算符栈中优先级不低于当前运算符的运算符依次弹出并输出,然后当前运算符入栈。
- 遇到左括号直接入栈;遇到右括号,弹出运算符直到左括号,左括号本身不输出。
优先级怎么比较?可以定义一张表:
| 运算符 | 优先级 |
|---|---|
+- | 1 |
*/ | 2 |
^ | 3(右结合) |
这里有个细节:^在数学中是右结合的(2^3^2等于2^(3^2)),所以它在“弹出栈顶运算符”时要注意不能弹出另一个^。如果你是初学者,我建议第一次实现只支持+ - * /和括号,避免在结合性上过早地耗掉耐心。
3.3 完整代码:Convert + Eval 两段式结构
下面给出一个完整的、支持括号和四则运算的版本。代码用 C++17 编写,兼容性良好:
#include <iostream> #include <string> #include <vector> #include <sstream> #include <stack> #include <cctype> #include <stdexcept> // 工具函数:把一个表达式字符串拆分为 token 向量 std::vector<std::string> tokenize(const std::string& expr) { std::vector<std::string> tokens; std::string num; for (size_t i = 0; i < expr.size(); ++i) { char ch = expr[i]; if (std::isspace(static_cast<unsigned char>(ch))) { continue; } if (std::isdigit(static_cast<unsigned char>(ch))) { num += ch; if (i + 1 == expr.size() || !std::isdigit(static_cast<unsigned char>(expr[i + 1]))) { tokens.push_back(num); num.clear(); } continue; } // 处理负数:如果 '-' 前面是数字或右括号,说明是减号;否则当作负号处理。 if (ch == '-' && (tokens.empty() || tokens.back() == "(" || tokens.back() == "+" || tokens.back() == "-" || tokens.back() == "*" || tokens.back() == "/")) { // 简化处理:把负号和后面的数字合并为一个 token,如 "-3" std::string negative_num = "-"; ++i; while (i < expr.size() && (std::isdigit(static_cast<unsigned char>(expr[i])) || expr[i] == '.')) { negative_num += expr[i]; ++i; } --i; tokens.push_back(negative_num); } else { tokens.push_back(std::string(1, ch)); } } return tokens; } // 获取运算符优先级 int precedence(const std::string& op) { if (op == "+" || op == "-") return 1; if (op == "*" || op == "/") return 2; return 0; } // 中缀 token 转后缀 token std::vector<std::string> toPostfix(const std::vector<std::string>& tokens) { std::vector<std::string> output; std::stack<std::string> ops; for (const auto& tok : tokens) { if (tok.size() > 1 || std::isdigit(static_cast<unsigned char>(tok[0])) || (tok[0] == '-' && tok.size() > 1)) { // 数字(包括负数)直接输出 output.push_back(tok); } else if (tok == "(") { ops.push(tok); } else if (tok == ")") { while (!ops.empty() && ops.top() != "(") { output.push_back(ops.top()); ops.pop(); } if (ops.empty()) { throw std::runtime_error("括号不匹配"); } ops.pop(); // 弹出左括号 } else { // 运算符 while (!ops.empty() && ops.top() != "(" && precedence(ops.top()) >= precedence(tok)) { output.push_back(ops.top()); ops.pop(); } ops.push(tok); } } while (!ops.empty()) { if (ops.top() == "(") { throw std::runtime_error("括号不匹配"); } output.push_back(ops.top()); ops.pop(); } return output; } // 计算后缀表达式 double evaluatePostfix(const std::vector<std::string>& postfix) { std::stack<double> nums; for (const auto& tok : postfix) { if (tok.size() > 1 || std::isdigit(static_cast<unsigned char>(tok[0])) || (tok[0] == '-' && tok.size() > 1)) { nums.push(std::stod(tok)); } else { if (nums.size() < 2) { throw std::runtime_error("表达式操作数不足"); } double right = nums.top(); nums.pop(); double left = nums.top(); nums.pop(); if (tok == "+") nums.push(left + right); else if (tok == "-") nums.push(left - right); else if (tok == "*") nums.push(left * right); else if (tok == "/") { if (right == 0) throw std::runtime_error("除零错误"); nums.push(left / right); } } } if (nums.size() != 1) { throw std::runtime_error("表达式格式错误"); } return nums.top(); } double calculate(const std::string& expr) { auto tokens = tokenize(expr); auto postfix = toPostfix(tokens); return evaluatePostfix(postfix); }这里有一个我觉得特别重要的实现细节:负数处理。在上面的tokenize里,我判断-是不是一元负号,依据是它前面是不是没有操作数或者紧跟左括号。如果是一元负号,就把它和后面的数字合并成一个 token。这样做的好处是,toPostfix和evaluatePostfix都不需要额外维护“这是一个负号而非减号”的状态,逻辑更干净。
3.4 后缀表达式求值:栈的教科书应用
后缀表达式求值代码看起来简单,甚至简洁得不像话,但这恰恰是栈这种数据结构的魅力所在。我来带你把执行过程拆开看看。以"3 4 + 2 *"为例:
- 读到
3,入栈。栈:[3] - 读到
4,入栈。栈:[3, 4] - 读到
+,弹出4、3,计算3 + 4 = 7,入栈。栈:[7] - 读到
2,入栈。栈:[7, 2] - 读到
*,弹出2、7,计算7 * 2 = 14,入栈。栈:[14]
这里容易踩的坑是:先弹出的是右操作数,后弹出的是左操作数。因为栈是后进先出的,比如8 2 /,弹出顺序是2、8,计算时8 / 2 = 4,而不是2 / 8。我第一次写的时候顺序搞反了,导致8 / 2输出0.25,排查了半天才想起来是操作数顺序的问题。这个小细节我在下面专门再提一次。
4. 实战踩坑记录:从测试失败到逐渐稳定的完整过程
写这两套代码的时候,我实际上经历了非常多的失败。这里挑几个最有代表性的问题,做成一个速查表,方便你复现时对照。
4.1 常见错误速查表
| 症状 | 根本原因 | 排查与修复方法 |
|---|---|---|
| 反向迭代器解引用崩溃或输出垃圾值 | operator*没做先自减偏移 | 确认rbegin()的base()是end(),需要先--base()再解引用 |
for (auto rit = v.rbegin(); rit != v.rend(); ++rit)死循环 | operator++写成了正向递增 | 反向迭代器的++内部必须是--current |
| 表达式含多个空格时解析出错 | tokenize没有忽略空白 | 在 tokenize 初始判断时统一跳过isspace |
8 / 2计算结果为0.25 | 弹出操作数顺序错误 | 后缀求值时,先弹出的赋给right,后弹出的赋给left |
输入1 + 2 * 3得到9而不是7 | 优先级判断失效 | 检查toPostfix中弹出条件:必须是栈顶优先级>=当前运算符优先级 |
| 括号不匹配时程序崩溃 | 没有检查运算符栈为空或括号残留 | 在循环结束时若栈内仍有(则抛出异常 |
除法5 / 0崩溃 | 未处理除零 | 在evaluatePostfix中比较right == 0时抛std::runtime_error |
4.2 一个典型的调试实录
举个具体的例子,有次我输入12 + 3 * (4 - 2),结果得到18,而不是正确结果18——等等,12 + 3 * 2 = 18,算出来貌似又是对的。这说明初版看似正确,但为了验证,我又试了1 + (2 + 3) * 4,期望是21,却得到13。
仔细看toPostfix的输出才发现,转换结果变成了1 2 3 + * 4 +,而不是预期的1 2 3 + 4 * +。原来问题出在弹出条件上:我写成precedence(ops.top()) > precedence(tok),而不是>=。因为*优先级低于),但在括号内部遇到+和*时,由于>而不是>=,+没有弹出,导致顺序错乱。
这个教训让我明白:严格的弹出条件,比你想象的更敏感。标准做法就是>=,除非是在处理右结合运算符(如^)时才需特殊区分。
4.3 内存与性能层面的额外观测
我用较大的表达式(比如 1000 个数字混合四则运算)测试时发现,std::vector<std::string>存 token 的方式有空字符串拷贝的损耗。对于一个小型计算器当然无所谓,但如果想做得更工程化,可以考虑用std::string_view或者把运算符和数字统一放在一个结构体中:
enum class TokenType { Number, Operator, LeftParen, RightParen }; struct Token { TokenType type; double value; // 当 type == Number 时有效 char op; // 当 type == Operator 时有效 };这样内存分配更集中,性能也好一些。在项目初期不必过度优化,但了解这个方向对后续扩展有好处。
5. 三个练习的深层联系:模板、栈与迭代器的交织
文章开头我提到这三件事是一个闭环,这里展开说一下它们有哪些有意义的交叉。
5.1 反向迭代器与适配器模式对计算器代码的启示
很多人以为反向迭代器只是一种“倒着遍历容器”的语法糖,实际上它演示的适配器思想可以迁移到很多场景。拿计算器代码来说,toPostfix和evaluatePostfix两个函数看起来完全不同,但它们都作用于 token 序列。如果把 token 序列抽象成一种容器,你就可以为它设计不同的迭代器,比如“只看运算符的迭代器”“只看数字的迭代器”。当然,对计算器这个规模的项目来说这是过度设计,但理解这种“间接层”的力量,是阅读优秀 C++ 库代码的基础。
5.2 栈结构为什么无处不在
逆波兰表达式的求值和反向迭代器有一个共同的核心:LIFO(后进先出)的次序反转。反向迭代器把正向遍历次序整体反转,后缀表达式把中缀表达式的运算次序通过栈来重排。这两者让我意识到,栈不只是一种数据结构,更是一种“延迟决策”的思维方式——你先把无法立即处理的东西压栈,等时机成熟再弹出处理。
5.3 从“能跑”到“能改”:复用与封装的意义
在我写完初版计算器之后,想给它加上对%取模运算的支持。由于我在toPostfix中使用了precedence函数,在evaluatePostfix中使用了 if-else 分支,增加一个运算符需要修改三处:tokenize 里的负号判断、precedence 表、求值分支。这个体验让我明白,写代码时留好扩展点是多么重要。反向迭代器这种用模板封装的方式,恰恰就是 C++ 处理扩展性的一种经典手段。
5.4 两份实现的代码量与可读性对比
| 实现方式 | 核心代码量 | 可读性 | 可扩展性 | 适用场景 |
|---|---|---|---|---|
| 中缀直接双栈求值 | 约 100 行 | 一般,状态变量多 | 差,加新运算符要改多处 | 简单的教学演示 |
| 中缀转后缀再求值 | 约 150 行 | 好,分离关注点 | 好,运算符表和求值函数解耦 | 实际项目、后续扩展 |
从这两种方案的对比能看出,代码行数不是核心指标,结构清晰度才是。在 C++ 这种语言里,你会不断面临“再写几行换取更清晰架构”的抉择。我的建议是:在练习项目中,多选后者。
6. 一些个人习惯和扩展建议
最后分享几个我实际操作中积累的习惯,以及你做完这个项目之后还可以怎么玩。
6.1 测试习惯:先写边界用例
不要一上来就测1 + 2,没有意义。你至少应该准备这样的测试用例集:
std::vector<std::pair<std::string, double>> tests = { {"1 + 2", 3}, {"1 + 2 * 3", 7}, {"(1 + 2) * 3", 9}, {"8 / 2 - 1", 3}, {" 5 * (2 + 3) ", 25}, {"1 + (2 + 3) * 4", 21}, {"-3 + 5", 2}, {"2 - -3", 5}, {"1 / 0", ???}, // 期望抛异常 {"((1 + 2)", ???}, // 期望抛异常 };把期望结果和异常场景都写出来,每一次代码修改后用这个列表回归,比我当年用“随手输几个式子试试”的测试方式高效得多。
6.2 扩展方向:从四则运算到更复杂的表达式
完成基础计算器之后,有几个很自然的延伸方向:
- 加入单目函数:如
sqrt、sin、cos。在中缀转后缀时,把这些函数名当作运算对象,遇到右括号时弹出函数并作为一元运算符处理。 - 加入变量:比如
x + 3,把变量名作为 token,用std::map<std::string, double>保存环境。 - 支持逻辑表达式:
a > b && c < d,这需要把运算符优先级表扩展,并让求值结果支持布尔类型。 - 把表达式求值用于其他场景:比如实现一个简易的二维向量计算器,或者把表达式作为参数传给脚本引擎。
在做扩展时,你会更深刻地体会到当时“中缀转后缀”这个决定带来的好处——新增加的功能不需要改动主流程,只需要在 token 识别和求值函数里加分支。
6.3 最后一个调试技巧:打印中间表示
在排查计算器问题时,我最常用的手段是在toPostfix返回之后,把它的结果打印出来:
auto t = tokenize("1 + (2 + 3) * 4"); auto p = toPostfix(t); for (const auto& s : p) std::cout << s << " "; std::cout << "\n"; // 期望输出:1 2 3 + 4 * +输出对了,再检查求值逻辑。如果输出已经不对,就坚决不要去调后面的求值代码——先解决解析层面的问题。这个习惯帮我在复杂表达式的调试上节省了一半的时间。
反向迭代器、计算器实现与逆波兰表达式这三个练习,单独看是三个独立的知识点,合在一起却能构建出你对 STL、栈和表达式解析的立体认知。我自己的体会是,C++ 学到中后期,阻碍你前进的不是语法细节,而是这些知识点之间缺少“连接的桥梁”。多写这样的小项目,认真记录设计与调试过程,比囫囵吞枣翻完几本大部头要扎实得多。