
几乎所有学 C 的人都会在某个阶段卡住语法书翻完了能写点小工具但一碰到 STL 源码、模板编程、表达式求值这些话题就开始发怵。我自己也是从这种状态过来的后来发现一个特别有效的突破方式——别去啃抽象概念去找几个看似独立、实则环环相扣的小项目动手做。今天这篇就想聊聊我近期完成的几个拓展练习反向迭代器实现、计算器实现和逆波兰表达式。这三块内容单看没什么稀奇但放在一起做恰好覆盖了容器迭代器适配、栈结构应用、表达式解析这几个 C 学习路上绕不开的硬骨头也是从“会写代码”迈向“理解设计”的一道台阶。这篇内容不是单纯罗列代码我会把每个环节的思考过程、踩坑记录和设计取舍都摊开来讲适合已经掌握类、模板、STL 基本用法的读者尤其适合正在为“迭代器到底是怎么工作”“表达式求值如何落地”这类问题困扰的朋友。如果你还没接触过模板也可以先看思路代码部分存下来以后再看。1. 三者为何要放在一起学一个完整的 C 能力闭环很多初学者会陷入一个误区觉得反向迭代器和计算器是两个风马牛不相及的话题。但真正把这三个练习做完之后我才意识到它们构成了一条完整的认知链反向迭代器解决的是“容器怎么被通用地遍历”的问题计算器解决的是“字符串怎么被解析计算”的问题而逆波兰表达式则是“计算器如何用栈高效实现”的经典答案。1.1 反向迭代器理解 STL 架构的钥匙迭代器是 C STL 的神经系统。只要用过vectorint::iterator遍历容器就接触过迭代器的基本形态。但反向迭代器有一个非常容易让人困惑的设计rbegin()返回的迭代器指向容器最后一个元素rend()指向第一个元素之前的理论位置。而当你对反向迭代器执行操作时它实际上是向容器头部方向移动。这个反直觉的设计背后隐藏的正是 STL 的适配器模式。反向迭代器不是一个全新的迭代器类型而是对正向迭代器的一种包装——通过一个ReverseIterator模板类把正向迭代器的映射为--把--映射为同时调整解引用的偏移量。理解了这一点你就不只是“会用反向迭代器”而是看懂了 STL 是如何用模板和类型萃取搭建出可复用组件的。1.2 逆波兰表达式计算器的数学内核我最早写的计算器程序是直接在std::string上从左到右扫描遇到数字就累积遇到运算符就看优先级决定要不要立即计算。这种写法在只有 - * /的情况下勉强能用一旦加入括号逻辑立刻变得混乱不堪。而逆波兰表达式后缀表达式的出现把“人读的表达式”和“机器算的表达式”彻底分离。在后缀表达式中2 3 * 4被写成2 3 4 * 。求值时只需要一个操作数栈遇到数字入栈遇到运算符弹出两个数字计算结果再入栈。没有括号、没有优先级判断、没有回溯一切都线性推进。这种简洁性正是栈这个数据结构的精髓——它天然适合处理这种“后进先出”的嵌套结构。1.3 计算器把零散知识串成完整项目有了反向迭代器对容器遍历的深层理解有了逆波兰表达式对运算规则的剥离计算器就成了一个完美的整合项目。它需要你处理输入的字符串解析、容器的动态管理、栈的灵活运用还要考虑异常处理除零、非法字符、括号不匹配。这个过程会把前面学到的零散知识点编织成一张网让你真正体会到“原来写一个能跑起来的工具需要这么多细节”。2. 反向迭代器的完整实现从适配器思想到代码落地反向迭代器的实现首要任务是搞清楚它和正向迭代器的关系。我花了很长时间才真正明白 STL 源码里那个看似奇怪的operator*实现它先拷贝一份内部的正向迭代器然后自减一次再解引用。2.1 核心原理为什么解引用要偏移假设有一个vectorint v {1, 2, 3, 4, 5}它的正向迭代器begin()指向 1end()指向 5 之后的虚拟位置。反向迭代器的rbegin()逻辑上指向 5rend()逻辑上指向 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_traitsIterator::value_type; using pointer typename std::iterator_traitsIterator::pointer; using reference typename std::iterator_traitsIterator::reference; using difference_type typename std::iterator_traitsIterator::difference_type; using iterator_category typename std::iterator_traitsIterator::iterator_category; ReverseIterator() : current() {} explicit ReverseIterator(iterator_type it) : current(it) {} template typename OtherIterator ReverseIterator(const ReverseIteratorOtherIterator 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::vectorint 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 ReverseIteratoriterator; using const_reverse_iterator ReverseIteratorconst_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 两段式结构下面给出一个完整的、支持括号和四则运算的版本。代码用 C17 编写兼容性良好#include iostream #include string #include vector #include sstream #include stack #include cctype #include stdexcept // 工具函数把一个表达式字符串拆分为 token 向量 std::vectorstd::string tokenize(const std::string expr) { std::vectorstd::string tokens; std::string num; for (size_t i 0; i expr.size(); i) { char ch expr[i]; if (std::isspace(static_castunsigned char(ch))) { continue; } if (std::isdigit(static_castunsigned char(ch))) { num ch; if (i 1 expr.size() || !std::isdigit(static_castunsigned 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_castunsigned 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::vectorstd::string toPostfix(const std::vectorstd::string tokens) { std::vectorstd::string output; std::stackstd::string ops; for (const auto tok : tokens) { if (tok.size() 1 || std::isdigit(static_castunsigned 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::vectorstd::string postfix) { std::stackdouble nums; for (const auto tok : postfix) { if (tok.size() 1 || std::isdigit(static_castunsigned 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 初始判断时统一跳过isspace8 / 2计算结果为0.25弹出操作数顺序错误后缀求值时先弹出的赋给right后弹出的赋给left输入1 2 * 3得到9而不是7优先级判断失效检查toPostfix中弹出条件必须是栈顶优先级当前运算符优先级括号不匹配时程序崩溃没有检查运算符栈为空或括号残留在循环结束时若栈内仍有(则抛出异常除法5 / 0崩溃未处理除零在evaluatePostfix中比较right 0时抛std::runtime_error4.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::vectorstd::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::vectorstd::pairstd::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::mapstd::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 学到中后期阻碍你前进的不是语法细节而是这些知识点之间缺少“连接的桥梁”。多写这样的小项目认真记录设计与调试过程比囫囵吞枣翻完几本大部头要扎实得多。