C 编译器制作入门

C 的类型语法

C 的类型语法

C 的类型声明语法以难读著称,尤其是函数指针、数组指针这类声明,经常让人感到绕晕。问题并不在于类型本身有多神秘,而在于 C 把“类型”和“变量名的使用形式”混在一起书写。

本章的目标不是让你死记硬背规则,而是给你一套稳定的读法:先把类型想象成一张图,再把这张图翻译成 C 的声明形式。

读完本章后,你应该能拆解 void (*x)(int)void (*signal(int, void (*)(int)))(int) 这类声明,并能判断一个声明到底表示“指针”“数组”“函数”,还是这些类型的组合。

用图理解类型

C 能表达的类型其实并不复杂,主要就是基本类型、指针、数组和函数。复杂声明难读的原因,是这些构造可以任意互相嵌套。

因此,我们先把语法形式放到一边,只看类型的结构关系,这样理解起来会容易得多。

下图表示“指向 int 的指针的指针”。从变量出发,沿着箭头依次读:先是一个指针,该指针指向另一个指针,后者再指向 int

教程示意图
表示 int ** 的类型图

也就是说,如果变量 x 具有上面的类型,那么 x 本身是指针;*x 仍然是指针;**x 才是 int

数组也可以用同样的方法表示。下面的图表示“长度为 20 的数组,数组元素是指向 int 的指针”。

教程示意图
表示 int *x[20] 的类型图

函数类型也一样可以画出来。下面的图表示一个函数:它接收两个参数,第一个是 int,第二个是 int *,返回值是 void *

教程示意图
带有参数和返回值的函数类型

再看一个更复杂的类型:指向函数的指针,而这个函数又返回另一个函数指针。

文字描述会显得很绕,但图里只是几段箭头的连接。

教程示意图
函数指针与返回函数指针的组合

真正的编译器内部也会采用类似的结构来表示类型:一个类型节点指向另一个类型节点。

指针类型指向它所指向的类型;数组类型保存元素类型和长度;函数类型保存参数类型列表和返回值类型。

因此,上面的图不是比喻,而是非常接近编译器内部数据结构的表示。

一种便于阅读的类型记法

为了说明方便,本章使用一种比 C 更直观的记法来描述类型:

例如 int ** 可以写成 * * intint *x[20] 的类型可以写成 [20] * int;接收 intint * 并返回 void * 的函数可以写成 func(int, * int) * void

这种记法不是 C 语法,而是本章为了说明类型结构引入的中间表示。它的优点是可以从左到右阅读,不需要在括号、星号、数组后缀和函数参数列表之间来回跳转。

从类型设计的角度看,这种记法也更接近 Go 语言等现代语言的写法:类型构造按照实际结构顺序排列,读者先看到“这是数组、指针还是函数”,再继续追踪它指向、包含或返回的类型。

C 类型声明的读法

C 声明通常可以拆成四个部分:

  1. 基础类型,例如 intunsigned intvoid
  2. 基础类型后面的星号,例如 ***
  3. 标识符,或用括号包起来的嵌套声明;
  4. 标识符后面的数组括号 [] 或函数参数列表 ()

例如 unsigned int *x() 可以拆成:基础类型 unsigned int,星号 *,标识符 x,以及函数参数列表 ()。这表示 x 是一个函数,返回值是 unsigned int *

没有嵌套括号的声明

如果星号后面直接跟变量名,读起来比较直接。下面是几个基本例子。

C 声明含义记法中文说明
int xintx 是 int
int *x* intx 是指向 int 的指针
int **x* * intx 是指向 int 指针的指针

函数声明中,变量名后面的 () 表示“这是函数”。返回值类型由基础类型和星号部分决定。

C 声明含义记法中文说明
int x()func() intx 是返回 int 的函数
int *x()func() * intx 是返回 int 指针的函数
int **x(int)func(int) * * intx 接收 int,返回 int 指针的指针

数组声明中,变量名后面的 [N] 表示“这是数组”。数组的元素类型由其余部分决定。

