先使用后自增,先自增后使用?偷懒的浆糊顺口溜!
众多教材害人不浅的地方在于,它用一句有点偷懒的顺口溜:
「先使用后自增,先自增后使用」
把底层的寄存器操作、内存回写,未定义行为与序列点等等,当成浆糊了。
// 此处默认读者看过 左、右值 的概念,若没有,请看《C和指针》。
// 当然其他书籍也可以。
虽然直接在内存上做数学运算是可以的, 但一般都是先把数据从内存拿到 CPU 的寄存器里,算完加法,再送回去。
具体的过程是这样的:
- 前置自增 ++i
CPU 把 i 对应的先前值(旧值),
存进「一个」临时的、匿名的寄存器(它叫 eax,学习汇编的读者会很熟悉它);
CPU 在「eax」寄存器里 +1之后,
把加完后的新值,写回 i。
以上过程全部结束后,向外界抛出(返回)i 中的值。
所以说,内存立刻改动。
外层使用者(即,用到 i 的外层表达式)所得到的,
是更新后的 i「所对应的右值」(或者:新值、算完的值)。
// i 只是某块内存的名称,i 本身是个左值;
// 但它是个变量,以上的过程实际是对它进行解引用,以更改它的右值。
- 后置自增 i++
CPU 依旧把 i 对应的先前值(旧值),
存进临时寄存器 eax,
此时,在 eax 当中的这个旧值,直接抛出给外界(也就是说,这时它已经直接返回了)!
然后,CPU 再把 i 当中的值(此时还是先前的值,即旧值)+1,写回内存。
所以说,这套过程所吐出来的右值,是寄存器「eax」当中的! // 很怪异,对吧?笔者被顺口溜折磨许久qwq
是个彻头彻尾的「旧的东西」!
虽然内存也被改动,
但是外层使用者(即,用到 i 的外层表达式)所得到的,
是和更新后的 i 一毛钱关系都没有的旧的值。
这就是顺口溜害人不浅的地方:
初学者一听先使用,就觉得:
那我用了它之后,它在内存里应该还是旧的呀
结果,调试的时候一看:
不是哥们?内存里的值怎么加 1 了?!
这时,他的认知大厦便会直接当场崩塌,只能怀疑自己的智商。
现在统一回答以上三个问题:
-
给谁使用?
- 给用到它的外层表达式(比如:赋值、算术加法、函数传参等等)使用。
-
如何使用?
- 前置自增,外层使用者(即,用到 i 的外层表达式)所得到的是「更新后的右值」;
- 后置自增,外层使用者(即,用到 i 的外层表达式)所得到的是「暂扣的、旧的右值分身」。
-
右值改不改动?
- 只要你用了包含它的外层表达式,i 的右值都会被改。
以上 1、2 两点,描述的是 C 语言在有序列点(如分号、逗号运算符、函数调用)隔离下的标准行为。 比如下面这个例子:
#include <stdio.h>
int main(){
int j = 1;
int back = j++;
int front = ++j;
// 这里,两个自增操作是分别放在两条独立的声明语句中的;
// 每条语句末尾都有一个序列点(这里为分号)。
// 两个自增被序列点明确隔开,顺序确定。
printf("%d %d\n", back, front);
printf("Not directly call printf func, back = j++, front = ++j.\n");
}
Arch_Linux, Linux 7.0.14-arch1-1,GCC 16 的运行结果:
1 3
Not directly call printf func, back = j++, front = ++j.
Arch_Linux, Linux 7.0.14-arch1-1,Clang 23 的运行结果:
1 3
Not directly call printf func, back = j++, front = ++j.
在各自独立的声明语句中,这套逻辑永远成立,Clang 和 GCC 也都遵守了。
// 虽然笔者的 GCC 偷了懒,没先把旧值抓进 eax 再算(是直接动内存算的),但「内存立刻改动,最后抛出新值」这个大前提,是正确的。
但是,再看一个例子:
#include <stdio.h>
int main(){
int i = 1;
printf("%d %d\n", i++, ++i);
// 注意,各参数之间没有序列点隔开!这时候,printf 才不关心我们到底要先执行哪一条(i++?还是 ++i?)语句!
// 如果我们的编译指令中有写 -Wall,那么,这时编译器会报出 未定义 的警告!
printf("Directly call printf func, i++ and ++i are inside.\n");
}
Arch_Linux, Linux 7.0.14-arch1-1,GCC 16 的运行结果:
2 3
Directly call printf func, i++ and ++i are inside.
Arch_Linux, Linux 7.0.14-arch1-1,Clang 23 的运行结果:
1 3
Directly call printf func, i++ and ++i are inside.
怎么不一样呢?
问,就是这时候,编译器的心情。
问,就是这时候,编译器编译出的具体过程不一样。
如果对此十分感兴趣,读者可以自行使用 gcc -S 指令查看详细的汇编指令(如果读者学习过汇编),这里我们只简述一行。
GCC 16 :add DWORD PTR [rbp-4], 1
GCC 上来就砍向内存,先把 i 从 1 硬改成 2,不保留任何副本。主打一个先斩后奏。
Clang 23:mov eax, DWORD PTR [rbp-4]
Clang 极其谨慎,它先小心翼翼地把旧值 1 拷到 eax 里存着,然后再改内存。主打一个拍照取证,铁证如山。
就因为这一条汇编指令不同,后面的分配就全盘洗牌了。
Clang 提前拍下了 i=1 的旧照片,所以左边的 i++ 铁定输出 1,右边的 ++i 老老实实输出改完后的 3。
但是 GCC 就不一样了。
同样一份源码,同样在一台电脑上跑,
GCC 16 跑出 2 3,Clang 23 跑出 1 3,
这就是为什么我们说,面对这种未定义代码,那些还在试图背诵「先使用后自增」顺口溜的人,是多么地可怜!
如果这确实是考试题目,并且让你人肉编译:
如果源码的答案是用 GCC 16 跑的,写 1 3 的人,直接全军覆没;
如果源码的答案是用 Clang 23 跑的,写 2 3 的人痛哭流涕。
如果果真出了这种题目,出题人应该被讨伐一下。
这就是编译器的心情。在同一个序列点里,不要试图和编译器的寄存器分配顺序玩心眼!
未指定是指:标准没规定,但不管选哪种方式,结果都是「可」预测的。
未定义是指:标准彻底撒手了。因为不管选哪种方式,结果都是「不可」预测的。
因此,
绝! 对! 不! 要! 写! printf("%d %d\n", i++, ++i); ,这种自增参数之间没有序列点隔开的语句!!
又,for 循环中常写 i++,实际上应当是 ++i;
但编译器已经十分「聪明」,不管你写的是哪一种,在 for(或者 while )循环的作用域当中,它都会自动优化成同种底层操作。
但仍然推荐在循环中写 ++i,因为:
我们可能无法保证自己一定不接触老的设备,或者,使用「没有半点优化能力」的编译器。