C语言奇怪的冷知识
最近学到几个 C 语言的冷知识,全是我平时踩坑或者见到的”怪现象”。挑几个有意思的分享一下,每个都能让你重新认识这门熟悉又陌生的语言。
一、数组越界导致的死循环
1 | int i, array[3]; |
这段代码是个经典炸弹:array 只有 3 个元素,循环却写到下标 3。诡异的是它可能不会崩溃,而是死循环——因为变量 i 和 array 在栈上相邻,i 恰好落在 array[3] 的位置上,每次 array[3] = 0 都会把循环变量 i 清零,i 永远到不了 4。
注意这个现象和变量定义顺序、编译器优化级别都有关系:开了 O2 之后编译器会重新排列栈空间,死循环可能就消失了。所以这类代码属于”未定义行为”,怎么表现都看编译器心情。
二、有符号数和无符号数的比较
1 | int num1 = -1; |
直觉上 -1 <= 1 毫无疑问,但这段代码会输出 num1 is bigger。C 语言规定:有符号和无符号整数比较时,有符号数会被隐式转换成无符号数。-1 转成无符号就是 4294967295(0xFFFFFFFF),当然比 1 大。
更隐蔽的是 sizeof 的返回值本身就是 size_t(无符号类型),所以 if (a - 1 < sizeof(int)) 这种写法在 a 为 0 时也会翻车——这是个埋在代码里的隐藏炸弹。
三、restrict 关键字
1 | int fun(int *restrict x, int *restrict y) |
restrict 是 C99 引入的类型限定符,用来向编译器承诺:这个指针是访问这块内存的唯一途径,没有别名。上面这个函数如果传两个相同的指针(fun(&num, &num)),行为就是未定义的。
它存在的意义是帮编译器优化:保证了没有别名之后,编译器可以放心地把读写重排序、缓存到寄存器,在高性能计算场景很常见。
四、0[array] 这种离谱的下标写法
1 | int f(int a) |
这一坨能编译通过,而且逻辑等价于 return a;。拆开看:
a[i]在 C 里的定义是*(a + i),所以加法交换律告诉我们i[a]和a[i]完全等价,0[array]就是array[0](int [2][1]){3, a}是一个复合字面量,造出一个二维数组typeof(f)直接把函数类型取出来声明函数指针&**********(&f)对函数指针解引用十次——函数指针解引用会退化回函数指针,所以加多少个*都无所谓(*&printf)同理,取 printf 的地址再解引用,还是 printf
五、大小写转换的极简写法
1 | int main(a) |
大小写字母的 ASCII 码只差第 5 位(0x20),所以 a ^= ' ' 一行就能完成大小写互换。配合老式的 K&R 函数定义(参数不写类型,默认 int),四行代码搞定转换——当然 gets 这种函数现在是要进博物馆的危险品了。
六、数组传参后的 sizeof 陷阱
1 | int n = 5; |
数组作为参数传进函数时会退化成指针,所以 func1 里的 sizeof(array) 得到的是指针大小(8),除以元素大小永远是 2——这就是”数组传参必须同时传长度”的原因。
func2 展示了一个冷门技巧:用 VLA 语法的形参 int (*array)[n] 声明”指向 n 个 int 的数组的指针”,这样 sizeof(*array) 就能正确得到整个数组的大小了。
七、函数参数的求值顺序
1 | int func1() |
这段代码的输出可能是 1 2,也可能是 2 1——C 标准不保证函数参数的求值顺序。如果你以为一定是先算 func1 再算 func2,那遇到依赖调用顺序的代码就会莫名出错。所以永远不要在参数里写有副作用的表达式。
写在最后
这些冷知识平时写业务可能一辈子碰不到,但理解它们基本等于把 C 语言的内存模型、类型系统、求值规则重新学了一遍。笔记文件里还有几个,之后挖到有意思的继续更新。
