直接说结论:i++和++i大概是C/C++里最“简单”也最容易被问住的一组运算符。刚入行时我们都背过“前置先加后用,后置先用后加”,可真到了写代码、查 Bug、做代码评审或者面试时,你会发现这个口诀只覆盖了 20% 的场景,剩下 80% 都是坑:表达式求值顺序、左值右值、运算符重载、自定义迭代器、函数传参……绕一圈下来,真正能把两个加加讲清楚的人并不多。
这篇文章的目标非常具体:把前置加加和后置加加从语义、编译原理、重载实现、常见翻车场景到配套练习题完整过一遍。适合正在学 C/C++ 的初学者,也适合写过几年、但总在代码评审中被同行问住的人。我会尽量用能直接跑起来的代码说话,顺带补充一些我实际踩过的坑和标准文档里不会写明白的细节。
1. 先建立直觉:前置加加与后置加加的本质差异
1.1 两道热身题,先检验一下你的直觉
先别急着往下翻,看两段代码,在心里写出答案,再跑一跑验证。
int i = 1; int a = i++; std::cout << a << " " << i; // 输出?int i = 1; int b = ++i; std::cout << b << " " << i; // 输出?第一段输出1 2,第二段输出2 2。如果你都答对了,说明基础语义没问题;但真正要搞懂的是“为什么”。后置加加表达式的值是自增前的旧值,副作用是让变量加1;前置加加表达式的值是自增后的新值,副作用同样是让变量加1。两个都会把变量加1,区别只在“表达式最终算出来的那个值”是旧还是新。
这个区别看着简单,但它会衍生出一大堆问题:这个“旧值”到底存到哪了?编译器会不会把它优化掉?为什么 C++ 里i++++编译不过而++++i可以?为什么自定义类的it++会比++it慢?这些都在后面。
1.2 语义拆开看:自增是副作用,表达式的值才是主角
很多教材喜欢说“i++ 先用后加,++i 先加后用”,这其实有歧义。准确的说法应该是:
- 后置加加:先产生一个“当前值的副本”作为表达式的求值结果,然后对变量执行自增(改写变量)。
- 前置加加:先对变量执行自增(改写变量),然后把新值作为表达式的求值结果。
关键在于“求值结果”和“副作用”是两件事。无论前置还是后置,对变量本身而言,副作用都是“加1写回去”。区别只在于返回值是旧值副本还是新值。有个比较贴切的类比:后置加加相当于你先用手机拍下当前文档的截图,再把文档改一版;前置加加则是先把文档改成新版,然后把新版给你看。截图里是旧版,文档里最终都是新版。
这个“截图”在编译器里怎么实现,实际开销有多大,是后面第二部分要展开的重点。
1.3 口诀“先用后加”为什么会误导人
“先用后加”这四个字在cout << i++这种场景里能对上:先把 i 的旧值传给了 cout,然后再自增。但一旦场景复杂一点,比如实参求值、指针运算、复合表达式,这口诀就会把人带偏。
我见过最多的一种误用发生在函数实参里:
int i = 0; func(i++, ++i);这种写法是典型的“行走的未定义行为”。因为i++和++i都在修改同一个变量 i,而函数实参的求值顺序在 C++ 标准里是未指定的。前面的口诀“先用后加”根本无法告诉你到底哪个实参先算、哪个后算,代码在不同编译器、不同优化选项下结果都可能不一样。对这种代码,正确做法是分开写:
int v1 = i++; int v2 = ++i; func(v1, v2);这也是我从实际工作中总结出来的第一原则:同一个表达式里,同一个变量绝对不要出现两次自增或自减。这不是风格问题,是程序正确性问题。
2. 编译器视角:性能差距到底在哪
2.1 内建类型上,二者几乎没有性能差距
很多人第一次听到“推荐用++i”是在性能优化的文章里,于是遇到 int 也小心翼翼地只写++i。其实在内建类型上,i++和++i在现代编译器里基本不存在性能差异。
以int i; int a = i++;为例,编译器可以把它翻译成类似下面的伪汇编:
mov edx, [i] ; 把 i 的旧值读出来 add [i], 1 ; i 自增 mov [a], edx ; 把旧值赋给 a而int a = ++i;对应:
add [i], 1 ; i 自增 mov edx, [i] ; 读新的 i mov [a], edx ; 把新值赋给 a区别只是“先读旧值再自增”还是“先自增再读新值”,都不需要额外开辟一块内存来保存“旧值副本”,因为旧值就在寄存器里,直接参与后续赋值就行。编译器在优化阶段会消除语义层面的临时对象。所以你在 for 循环里写for (int i = 0; i < n; i++)还是for (int i = 0; i < n; ++i),只要编译器优化开得正常,生成代码基本一致。
不过有一类编译器要注意:调试模式(debug 构建)下优化往往被关掉,语义上的临时值可能真的会多产生一点指令。但 debug 构建本身就没在关心性能,正常工程不需要为这个纠结。
2.2 自定义类型上,后置加加确实“慢半拍”
性能差异真正存在的地方是自定义类型,尤其是迭代器。我们在第 3 部分会详细讲重载实现,这里先看结论:后置加加在语义上必须返回自增前的旧值,所以实现时至少要做一次对象拷贝;前置加加返回的是自增后的对象本身,不需要拷贝。
class Counter { public: Counter& operator++() { // 前置 ++value_; return *this; } Counter operator++(int) { // 后置 Counter old = *this; // 必须保存旧值 ++value_; // 自增当前对象 return old; // 返回旧值副本 } private: int value_ = 0; };这段代码里,后置版本多了一次Counter old = *this;的拷贝构造,以及返回时临时对象的构造。对象越大、成员越复杂,拷贝成本越高。像std::vector的迭代器底层可能只是一个指针,拷贝几乎免费;但某些链式结构的迭代器内部可能带栈信息,拷贝就不是免费的。
这也是为什么很多 C++ 编码规范(比如常见的大厂 C++ 风格指南)建议:除非你确实需要旧值,否则一律用前置自增。在模板代码里尤其如此,因为模板不知道你传进来的迭代器是大是小,用前置版本是最不容易出错的选择。
2.3 前置返回左值,后置返回右值
关于“为什么++++i能编译而i++++不能”,其实和左值右值有关。这也是面试爱问的点。
内建运算符中:
++i的结果是左值(lvalue),所以可以继续参与自增,++++i等价于++(++i),合法。i++的结果是右值(prvalue),你无法对一个右值再自增,所以i++++在语法解析时就会失败。
这个性质在指针上更直观:++p返回的是指向新位置的左值,所以你可以*++p;而p++返回的是旧位置的值(右值),通常用于“先访问旧位置再移动指针”的场景。
int arr[] = {10, 20, 30}; int* p = arr; int x = *p++; // 等价于 *(p++),先解引用得到10,然后p指向arr[1] int y = *p; // 20 int* q = arr; int a = *++q; // 等价于 *(++q),先把q指向arr[1],再解引用得到20这两个表达式的优先级也值得单独记一下:后缀自增优先级高于解引用,所以*p++自动解释为*(p++);前缀自增和解引用优先级相同,结合方向从右到左,所以*++p解释为*(++p)。很多新手在这里翻车,看着*p++以为是“指针自增后取新地址的内容”,实际结果正好相反。
3. 运算符重载:后置加加的签名里为什么有个 int
3.1 用 int 占位参数区分前后置
C++ 没有单独的“后置自增关键字”,它靠函数重载区分。前置和后置operator++的参数不同:
Counter& operator++(); // 前置 Counter operator++(int); // 后置后置版本里那个int参数是纯占位符,调用时编译器会自动传一个 0,你在实现里不需要使用它。如果你手写调用,可以显式写c.operator++(0)来调用后置版本,但实际项目里没人这么写。
为什么 C++ 标准要设计成这种“不优雅”的占位参数?因为函数重载不能只靠返回类型区分,两个版本返回类型不一样也无法区分调用端(调用点不指定返回类型)。必须让参数个数或类型有所区别,于是标准委员会塞了一个虚拟的int参数。理解这一点后,以后再看到operator++(int)里的int,就不会觉得莫名其妙了。
3.2 后置为什么必须返回对象而不是引用
初学重载时很容易犯一个错误:把后置版本也写成返回Counter&。看起来好像更高效,实际上是严重的悬空引用问题。
后置自增的语义要求返回旧值,而旧值只能通过局部变量保存:
Counter operator++(int) { Counter old = *this; // 局部对象 ++(*this); return old; // 局部对象销毁,如果返回引用则悬空 }如果你把返回类型写成Counter&,return 时返回的是局部对象old的引用,函数结束后栈上对象已被销毁,调用方拿到的是一个已失效的引用,后续使用必然是灾难。所以前置版本返回*this的引用是安全的(因为调用方那个对象还活着),后置版本必须“按值返回”,不带引用。
这个坑我在给某个模拟项目写自定义迭代器时踩过。当时为了“优化”性能,把后置自增返回类型改成了引用,结果程序在特定数据量下随机崩溃,查了半天才发现是从迭代器拿到了悬空引用。后来翻标准文档才确认:后置自增的返回类型在语义上必须是旧值,旧值的生命周期只在本函数内部,不可能安全地返回引用。
3.3 让后置复用前置,避免逻辑重复
既然前后置都要做“自增”这个动作,最简洁的实现是让后置先拷贝旧值,再去调用前置版本完成自增:
Counter operator++(int) { Counter old = *this; // 保存旧值 ++(*this); // 调用前置版本完成自增 return old; // 返回旧值 }这样做的好处是逻辑只写一遍,后面如果自增规则变了(比如加进位、调整阈值),只需要改前置版本,后置自动同步。我在实际项目中的习惯是:前置版本承载全部自增逻辑,后置版本只做“保存旧值 + 委托前置 + 返回旧值”三件事。这个模式在--的重载里同样适用。
另外补充一个 C++11 之后的细节:后置版本返回局部对象,如果对象支持移动构造,编译器有比较大的概率触发返回值优化(RVO/NRVO)或移动构造,比拷贝会省一点。但不要过度依赖这个优化去写“后置也能很高效”的代码,实现规范才是关键。
3.4 内建自增没有重载,为什么也会有“左值/右值”之分
内建类型没有真正“实现”前置和后置,但语言规范给它们定义好了结果类别。++i的结果是左值,因为语义上它返回的是“自增后的那个存储位置”,类似i本身;i++的结果是右值,因为语义上它复制了旧值并返回这个副本。这个设计初看有点绕,但顺着“左值是有名字的存储位置、右值是临时值”这个定义去理解就很自然。
面试时如果被问到(++i) = 3是否合法,答案是合法,因为++i是左值,可以先自增再赋值。而(i++) = 3编译都过不了,右值不可赋值。这类题目能快速筛掉对运算符只是“背口诀”的人。
4. 真实场景里的高危写法与排查实录
4.1 for 循环到底选 ++i 还是 i++
先说结论:内建整型随便写,风格统一即可;迭代器循环推荐统一++it。
有些代码规范严格要求用前缀自增,理由是“泛型代码在自定义类型上可能因为后缀产生额外拷贝”。这套逻辑没问题。但如果你在一个只处理int的循环里,因为别人的规范而坚持++i,性能上并不会占便宜,最多是风格一致。反过来,在自定义迭代器循环里写it++,虽然现代 STL 的实现大概率会在内联后消除差异,但 debug 模式下你会发现迭代器后置自增的调用要多出不少指令,因为调试库往往不进行内联优化。
我自己的经验是:写迭代器循环一律用++it,写整型计数循环看团队规范,没有规范就选看得顺眼的一种并保持一致。比选哪个更重要的是不要在循环体里偷偷自增循环变量,尤其不要写成for (int i = 0; i < n; i = i++)这种自己折磨自己的形态。
4.2 复合表达式中的自增:见到就要拆
| 类型 | 表达式 | 状态 |
|---|---|---|
| 同类双写 | i++ + i++ | 同一个变量被两次无排序地修改,未定义行为 |
| 同类双写 | ++i + i++ | 仍是未定义行为,修改与修改之间没有先后序 |
| 混写+赋值 | i = i++ | 经典问题,标准层面存在争议,但所有规范都禁止 |
| 数组混写 | arr[i++] = i | C++17 后部分规则有调整,但可读性极差,不应出现 |
| 函数实参 | func(i++, ++i) | 实参求值顺序未指定,结果依赖编译器 |
这张表里的每一行,都是我真实在代码审查里见过别人写过的。i++ + i++是最典型的例子:两个后缀自增都在修改 i,但标准没有规定它们之间谁先谁后,这属于未定义行为,编译器可以按任何方式解释,甚至优化出你完全想不到的结果。我曾在某个模拟项目 X 里见过一个“只在 Release 下出错、Debug 下正常”的诡异 Bug,最后一行行排查找到data[pos++] = data[++pos];这样的表达式,当时第一反应就是:这种代码能活到现在简直是运气。
处理原则很简单:任何涉及自增/自减又同时读取同一个变量的复合表达式,一律拆成多条语句。性能不会因此变差,编译器照样能优化,但代码的可读性和可预测性完全是两个档次。
4.3 函数实参里的自增:实参求值顺序不可控
看这段代码:
int i = 1; printf("%d %d\n", i++, i++);即使在同一个编译器上,换一个优化选项,结果都可能不同。因为在 C/C++ 的语言规则里,函数实参的求值顺序是未指定的(C++17 之后是“不确定顺序”,但依然不保证从左到右)。两个i++都修改同一个变量,谁先谁后直接决定输出是1 2还是2 1,甚至可能是第三个结果。
解决方式非常简单:先单独计算,再传参。
int a = i++; int b = i++; printf("%d %d\n", a, b); // 输出确定:1 2同理,std::cout << i++ << i++这种链式输出也有类似问题,因为<<的重载调用本质上还是函数调用,实参求值顺序同样不保证。这些代码在真实项目里都应该直接写成分号分隔。
4.4 引用传参时的“能传/不能传”
++i是左值,i++是右值,这个差别在传参时直接表现为编译错误。
void f(int& x); void g(const int& x); void h(int&& x); int i = 0; f(++i); // 合法,++i 是左值,可以绑定到非 const 左值引用 f(i++); // 编译错误,i++ 是右值,不能绑定到非 const 左值引用 g(++i); // 合法,左值可以绑定 const 引用 g(i++); // 合法,右值可以绑定 const 引用 h(++i); // 编译错误,++i 是左值,不能绑定右值引用 h(i++); // 合法,i++ 是右值,可以绑定右值引用这个知识点如果面试被问到,最好能从“左值/右值的结果类别”推导,而不是硬记。只要记住前置返回左值、后置返回右值,上面四行都能瞬间判断出来。
实际场景中,我见过有人写process(container[i++])想同时取出元素和移动索引,结果形参恰好是const T&,编译通过但逻辑依赖实参求值顺序,属于雷区。更稳妥的写法仍然是拆开:
auto& item = container[i]; i++; process(item);4.5 排查方法小结
如果你在代码里看到带自增/自减的表达式觉得不对头,最简单的排查方法是把它拆成三条语句跑一遍,看结果是否和原来一致。如果不一致,说明原表达式本来就依赖未定义或未指定的行为,赶紧改掉。如果一致,也建议保留拆分开的版本,因为代码是给人看的,不是给编译器看的。
编译器警告也很有用。GCC/Clang 开启-Wall -Wsequence-point(或新版本自动包含的序列点警告),遇到i = i++这类表达式会直接警告。把警告当成错误来对待,是成本最低的避坑方式。
5. 编码规范与面试高频考点清单
5.1 我平时怎么用这两个运算符
在真实项目里,我的习惯可以总结成三条:
- 需要旧值时用后置,比如
v[i++]、*p++,这类场景后置是唯一合理选择。 - 只是推进循环、迭代器移动时用前置,因为语义更直接,泛型场景下不会有额外拷贝风险。
- 同一表达式里绝不出现两个以上对同一变量的自增/自减,永远拆开写。
第三条是硬底线。前两条是风格,第三条是安全。
3.1 节里我们说过后置需要保存旧值,所以你如果确实需要旧值,后置是必须的。比如从一个数组里“边取值边移动指针”的典型写法,后置就是最优雅的表达:
while (len--) { *dst++ = *src++; }这里的*dst++等价于“先赋值旧位置,再移动指针”,每个变量只自增一次,不存在未定义行为,是可以放心用的写法。很多人在看完前面的警示后变成“看到后置就条件反射反对”,这是另一个极端。后置本身没问题,问题只出在同一条表达式里对同一个变量多次自增。
5.2 代码审查时我重点查的几种写法
在 code review 中,只要看到下面任意一种,基本可以立刻请作者过来聊两句:
- 赋值表达式的两侧同时出现同一个自增变量
- 函数调用参数里出现多个自增变量
- 数组下标表达式里出现
i++,同时等号另一侧也有i - 自定义迭代器的后置自增在循环里高频出现
如果作者能说清楚每一步的求值顺序和标准依据,那算他厉害;但大多数情况下,更好的建议仍然是“拆开写,别炫技”。代码的第一读者是下个月要维护它的同事,而不是今年要跑出高分的编译器。
5.3 面试官喜欢的衍生考点
面试题除了直接问结果,通常还会从这几个角度深挖:
++i和i++的返回值分别是什么类别(左值/右值),为什么++++i合法而i++++非法。- 自定义类重载前后置自增时,为什么后置要加一个
int参数。 - 为什么模板/迭代器循环推荐前置,而不是后置。
- 给定表达式
i++ + ++i,问输出多少。正确答案不是某个数,而是“未定义行为”。 std::vector<int>::iterator的it++和++it在性能上有无区别,什么时候有区别。
第五个问题很有迷惑性。单纯看 STL 迭代器,vector的迭代器内部是指针,后置在发布版本里几乎和前置一样快。但你如果回答“完全没区别”,面试官马上会追问:那为什么规范还推荐前置?这时候能说出“模板泛型场景无法预知类型,后置存在额外拷贝风险,统一前置最稳”就过关了。
5.4 自减运算符的对称性
--i和i--的分析与自增完全对称,前置返回左值、后置返回右值,重载规则同样是operator--()和operator--(int),难点也是后置需要保存旧值。- 在链表遍历中常见的
cur = cur->next不是自减,但和cur++的区别也值得注意:很多链式结构不提供随机访问迭代器,cur++不一定存在,直接用指针成员赋值反而是最通用的写法。
面试中如果有人连续问完++i再问--i,基本是想确认你是真的理解了语义与重载机制,而不是只背了前半段。
6. 巩固练习:从基础输出到易错判断
6.1 基础输出题
题目1:
int i = 2; int a = i++; std::cout << a << " " << i;题目2:
int i = 2; int b = ++i; std::cout << b << " " << i;题目3:
int i = 0; int x = i++; int y = ++i; std::cout << x << " " << y << " " << i;参考答案:
题目1 输出2 3。i++表达式的值是旧值 2,赋给a,随后i自增为 3。
题目2 输出3 3。++i表达式的值是自增后的新值,也赋给b,所以b和i都是 3。
题目3 输出0 2 2。第一步i++让x=0,i=1;第二步++i先让i=2,再把新值 2 赋给y。最终x=0, y=2, i=2。
6.2 指针与优先级题
题目4:
int arr[] = {10, 20, 30}; int* p = arr; int x = *p++; int y = *p; std::cout << x << " " << y;题目5:
int arr[] = {10, 20, 30}; int* p = arr; int a = *++p; std::cout << a << " " << *p;参考答案:
题目4 输出10 20。*p++按优先级理解为*(p++),先解引用得到 10 赋给x,然后p指向arr[1],所以y=20。
题目5 输出20 20。*++p理解为*(++p),先把p移到arr[1],再解引用得到 20。此时a=20,*p=20。
这两题如果能一次做对,说明优先级和左右值的关系已经过关。做错的建议画一个“指针移动前后位置图”,一眼就能看明白。
6.3 运算符重载题
题目6:
写出Counter类的前置和后置自增声明与实现,并说明返回类型的区别。
参考答案:
class Counter { public: Counter& operator++() { ++value_; return *this; } Counter operator++(int) { Counter old = *this; ++(*this); return old; } private: int value_ = 0; };前置返回Counter&,因为返回的是被自增的那个对象本身,生命周期在函数外仍然有效;后置返回Counter(值),因为必须返回自增前的旧值副本,而副本是函数内构造的临时对象,不能返回引用,否则悬空。
题目7:
Counter c; Counter d = c++;为什么需要拷贝构造?如果Counter内部有一个非常大的容器,这个操作的成本体现在哪里?
参考答案:c++实现中首先执行Counter old = *this,把当前对象整体拷贝一份,再自增。如果内部是大型容器,这一步会触发容器的深拷贝,成本可能远超自增本身。这也是为什么泛型场景推荐前置自增。
6.4 易错判断题
题目8:
判断下面表达式的行为。
int i = 0; int r = i++ + i++;参考答案:这是未定义行为。两个i++都在修改同一个变量 i,但它们的副作用之间没有确定的先后顺序,标准不保证结果。不要试图预测输出,正确做法是拆成两句。
题目9:
判断++++i和i++++哪个能编译通过。
参考答案:++++i能编译,i++++不能。因为前置自增返回左值,可以继续自增;后置自增返回右值,不能作为下一次自增的操作对象。
题目10:
int* p = arr;下,*p++和(*p)++有什么区别?
参考答案:*p++等价于*(p++),结果是先取p当前指向的元素,然后把指针p后移;(*p)++是把p当前指向的元素值自增,指针不动。前者操作的是指针,后者操作的是指针指向的数据。这两行代码在实际项目中经常被混淆,一旦混用,要么数据没更新,要么指针悄悄越界。
6.5 综合场景题
题目11:
假设你要实现一个自定义链表迭代器,内部维护一个指向链表节点的指针,请写出operator++的两种版本,并解释为什么遍历推荐用前置。
参考答案:
class ListIterator { public: ListIterator& operator++() { node_ = node_->next; // 前置:直接走到下一个节点 return *this; } ListIterator operator++(int) { ListIterator old = *this; // 后置:先保存当前位置 node_ = node_->next; // 再移动 return old; // 返回旧位置 } private: Node* node_; };遍历推荐前置:后置需要先复制一份迭代器来保存旧值,虽然这里只多了一个指针的拷贝,但语义上多走一步;在更复杂的迭代器上(比如带栈信息)额外成本会更明显,统一用前置可以把选择成本降到零。
6.6 写给自己看的自检表
练习做完后,我建议你再回头确认这五条结论:
- 前置和后置都会让变量加 1,区别只在表达式的求值结果是新值还是旧值。
- 前置返回左值,后置返回右值,所以
++++i合法,i++++不合法。 - 自定义类型上后置通常多一次拷贝,泛型代码统一推荐前置。
- 后置重载的
int参数只是占位符,用来和前置版本区分。 - 同一表达式内对同一个变量多次自增/自减,属于未定义行为或至少是结果不可移植,必须拆开。
能把这五条清清楚楚讲给同事听,前置加加和后置加加这一关就算真正吃透了。
我自己在实际开发里有一个体会:越是“基础”的语法点,越容易在高压场景下出幺蛾子。i++和++i看似小,但它牵扯出的求值顺序、左值右值、重载机制、拷贝成本,每一项都是 C/C++ 的核心知识。与其在代码评审时被追问到沉默,不如今天就拿几道题自测一遍,把这两个运算符彻底归到自己的知识体系里。以后写代码、看面试题、做重构,都会感觉顺畅很多。