C语言篇:数组

1,516 阅读10分钟

本篇不是入门教学,仅补充细节,建议当作强化学习。

数组与指针

在很多情况下,数组可以当作第一个元素的指针来使用,但数组不是指针。在我“指针”那篇文章的“指针与数组”一节有详细介绍。

多维数组

众所周知,不管是几维的数组,都是按照一维数组存储的。

多维数组其实是元素为数组的数组。

我一直非常反对将二维数组表示为行和列的矩阵,不仅不符合底层原理,而且非常难以理解。如果三维的话表示成立体矩阵,四维、五维怎么表示?

以下,我用{}表示一个一维数组,每加一层{}表示升一个维度。

一维 array[5]

{ , , , , ,}

二维 array[3][]

{ {}, {}, {} }

三维 array[3][4][]

{ { {}, {}, {}, {} }, { {}, {}, {}, {} }, { {}, {}, {}, {} } }

{ [ (), (), (), () ], [ (), (), (), () ], [ (), (), (), () ] }
{ [                ], [                ], [                ] }
{                                                            }

为了提高辨识度,我将不同维度换用了不同的括号,并每次降一个维度。利用这种表示方式,不管提升多少个维度都可以准确且直观地表示,而且与存储方式完全一致。

初始化

申明数组时可以使用列表进行初始化,默认顺序是从第一项(下标0)开始依次初始化,没有被指定的项都被初始化为0。

若想申明一个数组,并把全部元素初始化为0,可以写为:

int array[5] = { 0 };

如果没有初始化列表,数组元素的值跟申明一个变量相同(全局变量初始化为0,局部变量未定义)。

初始化列表允许末尾多一个逗号分隔符,以方便增加元素。下面两个申明效果相同:

int array1[3] = { 1, 2, 3 };
int array2[3] = { 1, 2, 3, };

char数组初始化


如果申明char数组,可以使用字符串常量来初始化。注意:必须给字符串末尾的0留下空间

char str1[6] = "hello";                     // Correct
char str2[5] = "hello";                     // Error
char str3[5] = { 'h', 'e', 'l', 'l', 'o' }; // Correct

此处可以发现,初始化并不是赋值。申明中的“=”只是初始化标记,而不是赋值运算符,具体执行的操作由语境决定。

多维数组初始化


多维数组有两种初始化方法。

一种是按照存储顺序依次初始化,如:

int array[2][3] = { 1, 2, 3, 4, 5, 6 };

用上面提到的表示方法来表示初始化之后的数组:

{ { 1, 2, 3 }, { 4, 5, 6 } }

另外一种更加直观且灵活,可以指定子数组初始化:

int array[2][3] = { { 1, 2 }, { 3 } };

初始化结果为:

{ { 1, 2, 0 }, { 3, 0, 0} }

没有被指定的项都被初始化为0。

指定初始化


从C99开始,可以使用指定初始化器。方法如下:

int array[5] = { [2] = 1, [4] = 2 };

初始化结果为:

{ 0, 0, 1, 0, 2}

指定初始化可以与其他初始化方式一起使用,指定列表项之后的列表元素将对指定下标之后的数组元素依次初始化。也就是说,指定初始化只是重置了初始化起点。如下:

int array[5] = { 1, [3] = 2, 3 };

初始化结果为:

{ 1, 0, 0, 2, 3 }

指定初始化也可以用于多维数组,且用法相当灵活:

int array1[3][5] = { [1] = { [2] = 1, 2 }, { 3, 4 } };
int array2[3][5] = { [1][2] = 1, 2, [2][3] = 3 };

初始化结果分别为:

array1: { { 0, 0, 0, 0, 0 }, { 0, 0, 1, 2, 0 }, { 3, 4, 0, 0, 0 } }
array2: { { 0, 0, 0, 0, 0 }, { 0, 0, 1, 2, 0 }, { 0, 0, 0, 3, 0 } } 

指定项的顺序不一定是递增的,如果逆序使用指定初始化可能会覆盖之前的初始化结果。如下:

int array[5] = { [3] = 1, [1] = 2, 3, 4};

初始化结果为:

{ 0, 2, 3, 4, 0 }

可见,之前初始化的1被覆盖掉了。编译器会给出警告,但允许通过编译。

PS:C++不支持数组的指定初始化。

省略长度初始化


初始化数组时,可以省略第一个维度的长度,编译器根据初始化列表自动判断。

以下,我把完全相同的申明写为一组。

int array1[] = { 'o', 'n', 'e', '\0' };
int array2[] = "one";
int array3[4] = "one";

int array4[][3] = { { 1, 2 }, { 3 } };
int array5[2][3] = { { 1, 2 }, { 3 } };

int array6[][2][3] = { { { 1 }, { 2 } }, { { 3 }, { 4 } } };
int array7[2][2][3] = { { { 1 }, { 2 } }, { { 3 }, { 4 } } };

int array8[] = { [7] = 1, 2 };
int array9[9] = { [7] = 1, 2 };

变长数组

变长数组并不是数组的长度可以改变,而是允许使用变量来定义数组长度。

GNU C语言扩展很早就支持变长数组了。从C99开始,C标准引入了变长数组,但是这一功能并没有得到所有编译器的支持(如VC++的编译器),支持的编译器也可能会将这个功能禁用。如果要使用这个功能,需要让编译器严格遵守C99或以后的标准。

变长数组的申明非常简单,如下:

int var = 5;
int array[var];

