3.2 C 的数据类型的模型

3.2.1 基本类型和派生类型

假设有下面这样的声明:

  1. int (*func_table[10])(int a);

根据上节中介绍的方法,可以解释成:

指向返回 int 的函数(参数为 int a)的指针的数组(元素个数 10)

如果画成图,可以用这样的链结构1来表示:

1 由于日语和中文语序的差异,图中日文原书的箭头和本书相反。——译者注

3.2 C 的数据类型的模型 - 图1

图 3-1 用图表现“类型”

这种表示,在本书中称为“类型链的表示”。

姑且先忽视结构体、共用体、typedef 等类型,概要地进行说明,链的最后面的元素*基本类型,这里可能是 int 或者 double

* 如果按照日 语的语 序,应该是最前面的 元素。2

2 译本采用中文的语序。——译者注

此外,从倒数第 2 个元素开始的元素都是派生类型。所谓“派生类型”,就是指从某些类型派生出来的类型。

除了结构体、共用体之外,还有以下 3 种派生类型

  • 指针

  • 数组(“元素个数”作为它的属性)

  • 函数(参数信息作为它的属性)

关于派生类型,K&R中有这样一段描述(p.239):

除基本的算术类型以外,利用以下的方法可以生成概念上无限种类的派生类型。

  • 给出类型的对象的数组

  • 返回给出类型的对象的函数

  • 指向给出类型的对象的指针

  • 包含各种一系列对象的结构体

  • 能够包含各种类型的数个对象中的任意一个共用体

一般来说,这些对象的生成方法可以递归使用。

可能大家完全不明白这段描述在说什么。其实归纳一下,可以表述成下面这句话*

* 实际上,派生还有其他几个限制,关于这些我们在后面介绍。

从基本类型开始,递归地(重复地)粘附上派生类型,就可以生成无限类型。

通过如图 3-1 的方式将链不断地接长,就可以不断生成新的“类型”。

另外,在链中,最后的类型(数组(元素 10))整合了全体类型的意义,所以我们将最后的类型称为类型分类

比如,无论是“指向 int 的指针”,还是“指向 double 的指针”,结果都是“指针”;无论是“int 的数组”,还是“指向 char 的指针的数组”,结果都是“数组”。

3.2.2 指针类型派生

在 1.2.1 节中,引用了标准中的一节,在此,请允许我再次引用。

指针类型(pointer type)可由函数类型、对象类型或不完全的类型派生,派生指针类型的类型称为引用类型。指针类型描述一个对象,该类对象的值提供对该引用类型实体的引用。由引用类型 T 派生的指针类型有时称为“(指向)T 的指针”。从引用类型构造指针类型的过程称为“指针类型的派生”。这些构造派生类型的方法可以递归地应用。

“由引用类型 T 派生的指针类型有时称为‘(指向)T 的指针’”这句话,可以在图 3-2 中用链来表现。

3.2 C 的数据类型的模型 - 图2

图 3-2 指针类型派生

对于指针类型来说,因为它指向的类型各不相同,所以都是从既存的类型派生出“指向 T 的指针”这样的类型。

大多数处理环境中的指针,在实现上都只是单纯的地址,所以无论从什么类型派生出的指针,在运行时的状态都是大体相同的。但是,加上\运算符求值的时候,以及对指针进行加法运算的时候,由不同类型派生出来的指针之间就存在差异。

* 前面也曾经提到,如果解释得详细一些,对于指向 char 的指针和指向 int 的指针,偶尔存在位数不相同的处理环境。

前面已经提到,如果对指针进行加法运算,指针只前进指针所指向类型的大小的距离。这一点对于后面的说明有着非常重要的意义。

可以使用图 3-3 来解释指针类型。

3.2 C 的数据类型的模型 - 图3

图 3-3 指针类型的图解

3.2.3 数组类型派生

和指针类型相同,数组类型也是从其元素的类型派生出来的。“元素个数”作为类型的属性添加在类型后面(参照图 3-4)。

