——如果程序操纵着大量的数据,那它一定是用较少的方法办到的。
到目前为止,我们所见的变量都只是标量(
scalar
):标量具有保存单一数据项的能力。C语言也支持聚合(aggregate
)变量,这类变量可以存储成组的数值。在C语言中一共有两种聚合类型:数组(array
)和结构(structure
)。本章介绍一维数组(8.1节)和多维数组(8.2节)的声明与使用。8.3节讨论了C99中的变长数组
。本章主要讨论一维数组,因为与多维数组相比,一维数组在C语言中占有更加重要的角色。后面的章节(特别是第12章)也包含一些与数组有关的信息,第16章介绍结构
。
数组是含有多个数据值的数据结构,并且每个数据值具有相同的数据类型。这些数据值称为元素(element
),可以根据元素在数组中所处的位置把它们一个个地选出来。
最简单的数组类型就是一维数组
,一维数组中的元素一个接一个地编排在单独一行(如果你喜欢,也可以说是一列)内。
为了声明数组,需要指明数组元素的
类型
和数量
。例如,为了声明数组a
有10
个int
类型的元素,可以写成
int a[10]; //关键:数组名字、数组元素类型、元素个数
数组的元素可以是任何类型,数组的长度可以用任何(整数)常量表达式(5.3节)指定。因为程序以后改变时可能需要调整数组的长度,所以较好的方法是用宏来定义数组的长度:
#define N 10
...
int a[N];
为了存取特定的数组元素,可以在写数组名的同时在后边加上一个用方括号围绕的整数值[这被称为对数组取下标(
subscripting
)或进行索引(indexing
)]。数组元素始终从0
开始,因此长度为n
的数组元素的索引是0~n-1
。例如,如果a
是含有10
个元素的数组,那么这些元素可以按如下所示,依次标记为a[0], a[1], ..., a[9]
:
形如
a[i]
的表达式是左值
(可以被赋值的对象,也就是变量)(4.2节),因此数组元素可以像普通变量一样使用:
a[0] = 1;
printf("%d\n", a[5]);
++a[i];
一般说来,如果数组包含T
类型的元素,那么数组中的每个元素均被视为T
类型的变量。本例中,a[0]
、a[5]
和a[i]
可以看作int
类型变量。
数组和
for
循环结合在一起使用。许多程序所包含的for
循环是为了对数组中的每个元素执行一些操作。下面给出了在长度为N
的数组a
上的一些常见操作示例。
for (i = 0; i < N; i++)
a[i] = 0; /* clears a */
for (i = 0; i < N; i++)
scanf("%d", &a[i]); /* reads data into a */
for (i = 0; i < N; i++)
sum += a[i]; /* sums the elements of a */
注意,在调用scanf
函数读取数组元素时,就像对待普通变量一样,必须使用取地址符号'&'
小知识,C语言不要求检查下标的范围(所以,在编译前,最好使用静态检查工具进行检查)。当下标超出范围时,程序可能执行不可预知的行为。下标超出范围的原因之一是忘记了
n
元数组的索引是0~n-1
,而不是1~n
。看一段十分奇异的代码:
int a[10], i; //注意,i的地址可能在a[9]后面
for (i = 1; i <= 10; i++)
a[i] = 0;
/*
对于某些编译器来说,这个表面上正确的for语句却产生了一个无限循环!
当变量i的值变为10时,程序将数值0存储在a[10]中。
但是a[10]这个元素并不存在,因此在元素a[9]后数值0立刻进入内存。
如果内存中变量i放置在a[9]的后边(这是有可能的),那么变量i将被重置为0,进而导致循环重新开始。
*/
数组下标可以是任何整数表达式:
a[i+j*10] = 0;
当然,表达式甚至可能会有副作用:
i = 0;
while (i < N)
a[i++] = 0;
/*
在把变量i设置为0后,while语句判断变量i是否小于N。
如果是,那么将数值0赋值给a[0],随后i自增,然后重复循环。
注意,a[++i]是不正确的,因为第一次循环体执行期间将把0赋值给a[1]。
*/
小知识,当数组下标有副作用时一定要注意。例如,下面这个循环想把数组
b
中的元素复制到数组a
中,但它可能无法正常工作:
i = 0;
while (i < N)
a[i] = b[i++];
/*
表达式a[i]=b[i++]访问并修改i的值,如4.4节所述,这样会导致未定义的行为。
当然,通过从下标中移走自增操作可以很容易地避免此类问题的发生:
*/
for (i = 0; i < N; i++)
a[i] = b[i];
像其他变量一样,数组也可以在声明时获得一个初始值。但是,数组初始化的规则不太好掌握,因此我们现在介绍一些,其他的留在后面介绍。(见18.5节)
数组初始化器(
array initializer
)最常见的格式是一个用花括号括起来的常量表达式列表,常量表达式之间用逗号
进行分隔:
int a[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
如果初始化器比数组短,那么数组中剩余的元素赋值为0
:
int a[10] = {1, 2, 3, 4, 5, 6};
/* initial value of a is {1, 2, 3, 4, 5, 6, 0, 0, 0, 0} */
利用这一特性,可以很容易地把数组初始化为全0
:
int a[10] = {0};
/* initial value of a is {0, 0, 0, 0, 0, 0, 0, 0, 0, 0} */
初始化器完全为空是非法的,所以要在花括号内放上一个0
。初始化器比要初始化的数组长也是非法的。
如果给定了初始化器,可以省略数组的长度:
int a[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
/*
编译器利用初始化器的长度来确定数组的大小。
数组仍然有固定数量的元素(此例中为10),这跟显式地指定长度效果一样。
*/
经常有这样的情况:数组中只有相对较少的元素需要进行显式的初始化,而其他元素可以进行默认赋值。考虑下面这个例子:
int a[15] = {0, 0, 29, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, 48};
我们希望数组
元素2为29
,元素9为7
,元素14为48
,而其他元素为0
。对于大数组,如果使用这种方式赋值,将是冗长和容易出错的(想象一下两个非0元素
之间有200
个0
的情况)。
C99
中的指示器可以用于解决这一问题。上面的例子可以用指示器写为:
int a[15] = {[2] = 29, [9] = 7, [14] = 48};
//方括号和其中的常量表达式一起,组成一个指示器。
//我们也可以将先前的例子重写为
int a[15] = {[14] = 48 , [9] = 7, [2] = 29};
组成指示器的方括号里必须是整型常量表达式。如果待初始化的数组长度为n
,则每个表达式的值都必须在0
和n-1
之间。但是,如果数组的长度是省略的,指示器可以指定任意非负整数;对于后一种情况,编译器将根据最大的值推断出数组的长度。在接下来的这个例子中,指示符的最大值为23
,因此数组的长度为24
:
//数组的长度为24
int b[] = {[5] = 10 , [23] = 13, [11] = 36, [15] = 29};
初始化器中可以同时使用老方法(逐个元素初始化)和新方法(指示器):
int c[10] = {5, 1, 9, [4] = 3, 7, 2, [8] = 6}
/*
这个初始化器指定数组的前三个元素值为5、1和9,元素4的值为3,
其后两个元素为7和2,最后元素8的值为6,而没有指定值的元素均赋予默认值0。
*/
运算符
sizeof
可以确定数组的大小(字节数)。如果数组a
有10
个整数,那么sizeof(a)
通常为40
(假定每个整数占4
字节)还可以用
sizeof
来计算数组元素(如a[0]
)的大小。用数组的大小除以数组元素的大小可以得到数组的长度:
sizeof(a) / sizeof(a[0])
当需要数组长度时,一些程序员采用上述表达式。
例如,数组
a
的清零操作可以写成如下形式:
for (i = 0; i < sizeof(a) / sizeof(a[0]); i++)
a[i] = 0;
//如果使用这种方法,即使数组长度在日后需要改变,也不需要改变循环。
/*
当然,利用宏来表示数组的长度也有同样的好处,但是sizeof方法稍微好一些,因为不需要记忆宏的名字(有可能搞错)。
*/
有些编译器会对表达式i < sizeof(a) / sizeof(a[0])
给出一条警告消息,这稍微有点烦人。变量i
的类型可能是int
(有符号类型),而sizeof
返回的值类型为size_t
(无符号类型)。由7.4节可知,把有符号整数与无符号整数相比较是很危险的,尽管在本例中这样做没问题(因为i
和sizeof(a) / sizeof(a[0])
都是非负值)。为了避免出现这一警告,可以将i
的类型改成t
,或者像下面这样,将sizeof(a) /sizeof(a[0])
强制转换为有符号整数:
for (i = 0; i < (int) (sizeof(a) / sizeof(a[0])); i++)
a[i] = 0;
/*
表达式(int) (sizeof(a) / sizeof(a[0]))写起来不太方便,定义一个宏来表示它常常是很有帮助的:
*/
#define SIZE ((int) (sizeof(a) / sizeof(a[0])))
for (i = 0; i < SIZE; i++)
a[i] = 0;
数组可以有任意维数。例如,下面的声明产生一个二维数组(或者按数学上的术语称为矩阵):
int m[5][9];
/*
数组m有5行9列。如下所示,数组的行和列下标都从0开始索引;
为了访问i行j列的元素,表达式需要写成m[i][j]。表达式m[i]指明了数组m的第i行,而m[i][j]则选择了此行中的第j个元素。
*/
请注意!!不要把
m[i][j]
写成m[i,j]
。如果这样写,C语言会把逗号看作逗号运算符(返回最后一个表达式的值)
,因此m[i,j]
就等同于m[j]
。虽然我们以表格形式显示二维数组,但是实际上它们在计算机内存中不是这样存储的。C语言是按照行主序存储数组的,也就是从第0行开始,接着是第1行,以此类推。
通常我们会忽略这一细节,但有时它会对我们的代码有影响。
就像for
循环和一维数组紧密结合一样,嵌套的for
循环是处理多维数组的理想选择。例如,思考用作单位矩阵的数组的初始化问题
。(数学中,单位矩阵在主对角线上的值为1
,而其他地方的值为0
,其中主对角线上行、列的索引值是完全相同的。)我们需要以某种系统化的方式访问数组中的每一个元素。一对嵌套的for
循环可以很好地完成这项工作—— 一个循环遍历每一行,另一个循环遍历每一列:
#define N 10
double ident[N][N];
int row, col;
for (row = 0; row < N; row++)
for (col = 0; col < N; col++)
if (row == col)
ident[row][col] = 1.0;
else
ident[row][col] = 0.0;
/*
和其他编程语言中的多维数组相比,C语言中的多维数组扮演的角色相对较弱,
这主要是因为C语言为存储多维数据提供了更加灵活的方法:指针数组(13.7节)
*/
通过嵌套一维初始化器的方法可以产生二维数组的初始化器:
int m[5][9] = {{1, 1, 1, 1, 1, 0, 1, 1, 1},
{0, 1, 0, 1, 0, 1, 0, 1, 0},
{0, 1, 0, 1, 1, 0, 0, 1, 0},
{1, 1, 0, 1, 0, 0, 0, 1, 0},
{1, 1, 0, 1, 0, 0, 1, 1, 1}};
每一个内部初始化器提供了矩阵中一行的值。为高维数组构造初始化器可采用类似的方法。
C语言为多维数组提供了多种方法来缩写初始化器(少补零,多报错)。
0
。例如,下面的初始化器只填充了数组m
的前三行,后边的两行将赋值为0
://没初始化的两行将赋值为0
int m[5][9] = {{1, 1, 1, 1, 1, 0, 1, 1, 1},
{0, 1, 0, 1, 0, 1, 0, 1, 0},
{0, 1, 0, 1, 1, 0, 0, 1, 0}};
0
:int m[5][9] = {{1, 1, 1, 1, 1, 0, 1, 1, 1},
{0, 1, 0, 1, 0, 1, 0, 1},
{0, 1, 0, 1, 1, 0, 0, 1},
{1, 1, 0, 1, 0, 0, 0, 1},
{1, 1, 0, 1, 0, 0, 1, 1, 1}};
int m[5][9] = {1, 1, 1, 1, 1, 0, 1, 1, 1,
0, 1, 0, 1, 0, 1, 0, 1, 0,
0, 1, 0, 1, 1, 0, 0, 1, 0,
1, 1, 0, 1, 0, 0, 0, 1, 0,
1, 1, 0, 1, 0, 0, 1, 1, 1};
/*
因为一旦编译器发现数值足以填满一行,它就开始填充下一行。
*/
请注意!在多维数组中省略内层的花括号可能是很危险的,因为额外的元素(更糟的情况是丢失的元素)会影响剩下的初始化器。省略花括号会导致某些编译器产生类似
“missing braces around initializer”
这样的警告消息。
C99
的指示器对多维数组也有效。例如,可以这样创建2×2
的单位矩阵:
double ident[2][2] = {[0][0] = 1.0, [1][1] = 1.0};
//像通常一样,没有指定值的元素都默认值为0。
无论一维数组还是多维数组,都可以通过在声明的最开始处加上单词
const
而成为“常量”:
const char hex_chars[] =
{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'A', 'B', 'C', 'D', 'E', 'F'};
程序不应该对声明为const
的数组进行修改,编译器能够检测到直接修改某个元素的意图。
把数组声明为const
有两个主要的好处。它表明程序不会改变数组,这对以后阅读程序的人可能是有价值的信息。它还有助于编译器发现错误——const
会告诉编译器,我们不打算修改数组。
const类型限定符
(18.3节)不限于数组,后面将看到,它可以和任何变量一起使用。但是,const
在数组声明中特别有用,因为数组经常含有一些在程序执行过程中不会发生改变的参考信息。
在8.1节中说到,数组变量的长度必须用常量表达式进行定义。但是在
C99
中,有时候也可以使用非常量表达式。下面是8.1节的reverse.c
程序的修改版,其中用到了变长数组
。
//reverse2.c
/* Reverses a series of numbers using a variable–length array – C99 only */
#include
int main(void)
{
int i, n;
printf("How many numbers do you want to reverse? ");
scanf("%d", &n);
int a[n]; /* C99 only – length of array depends on n */
printf("Enter %d numbers: ", n);
for (i = 0; i < n; i++)
scanf("%d", &a[i]);
printf("In reverse order:");
for (i = n - 1; i >= 0; i--)
printf(" %d", a[i]);
printf("\n");
return 0;
}
/*output:
How many numbers do you want to reverse? 4
Enter 4 numbers: 1 2 3 4
In reverse order: 4 3 2 1
*/
以上程序中的数组
a
是一个变长数组(variable-length array, VLA)
。变长数组的长度是在程序执行时计算的,而不是在程序编译时计算的。变长数组的主要优点是程序员不必在构造数组时随便给定一个长度,程序在执行时可以准确地计算出所需的元素个数。如果让程序员来指定长度,数组可能过长(浪费内存)或过短(导致程序出错)。在reverse2.c
程序中,数组a
的长度由用户的输入确定而不是由程序员指定一个固定的值,这是与老版本不同的地方。变长数组的长度不一定要用变量来指定,任意表达式(可以含有运算符)都可以。例如:
int a[3*i+5];
int b[j+k];
像其他数组一样,变长数组也可以是多维的:
int c[m][n];
变长数组的主要限制是它们不具有静态存储期(18.2节;目前我们还没有发现具有这一特性的数组),另一个限制是变长数组没有初始化器。
变长数组常见于除
main
函数以外的其他函数。对于函数f
而言,变长数组的最大优势就是每次调用f
时长度可以不同。9.3节将讲述这一特性。
问1:为什么数组下标从
0
开始而不是从1
开始?
答:让下标从0
开始可以使编译器简单一点。而且,这样也可以使得数组取下标运算的速度略有提高。
问2:如果希望数组的下标从
1
到10
而不是从0
到9
,该怎么做呢?
答:这有一个常用的窍门:声明数组有11
个元素而不是10
个元素。这样数组的下标将从0
到10
,但是可以忽略下标为0
的元素。
问3:使用字符作为数组的下标是否可行呢?
答:这是可以的,因为C
语言把字符作为整数来处理。但是,在使用字符作为下标前,可能需要对字符进行“缩放”
。举个例子,假设希望数组letter_count
对字母表中的每个字母进行跟踪计数。这个数组将需要26
个元素,所以可采用下列方式对其进行声明:
int letter_count[26];
然而,不能直接使用字母作为数组letter_count
的下标,因为字母的整数值不是落在0~25
的区间内的。为了把小写字母缩放到合适的范围内,可以简单采用减去'a'
的方法;为了缩放大写字母,则可以减去'A'
。例如,如果ch
含有小写字母,为了对相应的计数进行清零操作,可以这样写:
letter_count[ch-'a'] = 0;
说明一下,这种方法不一定可移植,因为它假定字母的代码是连续的。不过,对大多数字符集(包括ASCII
)来说,这样做是没问题的。
问4:指示器可能会对同一个数组元素进行多次初始化操作。考虑下面的数组声明:
int a[] = {4, 9, 1, 8, [0] = 5, 7};
,这个声明是否合法?如果合法,数组的长度是多少?
答:这个声明是合法的。下面是它的工作原理:编译器在处理初始化器列表时,会记录下一个待初始化的数组元素的位置。正常情况下,下一个元素是刚被初始化的元素后面的那个。但是,当列表中出现指示器时,下一个元素会被强制为指示器指定的元素,即使该元素已经被初始化了。
下面逐步分析编译器处理数组a
的初始化器的操作:
4
初始化元素0
,下一个待初始化的是元素1
;9
初始化元素1
,下一个待初始化的是元素2
;1
初始化元素2
,下一个待初始化的是元素3
;8
初始化元素3
,下一个待初始化的是元素4
;[0]
指示符导致下一个元素是元素0
,所以用5
初始化元素0
(替换先前存储的4
)。下一个待初始化的是元素1
;7
初始化元素1
(替换先前存储的9
)。下一个待初始化的是元素2
(跟本例不相关,因为已经到达列表的末尾)。最终效果跟下面的声明一样:
int a[] = {5, 7, 1, 8};
因此,数组的长度为4
。
问5:如果试图用赋值运算符把一个数组复制到另一个数组中,编译器将给出出错消息。哪里错了?
答:赋值语句a = b; /*a and b are arrays*/
,看似合理,但它确实是非法的。非法的理由不是显而易见的,这需要用到C语言中数组和指针之间的特殊关系,这一点将在第12章探讨。
把一个数组复制到另一个数组中的最简单的实现方法之一是,利用循环对数组元素逐个进行复制:
for (i = 0; i < N; i++)
a[i] = b[i];
另一种可行的方法是使用来自
头的函数memcpy
(意思是“内存复制”)。memcpy
函数(23.6节)是一个底层函数,它把字节从一个地方简单地复制到另一个地方。为了把数组b
复制到数组a
中,使用函数memcpy
的格式如下:
memcpy(a, b, sizeof(a));
许多程序员倾向于使用memcpy
函数(特别是处理大型数组时),因为它潜在的速度比普通循环更快。
问6:C99不允许
goto
语句绕过变长数组的声明。为什么会有这一限制呢?
答:在程序执行过程中,遇到变长数组声明时通常就为该变长数组分配内存空间了。用goto语句绕过变长数组的声明可能会导致程序对未分配空间的数组中的元素进行访问。
本文是博主阅读《C语言程序设计:现代方法(第2版·修订版)》时所作笔记,日后会持续更新后续章节笔记。欢迎各位大佬阅读学习,如有疑问请及时联系指正,希望对各位有所帮助,Thank you very much!