资讯详情

资讯详情

C/C++自增运算符辨析:++i与i++的语义、性能及工程实践

很多写 C/C 的朋友写循环时都是习惯性敲出for (int i 0; i n; i)而另一些人则坚持写i。在代码评审里i和i的讨论几乎永远存在语义到底差在哪效率又差多少有人翻出反汇编说“完全一样”有人用迭代器跑出明显差异还有人直接丢下一句“反正就是一个先用后加、一个先加后用”。其实两边说得都对只是经常在错误的前提下去比较。今天我把这组运算符拆开聊透顺便讲讲为什么我建议默认写i以及什么时候必须用i。这篇文章不是教你背口诀而是尽量把“表达式语义、编译优化、重载设计、代码习惯”这几个层面串起来。不管你是刚接触 C/C 的初学者还是已经写了好几年业务代码的老兵我都建议从语义开始看因为效率差异是语义差异在下游的结果。1. 先把这组运算符放在台面上语义差别才是根子1.1 一秒钟看懂 i 和 i 的区别先把最基础的东西说清楚。假设变量i当前值是 1int i 1; int a i; // i 先变成 2再把 2 赋值给 a int b i; // 把 i 当前值 2 赋值给 b然后 i 变成 3执行完上面三行后a 2b 2i 3。这就是最常见的口诀来源i先自增再返回新值。i先返回旧值再自增。但这里有个容易被忽略的点“先”和“后”只是描述层面真正差异是表达式的结果值不同。前缀自增的结果是“自增后的值”后缀自增的结果是“自增前的值”。副作用都是让变量加 1但表达式拿到的值完全不一样。很多人把i和i单纯理解为“谁先执行的问题”一旦放进复杂表达式里就开始混乱。比如a i i它到底等于多少这类问题恰恰是因为过度关注“先后”而忽略了“结果是什么”。单独一个表达式里如果同时有多次对i的读写还可能踩到未定义行为的坑后面会专门讲。1.2 一个生活化类比旧号码和新号码我经常用排队取号的场景来帮助理解。假设柜台发号机当前显示 1 号你的任务是“叫号”叫完之后号码要加 1。后缀自增就像先把当前 1 号牌递给你然后机器跳到 2。你拿到的是旧号码 1。前缀自增就像机器先跳到 2 号然后把 2 号牌递给你。你拿到的是新号码 2。两种方式都完成了“号码数量加一”这个动作但你手里拿到的号码不一样。程序里变量i就是发号机表达式的值就是你手里拿到的号码。如果业务场景根本不关心拿到的号码是多少那用哪个都行如果后续要继续用旧号码就必须用后缀。这个类比还引出一个真正的要点不要在一条语句里反复使用同一个自增变量。发号机叫号时顺便把旧号递给你和你同时用旧号和新号做运算容易让别人看不明白也容易引发未定义行为。代码不是越聪明越好而是越清楚越好。2. 效率差异内置类型和类类型要分开看2.1 内置类型上现代编译器基本帮你抹平了很多人争论i和i的性能其实是拿内置 int 在比。对int这种内置类型来说分别执行下面两条语句现代编译器在优化开的条件下生成的汇编几乎一致i; i;因为这两条语句的最终效果都是“给i的内存或寄存器加 1”而表达式的结果没有被使用编译器完全可以把多余动作丢掉。你拿-O2或-O3编译大概率只能看到一条类似addl $1, -4(%rbp)或add $1, %eax的指令。即使是在赋值场景下int x i; int y i;由于i是简单类型编译器也可能优化成同样的寄存器操作只是需要保证赋值给x和y的值符合语义。现代 CPU 对整数的拷贝和加法成本几乎可以忽略所以对内置类型讨论性能没有太大意义。用i写一百万次循环和用i写一百万次循环耗时差距在起飞阶段就已经被噪声淹没了。但这里有个前提“编译器能优化”并不等于“这个运算符在语义上没有差别”。优化不改变语义它只是把不必要的工作去掉。所以结论应该是内置类型上不需要为性能纠结选哪个都可以但后面会说到习惯问题。2.2 迭代器和自定义类型后缀多了一次“搬东西”一旦操作对象从int变成类类型情况就完全不同了。这里最典型的例子是容器的迭代器比如std::listint::iterator或std::mapint, int::iterator。迭代器本质上是一个类对象重载了operator。前缀it的典型实现是移动内部指针或索引然后返回对象自身的引用。后缀it的典型实现则多一步为了返回“旧迭代器”必须先拷贝一份当前状态再把原迭代器前移最后返回那个拷贝。我见过一个很形象的总结后缀自增自带一次“快照”。这个快照不是免费的。它意味着至少一次拷贝构造以及一次临时对象的析构。如果这个迭代器内部维护了复杂状态成本会更高。我们看一个简单的自定义计数器struct Counter { int value 0; Counter operator() { value; return *this; } Counter operator(int) { Counter old *this; value; return old; } };后缀operator(int)里必须执行Counter old *this;这一步就是快照。如果Counter只有一个int编译器可能通过 NRVO 或常量传播把这个拷贝优化掉但如果Counter内部是一个std::vectorstd::string每次拷贝都需要重新分配堆内存、复制字符串那就不是“可能优化掉”那么简单了。2.3 真正拉开差距的是临时对象的“体积”我在实际项目里遇到过类似情况有个对象内部维护了一个不小的缓存每次自增都会更新缓存内容。一开始重载后缀自增时没多想随手写成“保存旧值、更新缓存、返回旧值”结果在高频路径上发现性能异常。后来单独测了下才发现每次后缀自增都触发了一次深拷贝而前缀自增完全不需要。所以效率差异的公式可以简化成后缀自增额外成本 保存旧值的拷贝成本 临时对象析构成本。对于int或内置指针这个成本接近 0对于普通类对象取决于拷贝构造的代价对于带std::vector、std::string、锁、文件句柄的对象成本可以很可观。有一点必须说明现在的编译器越来越聪明如果你的类很简单或者自增逻辑可以被内联后缀自增的拷贝也可能被优化掉。正因为“可能被优化掉”所以很多人觉得it和it没区别。但你要明白编译器优化针对的是“简单情况”遇到复杂类型或跨编译单元的场景优化就不一定成立了。把安全建立在“编译器应该能优化”上不如直接写一个从源头就少产生临时对象的写法。3. 实操细节重载前后缀代码里要留神3.1 重载函数签名和典型实现如果你需要让自己设计的类也支持自增重载这两个运算符是基本功。C 里为了区分前后缀定了这样一个规则struct MyInt { int val 0; // 前缀返回引用 MyInt operator() { val; return *this; } // 后缀多一个 int 形参调用时传 0用于重载决议区分 MyInt operator(int) { MyInt old *this; val; return old; } };几个细节值得注意。第一后缀多出来的int参数没有名字也不需要真的传任何有意义的值它只是语法标记告诉编译器“这里是后缀版本”。第二前缀返回引用后缀返回值。这是语义决定的前缀自增后原始对象已经被修改可以安全返回自身后缀必须返回修改前的快照所以只能返回值。第三关于后缀返回值是否加const这是我在评审时经常提的问题。C11 之前有人会写const MyInt operator(int)目的是防止对临时对象做修改。但 C11 之后有了移动语义返回const会阻碍移动操作也可能导致某些合法调用无法编译。我建议直接返回非const的普通对象和标准库迭代器保持一致。后缀实现的经典顺序是“先保存旧值再修改自身最后返回旧值”。顺序千万不要弄反否则你返回的就不是旧值了。很多新手会写成MyInt operator(int) { val; MyInt old *this; return old; }这样写等于返回的是“自增后的新值”不仅语义错了而且会让人在排查时一头雾水。3.2 重载和调用时容易踩的坑重载好前后缀之后真正的坑还在使用阶段。先说一个模板场景。如果你写一个模板函数需要自增泛型对象最好写成t而不是t。原因很简单t要求T支持拷贝构造并能返回值而t只要求T能返回引用。有些类为了禁止拷贝会主动删除拷贝构造甚至只提供前缀自增。如果模板里写死了后缀那些类型根本编译不过。再来看表达式里的坑int i 0; i i; // 危险这条语句在 C/C 里是未定义行为。因为i既会读取i又会对i产生自增副作用而在同一个表达式里这种“读写”没有确定的顺序约束。不同编译器、不同优化级别跑出来的结果可能都不一样甚至和你预想的完全相反。我在项目里见过有人这样写理由是“想先取旧值再赋值给 i”但正确的写法应该是int old i; i; i old;或者更直接地如果只是想加 1就只写一行i;。另一个典型坑是函数实参foo(i, i);在 C17 之前函数实参的求值顺序是未指定的你无法确定两个i谁先执行。C17 之后规定每个实参的求值顺序是“不确定顺序”但依然是未指明谁先。这种代码不仅在语义上靠运气还会让静态分析工具报警。正确做法是把自增拆开foo(i, i 1); i 2;如果你确实需要把两个旧值传进去再让i前进 2那就先保存int first i; int second i; foo(first, second);看起来多写了两行但行为的确定性大大提升。代码是给编译器看的更是给后面维护的人看的。4. 从效率到习惯团队规范和代码评审里的约定4.1 遍历容器时到底怎么写先看最日常的迭代器遍历。我强烈建议默认写积极推荐的版本for (auto it v.begin(); it ! v.end(); it) { // do something }这里的it不只是“为了效率”更是为了统一习惯。因为当你从std::vectorint切到std::listint时迭代器类型从“近似指针”变成“链表节点包装”后缀自增的临时对象成本会变得可见。你很难要求每个人在写每一处循环时都重新思考“哦这个迭代器代价高不高”直接用it就免掉了这个问题。有趣的是C 的范围 for 循环在生成代码时内部对迭代器的自增就是前缀形式。也就是说for (const auto item : v) { }等价展开后迭代器增量用的是__begin。所以很多时候你根本没有机会在范围 for 里写成后缀。这也是为什么现代 C 代码里显式迭代器循环的it更容易被评审挑出来语言默认选择已经很明确手动写后缀没有额外收益。如果你是写整型循环比如for (int i 0; i n; i)那i完全可以。但为了避免在类类型和整型之间来回切换时写错现在很多团队干脆要求一律i。这样做不是为了压榨性能而是宁可养成统一的肌肉记忆也不在代码评审时反复纠结。4.2 后缀自增也不是一无是处统一用前缀不代表后缀应该被消灭。后缀自增的真正价值在于你需要旧值。经典的写法都是这种模式// 向缓冲区尾部写入数据tail 记录下一个写入位置 buffer[tail] value; // 取出当前位置元素同时游标后移 int cur list[pos]; // 消费旧索引并把索引推进一位 consume(i);这些例子的共同点是表达式需要“当前位置的值”同时希望“之后位置前进”。用后缀自增天然表达这个意图用前缀反而要多写一个临时变量buffer[tail] value; tail;如果代码里只有一处第二种写法也不算差但如果这种“写入后移动”的模式出现几十次用tail会简洁得多。所以我的结论是默认用前缀需要旧值时才用后缀。这不是教条而是让代码意图更清晰的选择。4.3 把习惯延伸到别的语言很多写 Java 或 C# 的人也会习惯性纠结i和i。在 Java 里基本类型不是对象编译器在大多数情况不会让前后缀产生可观测的性能差异所以很多人都无脑用i。而到了写 C 的迭代器遍历时如果沿用这个习惯就很容易写出不必要的后缀调用。Go 语言则走了另一个路线它把和--变成了语句而不是表达式直接消灭了“在复杂表达式里使用自增结果”的可能性。Rust 甚至没有传统意义的自增运算符遍历集合时更常用迭代器和闭包。回头看这些设计你会发现 C/C 里的前后缀自增确实是一种需要小心使用的工具默认选择无副作用的写法总没有错。5. 微观基准测试自己动手验证一遍5.1 测试环境和测试代码为了直观感受差异可以自己写一个微型基准。下面的代码定义了一个简单的计数器分别用前置和后置自增做同样次数的循环#include chrono #include iostream struct Counter { int v 0; // 前缀 Counter operator() noexcept { v; return *this; } // 后缀 Counter operator(int) noexcept { Counter old *this; v; return old; } }; template bool use_prefix __attribute__((noinline)) int run() { Counter c; for (int i 0; i 20000000; i) { if constexpr (use_prefix) { c; } else { c; } } return c.v; } int main() { auto t0 std::chrono::steady_clock::now(); int r1 runtrue(); auto t1 std::chrono::steady_clock::now(); int r2 runfalse(); auto t2 std::chrono::steady_clock::now(); std::cout prefix: std::chrono::duration_caststd::chrono::microseconds(t1 - t0).count() us\n; std::cout suffix: std::chrono::duration_caststd::chrono::microseconds(t2 - t1).count() us\n; std::cout r1 r2 \n; }编译时可以用g -O2 -stdc17 bench.cpp -o bench也可以试试-O0往往能看到更明显的差异。不过这个基准最简单因为Counter内部只有一个int编译器在-O2下可能把后缀的临时对象优化掉最终结果可能相近。这并不说明后缀免费只是说明这个小物件太小了不值得为它操心。5.2 实际测试结果和解读我自己对不同类型跑过类似测试结论大致是内置int循环-O2下前后缀几乎无差别汇编上也看不出实质不同。上面这种Counter-O0后缀会明显慢一截-O2可能被优化到接近无差别。一个带std::vectorstd::string成员的对象即使-O2如果后缀实现里的拷贝没被编译器看穿后缀依然会慢很多。std::listint::iterator遍历在部分标准库实现和优化条件下it比it慢但有的编译器也能内联优化。所以我不太爱给一个“慢几倍”的统一结论因为差异取决于类型复杂度和优化能力。真正有指导意义的观察是每一次后缀自增都可能留下一个旧值副本副本成本越低前后差异越小成本越高差距越明显。而前缀自增从来不包含这个副本所以在不确定的代码路径上前缀永远是不吃亏的选择。如果你要做一个更严谨的基准建议把自增函数放到单独的编译单元避免编译器看到实现后做激进的优化。用一个noinline且“隐藏实现”的Counter再配合有实际成员的复杂对象往往能看到可复现的差距。6. 常见问题与排查技巧实录6.1 常见问题速查表我整理了一份在代码评审和问题排查中经常用到的小表遇到相关代码可以直接对照问题原因正确做法i i结果不确定同一表达式里对i多次读写存在未定义行为拆分成两条语句或只写ifoo(i, i)两个参数顺序不确定实参求值顺序未指定先保存旧值再分别传参后缀重载返回const T阻碍移动语义无法对临时对象继续修改返回非const T模板里写t编译失败类型不可拷贝或未定义后缀自增改用t无歧义时也用terase(it)之后误用it后缀和erase的迭代器失效规则叠加优先使用it c.erase(it)或明确保存旧值复杂对象后缀自增性能差每次保存旧值都触发深拷贝用前缀或把“取旧值”和“自增”拆开这张表并不是要背下来而是提醒一个核心思路自增操作尽量不要和别的读写混在同一表达式里。单独一行i几乎不会出错一旦变成arr[i]x、func(i)、ii这类写法就要立刻检查副作用顺序和迭代器失效规则。6.2 用编译器和静态检查工具帮你兜底如果代码里已经出现了可疑写法可以用编译器警告和 sanitizer 快速定位。GCC 的-Wall会包含-Wsequence-point警告对i i这类未定义行为通常会直接给出提示g -Wall -Wextra -O0 -stdc17 test.cpp我印象里 GCC 遇到i i时会输出类似 “operation on i may be undefined” 的警告。遇到这种告警不要靠“我运行了一下看起来很稳”来敷衍因为未定义行为在某个编译器上恰好得到预期结果不代表在另一个优化级别或换个架构下还能正确。想进一步排查运行时问题可以加上g -fsanitizeundefined -fsanitizeaddress test.cpp -o test虽然 sanitizer 不能覆盖所有未定义行为但能抓到一部分有问题的表达式尤其是越界访问和未定义表达式求值。静态分析工具如clang-tidy也会对这类代码给出提示可以把它接进 CI 或 pre-commit 流程让习惯偏差更早暴露。我在实际排查中最常犯的错是带着“这段代码我已经看熟了”的心态去看可疑表达式。后来发现把自己想象成一个从没写过这段代码的实习生按语义逐行拆解往往比盯着屏幕苦想更高效。如果觉得某个自增表达式含义不清晰那就是改写信号而不是继续分析的信号。再说一个个人体会写了这么多年代码我越来越倾向于“无必要不后缀”。如果你只是想让一个数字加一就用i如果你需要旧值就单独把旧值保存下来或者明确用i。代码评审时我不会因为别人多写了一个i就否定他但我会问一句这里真的需要旧值吗多数时候问题一出口作者自己就开始怀疑了。好的习惯不是机械地记住“一定要用i”而是在每次落笔前都清楚自己到底想要哪个值并且用最不容易出错的语法把它表达出来。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →