7、序列点与副作用:序列点是什么?在两个序列点之间多次修改变量、函数参数的求值顺序、&&和||的短路求值。

好,咱们今天聊一个让很多C语言开发者栽过跟头的话题——序列点与副作用。说实话,我刚开始学C的时候,也在这上面吃过亏。那时候写了一段代码,自己怎么看都觉得逻辑没问题,可每次运行结果都不一样。后来查了两天资料,才发现是序列点的问题。

你想想看,C语言里有些表达式,表面上看起来很简单,但编译器在背后做了很多你意想不到的事情。如果不理解序列点,你写的代码可能就像一颗定时炸弹——今天能跑,明天换个编译器就炸了。

什么是序列点?

序列点,说白了就是C语言执行过程中的一些“检查点”。在这些点上,所有之前的副作用都必须完成,所有之后的操作都还没开始。嗯,你可以把它想象成跑步比赛中的接力区——在接力区内,前一棒必须把棒子交到后一棒手里,交接完成之后,后一棒才能起跑。

C语言标准中定义了几个关键的序列点:

  • 每个完整表达式结束时(比如分号处)
  • &&、||、?: 和逗号运算符的左操作数求值后
  • 函数调用时(所有实参求值之后、进入函数体之前)

核心原则:在两个序列点之间,一个对象最多只能被修改一次。如果修改了,就不能再读取它的值(除非是为了计算要写入的新值)。

两个序列点之间多次修改变量——经典陷阱

来看一个我当年踩过的坑:

int i = 0;
int arr[] = {1, 2, 3};
arr[i++] = i++;  // 未定义行为!

为什么会这样?因为 arr[i++] = i++ 这个表达式里,i 被修改了两次。两个 i++ 之间没有序列点,编译器可以自由决定先算左边还是先算右边。结果可能是 arr[0] = 1,也可能是 arr[1] = 0,甚至可能把程序搞崩溃。

我个人习惯是:只要一个表达式里出现两次以上对同一变量的修改,我就立刻拆开写。比如上面那段代码,改成:

arr[i] = i + 1;
i += 2;

这样虽然多了一行,但代码的行为是确定的,不会让你半夜被运维电话吵醒。

警告:不要以为只有 ++-- 才算修改。赋值 =、复合赋值 +=*= 等,都算修改。两个序列点之间,同一个变量只能被修改一次。

函数参数的求值顺序——另一个大坑

我记得有一次,团队里一个同事写了这样的代码:

int x = 1;
printf("%d %d", x++, x++);  // 未定义行为!

他问我:“为什么打印出来的是 1 2,而不是 2 1?”

我告诉他:C语言标准没有规定函数参数的求值顺序。编译器可以从左到右求值,也可以从右到左求值,甚至可以先求中间再求两边。你看到的 1 2 只是你的编译器恰好这么做了,换个编译器结果可能完全不同。

这里的关键是:函数调用本身是一个序列点,但参数列表内部没有序列点。所有参数求值完成之后、进入函数体之前,才有一个序列点。所以参数之间的求值顺序是未指定的。

我曾经在项目里见过这样的代码:

func(++a, a + b, b++);  // 未定义行为!

这里 a 被修改了两次(++ab++ 虽然不同变量,但 a++a 中被修改,又在 a + b 中被读取),而且 b 也被修改了。整个表达式乱成一锅粥。

我的建议:函数参数里不要写任何带副作用的表达式。先把值算好存到临时变量里,再传进去。代码多两行,但心里踏实。

&& 和 || 的短路求值——这个倒是靠谱的

好消息是,&&|| 的求值顺序是C语言标准明确规定的。它们有序列点,所以行为是确定的。

&&|| 的规则很简单:

  • &&:先算左边,如果左边为假,右边就不算了(短路)
  • ||:先算左边,如果左边为真,右边就不算了(短路)

而且,左操作数求值之后、右操作数求值之前,有一个序列点。这意味着:

int i = 0;
if (i++ && i++) {  // 行为是确定的!
    // ...
}

这段代码是安全的。因为 && 左边 i++ 求值后(i 变成1,但 i++ 返回0),有一个序列点。然后发现左边为假,右边就不执行了。最终 i 的值是1。

但注意:安全不代表推荐。我个人还是建议不要在条件表达式里写带副作用的代码。你想想看,三个月后你自己回来看这段代码,还得想半天 i 到底被加了几次,何必呢?

知识体系总览

下面这张图帮你理清序列点的核心逻辑:

序列点与副作用知识体系 序列点 什么是序列点 副作用完成的检查点 序列点出现的位置 分号、&& || ?: 逗号、函数调用 常见陷阱 多次修改、参数求值顺序 完整表达式结束 短路运算符 函数调用 && 左→右 || ?: 和逗号 同一变量多次修改 参数顺序 读写混合无序列点 核心原则:两个序列点之间,一个对象最多修改一次 不确定的行为,就不要依赖它

避坑指南

最后,我总结几条实战经验,都是我用真金白银的加班换来的:

我曾经在维护一个老项目时,发现一段代码里写了 a[i] = i++。当时觉得没问题,结果客户那边用不同的编译器编译后,程序行为完全不一样。从那以后,我给自己定了一条铁律:

  • 表达式里不要同时读写同一个变量,除非你能明确说出两个操作之间有没有序列点
  • 函数参数里不要放带副作用的表达式,老老实实先算好再传
  • 善用 && 和 || 的短路特性,但不要在条件里写 i++ 这种代码——代码是写给人看的,不是写给编译器看的
  • 如果拿不准,就拆开写。多两行代码不丢人,半夜被叫起来修bug才丢人

嗯,序列点这个话题就聊到这儿。记住一句话:不确定的行为,就不要依赖它。编译器不会帮你兜底,只有你自己能对自己的代码负责。