图 3-4 数组类型派生

数组类型本质就是将一定个数的派生源的类型进行排列而得到的类型。如图 3-5 所示。

3.2 C 的数据类型的模型 - 图4

图 3-5 数组行的图解

3.2.4 什么是指向数组的指针

“数组”和“指针”都是派生类型。它们都是由基本类型开始重复派生生成的。

也就是说,派生出“数组”之后,再派生出“指针”,就可以生成“指向数组的指针”。

一听到“指向数组的指针”,有人也许要说:

这不是很简单嘛,数组名后不加[],不就是“指向数组的指针”吗?

抱有这个想法的人,请将 1.3 节的内容重新阅读一下!的确,在表达式中,数组可以被解读成指针。但是,这不是“指向数组的指针”,而是“指向数组初始元素的指针”。

实际地声明一个“指向数组的指针”,

  1. int (*array_p)[3];
  2. array_p 是指向 int 数组(元素个数3)的指针。

根据 ANSI C 的定义,在数组前加上&,可以取得“指向数组的指针”*。因此,

* 这里是“数组可以解读成指向它初始元素的指针”这个规则的一个例外(参照 3.3.3 节)

  1. int array[3];
  2. int (*array_p)[3];
  3. array_p = &array; ←数组添加&,取得“指向数组的指针”

这样的赋值是没有问题的,因为类型相同。

可是,如果进行

  1. array_p = array;

这样的赋值,编译器就会报出警告。

“指向 int 的指针”和“指向 int 的数组(元素个数 3)的指针”是完全不同的数据类型。

但是,从地址的角度来看,array&array 也许就是指向同一地址。但要说起它们的不同之处,那就是它们在做指针运算时结果不同。

在我的机器上,因为 int 类型的长度是 4 个字节,所以给“指向 int 的指针”加 1,指针前进 4 个字节。但对于“指向 int 的数组(元素个数 3)的指针”,这个指针指向的类型为“int 的数组(元素个数 3)”,当前数组的尺寸为 12 个字节(如果 int 的长度为 4 个字节),因此给这个指针加 1,指针就前进 12 个字节(参照图 3-6)。

3.2 C 的数据类型的模型 - 图5

图 3-6 对“指向数组的指针”进行加法运算

道理我明白了,但是一般没有人这么用吧?

可能有人存在以上的想法。但真的有很多人就是这么用的,只不过是自己没有意识到。为什么这么说呢?在后面的章节中将会说明。

3.2.5 C语言中不存在多维数组!

在 C 中,可以通过下面的方式声明一个多维数组:

  1. int hoge[3][2];

我想企图这么干的人应该很多。请大家回忆一下 C 的声明的解读方法,上面的声明应该怎样解读呢?

是“int 类型的多维数组”吗?

这是不对的。应该是“int 的数组(元素个数 2)的数组(元素个数 3)”。

也就是说,即使 C 中存在“数组的数组”,也不存在多维数组*

* 在 C 标准中,“多维数组”这个词最初出现在脚注中,之后这个词也会不时地出现在各个角落。尽管“不存在多维数组”这个观点会让人感觉有些极端,但如果你不接受这个观点,对于 C 的类型模型的理解,可能就会比较困难。

“数组”就是将一定个数的类型进行排列而得到的类型。“数组的数组”也只不过是派生源的类型恰好为数组。图 3-7 是“int 的数组(元素个数 2)的数组(元素个数 3)”。

3.2 C 的数据类型的模型 - 图6

图 3-7 数组的数组

要 点

C 语言中不存在多维数组。

看上去像多维数组,其实是“数组的数组”。

对于下面的这个声明:

  1. int hoge[3][2];

可以通过 hoge[i][j]的方式去访问,此时,hoge[i]是指“int 的数组(元素个数 2)的数组(元素个数 3)”中的第 i 个元素,其类型为“int 数组(元素个数 2)”。当然,因为是在表达式中,所以在此时此刻,hoge[i]也可以被解读成“指向 int 的指针”。

