很多写 C/C++ 的朋友写循环时都是习惯性敲出for (int i = 0; i < n; i++),而另一些人则坚持写++i。在代码评审里,i++和++i的讨论几乎永远存在:语义到底差在哪,效率又差多少?有人翻出反汇编说“完全一样”,有人用迭代器跑出明显差异,还有人直接丢下一句“反正就是一个先用后加、一个先加后用”。其实两边说得都对,只是经常在错误的前提下去比较。今天我把这组运算符拆开聊透,顺便讲讲为什么我建议默认写++i,以及什么时候必须用i++。
这篇文章不是教你背口诀,而是尽量把“表达式语义、编译优化、重载设计、代码习惯”这几个层面串起来。不管你是刚接触 C/C++ 的初学者,还是已经写了好几年业务代码的老兵,我都建议从语义开始看,因为效率差异是语义差异在下游的结果。
1. 先把这组运算符放在台面上:语义差别才是根子
1.1 一秒钟看懂 ++i 和 i++ 的区别
先把最基础的东西说清楚。假设变量i当前值是 1:
int i = 1; int a = ++i; // i 先变成 2,再把 2 赋值给 a int b = i++; // 把 i 当前值 2 赋值给 b,然后 i 变成 3执行完上面三行后,a == 2,b == 2,i == 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::list<int>::iterator或std::map<int, 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::vector<std::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,这是我在评审时经常提的问题。C++11 之前有人会写const MyInt operator++(int),目的是防止对临时对象做修改。但 C++11 之后有了移动语义,返回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++);在 C++17 之前,函数实参的求值顺序是未指定的,你无法确定两个i++谁先执行。C++17 之后规定每个实参的求值顺序是“不确定顺序”,但依然是未指明谁先。这种代码不仅在语义上靠运气,还会让静态分析工具报警。正确做法是把自增拆开:
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::vector<int>切到std::list<int>时,迭代器类型从“近似指针”变成“链表节点包装”,后缀自增的临时对象成本会变得可见。你很难要求每个人在写每一处循环时都重新思考“哦这个迭代器代价高不高”,直接用++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 = run<true>(); auto t1 = std::chrono::steady_clock::now(); int r2 = run<false>(); auto t2 = std::chrono::steady_clock::now(); std::cout << "prefix: " << std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count() << " us\n"; std::cout << "suffix: " << std::chrono::duration_cast<std::chrono::microseconds>(t2 - t1).count() << " us\n"; std::cout << r1 << " " << r2 << "\n"; }编译时可以用:
g++ -O2 -std=c++17 bench.cpp -o bench也可以试试-O0,往往能看到更明显的差异。不过这个基准最简单,因为Counter内部只有一个int,编译器在-O2下可能把后缀的临时对象优化掉,最终结果可能相近。这并不说明后缀免费,只是说明这个小物件太小了,不值得为它操心。
5.2 实际测试结果和解读
我自己对不同类型跑过类似测试,结论大致是:
- 内置
int循环:-O2下前后缀几乎无差别,汇编上也看不出实质不同。 - 上面这种
Counter:-O0后缀会明显慢一截,-O2可能被优化到接近无差别。 - 一个带
std::vector<std::string>成员的对象:即使-O2,如果后缀实现里的拷贝没被编译器看穿,后缀依然会慢很多。 std::list<int>::iterator遍历:在部分标准库实现和优化条件下,it++比++it慢,但有的编译器也能内联优化。
所以我不太爱给一个“慢几倍”的统一结论,因为差异取决于类型复杂度和优化能力。真正有指导意义的观察是:每一次后缀自增都可能留下一个旧值副本,副本成本越低,前后差异越小;成本越高,差距越明显。而前缀自增从来不包含这个副本,所以在不确定的代码路径上,前缀永远是不吃亏的选择。
如果你要做一个更严谨的基准,建议把自增函数放到单独的编译单元,避免编译器看到实现后做激进的优化。用一个noinline且“隐藏实现”的Counter,再配合有实际成员的复杂对象,往往能看到可复现的差距。
6. 常见问题与排查技巧实录
6.1 常见问题速查表
我整理了一份在代码评审和问题排查中经常用到的小表,遇到相关代码可以直接对照:
| 问题 | 原因 | 正确做法 |
|---|---|---|
i = i++结果不确定 | 同一表达式里对i多次读写,存在未定义行为 | 拆分成两条语句,或只写++i |
foo(i++, i++)两个参数顺序不确定 | 实参求值顺序未指定 | 先保存旧值,再分别传参 |
后缀重载返回const T | 阻碍移动语义,无法对临时对象继续修改 | 返回非const T |
模板里写t++编译失败 | 类型不可拷贝,或未定义后缀自增 | 改用t++无歧义时也用++t |
erase(it++)之后误用it | 后缀和erase的迭代器失效规则叠加 | 优先使用it = c.erase(it),或明确保存旧值 |
| 复杂对象后缀自增性能差 | 每次保存旧值都触发深拷贝 | 用前缀,或把“取旧值”和“自增”拆开 |
这张表并不是要背下来,而是提醒一个核心思路:自增操作尽量不要和别的读写混在同一表达式里。单独一行++i几乎不会出错,一旦变成arr[i++]=x、func(i++)、i=i++这类写法,就要立刻检查副作用顺序和迭代器失效规则。
6.2 用编译器和静态检查工具帮你兜底
如果代码里已经出现了可疑写法,可以用编译器警告和 sanitizer 快速定位。GCC 的-Wall会包含-Wsequence-point警告,对i = i++这类未定义行为通常会直接给出提示:
g++ -Wall -Wextra -O0 -std=c++17 test.cpp我印象里 GCC 遇到i = i++时会输出类似 “operation on 'i' may be undefined” 的警告。遇到这种告警,不要靠“我运行了一下看起来很稳”来敷衍,因为未定义行为在某个编译器上恰好得到预期结果,不代表在另一个优化级别或换个架构下还能正确。
想进一步排查运行时问题,可以加上:
g++ -fsanitize=undefined -fsanitize=address test.cpp -o test虽然 sanitizer 不能覆盖所有未定义行为,但能抓到一部分有问题的表达式,尤其是越界访问和未定义表达式求值。静态分析工具如clang-tidy也会对这类代码给出提示,可以把它接进 CI 或 pre-commit 流程,让习惯偏差更早暴露。
我在实际排查中最常犯的错,是带着“这段代码我已经看熟了”的心态去看可疑表达式。后来发现,把自己想象成一个从没写过这段代码的实习生,按语义逐行拆解,往往比盯着屏幕苦想更高效。如果觉得某个自增表达式含义不清晰,那就是改写信号,而不是继续分析的信号。
再说一个个人体会:写了这么多年代码,我越来越倾向于“无必要,不后缀”。如果你只是想让一个数字加一,就用++i;如果你需要旧值,就单独把旧值保存下来,或者明确用i++。代码评审时,我不会因为别人多写了一个i++就否定他,但我会问一句:这里真的需要旧值吗?多数时候,问题一出口,作者自己就开始怀疑了。好的习惯不是机械地记住“一定要用++i”,而是在每次落笔前,都清楚自己到底想要哪个值,并且用最不容易出错的语法把它表达出来。