什么是 C++ 中的序列点,它与未定义行为有什么关系?

面向初学者的在线Python训练器

轻松学习Python,无需理论负担。在浏览器中直接解决带自动检查的实践任务,无需安装任何东西。

开始课程

调试器的秘密武器:C++ 程序员容易忽视的问题



你是否曾经写过类似 i = i++ + ++i; 的代码,并在不同的编译器上得到不同的结果?或者遇到过一个奇怪的 bug,只在服务器上复现,而在你的笔记本电脑上却没问题?认识一下:undefined behavior(未定义行为)——C++ 开发者最阴险的敌人之一。而这一切混乱的核心就是序列点 (sequence points)。



在本文中,我们将探讨什么是序列点,它们与 C++ 中的计算顺序有何关联,以及——最重要的是——如何避免踩坑。本文内容基于 Stack Overflow 上一个广受关注的问题,该问题获得了数千次浏览。我们开始吧!



什么是序列点 (sequence points)?



想象一下你是一位管弦乐队的指挥。每位乐手(处理器指令)都必须在严格确定的时刻开始演奏。序列点是一个时间点,在此刻所有之前的“副作用”(变量修改、函数调用)都已保证完成,而后续的副作用尚未开始。



在 C++98/03 标准中,使用了术语 sequence point。在 C++11 中,它被更严格的“sequenced before / sequenced after”关系所取代,但本质不变:在两个序列点之间,编译器完全有权重新排列操作顺序,只要不改变结果(根据“as-if”规则)。



简单来说:如果你试图在两个序列点之间两次修改同一个变量——你就会得到未定义行为。编译器可以做任何事情:从正确的结果到格式化你的硬盘(开个玩笑,但几乎是真的)。



经典的序列点示例

  • 分号 ; —— 表达式的结束
  • 逗号 , 作为运算符(但不是在函数参数中!)
  • for 循环中每个表达式的结束(初始化、条件检查和步进之后)
  • 逻辑运算符 && 和 ||
  • 三元运算符 ?:
  • 函数调用(在函数体开始执行之前)


序列点如何导致未定义行为?



这是一个每个 C++ 开发者都应该知道的经典示例:



int i = 0;i = i++ + ++i;  // UB! 未定义行为


为什么这是 UB?因为在两个序列点之间(本例中,在表达式的开始和结束之间),变量 i 被修改了两次:

  1. 后缀自增 i++
  2. 前缀自增 ++i
  3. 赋值 i = ...


编译器不知道按什么顺序执行这些操作。结果?一个编译器可能输出 2,另一个输出 3,第三个输出 0。而在优化模式下,这段代码甚至可能被完全丢弃。



另一个棘手的情况:函数参数



int foo(int a, int b) { return a + b; }

int x = 5;int result = foo(++x, ++x); // UB!


这里,参数之间的逗号是分隔符,而不是逗号运算符。在计算参数 ++x 之间没有序列点!两个自增操作是“并行”执行的(从标准的角度来看),我们再次得到了未定义行为。



实际示例:UB 在真实代码中的表现



示例 1:不易察觉的索引 bug



int arr[5] = {0, 10, 20, 30, 40};int idx = 1;arr[idx] = idx++;  // UB!


乍一看很简单:我们想把 1 写入 arr[1],然后递增 idx。但标准不保证计算索引 idx 和右侧 idx++ 的顺序。结果?可能写入 arr[1],也可能写入 arr[2]。



示例 2:复杂

博客

书籍推荐