关于这一点,3.3.5 节中会有更详细的说明。

那么,如果将这个“伪多维数组”作为函数的参数进行传递,会发生什么呢?

试图将“int 的数组”作为参数传递给函数,其实可以直接传递“指向 int 的指针”。这是因为在表达式中,数组可以解释成指针

因此,在将“int 的数组”作为参数传递的时候,对应的函数的原型如下:

  1. void func(int *hoge);

在“int 的数组(元素个数 2)的数组(元素个数 3)”的情况下,假设使用同样的方式来考虑,

int 的数组(元素个数 2)的数组(元素个数 3)

其中下划线部分,在表达式中可以解释成指针,所以可以向函数传递

指向 int 的数组(元素个数 2)的指针

这样的参数,说白了它就是“指向数组的指针”。

也就是说,接收这个参数的函数的原型为:

  1. void func(int (*hoge)[2]);

直到现在,有很多人将这个函数原型写成下面这样:

  1. void func(int hoge[3][2]);

或者这样:

  1. void func(int hoge[][2]);

其实,

  1. void func(int (*hoge)[2]);

就是以上两种写法的语法糖,它和上面两种写法完全相同。

关于将数组作为参数进行传递这种的情况下的语法糖,在 3.5.1 节中会再一次进行说明。

3.2.6 函数类型派生

函数类型也是一种派生类型,“参数(类型)”是它的属性。

3.2 C 的数据类型的模型 - 图7

图 3-8 函数类型派生

可是,函数类型和其他派生类型有不太相同的一面。

无论是 int 还是 double,亦或数组、指针、结构体,只要是函数以外的类型,大体都可以作为变量被定义。而且,这些变量在内存占用一定的空间。因此,通过 sizeof 运算符可以取得它们的大小。

像这样,有特定长度的类型,在标准中称为对象类型

可是,函数类型不是对象类型。因为函数没有特定长度。 所以 C 中不存在“函数类型的变量”(其实也没有必要存在)。

数组类型就是将几个派生类型排列而成的类型。因此,数组类型的全体长度为:

派生源的类型的大小×数组的元素个数

可是,函数类型是无法得到特定长度的,所以从函数类型派生出数组类型是不可能的。也就是说,不可能出现“函数的数组”这样的类型。

可以有“指向函数的指针”类型,但不幸的是,对指向函数类型的指针不能做指针运算,因为我们无法得到当前指针类型的大小。

此外,函数类型也不能成为结构体和共用体的成员。

总而言之:

从函数类型是不能派生出除了指针类型之外的其他任何类型的。

不过“指向函数的指针类型”,可以组合成指针或者作为结构体、共用体的成员。毕竟“指向函数的指针类型”也是指针类型,而指针类型又是对象类型。

另外,函数类型也不可以从数组类型派生。

可以通过“返回~的函数”的方式派生出函数类型,不过在 C 中,数组是不能作为函数返回值返回的(参照 1.1.8 节)。

要 点

从函数类型是不能派生出除了指针类型之外的其他任何类型的。

从数组类型是不能派生出函数类型的。

3.2.7 计算类型的大小

除了函数类型和不完全类型(参照 3.2.10 节),其他类型都有大小。

通过

  1. sizeof(类型名)

编译器可以为我们计算当前类型的大小,无论是多么复杂的类型。

  1. printf("size..%d\n", sizeof(int(*[5])(double)));

以上的语句表示输出

  1. 指向返回 int 的函数(参数为double)的指针的数组(元素个数5)的大小。

在这里顺便对以前的内容也做一下复习:模仿编译器的处理方式,尝试计算各种类型的大小。

另外,我们考虑使用如下构成的机器来作为处理环境。

  1. int 4 个字节
  2. double 8 个字节
  3. 指针 4 个字节

