复杂声明与 typedef:右左法则拆解、用别名拯救可读性
引言:C 的声明能把人看花眼
到这一章为止,我们已经在前面见过不少「初看吓人」的声明了——第 8 章的 int* a[5](指针数组)和 int (*a)[5](数组指针),第 9 章的 int (*fp)(int)(函数指针)。单个看还扛得住,可一旦它们组合起来、或者你打开某个系统头文件撞见 int (*arr[3])(int) 这种,脑子就容易卡壳。这一章只做两件事:一是教你一个系统地读懂任何 C 声明的方法——右左法则(也叫螺旋法则),二是教你用 typedef 给那些拗口的类型起个别名、从此不用再受这份罪。前几章其实一直在埋伏笔,这里统一收口。
优先级是钥匙:[] 和 () 高于 *
读 C 声明之前,先记住一条铁律(§6.7.6 声明符语法):在声明里,后缀的 [](数组)和 ()(函数)优先级高于前缀的 *(指针),而圆括号 (...) 能改变一切。这不是新规则——它就是表达式里那套优先级在声明里的延续(C 之所以这么设计,正是为了让「声明长得像使用」:*p[i] 怎么算优先级,int* p[5] 就怎么算)。
所以当你看到 int* a[5],a 先和优先级高的 [5] 结合(a 是数组),再回到左边的 int*(每个元素是指针)——它是指针数组。看到 int (*a)[5],括号强行让 a 先和 * 结合(a 是指针),再去右边看它指向什么([5],是个 5 元素的数组)——它是数组指针。差别全在那个括号,含义完全相反。
右左法则:从变量名出发,螺旋向外
「右左法则」就是把上面的优先级规则固化成一套机械步骤。Peter van der Linden 在《Expert C Programming》里把它总结成几条(他叫「The Precedence Rule」):先找到变量名,然后按优先级螺旋向外读——括号里的最先生效、然后是右边的后缀 []/()、最后是左边的前缀 *,读完一层回到外层继续。听起来抽象,拿四个经典声明逐个拆就懂了。
第一个,int* a[5]。从名字 a 出发,右边是 [5](优先级高于左边的 *),所以先说「a 是 5 个元素的数组」;然后回到左边看元素类型——int*,所以「每个元素是指向 int 的指针」。合起来:a 是「5 个 int* 的数组」(指针数组),第 8 章见过。
第二个,int (*a)[5]。从 a 出发,它被括号包着、括号优先级最高,所以先和 * 结合——「a 是个指针」;走出括号,右边是 [5],所以「指向的是 5 个元素的数组」;最后看左边 int——「数组的元素是 int」。合起来:a 是「指向『5 个 int 的数组』的指针」(数组指针),第 8 章也见过。一字之差(那个括号),含义完全相反。
第三个,int (*fp)(int)。从 fp 出发,括号先让它和 * 结合——「fp 是个指针」;走出括号,右边是 (int)——「指向的是一个接受 int、返回...的函数」;最后看最左边的 int——「函数返回 int」。合起来:fp 是「指向 int(int) 函数的指针」(函数指针),第 9 章主角。
第四个,这是个坑,int *f(void)(注意 * 没被括号包起来)。从 f 出发,右边是 (void)(优先级高于 *)——「f 是个函数」;走出右边,回到左边——「返回 int*」。合起来:f 是「返回 int* 的函数」,不是「指向函数的指针」。* 贴在谁那里、有没有括号,决定一切——int *f(void) 和 int (*f)(void) 长得像,前者是返回指针的函数、后者是函数指针,风马牛不相及。
现在我们把第一个和第二个声明真跑一下,用 sizeof 实锤它们是两种截然不同的类型——这比任何口头解释都直白:
#include <stdio.h>
int main(void) {
/* 指针数组:5 个 int*,每个元素是一个指针 */
int* a[5];
printf("int* a[5] 指针数组\n");
printf(" sizeof(a) = %zu (5 个指针,5*8=40)\n", sizeof(a));
printf(" sizeof(a[0]) = %zu (单个元素是一个 int*,8)\n\n", sizeof(a[0]));
/* 数组指针:一个指针,指向「5 个 int 的数组」 */
int m[5] = {10, 20, 30, 40, 50};
int (*p)[5] = &m;
printf("int (*p)[5] 数组指针\n");
printf(" sizeof(p) = %zu (一个指针,8)\n", sizeof(p));
printf(" sizeof(*p) = %zu (它指向的是 5 个 int 的数组,20)\n", sizeof(*p));
printf(" (*p)[2] = %d (顺着指针取数组第 2 个)\n", (*p)[2]);
return 0;
}$ gcc -std=c11 -Wall read_decl.c -o rd && ./rd
int* a[5] 指针数组
sizeof(a) = 40 (5 个指针,5*8=40)
sizeof(a[0]) = 8 (单个元素是一个 int*,8)
int (*p)[5] 数组指针
sizeof(p) = 8 (一个指针,8)
sizeof(*p) = 20 (它指向的是 5 个 int 的数组,20)
(*p)[2] = 30 (顺着指针取数组第 2 个)int* a[5] 的 sizeof(a) 是 40——它是一整个数组,装着 5 个指针、每个 8 字节,5×8=40。int (*p)[5] 的 sizeof(p) 却只有 8——它就一个指针,只不过它指向的类型「恰巧是一整个 5 元素的 int 数组」,所以 sizeof(*p) 是 20(5×4)。一个占地 40 字节、是个数组;一个占地 8 字节、是个指针——尽管两者写法只差一对括号,内存里完全是两码事。(*p)[2] 还顺手验证了它能顺着指针去取数组里的元素(得 30),这正是第 8 章用它当「行指针」遍历二维数组的原理。
再来个组合型声明验证,把「数组指针」和「函数指针」叠一起:函数指针数组 int (*arr[3])(int)。先用右左法则读它:从 arr 出发,右边 [3](优先级高于 *)——「arr 是 3 个元素的数组」;回到左边,括号包着 *arr——「每个元素是个指针」;走出括号,右边 (int)——「指向的是接受 int 的函数」;最左边 int——「返回 int」。合起来:arr 是「3 个『指向 int(int) 函数的指针』的数组」(函数指针数组)。真跑给你看:
展开代码 (共 22 行)收起代码
#include <stdio.h>
int dbl(int x) {
return x * 2;
}
int neg(int x) {
return -x;
}
int sq(int x) {
return x * x;
}
int main(void) {
/* int (*arr[3])(int):3 个「指向 int(int) 函数的指针」的数组 */
int (*arr[3])(int) = {dbl, neg, sq};
printf("int (*arr[3])(int) 函数指针数组\n");
printf(" sizeof(arr) = %zu (3 个函数指针,3*8=24)\n", sizeof(arr));
printf(" arr[0](5) = %d (dbl)\n", arr[0](5));
printf(" arr[1](5) = %d (neg)\n", arr[1](5));
printf(" arr[2](5) = %d (sq)\n", arr[2](5));
return 0;
}$ gcc -std=c11 -Wall spiral.c -o sp && ./sp
int (*arr[3])(int) 函数指针数组
sizeof(arr) = 24 (3 个函数指针,3*8=24)
arr[0](5) = 10 (dbl)
arr[1](5) = -5 (neg)
arr[2](5) = 25 (sq)sizeof(arr) 是 24(3 个指针 × 8 字节),arr[0](5)/arr[1](5)/arr[2](5) 分别得 10/-5/25,正好是 dbl(5)/neg(5)/sq(5) 的结果。这个声明第一次看大概率会懵——但用右左法则一步步拆,它就是「数组,元素是指针,指针指向函数」。再复杂的声明,套路都一样。
typedef 拯救声明
上面那个 int (*arr[3])(int) = {dbl, neg, sq} 已经够难读了,你要是在代码里反复写它,自己和后来人都会疯。typedef 就是干这个的——给一个类型起个短名字。先看对照:
/* 不用 typedef:每次都得把这一坨函数指针声明抄一遍 */
int (*ops[])(int, int) = {add, sub};
/* 用 typedef:把「指向 int(int,int) 函数的指针」收成一个别名 BinOp */
typedef int (*BinOp)(int, int);
BinOp ops[] = {add, sub};第二行的 BinOp ops[] = {add, sub}; 一眼就能看出是「一组二元运算」,读和改都比上面那坨清爽太多。真跑给你看 typedef 后怎么用:
#include <stdio.h>
int add(int a, int b) {
return a + b;
}
int sub(int a, int b) {
return a - b;
}
/* typedef 给「指向 int(int,int) 函数的指针」起别名 BinOp */
typedef int (*BinOp)(int, int);
int main(void) {
/* 用了 typedef:干净,一眼看出 ops 是「一组二元运算」 */
BinOp ops[] = {add, sub};
printf("ops[0](3, 4) = %d (add)\n", ops[0](3, 4));
printf("ops[1](3, 4) = %d (sub)\n", ops[1](3, 4));
return 0;
}$ gcc -std=c11 -Wall typedef_simplify.c -o ts && ./ts
ops[0](3, 4) = 7 (add)
ops[1](3, 4) = -1 (sub)ops[0](3,4) 得 7、ops[1](3,4) 得 -1,正是 add(3,4) 和 sub(3,4)。重点不在于它能跑(不用 typedef 也能跑),而在于读代码的人一眼就懂——BinOp 这个名字直接告诉你「这是个二元运算符」。这就是 typedef 最大的价值:给那些拗口的复合类型起一个有语义的名字,把「怎么声明」的复杂性藏起来,只暴露「它是什么」。回调表、状态机分发表、运算符分派——只要函数指针一出场,基本都会配一个 typedef。
typedef 是什么:给类型起别名
typedef 长得像个变量声明,但前面加了 typedef 这个关键字(语法上它是个「存储类说明符」,但 ISO 自己都说这纯粹是为了语法方便,§6.7.9)。理解它的窍门是:先假装你在声明一个变量、把那个变量名写成你想要的别名,然后在最前面加 typedef——这个别名就成了那种类型的同义词。比如 int (*BinOp)(int, int) 如果是变量声明,BinOp 会是个「指向 int(int,int) 函数的指针」的变量;加个 typedef 在前面,BinOp 就成了这种指针类型的名字(而不是变量)。
关键要分清:typedef 不创造新类型,只是给已有类型起个别名(§6.7.9 说它「不引入新类型,只引入同义词」)。所以 BinOp fp = add; 和 int (*fp)(int,int) = add; 在编译器眼里是同一个东西——BinOp 和 int (*)(int,int) 完全互通,能互相赋值、传参、比较。它纯粹是个写给读代码的人的可读性工具,编译器会老老实实理解它、有作用域(在哪个 {} 里定义就在哪用,出了作用域失效),调试时类型名也保留。
typedef vs #define:类型别名不是文本替换
typedef 看起来像是在做 #define 那种「文本替换」,但两者有本质区别,这一点呼应第 4 章「const vs #define」——typedef 是编译期类型层的,#define 是预处理文本替换。最经典的坑、也是最常考的对照是这个:
#include <stdio.h>
#define IPTR_MACRO int*
typedef int* IPTR_TYPEDEF;
int main(void) {
/* #define 是文本替换:int* chalk, cheese; -> chalk 是指针,cheese 是 int! */
IPTR_MACRO chalk, cheese;
chalk = NULL;
printf("sizeof(chalk) = %zu (int*,指针)\n", sizeof(chalk));
printf("sizeof(cheese) = %zu (int!宏没罩住 cheese)\n", sizeof(cheese));
/* typedef 对每个声明符都生效:两个都是指针 */
IPTR_TYPEDEF x, y;
printf("sizeof(x) = %zu, sizeof(y) = %zu (typedef 两个都是指针)\n", sizeof(x), sizeof(y));
return 0;
}$ gcc -std=c11 -Wall typedef_vs_define.c -o td && ./td
sizeof(chalk) = 8 (int*,指针)
sizeof(cheese) = 4 (int!宏没罩住 cheese)
sizeof(x) = 8, sizeof(y) = 8 (typedef 两个都是指针)看仔细了:IPTR_MACRO chalk, cheese; 经过预处理展开成 int* chalk, cheese;——* 只跟着 chalk,cheese 落在了 * 后面、成了纯 int,所以 sizeof(chalk) 是 8(指针)、sizeof(cheese) 是 4(int)。这就是文本替换的盲区,预处理只管贴文本、不管「这个声明有几个声明符」。而 typedef 把「指向 int 的指针」封装成一个完整的类型,IPTR_TYPEDEF x, y; 里 x 和 y 都是完整的指针(都是 8 字节)。除此之外,typedef 有作用域(块级/文件级),#define 是全局文本(从定义点到 #undef 都生效);typedef 被编译器理解(能参与类型检查、报错信息里出现),#define 在编译器看到代码之前就被替换掉了。所以凡是「给类型起名」的场景,一律用 typedef,别用 #define 冒充。
哪怕再复杂也能拆:右左法则挑战地狱声明
到这里我们可以挑战那个著名的地狱级声明了——标准库 signal 函数的原型(§7.14):
void (*signal(int sig, void (*func)(int)))(int);第一眼绝对懵。别慌,用右左法则拆。先抓最外层的名字 signal,它不在任何括号里被 * 包,右边直接是 (int sig, void (*func)(int))——这是函数参数列表,所以先说「signal 是一个函数,参数有两个」。参数里嵌的 void (*func)(int) 我们刚才拆过——它是个函数指针(func 是「指向 void(int) 函数的指针」)。关键来了:signal 这个函数本身返回什么?把参数列表 (int sig, void (*func)(int)) 整个划掉、看剩下什么:
void (*signal(...) )(int);剩下 void (*signal)(int) 这种结构——也就是「signal 返回的是一个指针,这个指针指向一个 void(int) 的函数」。把整个合起来:signal 是一个函数,它接受一个 int 和一个「指向 void(int) 函数的指针」,返回一个「指向 void(int) 函数的指针」。换句话说,signal 拿一个信号编号和一个处理函数指针进去,换回来一个新的处理函数指针——这就是 Unix 信号注册函数的全部签名。这种声明在工程里几乎一定要用 typedef 改写,标准库那本书(Eric S. Raymond 等都提过)给的标准改法是:
typedef void (*sighandler_t)(int); /* 「指向 void(int) 函数的指针」起名 sighandler_t */
sighandler_t signal(int sig, sighandler_t func); /* 立刻清爽 */改写后,signal 的签名一眼就能看懂。所以右左法则解决的是「读懂别人(或老代码)写的拗口声明」的问题,而 typedef 解决的是「自己别再写出拗口声明」的问题——两者配合,就是处理 C 复杂声明的完整姿势。这一章我们不深讲 signal 的语义(那是阶段 5 系统编程的话题),只用它演示一件事:再吓人的声明,右左法则一层层拆,也能拆明白。
小结
C 声明之所以能堆出 int (*arr[3])(int) 这种吓人的样子,根子在优先级(§6.7.6):后缀的 [](数组)和 ()(函数)优先级高于前缀的 *(指针),圆括号能改写一切。右左法则(Peter van der Linden 的「Precedence Rule」)把读声明固化成机械步骤——从变量名出发,先吃括号、再吃右边后缀、最后吃左边前缀,螺旋向外。四个经典声明逐个拆开看:int* a[5] 是「5 个 int* 的数组」(指针数组)、int (*a)[5] 是「指向『5 个 int 的数组』的指针」(数组指针)、int (*fp)(int) 是「指向 int(int) 函数的指针」(函数指针)、而 int *f(void) 是「返回 int* 的函数」——* 贴谁、有没有括号,决定一切,int *f(void) 和 int (*f)(void) 长得像却风马牛不相及。真跑 sizeof 实锤:int* a[5] 占 40 字节(5 个指针)、int (*p)[5] 只占 8 字节(一个指针),一对括号之差是「数组」和「指针」的鸿沟;int (*arr[3])(int) 真跑 sizeof 是 24、arr[i](5) 分别得 10/-5/25,证明右左法则拆出来的「函数指针数组」确实成立。typedef 不创造新类型(§6.7.9,只是已有类型的同义词),但能把拗口的函数指针声明收成有语义的别名:typedef int (*BinOp)(int,int); 之后 BinOp ops[] = {add, sub} 比裸写 int (*ops[])(int,int) 干净太多,真跑 ops[0](3,4)=7、ops[1](3,4)=-1。typedef 和 #define 都像「起名字」,但本质不同——真跑 #define int_ptr int* 的经典坑:int_ptr chalk, cheese 经预处理展开成 int* chalk, cheese,chalk 是指针、cheese 落成纯 int(sizeof 8 vs 4),而 typedef 把指针封装成完整类型、对每个声明符都生效(都是 8);外加 typedef 有作用域、被编译器理解,所以「给类型起名」一律用 typedef。最后哪怕撞见地狱声明 void (*signal(int, void (*)(int)))(int),右左法则一层层拆也能拆明白(signal 是「接受信号编号和处理函数、返回新处理函数」的函数),而工程里这种声明一律该用 typedef 改写。右左法则解决「读懂」、typedef 解决「别再写出」——这就是 C 复杂声明的完整应对姿势。
参考资源
- ISO/IEC 9899:2011 §6.7(声明)、§6.7.6(声明符:
*/[]/()的语法与优先级)、§6.7.6.3(函数声明符)、§7.14(signal函数原型) - Peter van der Linden《Expert C Programming: Deep C Secrets》第 3 章「Unscrambling Declarations」(右左法则 / Precedence Rule、声明图解法、
signal原型拆解、typedefvs#define的chalk/cheese经典对照) - K. N. King《C Programming: A Modern Approach》第 18 章(声明符语法、
typedef的用法与限制) - Brian W. Kernighan & Dennis M. Ritchie《The C Programming Language》第 5.12 节(复杂声明、
cdecl程序) - Andrew Koenig《C Traps and Pitfalls》第 3 章(声明带来的陷阱)
- 阶段2·第8章:多级指针与指针数组(指针数组
int*[]vs 数组指针int(*)[5]、右左法则引子)、第9章:函数指针(int (*fp)(int)) - 阶段2·第4章:const 限定(
constvs#define对照——本章typedef也是编译期类型层而非文本替换)、阶段0·第4章:预处理深入(#define文本替换) - 第 2 章:整型家族与 sizeof(
sizeof验证类型大小、size_t用%zu打印)