
做C/C这几年面试新人时我常会问一个问题for循环里你写i还是i十个人有八个答“没区别都行”剩下两个会补一句“i先返回值再加i先加再返回”。这个回答没错但只对了一半。真正想清楚这件事牵扯到表达式求值顺序、临时对象开销、运算符重载甚至代码规范。这篇博文我就从语义、效率、习惯三个层面把i和i这个老话题完整拆一遍。1. 先搞懂旧账i和i的语义差异到底差在哪很多教材喜欢用“先加后用”和“先用后加”来概括这两个运算符这个说法在简单场景下没问题但放到复杂表达式里就容易出乱子。所以第一步先把底层语义钉死后面所有讨论才有根基。1.1 从表达式求值说起返回值与副作用的时间线C/C里自增运算符有两件事要做改变对象的值副作用以及产生一个表达式结果返回值。区别就在这个“结果”是从哪里来的。i的规则是先把i加 1然后返回加完之后的新值。换句话说i表达式的结果就是i本身准确地说是i的左值引用C里甚至可以拿它取地址副作用发生在求值完成之前。i的规则是先保存i当前的值作为表达式结果然后把i加 1。副作用发生在求值完成之后返回值是旧值的一个副本。这里就引出了一个很重要的点i的结果是“旧值的快照”这个快照不可能是i本身只能是一个临时拷贝。内置类型int的拷贝几乎不花时间但这个“创建一个临时值”的动作在类型变大之后成本会成倍上升。用生活里的例子来感受一下i相当于告诉别人“这是我现在的手机号”i相当于先发一张写有旧号码的名片再偷偷把手机号换了。名片已经递出去改不改号码都与这张名片无关了。1.2 三个变量一条隐藏规则别在同一表达式里重复自增同一对象在实际项目中比“返回值”更让人头疼的是“副作用时机”。C标准对i i这类表达式的行为定义为未定义undefined behavior不报错、不警告但结果无法预测。同一个对象在一个表达式里被多次修改编译器可以按任意顺序执行不同编译器、不同优化级别、甚至同一编译器在不同上下文里都可能给出不同结果。int i 1; int a i i; // 未定义行为别写这个问题和i、i本身无关而是“在同一序列点之间多次修改同一变量”本身就是禁区。但很多人会把账算到自增运算符头上于是干脆在表达式中禁用自增。这种因噎废食不可取你只需要记住一条生活经验别在一行代码里对同一个变量做两次以上自增/自减操作。所有涉及自增的复杂表达式先拆成多条语句每条只做一件事。2. 效率账内置类型看不出来类类型差距拉大聊完语义直接进入标题里“效率”这个关键词。先说结论在纯int场景下i和i的机器码几乎一模一样性能差异可以忽略但在 C 的迭代器、智能指针、自定义类重载运算符后i的临时对象开销是实打实的。2.1 内置类型的“障眼法”现代编译器如何抹平差异对int i 0; i;和i;这种孤立语句主流编译器GCC、Clang、MSVC在开优化之后生成的汇编完全一致都是add dword ptr [rbp-4], 1。因为i虽然返回旧值但旧值没人用编译器就把临时拷贝这个动作优化掉了。但是在“返回值被使用”的情况下两者会体现出差别的第一层证据。比如int a i; // 需要把旧值 0 存到 a再给 i 加 1 int b i; // 先把 i 加 1再把新值存到 b这两行代码的汇编是不同的i多了一条mov指令用来保存旧值。不过这只是一个寄存器到寄存器的拷贝对int来说仍然是纳秒级循环一百万次也测不出明显差距。所以内置类型下谈效率本质是在说“编译器够不够聪明”。从这里引出一个习惯如果i的旧值你根本不需要就写i。不是因为能省多少时间而是因为你在告诉读者“我不需要旧值”逻辑上更干净也不会在未来改写代码时留下隐患。2.2 迭代器和类类型临时对象的真实代价把问题放到 C 的迭代器场景情况完全不同。std::vectorint::iterator虽然底层可能只是一个指针的薄封装但从语言层面它是一个类类型它的operator是函数调用不再是 CPU 的一条加法指令。我们来看标准库迭代器典型的两种实现思路。前置版本iterator operator() { ptr; return *this; }后置版本iterator operator(int) { iterator tmp *this; // 拷贝当前状态 ptr; // 修改自身 return tmp; // 返回旧拷贝 }注意后置版本那个局部变量tmp。每次it都构造一个临时对象函数结束时还要销毁它。如果迭代器内部封装的是一个容器节点指针拷贝本身不贵但如果迭代器内部有多个成员、或者指向的是链表的某个节点且拷贝有引用计数操作那这个临时对象的构造和析构成本就不可忽视了。在 C 里有个行业共识能写前置自增就不写后置自增。这个共识不是某个人拍脑袋定的而是赫布·萨特Herb Sutter在《More Exceptional C》里明确建议的“缺省使用前置自增/自减”。原因就是后置多一次临时对象构造而前置没有。2.3 为什么标准库的迭代器习惯上全用前置打开任何一份手写遍历代码老手几乎全部写for (auto it v.begin(); it ! v.end(); it)。这不是习惯问题是效率问题。标准模板库STL的容器类型繁多有些迭代器比如std::list的迭代器封装了节点指针后置自增的临时对象虽然不重但在性能敏感的循环体里空转一次构造/析构是不划算的。我在实际开发里见过一个比较典型的性能问题一份处理百万级日志条目的代码遍历std::mapstd::string, std::string时用了后置it单次循环生成两个临时迭代器对象字符串的引用计数反复增减耗时比前置版本多出约15%。把it改成it后同样逻辑、同样编译器时间直接回落。这就是“效率与习惯”的关系不是每个场景都能感受到差异但长期使用低效写法总会在某个真实项目中踩到那个15%。3. 换个视角看习惯从“顺序”到“约定俗成”标题里另一个关键词是“习惯”。如果你只是想跑通代码i和i怎么选都行但如果你想写一份让别人不敢指指点点的代码这里就有门道了。3.1 循环里的默认选择for 循环为什么推荐写 i在普通for (int i 0; i n; i)里i和i对int来说几乎没有性能差异。但推荐i的理由并不仅限于性能还有几个非常实际的原则。第一一致性。如果你在同一个文件里既用i又用i读者看到你的循环时就要多想想这里用到旧值了吗如果没有为什么不统一成i久而久之代码评审时总会有人问一句你要花时间去解释解释本身就是一种隐性成本。第二避免“先入为主”的习惯污染。如果新手从一开始就写i当他开始写自定义类、迭代器时也会习惯性用后置——那时候性能损失就来了。不如在一开始就养成前置自增的习惯让这个选择变成一个下意识的动作。第三后置自增的副作用在语义上更隐蔽。i在循环体的末尾改变i表示“当前这次循环用旧的i下次循环才用新的”。而这个“旧值”在绝大多数循环中根本不会被使用。一个永远不被使用的东西值得你为它多写一个符号吗所以我的建议很简单所有循环变量、迭代器的自增无脑写i。不需要思考不需要权衡直接形成肌肉记忆。3.2 函数实参、下标操作等隐蔽场景除了循环还有几个容易踩坑的场景值得单独列一下。函数实参里的顺序问题。看这段代码std::cout i i std::endl;这里i和i的求值顺序在 C11 之前是未指定的同一个表达式里读和写同一个变量你无法确定先读旧值还是先加再读。C11 之后部分场景规则有调整但这种写法的可读性依然很差。我见过真人在这种表达式上栽过跟头排查了两个小时最后发现是求值顺序问题。下标操作。v[i] x;是合法写法意思是“把 x 放到当前 i 的位置然后 i 加一”。这在某些算法题里常见但放到生产代码里配合复杂的表达式边界很容易看走眼。我的经验是拆成两行v[i] x; i;多两行代码少一次脑内编译。字符串和指针遍历。写while (*p) { ... }是 C 语言老手很喜欢的风格但它在入栈、出栈、取指针后置加之间的交互关系上很精妙也很容易被后来者误读。更糟的是一旦循环体里还用了p很容易出现“p 已经指向下一个位置”的 bug。结构化的for循环能大幅减少这类心智负担。3.3 代码审查中的两条红线在代码评审中我把自增相关的规范总结成两条红线跟团队讲过很多次红线一对迭代器和类类型禁止写后置自增/自减。原因就是临时对象。这条规则简单、可机械执行用静态检查工具就能扫出来。红线二同一个表达式中禁止两次以上修改同一个变量包括自增自减。这条比红线一更重要因为它直接关系到未定义行为。这两条规则不需要理解所有底层原理只需要当约定俗成的职业习惯去遵守。遵守久了你写出的代码会自然而然远离一整类 bug。4. 实操现场从汇编层看i和i理论讲了不少我们直接上手看证据。下面这些实验我都在本机 GCC 环境下跑过编译参数、结果都列出方便你复现。4.1 内置类型的小实验开不开优化差多少先看最简单的情况源码int f_increment(int i) { return i; } int f_post_increment(int i) { return i; }不开优化-O0编译后两者汇编差异明显。f_post_increment多了两条指令把旧值移到另一个寄存器自增后再把旧值作为返回值用。开-O2后两者都变成几条简单的加法/移动指令差异依旧存在但极小。这说明什么内置类型下后置自增多出来的开销是一个寄存器的搬运。搬运一个 int 的时间可以忽略不计但如果你在一个一亿次的循环里每次多搬运一次总量就攒起来了。// g -O2 编译 x86-64 的结果示意 // i: leal 1(%rdi), %eax; ret // i: movl %edi, %eax; addl $1, %edi; ret不优化时i用两条指令保存旧值优化后得益于返回值没用上旧值的这个事实编译器能直接剪掉多余动作。但如果你真的需要旧值两条指令谁也跑不掉。4.2 自定义类类型的大实验临时对象一个都跑不掉真正拉开差距的是类类型。我写一个模拟迭代器的极简类struct FakeIterator { int val; FakeIterator(int v) : val(v) {} FakeIterator operator() { val; return *this; } FakeIterator operator(int) { FakeIterator tmp *this; // 拷贝 val; return tmp; } };跑一百万次自增前置版本耗时几乎为 0后置版本会多出明显的构造和析构时间。在-O2下如果编译器能确定临时对象没用后置也可能被优化掉但只要这个类稍微复杂一点——比如含有一个std::string、一个std::vector成员——编译器就不敢轻易消除拷贝后置的开销就会成倍数上升。我测试过一个更真实的场景类里有两个std::string成员后置版本一百万次循环耗时是前置版本的 5 倍以上。这不是理论说教是新手的代码和老手的代码在性能上拉开差距的最常见原因之一。4.3 操作符重载的正确写法别忘了返回类型如果你自己在写类重载自增运算符时有两个细节很多人写错前置operator()返回类型必须是T这样才支持(i)这种连续自增也符合内置类型的语义。后置operator(int)返回类型必须是T按值返回因为你要返回的是旧值的拷贝不可能返回引用。struct Counter { int n; // 前置返回引用 Counter operator() { n; return *this; } // 后置返回旧值拷贝 Counter operator(int) { Counter old *this; n; return old; } };很多新手把后置版本返回Counter编译器直接报错因为返回的引用的对象是局部变量函数结束时已经销毁了——悬垂引用比不写还糟。这个错误属于学了运算符重载之后最常踩的坑之一。另一个细节后置版本里的参数int只是用来区分前置/后置的哑元dummy没有实际作用。调用时你不能显式传这个参数编译器根据obj还是obj自动选择重载。这个int参数的存在就是为了让两个函数签名不同是语言层面的一种“标签”技巧。5. 常见问题与排查技巧实录最后把我在实际工作中遇到的、网上经常有人问的问题整理成速查表有些是语法层面的有些是行为层面的每个都值得你留个心眼。5.1 常见错误清单速查现象问题根源解决办法i i结果不稳定同一表达式多次修改同一变量未定义行为拆成多行避免在同一表达式内多次自增循环用it性能慢迭代器后置自增多一次临时对象构造改为itoperator(int)返回引用导致崩溃返回了局部对象的引用改为按值返回旧对象while (*p)读取结果不对后置在取值后才移动指针循环体里再用p时位置已变换成结构化for循环在条件表达式里if (i)结果与预期不符i返回旧值永远先判断再自增明确意图写成if (i) { i; }i在宏定义里多次展开导致意外修改宏是文本替换参数被求值多次使用内联函数替代宏5.2 一个折磨人的真实案例循环里自增与容器修改分享一个我排查过的生产问题。同事的代码大致是这样std::vectorint v {1, 2, 3, 4, 5}; for (auto it v.begin(); it ! v.end(); it) { if (*it % 2 0) { v.erase(it); } }这段代码行为完全不可预测。erase会使当前迭代器失效之后再用it访问的已经是失效迭代器轻则跳过元素重则崩溃。问题表面看是“循环迭代器失效”但根子上也牵扯到对it语义的理解后置自增返回的是旧迭代器而旧迭代器已经被erase干掉了。正确写法是for (auto it v.begin(); it ! v.end();) { if (*it % 2 0) { it v.erase(it); } else { it; } }这个案例教会我一件事遍历容器时先把“自增”和“修改容器”的关系搞清楚再去看i还是i。当你发现it写起来别扭、难以和erase协同工作时改成前置自增往往能逼着你把逻辑写得更加明确。5.3 踩过几次坑之后我给新手的三个建议第一默认前置只在需要旧值时用后置。大众评审看到i不会问为什么看到i反而会多看一眼。多一眼就是多一个解释的机会没必要给自己找事。第二不要在复杂表达式里搞自增。宁可多写一个变量也不要写arr[i] arr[i]这种靠“右值先取再自增”的隐式顺序吃饭的代码。即使编译器给你过了代码评审也会问你这块是在取旧值还是新值第三写自己的类时把前置和后置自增都实现了再发布。如果你只实现了前置用户想写it就编译不过如果你只实现了后置用户想写it就会走隐式转换虽然能编译但转换本身可能带来额外开销。把两个版本都写上按标准模式返回引用/值这是对外接口的基本礼仪。写在最后的一个小建议我个人做代码评审时最在意的其实不是性能而是可预测性。一个表达式里用了i我需要停下来想想这个旧值被谁用了、副作用什么时候发生写成i我一眼看完毫无负担。真正的高手不会在代码里给人出阅读理解题。从今天开始把“默认写i”刻进肌肉记忆它能帮你省下未来无数个午夜排查的宝贵时间。