【注意!】

在这里我们为了说明方便,特别地对处理环境做了假定。但是, C 语言的标准并没有对 intdouble 和指针的大小进行任何规定,数据类型的大小完全取决于各处理环境的具体实现。

因此,我们通常不需要去留意数据类型的物理大小,更不应该依赖数据类型大小进行编程。

可以像下面这样,以日语3词组的顺序计算类型的大小:

3 这里同样可以用中文词组的顺序来计算。——译者注

  • 基本类型

基本类型必定依赖处理环境进行计算。

  • 指针

指针的大小是依赖处理环境决定的。大部分情况下,指针的大小和派生源的大小没有关系,它的大小是固定的。

  • 数组

数组的大小可以通过派生源类型的大小乘以元素个数得到。

  • 函数

函数的大小无法计算。

现在,可以尝试计算刚才的示例。

指向返回 int 的函数(参数为 double)的指针的数组(元素个数 5)的大小。

  • 指向返回 int 的函数(参数为 double)的指针的数组(元素个数 5)。

因为是 int 类型,所以在当前假定的处理环境中,计算结果为 4 个字节。

  • 指向返回 int 的函数(参数为 double)的指针的数组(元素个数 5)。

因为是函数,所以无法计算大小。

  • 指向返回 int 的函数(参数为 double)的指针的数组(元素个数 5)。

因为是指针,所以在当前假定的处理环境中,计算结果为 4 个字节。

  • 指向返回 int 的函数(参数为 double)的指针的数组(元素个数 5)。

因为是派生源的大小为 4 的“元素个数为 5 的数组”,所以计算结果为 4×5=20 个字节。

同样地,表 3-3 中整理了对各种类型的大小进行计算的结果。

表3-3 计算各种类型的大小

声  明 中文的表现 大  小
int hoge; hogeint 4个字节
int hoge[10] hogeint的数组 4×10=40个字节
int *hoge[10]; hogeint的指针的数组(元素个数10) 4×10=40个字节
double *hoge[10] hogedouble的指针的数组(元素个数10) 4×10=40个字节
int hoge[2][3]; hogeint的数组(元素个数3)的数组(元素个数2) 4×3×2=24个字节

3.2.8 基本类型

派生类型的底层是基本类型

基本类型指,charint 这样的整型以及 floatdouble 这样的浮点型。这些类型加上枚举类型,统称为算术型*

* 在标准中,枚举类型没有包括在基本类型(basic type)中(6.1.2.5)。在 K&R 中,它们被混在一起了。

此外,在 C 中,通过 short int 声明一个变量,和单纯地通过 short 声明一个变量,意义是完全一样的。

对于整型和浮点型,怎样的写法是允许的,哪种写法和哪种写法的意义是相同的,这些内容非常琐碎,特整理如下(表 3-4)。

表3-4 整数型、浮点型的种类

推  荐 同义的表现
char
signed char
unsigned char
short signed shortshort intsigned short int
unsigned short unsigned short int
int signedsigned int,无指定类型
unsigned int unsigned
long signed longlong intsigned long int
unsigned long unsigned long int
float
double
long double

charsigned char 或者 unsigned char 同义。至于默认情况下,char 究竟是有符号的还是无符号的,C 标准并没有定义,而是取决于处理环境。

根据处理环境不同,long long 等写法有可能也是允许的,这些都不在标准的约束范围之内。ANSI C 以前的 C,存在 long float 这样的写法,它和 double 同义,但是在 ANSI C 之后这种写法就被废弃了。

另外,对于这些类型的大小,sizeof(char)(包含 signedunsigned)必定返回 1。其他的类型全部依赖处理环境的定义。即使是 char,在 sizeof 肯定会返回 1 的情况下,也没有规定肯定是 8 位,现实中也存在 char 为 9 的处理环境。

