C++ 执行顺序揭秘:sequenced-before深度解析

93 阅读8分钟

一、前言

在日常开发中,大部分 C++ 程序员写代码时的直觉是:代码的执行顺序就是从左到右、从上到下。但事实并非如此。编译器为了优化性能,经常会在不改变“外部可见结果”的前提下,自由地调整代码的执行顺序。这种“看不见的重排”往往超出了开发者的预期。
于是就出现了一个问题:哪些操作之间的先后顺序是标准保证的,哪些顺序是未定义或未指定的? 如果搞不清楚,就很容易写出在不同编译器、不同优化等级下表现完全不同的代码。
为了解决这个问题,C++ 标准引入了一个关键概念:sequenced-before(先行顺序)。它是理解表达式执行顺序的基石,也是编译器优化边界的红线。今天我们就来聊一下sequenced-before。

二、历史背景:从 Sequence Point 到 Sequenced-before

在 C89/C90(C 标准) 和 C++98/C++03(早期 C++ 标准) 中,表达式的执行顺序由“sequence point(序列点)”来描述。标准里规定了一些典型的序列点,例如:

  • 每条完整表达式的结尾 ; 是一个序列点
  • 逻辑运算符 &&、||、条件运算符 ?: 在子表达式之间形成序列点
  • 逗号运算符 , 左右两边也被强制序列化

序列点的作用是告诉编译器:在这个点之前的所有副作用必须完成,之后的求值才能开始。
但这种机制的问题是:

  • 粒度过粗:它只在少数几个语法位置生效,无法覆盖复杂表达式中的求值细节。
  • 难以扩展到多线程内存模型:C++11 开始引入了原子操作和内存序(memory order)。要定义“线程 A 的写在 B 的读之前可见”,就必须有一个比 sequence point 更精细、更普适的求值顺序模型。

因此,从 C++11 起,标准废弃了 sequence point 的术语,改用三个新的关系

  • sequenced-before —— 明确先于
  • indeterminately sequenced —— 顺序不确定,但不会交错
  • unsequenced —— 可能交错,若对同一对象有冲突则 UB

这个新的体系不仅解决了单线程中“副作用到底先发生还是后发生”的问题,更重要的是,它与多线程语义中的 happens-before / synchronizes-with 搭配,构建出了现代 C++ 的完整内存模型。

三、核心定义

三种顺序(since C++11)

C++ reference中对sequenced before的定义如下: sequenced before 总结起来,如下:

  1. sequenced-before 如果 A sequenced-before B,那么 A 的值计算和副作用在 B 开始前必须完成。
  2. indeterminately sequenced A 和 B 不会交错,但标准未规定谁先谁后。
  3. unsequenced A 和 B 可能交错执行。如果它们都对同一对象有副作用或一写一读,就会触发 未定义行为(UB)。

C++ 表达式求值顺序规则

C++ 标准定义了一系列 sequenced-before / unsequenced / indeterminately sequenced 关系,来约束表达式和副作用的执行顺序,部分核心规则如下图所示: sequenced before rule 将标准中的规则总结了一下,汇总到了下表中:

类别规则描述标准 / 版本说明
语句级顺序每个完整表达式 (full-expression) 都在下一个完整表达式之前完成C++11+
操作数与运算符操作数的值计算先于运算符结果的值计算,但副作用可能无序C++11+
函数调用函数名求值(函数地址)在实参之前;参数间求值未指定顺序(indeterminately sequenced),但不会交错C++11~C++20
函数契约(contracts)前置/后置条件在参数求值后、函数体前/后执行C++26 draft
自增自减后置运算的值计算先于副作用;前置运算副作用先于值计算C++11+
逻辑与 / 或 / 逗号 / 条件运算符左操作数 sequenced-before 右操作数C++11+
赋值与复合赋值左右值都先求值,之后才发生赋值副作用C++11+
初始化列表初始化中前一项 sequenced-before 后一项;返回语句的临时构造 sequenced-before 局部变量析构C++11+
new 表达式C++11/C++14/C++17 前:调用 operator new 与构造函数参数求值是 indeterminately sequenced
C++17+:调用 operator new sequenced-before 构造函数参数求值
C++11~C++17+
特殊运算下标表达式 E1[E2]、成员指针表达式 E1->*E2E1.*E2、移位表达式 E1<<E2 / E1>>E2,E1 sequenced-before E2C++17+
函数参数初始化各参数初始化彼此 indeterminately sequencedC++11+
函数调用顺序(C++17)13) 函数名 sequenced-before 每个实参与默认参数
14) 每个参数的值计算和副作用相对于其他参数是 indeterminately sequenced
C++17+
重载运算符遵循对应内置运算符的求值顺序规则C++11+
简单/复合赋值(C++17 补充)E2 sequenced-before E1C++17+
逗号分隔的表达式列表(C++17)在括号初始化中,每个表达式求值如同函数调用,彼此 indeterminately sequencedC++17+
函数调用例外(par_unseq 并行算法)std::execution::par_unseq 执行的函数调用 unsequenced,可任意交错C++17+

