我刚工作那会儿。
还在写 C。
后来开始写 C with classes。
脑子里只有两件事。
少写几行。
一行搞定。
那时候我真心觉得。
把三行缩成一行。
是一种技术。
直到有一天。
线上报警。
我盯着那行“很酷”的表达式。
怎么都想不通。
换个编译器。
换个优化级别。
结果还不一样。
那一刻你会很迷信。
你会觉得语言在闹鬼。
当年:C/C++ 把“先算谁”当成编译器的自由
早年的 C/C++ 很务实。
它想让编译器随便怎么排。
只要最后结果对。
就行。
所以它对很多表达式。
压根不承诺。
左边先算。
还是右边先算。
你如果在同一个表达式里。
一边读一个变量。
一边改它。
就很容易撞上 UB。
UB 的意思很残酷。
不是“结果不对”。
而是“什么都可能发生”。
甚至。
你以为你在测逻辑。
其实你在测编译器心情。
第一反应:我以为“参数从左到右算”
我当年最爱栽的坑。
就是函数参数。
int i = 0;
foo(i, i++);
你可能也会下意识脑补。
“先算第一个参数”。
“再算第二个参数”。
于是你觉得。
foo(0, 0)。
或者。
foo(0, 1)。
但你看。
这里真正可能发生的。
其实是这两种:
第一种。
先算 i。
再算 i++。
于是调用是:
// i == 0
foo(0, 0);
// i == 1
第二种。
先算 i++。
再算 i。
于是调用是:
// i++ 把 i 从 0 变成 1,但它自己“返回旧值 0”
foo(1, 0);
// i == 1
注意这个细节。
第二个参数永远是旧值。
顺序变了。
变的是第一个参数。
这也是为什么。
这种 bug 排查起来很恶心。
你盯着“第二个参数怎么会是 0”。
盯半天。
最后发现。
它本来就可能是 0。
更深的坑:不是“不保证顺序”,而是“可能交叉执行”
你如果只是把它理解成。
“可能先左后右”。
那还算温柔。
真正可怕的是早年的规则里。
某些子表达式之间。
允许“交叉”。
也就是。
编译器可以把两边的指令。
像洗牌一样插在一起。
读和写打架。
就可能直接掉进 UB。
这也是当年很多人写 C/C++。
会对“自增在表达式里出现两次”特别敏感。
不是玄学。
是经验。
当年我们怎么自救:靠“sequence point”凑活
你如果写过老 C。
大概率听过一句口头禅。
“在分号之前别乱改同一个变量”。
这其实是在说。
当年只有少数地方。
有类似“到这里为止,前面的副作用都结算完”的点。
比如 &&、||、逗号运算符。
于是很多工程代码。
会靠这些点来兜底。
写得很拧巴。
但能活。
问题是。
函数参数这种地方。
你是躲不掉的。
日志里一不小心。
也会踩。
C++11:给这件事起了名字,开始能“讲道理”
后来标准把这套玄学。
换成了更精确的词。
sequenced before。
unsequenced。
indeterminately sequenced。
你不用死背定义。
你只要记住一个工程化的翻译。
unsequenced。
代表“可能交叉”。
这是 UB 的温床。
indeterminately sequenced。
代表“一定有先后”。
但你不知道是谁先。
结果就会变成。
“不确定”。
但至少不是 UB。
C++17 的补丁:把很多“可能交叉”收紧成“不交叉但顺序未知”
C++17 做了几件很务实的事。
它没有把世界变成“从左到右”。
它只是把一些最常见的炸点。
从“可能交叉执行”。
收紧成“必须完整算完一个,再算另一个”。
函数调用就是其中之一。
从 C++17 开始。
同一次调用里。
各个实参之间。
变成了 indeterminately sequenced。
你还是不知道谁先算。
但至少不会互相穿插。
于是。
像下面这种。
就从“UB”变成了“结果不确定但有定义”:
int i = 0;
foo(++i, ++i);
你只需要接受两件事。
i 最后一定会变成 2。
但 foo 收到的两个参数。
可能是 (1, 2)。
也可能是 (2, 1)。
但别误会:foo(i, i++) 依然不是好代码
我见过不少人听到。
“C++17 把它从 UB 修成了有定义”。
就开始大胆。
这很危险。
因为它会把“语义”变成一种彩票。
你同事读代码。
得先在脑子里模拟两条执行路径。
你线上排查。
得先确认你现在跑的是哪一条。
你写得越“省行数”。
你就越是把成本。
转移给了未来的自己。
第二个典型事故:我在日志里写了 cout << i << i++
当年这个坑也很经典。
int i = 0;
std::cout << i << i++;
你直觉上会以为。
打印 0。
然后打印 0。
最后 i 变成 1。
在 C++17 之前。
这行在标准意义上就是 UB。
你今天跑出来“像是正常结果”。
明天换个编译器。
可能就不演了。
到了 C++17。
它才算被“救”成了有定义。
但顺序依然不保证。
更深的坑:不是“不保证顺序”,而是“可能交叉执行”
你如果只是把它理解成。
“可能先左后右”。
那还算温柔。
真正可怕的是早年的规则里。
某些子表达式之间。
允许“交叉”。
也就是。
编译器可以把两边的指令。
像洗牌一样插在一起。
读和写打架。
就可能直接掉进 UB。
这也是当年很多人写 C/C++。
会对“自增在表达式里出现两次”特别敏感。
不是玄学。
是经验。
第三个坑:赋值和下标,C++17 也收紧了,但结果可能更反直觉
我再举两个我见过的“看起来没问题”的一行流。
第一个。
int i = 0;
i = i++ + 2;
这行在 C++17 之前。
属于典型的 UB。
到 C++17。
它变成了有定义。
i++ 先产生旧值 0。
把 i 改成 1。
再算 0 + 2 得到 2。
最后赋值。
i 变成 2。
第二个更阴。
int i = 0;
int a[3] = {0, 0, 0};
a[i] = i++;
很多人脑补的是。
把 0 写进 a[0]。
然后 i 变成 1。
但在 C++17 的收紧规则里。
赋值表达式会保证。
右边的 i++ 先算。
算完了。
再去算左边的 a[i]。
所以真实发生的更像:
// 先算 RHS:i++ 产生 0,把 i 变成 1
// 再算 LHS:a[i] 此时用的是 i == 1
// 所以写的是 a[1] = 0
这行没有 UB。
但它会坑人。
它把“顺序”藏在标准条文里。
不藏在代码里。
我当年打的补丁:硬背规则
那几年我确实干过这种事。
看到一行表达式。
先在脑子里背诵。
&& 左到右。
?: 有序。
逗号运算符有序。
赋值在 C++17 之后更有序。
然后我才敢写。
后来我发现。
这是一条死路。
规则会变。
同事不会跟着你一起背。
review 的时候。
大家只会皱眉。
我真正需要的性质:顺序必须“长在代码里”
所以我最后回到了最朴素的写法。
拆开。
让每一步的副作用。
在语句边界结算。
int i = 0;
int x = i;
int y = i++;
foo(x, y);
你很难把它写成 UB。
你也很难误读。
你不需要知道编译器到底先算哪个参数。
因为你已经把顺序写死了。
横向对比:为什么很多语言敢保证“从左到右”
你可能会问。
那为什么别的语言。
不会这么闹心?
很多托管语言。
从一开始就选择。
牺牲一部分自由度。
换取更稳定的语义。
比如参数求值。
他们就干脆规定成从左到右。
这样你写出“花活”。
最多是难读。
不会一脚踩进 UB。
C/C++ 当年选择了另一条路。
给编译器更多优化空间。
代价就是。
程序员要对“顺序”更敬畏。
关键结论
C++17 确实让一部分老坑。
从 UB 变成了“有定义但不确定”。
但它没有把“写得像谜语的表达式”。
变成好代码。
小结:把顺序写出来,比背标准可靠
当年这些坑之所以恐怖。
是因为它们长得像“没问题”。
你以为你在写业务。
结果你在跟求值顺序赌。
最稳的工程办法还是朴素。
拆开。
让顺序变成代码。