又是先上完整代码。下面这个程序能编译、能运行,但它看起来像是谁打字的时候手滑了:

抽象-尊嘟假嘟.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

char str[][4] = <%"o.o","O.O"%>;

int fun(int *p)
{
printf("%p\n", p);
printf("%p\n", p+1);
}

int main()
<%
int a;
fun(&a);
srand((time(0)));
printf("%s\n", (rand() % 2)[str]);

int b[10] = {0,1,2,3,};
printf("%d", *(&b[0]+1));
return 0;
%>

<%"o.o","O.O"%> 是什么?花括号被谁吃了?(rand() % 2)[str] 这种下标写法是在下什么棋?这个程序每次运行,都会在打印完两个内存地址之后,随机输出一张脸:o.o 或者 O.O——尊嘟,还是假嘟,程序自己也不告诉你是哪种,全看运气。

真相一:<% 就是 {——两字符替代记号

那一堆 <%%>digraphs(两字符替代记号),C95 标准正式引入。它们的用途非常正经:有些国家的老键盘/字符集打不出 #{}[] 这些符号,所以标准规定了一整套替身:

替代记号 等价符号 替代记号 等价符号
<: [ %: #
:> ] %:%: ##
<% { :: (C23 才有)
%> }

所以 <%"o.o","O.O"%> 就是 {"o.o","O.O"}int main() <% ... %> 就是 int main() { ... }编译器完全一视同仁——这不是什么方言外挂,是 ISO 标准的正式成员。GCC 默认就认,你不用加任何编译选项。

顺带一提它的大哥 trigraphs(三字符组,??< 也是 {),因为太反人类,C++17 里已经被移除;digraphs 活了下来,成了标准里少有的”合法怪话”。

真相二:(rand() % 2)[str]——下标可以写在左边

str 是一个二维数组,两行内容分别是 "o.o""O.O"(各占 4 字节,含结尾的 \0)。这行:

1
printf("%s\n", (rand() % 2)[str]);

(rand() % 2)[str] 等价于 *(rand() % 2 + str)——随机取第 0 行或第 1 行的地址,交给 %s 打印整行。是的,在 C 语言里,下标写在方括号里面和写在方括号外面是一回事arr[1]1[arr] 完全等价,因为数组下标本来就被定义成”指针加偏移再解引用”。这个写法放在这里是炫技,但它炫的技是货真价实的语言规则。

于是每次运行,程序随机甩出一张脸——o.o 是”假嘟”,O.O 是”尊嘟”。我连跑四次,它变了两张脸,如下:

1
2
3
4
5
6
7
8
9
000000492D9FFBEC
000000492D9FFBF0
O.O
1
---
00000010FFBFF8AC
00000010FFBFF8B0
o.o
1

真相三:剩下两行也在偷偷讲课

fun(&a) 打印了两个地址——pp+1。仔细看输出:两个地址恰好差 4。这就是指针算术的定义:p+1 加的不是 1,而是 1 个 sizeof(int)。以及最后那行输出 1

1
2
int b[10] = {0,1,2,3,};
printf("%d", *(&b[0]+1));

&b[0] 是第一个元素的地址,+1 跨过一个 int,解引用正好是 b[1]——等于 1。而且注意初始化列表 {0,1,2,3,} 结尾多了一个逗号,这是 C 标准明确允许的(让我想起隔壁用分号当分隔符的那些狠活,以后有机会再讲)。

一个 22 行的小程序,塞下了 digraphs、随机数、二维数组行地址、下标交换律、指针算术、尾逗号六个知识点——这就是抽象代码的美德:它让人为了搞明白一个笑话,读完了一章《C 陷阱与缺陷》

系列其他文章:下划线完形填空会自杀的代码。系列缘起见抽象代码宣言