最近学到几个 C 语言的冷知识,全是我平时踩坑或者见到的”怪现象”。挑几个有意思的分享一下,每个都能让你重新认识这门熟悉又陌生的语言。

一、数组越界导致的死循环

1
2
3
4
5
6
7
8
9
10
int i, array[3];

printf("%p\n", &i);
printf("%p\n\n", &array[3]);

for (i = 0; i <= 3; i++)
{
array[i] = 0;
}
printf("%d\n", i);

这段代码是个经典炸弹:array 只有 3 个元素,循环却写到下标 3。诡异的是它可能不会崩溃,而是死循环——因为变量 iarray 在栈上相邻,i 恰好落在 array[3] 的位置上,每次 array[3] = 0 都会把循环变量 i 清零,i 永远到不了 4。

注意这个现象和变量定义顺序、编译器优化级别都有关系:开了 O2 之后编译器会重新排列栈空间,死循环可能就消失了。所以这类代码属于”未定义行为”,怎么表现都看编译器心情。

二、有符号数和无符号数的比较

1
2
3
4
5
6
7
int num1 = -1;
unsigned int num2 = 1;

if (num1 <= num2)
printf("num2 is bigger\n");
else
printf("num1 is bigger\n");

直觉上 -1 <= 1 毫无疑问,但这段代码会输出 num1 is bigger。C 语言规定:有符号和无符号整数比较时,有符号数会被隐式转换成无符号数。-1 转成无符号就是 4294967295(0xFFFFFFFF),当然比 1 大。

更隐蔽的是 sizeof 的返回值本身就是 size_t(无符号类型),所以 if (a - 1 < sizeof(int)) 这种写法在 a 为 0 时也会翻车——这是个埋在代码里的隐藏炸弹。

三、restrict 关键字

1
2
3
4
5
6
int fun(int *restrict x, int *restrict y)
{
*x = 1;
*y = 2;
return *x;
}

restrict 是 C99 引入的类型限定符,用来向编译器承诺:这个指针是访问这块内存的唯一途径,没有别名。上面这个函数如果传两个相同的指针(fun(&num, &num)),行为就是未定义的。

它存在的意义是帮编译器优化:保证了没有别名之后,编译器可以放心地把读写重排序、缓存到寄存器,在高性能计算场景很常见。

四、0[array] 这种离谱的下标写法

1
2
3
4
5
6
7
8
9
10
11
int f(int a)
{
return 0[(1[(int [2][1]){3, a}])];
}

int main()
{
typeof(f) *p = &(**********(&f));
(*&printf)("%d\n", p(10));
return 0;
}

这一坨能编译通过,而且逻辑等价于 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
2
3
4
5
6
7
int main(a)
{
gets(&a);
a ^= ' ';
puts(&a);
return 0;
}

大小写字母的 ASCII 码只差第 5 位(0x20),所以 a ^= ' ' 一行就能完成大小写互换。配合老式的 K&R 函数定义(参数不写类型,默认 int),四行代码搞定转换——当然 gets 这种函数现在是要进博物馆的危险品了。

六、数组传参后的 sizeof 陷阱

1
2
3
4
5
6
7
8
9
10
11
12
13
int n = 5;

void func1(int *array)
{
int num = sizeof(array) / sizeof(array[0]);
printf("%d\n", num);
}

void func2(int (*array)[n])
{
int num = sizeof(*array) / sizeof(**array);
printf("%d\n", num);
}

数组作为参数传进函数时会退化成指针,所以 func1 里的 sizeof(array) 得到的是指针大小(8),除以元素大小永远是 2——这就是”数组传参必须同时传长度”的原因。

func2 展示了一个冷门技巧:用 VLA 语法的形参 int (*array)[n] 声明”指向 n 个 int 的数组的指针”,这样 sizeof(*array) 就能正确得到整个数组的大小了。

七、函数参数的求值顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int func1()
{
printf("1\n");
return 1;
}

int func2()
{
printf("2\n");
return 2;
}

int main()
{
printf("%d %d\n", func1(), func2());
return 0;
}

这段代码的输出可能是 1 2,也可能是 2 1——C 标准不保证函数参数的求值顺序。如果你以为一定是先算 func1 再算 func2,那遇到依赖调用顺序的代码就会莫名出错。所以永远不要在参数里写有副作用的表达式。

写在最后

这些冷知识平时写业务可能一辈子碰不到,但理解它们基本等于把 C 语言的内存模型、类型系统、求值规则重新学了一遍。笔记文件里还有几个,之后挖到有意思的继续更新。