变长数组不能初始化。因为变长数组的长度在程序运行时才能确定,编译器无法判断初始化列表是否合法,所以只能规定变长数组不能初始化。

变长数组不能在全局位置定义。因为全局变量储存在静态区,编译时就必须确定,而变长数组的长度在运行时才能确定。

结构体或联合体中不能包含变长数组。因为结构体的成员类型和大小必须在编译时确定。

一般而言,sizeof运算符在编译时返回结果,但变长数组是个例外,只能在运行时返回结果。跟普通数组一样,对变长数组使用sizeof运算符也返回数组占用的字节数。

有了变长数组,也就有了与其配套的行指针。

int var = 5;
int (*pt)[var] = 0;

使用方法与一般行指针相同。

结构体或联合体中不能包含变长数组对应的行指针。理由同上。

一般而言,类型信息在编译时确定,但变长数组对应的行指针是个例外,只能在运行时确定。这就导致一些在编译时进行的语法检查无法适用于它,进而可能导致BUG。比如int (*)[3]int (*)[5]是不相容的,相互赋值时会触发编译错误,但如果把35都换成变量,则编译器无法进行检查,这样的赋值总是会成功。

因为变长数组有许多的例外,完整实现它是比较困难的,这也是一些编译器不支持变长数组的原因。

PS:C++不支持变长数组及相应指针

柔性数组

GNU C语言扩展很早就定义了零长度数组,C99引入了类似的功能,称为“柔性数组”。

上面说到,结构体或联合体中不能包含变长数组,柔性数组在某些方面解决了这个问题。

柔性数组允许定义一个不完整类型(未指定长度的数组),再配合内存管理函数一起使用。从功能上看很像指针。实际上,柔性数组简化了指针操作。

一个典型的用例如下:

struct S {
    char var;
    int array[];
};

struct S *sp = malloc(sizeof(struct S) + sizeof(int) * 3);
sp->array[1] = 5;
/* other operations... */
free(sp);

我们首先在结构体S中定义了一个柔性数组成员array,然后通过malloc函数分配足够的空间,当通过指针sp访问这个结构体时,array可以直接当作拥有3个成员的数组来使用,这得益于C语言没有数组边界检查。

假如我们不使用柔性数组成员,要在结构体中访问变长数组,就需要在结构体S中定义一个指针,再使用malloc为其分配空间,结束的时候还需要单独释放这部分空间。但是这样的话,数组和结构体的内存空间就不连续了,如果想实现完全一样的功能,就需要在结构体外定义一个指针,通过这个指针操作结构体末尾的内存空间。但是这样既不安全(需要手动内存对齐),也不优雅。

柔性数组只能作为结构体成员,并且必须是最后一个成员。柔性数组本来就是为了在结构体中访问变长数组,所以只能作为结构体成员;从实现和用法来看,也只能是最后一个成员,否则会覆盖其他成员的值。

柔性数组不能作为联合体成员。虽然解决了结构体的问题,但仍不可用于联合体。(GNU C语言扩展允许零长度数组作为联合体成员,但标准C不允许柔性数组这么做,标准C也不允许零长度数组。)

包含柔性数组成员的结构体不能作为其他结构体的成员,也不能作为数组元素。主要是为了防止数据覆盖,也便于实现。

对包含柔性数组成员的结构体做sizeof运算时,结果在编译时就会产生,柔性数组的长度为0,结构体的大小为其他成员组成结构体的大小,但包含因柔性数组产生的末尾内存对齐。例如:

struct S {
    char var;
    int array[];
};

sizeof(struct S) == 4;

因为int占4个字节,所以根据结构体的对齐规则,array前面必须是4的整数倍个字节,而array本身不具备大小,所以结果是4。

这个特性使得我们可以放心地在malloc函数中使用sizeof计算分配的空间大小,而不必担心内存对齐带来的问题。

直接对结构体内的柔性数组做sizeof运算会导致编译错误。

需要注意的是,当直接操作包含柔性数组成员的结构体时,该结构体的大小始终被认为是和sizeof结果相同的大小,柔性数组就被忽略了。比如:

struct S {
    char var;
    int array[];
};

struct S *sp1 = malloc(sizeof(struct S) + sizeof(int) * 3);
struct S *sp2 = malloc(sizeof(struct S) + sizeof(int) * 5);
sp1->array[1] = 5;
*sp2 = *sp1;
free(sp1);
free(sp2);

此赋值表达式只会处理array之前的数据,array[1]的数据并不能拷贝到sp2指向的结构体。在函数参数传递时也存在相同的问题。

虽然存在许多限制,但柔性数组的使用非常广泛,尤其是在网络编程领域,其经常需要接收和发送结构化的不固定长度的数据。

PS:C++不支持柔性数组。

一些无关紧要的话:

光是一个数组,C和C++就出现了好多分歧。不要再相信“C++兼容C”的傻话了。

建议将C和C++完全当成两门语言来学习,因为历史原因,C++保留了大部分C的语法,此外,两门语言的设计哲学、设计目的、应用场景完全不同。ANSI C是C和C++互通的底线,之后两门语言各自发展。即使这样,在相互兼容的部分还是有很多细微的差异,能在C++编译器通过的C代码不一定以C的原理工作。

C++很优秀,但不应该拘泥与C,应拥抱新特性,不断革新。

C很古老,但也在稳中求进,不应该将一门成熟、完整、独立的语言与C++一概而论。