偶尔有一些不靠谱的 C 语言入门书籍会跟大家乱嚼舌头:“int 的大小是依赖于硬件的,所以尽量不要使用 int。”这种观点完全是错误的。不只是 int,无论是 short 还是 long,它们的大小都依赖于处理环境。具有讽刺意味的是,几乎所有持有这种观点的入门书的例程也在使用 int。言行不一?说话不算数?哎,我都不知道该怎么说了……

不过标准还是规定了每种类型可以表示的值的范围:

  • 有符号 char,±127

  • 无符号 char,0~255

  • 有符号 int,有符号 short,±32767

  • 无符号 intunsigned short,0~65535

  • 有符号 long,±2147483647

  • 无符号 long,0~4294967295

请注意在这里有符号 int 的最小值不是-32768,这是因为考虑到有的机器对于负数使用 1 的补码4

4 在二进制原码的情况下,有符号 int 的最小值为-32767。但是补码系统中有符号 int 的最小值为-32768,因为负数需要把符号位以后的部分取反加 1。——译者注

3.2.9 结构体和共用体

在语法上,结构体和共用体是作为派生类型使用的。

可是直到现在,我们还没有专门去说明结构体和共用体。其理由如下:

  • 虽然结构体和共用体在语法上属于派生类型,但是在声明中它和数据类型修饰符(也就是 intdouble 等)处于相同的位置。

  • 只有派生指针、数组和函数的时候,类型才可以通过一维链表表示。结构体、共用体派生类型只能用树结构进行表现。

结构体类型可以集合几个其他不同的类型,而数组只能线性地包含同一个类型。

共用体的语法和结构体相似,但是,结构体的成员是“排列地”分配在内存中,而共用体的成员则是“重叠地”分配在内存中。在第 5 章将会介绍共用体的用途。

让我们通过图 3-9,尝试使用“类型链的方式”来表现结构体和共用体的派生。

3.2 C 的数据类型的模型 - 图8

图 3-9 结构体类型的派生

3.2.10 不完全类型

不完全类型指“函数之外、类型的大小不能被确定的类型”。

总结一下,C 的类型分为:

  • 对象类型(char、int、数组、指针、结构体等)

  • 函数类型

  • 不完全类型 结构体标记的声明就是一个不完全类型的典型例子。

对于男性(Man),他可能有妻子(wife)。如果是未婚男性,wife 就是 NULL。所以,Man 这样的类型,可以声明成下面这样:

  1. struct Man_tag {
  2. struct Woman_tag *wife; /*妻*/
  3. };

作为妻子,可以这样声明:

  1. struct Woman_tag {
  2. struct Man_tag *husband; /*夫*/
  3. };

这种情况下,struct Man_tagstruct Woman_tag 是相互引用的,所以无论先声明哪一边都很麻烦。

可以像下面这样通过先声明结构体标记来回避以上问题:

  1. struct Woman_tag; tag 提前声明
  2. struct Man_tag {
  3. struct Woman_tag *wife; /* 妻 */
  4. };
  5. struct Woman_tag {
  6. struct Man_tag *husband; /* 夫 */
  7. };

在我的环境中,结构体必须使用 typedef,所以,

  1. typedef struct Woman_tag Woman; 提前对 tag 进行类型定义
  2. typedef struct {
  3. Woman *wife; /* 妻 */
  4. } Man;
  5. struct Woman_tag {
  6. Man *husband; /* 夫 */
  7. };

对这种情况,在 Woman 类型的标记被声明的时候,还不知道其内容,所以无法确定它的大小。这样的类型就称为不完全类型

因为不能确定大小,所以不能将不完全类型变成数组,也不能将其作为结构体的成员,或者声明为变量。但如果仅仅是用于取得指针,是可以使用不完全类型的。上面的结构体 Man,就是将 Woman 类型的指针作为它的成员。

之后,在定义 struct Woman_tag 的内容的时候,Woman 就不是不完全类型了。

在 C 标准中,void 类型也被归类为不完全类型。