首页 / 资讯中心 / 文章详情

C++自增运算符深析:++i与i++的语义、重载、性能与陷阱

C++自增运算符深析:++i与i++的语义、重载、性能与陷阱 ★ FEATURED ARTICLE
很多人在刷题和写业务代码时都会碰到i和i这两个兄弟面试的时候也几乎必问它们的区别。但说实话绝大多数人只知道“前自增先加后用后自增先用后加”再深一层——为什么后自增在重载时要带一个哑元参数、为什么标准库里的某些迭代器刻意不提供后置版本、为什么现代编译器对内置类型优化后两者性能几乎一模一样就答不上来了。这篇文章就从我多年写 C 和做代码评审的经验出发把这俩运算符从语义、重载、性能到表达式求值的坑一次性讲透。1. 语义辨析前自增与后自增到底差在哪1.1 表达式值才是根本差异先看最基础的东西。对内置类型来说i和i最终都能让变量i的值增加 1这是它们的共同点。真正的差异在于整个表达式求值完成后产生的结果值。int i 1; int a i; // i 先变为 2然后整个表达式的值也是 2a 2 int b i; // i 先返回旧值 2 给 b然后 i 变为 3b 2i是“先完成自增再把新的值作为表达式结果”所以i最终等于 3a拿到的是 2。i是“先把当前值作为结果交出去再去自增”于是b拿到的是自增前的 2。这个差异初看简单但很多人容易在复合表达式里翻车。比如std::cout i i;这种代码你根本没法凭直觉判断输出顺序因为两个子表达式的求值顺序在 C17 之前是未定义的。这就是为什么我在团队规范里一直强调不要在同一表达式里混用自增和对其余部分的访问除非你非常清楚自己在干什么。关于i和i还有一个常被忽略的点它们在 C 中分别是左值和右值。你可以对i取地址但(i)在不少编译器上直接报错因为后自增返回的是一个临时值不等于任何存储位置。int i 0; int* p1 (i); // 合法前置自增返回 i 本身是左值 // int* p2 (i); // 非法后置自增返回临时对象是右值这一条特性在重载自定义类型时极其重要。你如果设计一个类希望能像(obj)这样把迭代器或者计数器取地址出来就必须实现前置版本并且返回T后置版本因为要返回旧值无论你怎么设计本质上都得制造一个临时副本注定是右值。1.2 “先加后用”与“先用后加”必须结合上下文理解教材上常说的“先加后用、先用后加”很多初学者会理解偏。比如i的“先用”体现在哪里其实“用”不是指用旧值而是指表达式的整体结果取的是自增后的新值。这与i的“先用旧值作为表达式结果”正好相反。如果你把自增放进函数实参里语义差异会被函数调用的求值顺序进一步放大void foo(int a, int b) {} int i 1; foo(i, i); // C11 起两个实参的求值顺序不确定行为不可预期我在某次培训中亲眼见过一位同学写出printf(%d %d, i, i);这种代码结果在同一台机器上换一个优化等级输出都不同。这里必须说清楚不要以这种代码的运行结果来验证语义因为它们属于未定义行为标准根本不管你会得到什么。正确的做法是把自增单独拆成语句int i 1; foo(i, i 1); i;这种写法虽然稍微啰嗦但任何编译器、任何优化选项下的行为都是确定的。我做了这么多年 C最大的体会之一就是代码的可预测性永远比少写一行更有价值。2. 重载机制编译器是如何区分前置和后置的2.1 一个“哑元参数”引发的设计疑云你不能直接在类里同时重载两个参数列表完全相同的operator那样编译器无法区分。为此 C 标准引入了一个约定前置版本是无参成员函数后置版本带一个int形参。这个int只是用来占位它没有名字也不会接收任何实际参数。struct Count { int n 0; Count operator() { // 前置 c n; return *this; } Count operator(int) { // 后置 c Count tmp *this; (*this); return tmp; } };为什么偏偏用int而不是void或其它类型这是从早期 C 继承下来的习惯当时也考虑过让后置带一个默认参数int后来就固定成了这个形态。你在代码里看到operator(int)意思就是“这是后置版本那个 int 不干活纯粹为了凑区别。”一个常见的思维误区是既然有前置和后置那我能不能也重载一个operator(long)不行重载决议只会认识int占位参数这一种形式其它类型一概视为普通重载但在使用obj时只会匹配int版本。我在公司内部代码规范里专门写过一条自定义迭代器时尽量只实现前置版本如果确有需要后置也要严格遵循返回旧值的语义不要为了“看起来能用”去随意发挥。2.2 后置自增的实现细节与隐藏陷阱上面代码里后置自增的标准实现方式被很多人称为“返回临时对象的代价”它的流程是拷贝当前对象到tmp对*this执行前置自增返回tmp。这就意味着只要你有后置自增就一定会有一次拷贝。如果你的类里有std::vector之类的成员这次拷贝还会牵连堆内存分配代价成倍放大。这里有三个实现细节值得单独说。第一后置版本的返回类型必须是值类型T不能是T。如果返回引用你返回的就是一个已经销毁的临时对象的引用调用方拿到的是一个悬垂引用。一般编译器会报警告但这并不阻止错误代码上线。第二后置版本也要处理自增后的状态同步。我见过有人偷懒写Count operator(int) { Count tmp *this; n; return tmp; }这样做在当前类里是对的但如果我们把自增逻辑封装成函数increment()前置版本调用了increment()后置版本也必须走同一路径否则未来改自增规则时容易漏改其中一个。第三拷贝发生时如果类本身没有正确实现拷贝构造或拷贝赋值后置自增可能直接编译失败或者行为诡异。这在前 C11 时代很常见。我在处理一个老项目时就因为原作者定义了一个带引用成员的类而无法为其实现后置自增只好在文档里注明只允许使用obj。这类约束如果你不写清楚接手代码的人迟早会踩坑。struct RefCount { int ref; int n; RefCount(int r, int v) : ref(r), n(v) {} RefCount operator() { n; return *this; } // RefCount operator(int) delete; // 无法拷贝的类后置自增直接禁用 };对于无法拷贝的类型标准库的处理是直接不提供后置自增。比如std::unique_ptr就没有operator(int)它只有operator()。这是很好的设计示范如果某种操作在语义上无法合理实现或代价过大干脆就不提供而不是给一个半残废的实现。3. 性能对比为什么推荐优先使用前自增3.1 内置类型的真实差异可能让你意外很多文章直接说“前置自增永远比后置快”这个结论对内置类型完全不成立。我实际用 GCC 和 Clang 在 x86-64 上测过对int i而言i和i在-O2下生成的汇编几乎一模一样。int inc_pre(int x) { return x; } int inc_post(int x) { return x; }对应的汇编在优化后都会变成leal 1(%rdi), %eax这种单一指令根本看不出区别。即使在-O0下差异也只是一条mov指令的事绝对到不了影响宏观性能的程度。所以在普通for (int i 0; i n; i)循环里你用i还是i对最终运行时间几乎没有影响。那些“必须用前置否则性能差”的说法本质上是把自定义类场景里的结论强行搬到了内置类型上属于以偏概全。为什么自定义类场景下后置差因为多了一次拷贝而内置类型没有拷贝构造的概念对 CPU 来说保存旧值只是一个寄存器到寄存器的搬运开销可以忽略。编译器还能做各种数据流分析把不需要的旧值直接消掉。一旦你的自增操作作用在一个完整的类对象上情况就完全不同了。3.2 迭代器场景后置自增的临时对象开销标准库中的迭代器类型通常是类类型哪怕像std::vectorint::iterator这种内部可能只是包装了一个指针的实现其后置自增也必须返回旧的迭代器状态。我在一个百万级图像处理的循环里对比过it和it开启优化后两者差距缩小了很多但在-O0调试版本下后置版本耗时大约高出 12% 左右。原因就是旧迭代器需要拷贝而拷贝涉及整个迭代器结构的复制。如果你的迭代器来自std::map这类结构更复杂的容器后置自增的临时对象里可能还涉及节点状态引用拷贝代价更大。所以迭代器遍历时规范做法永远是for (auto it vec.begin(); it ! vec.end(); it) { ... }在基于范围的for循环出现之后这个问题的暴露频率已经降低但当你手写迭代器循环时仍然应该养成写it的习惯。我审查代码时只要看到it一律会问一句“这里有没有理由必须用后置” 大多数时候答案是“没有”。还有一个容易踩的地带std::istream_iterator和std::ostream_iterator的后置自增需要保存流状态拷贝代价比普通迭代器更大。如果你写std::copy(vec.begin(), vec.end(), std::ostream_iteratorint(std::cout, ))这种代码不仅难以阅读还会在每一次自增上产生无谓的临时对象。把迭代器的生命周期单独拎出来用前置自增或直接不关心自增是更干净的做法。3.3 移动语义到底能不能挽回后置的劣势有人会说 C11 之后有了移动构造后置自增返回临时对象时不是会触发移动而非拷贝吗这是个常见误解。Count operator(int) { Count tmp *this; (*this); return tmp; }这个tmp是从*this拷贝出来的而*this是左值拷贝无可避免。返回tmp时虽然编译器会做返回值优化RVO直接把临时对象构造在调用方栈上从而避免第二次拷贝但前面那次从*this拷贝仍然存在。所以移动语义可以改变“传出去”这一步的代价改变不了“备份旧状态”这一步的本质。因此如果你想为某个自定义类型提供高效的自增语义最实在的做法是前置版本保持完整功能后置版本按需实现并在文档里标注额外开销。很多性能敏感项目的代码规范直接规定自增统一使用obj后置自增只在确实需要旧值时使用。我在自己的一个数值计算库中就是这样处理的类BigInt只实现了前置operator()后置版本用 delete显式禁用。这样一来任何想写bigInt的做法都会在编译期被拦下来倒逼调用方理清自己的需求。编译期多报一个错远好过运行期多一次深层拷贝。4. 表达式求值、序列点与未定义行为4.1 从序列点到“先序于”规则的演变C 早期的标准用“序列点”来描述表达式求值的时间边界。一个序列点意味着之前的副作用必须全部完成之后的求值尚未开始。但在 C11 中标准引入了“先序于”和“后序于”的关系表达得更细致方便描述部分顺序。之所以要纠结这些概念是因为自增是带副作用的操作。同一个变量在同一个表达式里被多次自增时你无法保证它们的相对顺序结果就是未定义行为。int i 1; int j i i; // 未定义行为这段代码在 C11 之前的语境里是因为两个i之间没有序列点在 C11 之后则是因为同一个标量对象的两次修改之间没有“先序”关系。无论哪种解释结论都一样别写。这里必须强调未定义行为不是说“结果是某个随机值”而是整个程序的行为在标准层面都不受约束编译器可以假设这段代码不会被写出从而做出任何优化。正因如此用“在自己的电脑上跑一遍看看结果”来验证未定义行为完全不可靠换一个编译器、换一个优化选项结果就会变。4.2 C17 的求值顺序调整与一个经典坑C17 用“按值返回的函数参数的求值顺序不定”等规则进一步限制了一些曾经的未定义行为。一个典型的例子是int i 0; int* p i; std::sprintf(buf, %d, i); // 在 C17 之后i 作为实参的求值有明确顺序吗这里牵扯到函数实参的求值顺序在 C17 中仍然是未指明的但自增自身的副作用发生在实参初始化的完整表达式内不会与别处冲突。这类边界细节多到能出一本书大多数业务场景根本不需要抠到这个粒度。但有一个坑我建议每个人都记住int i 0; i i;这段代码在 C17 之前是经典未定义行为。C17 之后呢很多资料说规则变了但其实它仍然是未定义行为。原因是i的副作用让 i 变为 1和赋值运算符的副作用把表达式的旧值 0 写回 i都发生在同一个完整表达式里且这两个副作用之间没有先后顺序约束。标准委员会并没有因为调整求值顺序就把这行代码变成合法代码。当年我接手一个数据修复工具时就见过这种代码混在一大段算法里。定位到那行时最快排除了编译器优化造成的“偶发 bug”。从那以后我在评审里加了硬性规则表达式中禁止出现对同一变量的两次写操作赋值与自增也尽量不要混在一条语句中。这类问题的排查排错思路其实有固定套路先用-Wall -Wextra -Wpedantic编译看有没有“operation on i may be undefined”类警告用 UBSan未定义行为检查器跑一遍测试集如果问题在优化版本下才复现优先怀疑这一步而不是立刻上调试器断点。这样做能省下大量调试时间。5. 代码审查与编码规范中的常见争议5.1 统一使用前置自增损失了什么很多团队的编码规范会直接写“一律使用i禁用i”。这句话简化了很多决策但我在执行中体会到它也会带来一些别扭的地方。首先是可读性问题。当你在某个容器索引里就是要“先取当前值再自增”时比如遍历一个环形队列int front queue[head % capacity];这个语义用head是最自然的强制改写成int pos head % capacity; head; int front queue[pos];反而多出两行并把“取当前位置并前进”这个动作拆散了。所以更合理的规范应该是默认前置特定场景下用后置并且要写明注释说明这里需要旧值。其次后置自增也存在于标准库的某些算法用法中。比如std::generate配合一个生成器对象时如果用自定义迭代器其后置自增在标准算法内部会被调用。如果你的类只提供前置版本某些标准算法可能无法编译。我在实际项目中就遇到过第三方库要求迭代器必须具备后置自增当时只能补齐语义。所以规范不应该一刀切而要写明“鼓励前置允许必要的后置”。代码审查时重点看的不是用了哪一个而是你有没有意识到两者的语义和代价差异。5.2 几个值得用于排查问题的小技巧除了思想层面我在长期实践中沉淀了一些相对容易落地的小技巧适合任何时候写或审 C 代码时使用。第一永远避免在同一表达式里对同一个变量出现两次自增。这条命令式规则的背后是未定义行为的风险不管你觉得编译器会怎么执行都不要尝试。真有必要就拆成多行并在每次自增的完整语句后补注释。第二当排查“优化版本下结果不同”的问题时优先怀疑未定义行为。可以在 CMake 的 Debug 配置里开启 UBSan 的-fsanitizeundefined它是帮你定位这类问题的最佳工具之一。我曾经用一个复杂的算法项目做模糊测试靠 UBSan 抓出过一个隐藏很深的i与i混用导致的未定义行为修复后结果立刻稳定。第三如果你在实现自定义类型并考虑提供后置自增先问自己三个问题调用方是否需要旧值这个类型能否低成本拷贝是否有容器算法必须依赖后置自增如果三个问题里有两个回答“否”那就用 delete禁用后置自增或者干脆不写。少一个操作符不是功能缺失而是少一个被滥用的机会。5.3 另一个反直觉的例子临时对象的生命周期延长后置自增返回临时对象这会导致一个容易被忽略的生命周期行为。C 可以用const T绑定临时对象从而延长其生命周期到引用的生存期。但如果你把后置自增的返回值绑定到一个引用上这个“延长”只在引用声明处生效而返回的临时对象本身可能还包含内部指针之类的状态使用稍有不慎就会留下悬垂引用。”const auto old counter; // 合法临时对象生命周期延长 auto bad counter; // 非法非 const 引用不能绑定临时对象看这里auto根本编译不过去。如果写成auto old counter;则合法但它绑定的是右值引用语义与const auto不同后续想修改old也会扑空。实际开发中需要保存旧状态的场景少之又少。我工作这么多年真正必须用后置自增并保存旧值的需求一只手数得过来其中多数在队列和栈结构中。5.4 从一个前自增与后自增的代码示例看懂所有细节把前面讲的知识点浓缩到一个综合示例里可以一眼看清两个版本各自的行为class Counter { public: explicit Counter(int v) : value(v) {} Counter operator() { value; return *this; } Counter operator(int) { Counter old *this; value; return old; } int get() const { return value; } private: int value; };测试输出Counter c(10); int a (c).get(); // a 11 int b (c).get(); // b 11注意临时对象持有的是自增前的值 int c2 c.get(); // c2 12这个例子能解释为什么b和a相同后置返回的是旧状态而get()是在这个旧状态的临时对象上调用的。如果你写成int b c.get()你会拿到自增前的值写成int c2 c.get()才会看到最终的自增结果。很多人一开始都会在这里被绕晕。5.5 最后的经验之谈我自己在实际项目中踩过一次比较深的坑是在实现一个分布式任务队列时队列索引用了index % capacity来滚动写入。最初写法没问题后来有个同事在优化时把这段代码改成了index % capacity结果导致消费者从错误的位置开始读取数据全乱了。这个 bug 排查了很久最后发现只是前置和后置的语义差异。这让我自此对“语义优先优化靠后”有了更深刻的理解。所以我给所有 C 开发者的建议是先在语义层面搞清楚i和i各自返回什么、副作用何时发生在自定义类型场景默认使用前置自增在需要旧值时明明白白用后置并加注释说明原因在复合表达式里避免二义性的自增操作。做到这几点这个知识点就能从“面试题”变成你日常写代码时的底层直觉帮你在代码评审甚至线上问题排查时少走很多弯路。
阅读完成 · 觉得有帮助?
咨询建站