👉 简而言之,sequenced-before 关系是标准赋予开发者的“最小安全保证”。

四、对编译器优化的影响

1. as-if rule对编译器的约束

C++ 有一个著名的 as-if rule: 编译器可以在不改变程序“可观察行为(observable behavior)”的前提下,自由地重排、合并甚至删除代码。所谓“可观察行为”,主要包括:

  • 程序的输入/输出结果
  • volatile 对象的访问
  • IO操作

除此之外,编译器都可以随意发挥。

2. sequenced-before 的作用

C++ 标准定义的 sequenced-before 描述的是 逻辑上的执行顺序:

  • 如果标准规定 A sequenced-before B,意味着:

    • A 的值计算和副作用 逻辑上先于 B
    • 任何涉及同一对象的操作必须遵守这个顺序,避免未定义行为(UB)

注意:这里的顺序是 逻辑顺序,不是指令层面的严格顺序。

3. sequenced-before 与编译器优化

编译器在优化时,可以按照 as-if rule 自由调整指令顺序,只要程序的可观测行为保持一致:

  • 逻辑上符合 sequenced-before,但实际指令可能被重排
  • 编译器可能将 A 的计算提前或延后,只要不改变最终对外部可观察状态的结果

这也是为什么同一段代码在不同编译器或不同优化等级下可能生成不同的汇编,但程序行为依旧正确

4. 未序列化副作用 → 优化黑洞

如果表达式里有两个 unsequenced 的副作用,例如对同一变量的两次写,或写+读:

  • 标准规定结果是 未定义行为(UB)
  • 编译器被允许“假设 UB 永远不会发生”,参考C++标准

这可能会导致编译器在优化时直接删除、重排甚至完全忽略相关代码。

5. 小结

  • sequenced-before = 逻辑约束
  • 可观测副作用 = 编译器必须遵守的优化边界
  • 开发者应关注的是:
    • 不要在一个表达式中对同一对象产生多个未序列化副作用
    • 避免依赖编译器实现顺序

用一句话概括:sequenced-before 告诉你逻辑上先做什么,可观测副作用告诉编译器不能乱做什么。

五、示例与反例

❌ 典型 UB

int i = 1;
int x = i++ + i++; // 两个写操作未序列化 → UB

❌ 参数求值(C++17 之前)

int i = 0;
f(++i, ++i); // C++11 下 UB,C++17 后不再 UB,但结果未指定

❌ 混合读写

int i = 1;
std::cout << i << i++; // 一个读,一个写 → 老标准下 UB

✅ 安全写法

int i = 1;
int t = i++;
int x = t + i; // 顺序明确,可移植

六、C++17 的改进

C++17 收紧了部分求值规则:

  • 函数实参的求值顺序不再允许交错
  • 某些运算符从“未定义”提升为“未指定”

这让一些历史上的 UB 表达式“变安全”了。但注意:虽然不再 UB,但结果依然可能随实现不同而不同,不建议依赖。

七、 实战避坑清单

✅ 避免在一个表达式里对同一对象进行多次修改
✅ 不要在函数参数里混合副作用
✅ 开启警告:-Wall -Wextra -Wunsequenced
✅ 写清晰可移植的代码,不依赖未指定/未定义行为
✅ 命名和逻辑清晰,便于理解副作用顺序

八、总结

sequenced-before 是 C++11 引入的核心语义,用来精确描述表达式的执行顺序。

  • 它是构建多线程内存模型(happens-before)的基础。
  • C++17 对求值顺序进一步收紧,但仍需保持良好习惯:不要依赖未指定的顺序。

最佳实践就是:副作用拆开写,跨线程用原子/锁。

📬 欢迎关注公众号“Hankin-Liu的技术研究室”,收徒传道。持续分享信创、软件性能测试、调优、编程技巧、软件调试技巧相关内容,输出有价值、有沉淀的技术干货。