C 声明含义记法中文说明
int x[5][5] intx 是长度为 5 的 int 数组
int *x[5][5] * intx 是长度为 5 的数组,元素是 int 指针
int **x[4][5][4] [5] * * intx 是二维数组,元素是 int 指针的指针

带嵌套括号的声明

声明里出现 (*x) 这类括号时,要把括号内外分开读。括号的作用是改变结合顺序:如果没有括号,[]() 会优先和变量名结合;加上括号后,星号可以先和变量名结合。

int (*x)() 为例。先把括号里的 *x 看成一个占位类型 * ___。括号外的 int y() 表示 func() int。把外层类型填入占位处,得到 * func() int。因此 x 是一个指针,指向一个“返回 int 的函数”。

再看 void (*x[20])(int)。括号里的 *x[20] 表示 [20] * ___;括号外的 void y(int) 表示 func(int) void。组合后得到 [20] * func(int) void。也就是说,x 是长度为 20 的数组,数组元素是函数指针,所指函数接收 int 并返回 void

下面是 Unix 中著名的 signal 函数声明:

void (*signal(int, void (*)(int)))(int);

这条声明可以按“先分开、再填回”的方式读。先把最外层括号 (*signal(int, void (*)(int))) 暂时当作一个普通标识符 y,外层就变成 void y(int),也就是 func(int) void

再看括号内部:*signal(int, void (*)(int))。其中 signal(...) 表示 signal 是函数;前面的 * 表示这个函数的返回值要填进一个“指针”结构中。因此内层的大致形状是 func(参数1, 参数2) * ___

  1. 第一个参数是 int
  2. 第二个参数是 void (*)(int),也就是“指向函数的指针”;该函数接收 int,返回 void。用本章记法写作 * func(int) void

把两个参数填入内层,再把外层返回类型填回占位符,最终得到 func(int, * func(int) void) * func(int) void。换句话说,signal 是一个函数:它接收一个 int 和一个函数指针,并返回另一个函数指针;这两个函数指针所指向的函数都接收 int 并返回 void

这条声明可以读作:signal 是一个函数,它接收两个参数:第一个参数是 int,第二个参数是“指向函数的指针”,该函数接收 int 并返回 voidsignal 的返回值也是一个函数指针,所指函数同样接收 int 并返回 void

专栏:C 声明语法的设计意图

C 的声明语法有一个基本思路:声明时的写法尽量接近使用时的写法。例如 int *x[20] 可以理解为:如果写 *x[20],得到的是 int。因此 x 本身必须是“数组,数组元素是指针”。这个设计有内在逻辑,但可读性并不好。复杂声明中,人脑需要反复在标识符、括号、星号和参数列表之间跳转,所以实际写代码时,应尽量用 typedef 或分解声明来降低复杂度。

例如 int *(*x[20])() 可以理解成这样一个问题:怎样给 x 定义类型,才能让表达式 *(*x[20])() 的结果是 int?顺着使用形式倒推,x 首先必须是数组,数组元素必须能被解引用,解引用后还要能作为函数调用,而函数返回值还要再解引用一次。这个思路解释了 C 声明为什么“有逻辑但不好读”。

练习题

下面的表把若干 C 声明转换成本章使用的类型记法。读的时候建议先找到变量名,再按“后缀优先、括号改变结合顺序”的规则展开。

C 声明含义记法
int xint
int *x* int
int x[][] int
int x()func() int
int **x* * int
int (*x)[]* [] int
int (*x)()* func() int
int *x[][] * int
int x[][][] [] int
int *x()func() * int
int ***x* * * int
int (**x)[]* * [] int
int (**x)()* * func() int
int *(*x)[]* [] * int
int (*x)[][]* [] [] int
int *(*x)()* func() * int
int **x[][] * * int
int (*x[])[][] * [] int
int (*x[])()[] * func() int
int *x[][][] [] * int
int x[][][][] [] [] int
int **x()func() * * int
int (*x())[]func() * [] int
int (*x())()func() * func() int