数组是一块固定大小、由相同类型元素组成的内存,元素在内存中一个接一个地排列。这种连续布局就是全部的关键:正因为如此,索引访问是瞬时的,数组和指针关系如此紧密,C 语言也才能只给你第零个元素的地址,让你通过算术运算找到其余元素。
声明数组
元素类型、名称,再加上方括号里的大小:
int scores[5]; // 5 ints
double prices[100]; // 100 doubles
char initials[3]; // 3 chars
大小是元素的个数,必须在声明数组的地方就已知,通常是一个字面量或用 #define 定义的常量。内存会立即预留,之后不会再分配。
刚声明的局部数组里装的是垃圾值,而不是零:
第二行可能打印出零,可能打印出很大的数,而且每次运行、调试版和发布版之间都可能不同。读取这些值是未定义行为;先初始化,再读取。
初始化
有几种写法,都很有用:
最值得记住的是 {0} 这个惯用法:没有列出的元素都会被初始化为零,所以一个零就能把整个数组清零。指定初始化器([4] = 99)让你设置特定位置,其余保持为零,这对大部分为空的查找表很方便。
有一件事做不到:在声明之后再给整个数组赋值:
int a[3] = {1, 2, 3};
int b[3];
b = a; // error: an array is not assignable
memcpy(b, a, sizeof a); // this is how you copy one
索引从零开始
第一个元素是 arr[0],最后一个是 arr[n-1]。不存在 arr[n]。
运行最后那个循环,看看这些地址:每个地址都正好比前一个多 sizeof(int) 个字节。元素确实是首尾相接排列的:
index 0 1 2 3 4
+--------+--------+--------+--------+--------+
value | 88 | 95 | 75 | 60 | 100 |
+--------+--------+--------+--------+--------+
offset +0 +4 +8 +12 +16 bytes
^
&scores[0], which is also what `scores` decays to
从零开始索引并不是随意的选择:它让索引成为相对起点的偏移量,而这正是地址运算所需要的。scores[i] 被定义为 *(scores + i);详见指针与数组。
求数组长度
C 语言在运行时不会把数组长度存在任何地方,但编译器知道它,而 sizeof 能让你拿到它:
sizeof(arr) / sizeof(arr[0]) 是惯用写法。除以 arr[0] 而不是写死的 sizeof(double),意味着即使你改了元素类型,这行代码也照样有效。
陷阱在于:这只在声明数组的地方有效。把数组传给函数后,形参是一个指针,sizeof 测量的就变成了指针,通常是 8 个字节,得出的“长度”是 1 或 2。这就是为什么 C 语言里每个接收数组的函数都还要接收一个元素个数:
遍历数组
标准写法是一个从 0 开始、条件为 i < n 的 for 循环:
注意是 i < n,不是 i <= n。有 n 个元素时,有效索引是 0 到 n - 1,所以 <= 会多跑一轮,读到末尾之外。用 temps[0] 而不是 0 来初始化 hottest 和 coldest,是另一个值得照搬的习惯:把最大值从零开始算,遇到全是负数的数据时会悄悄出错。
越界是未定义行为
这是 C 语言里最让从其他语言转过来的人吃惊的部分。它没有边界检查。编译时没有,运行时也没有。对一个五个元素的数组访问 arr[10] 并不是错误,它只是编译器一声不吭执行的一次地址计算。
int arr[5] = {1, 2, 3, 4, 5};
arr[7] = 99; // writes 8 bytes past the end of the array
int x = arr[-1]; // reads before the start
接下来发生的是未定义行为,它的症状五花八门,毫无帮助:
- 看起来正常运行,因为它碰到的内存恰好没被使用;
- 另一个变量莫名其妙地变了;
- 程序因段错误(segmentation fault)崩溃,而且可能是很久以后;
- 在
-O0和-O2下表现不同。
“看起来正常运行”才是最危险的,因为这意味着 bug 会被发布出去。三种防御方法:
- 用
i < n写循环。大多数越界都是循环条件里的差一错误。 - 校验来自输入的索引。
- 开发时用 sanitizer 编译。
gcc -fsanitize=address -g prog.c会把大多数越界变成一份即时报告,指出文件、行号和涉及的数组。
其他类型的数组
同样的语法适用于任何元素类型,包括结构体:
char word[6] = "hello"; 这一行值得停下来看看:C 语言的字符串只是一个最后一个有效字节为 '\0' 的 char 数组。正是因为这个结束符,五个字母需要六个位置。
变长数组简介
C99 允许数组的大小是一个运行时的值:
int n = get_count();
int buffer[n]; // a variable-length array
有两点要注意。数组一旦创建,大小就固定了,所以变长数组(VLA)不是一个可以增长的列表。而且它的内存来自栈,如果 n 很大或被攻击者控制,就可能导致栈溢出并让程序崩溃,这也是包括 Linux 内核在内的许多项目直接禁用 VLA 的原因。另外,C11 的实现可以选择不支持它。
当大小确实要到运行时才知道,就改用动态分配:
注意,无论 arr 是数组还是指向已分配内存的指针,arr[i] 的读法完全一样:索引语法并不在乎。详细内容见动态内存。
常见问题
C 语言中如何声明数组?
写出元素类型、名称,再在方括号里写大小:int scores[5]; 为五个 int 预留空间。大小必须是在声明处就已知的常量(变长数组除外),而且除非你提供初始值,否则元素一开始是未初始化的。
C 语言中如何求数组的长度?
用 sizeof(arr) / sizeof(arr[0]):总字节数除以单个元素的大小。它只在声明数组的作用域内有效:数组一旦传给函数,就退化成了指针,sizeof 测量的就变成了指针的大小。
C 语言中越过数组末尾会发生什么?
没有任何东西会阻止你。C 语言不做边界检查,所以对一个 5 个元素的数组访问 arr[10],会读写那个位置上的任意内存。这是未定义行为:可能打印出乱码,可能破坏另一个变量,也可能崩溃,而且它可能一直看起来正常,直到某天出问题。
C 语言中如何把数组的所有元素初始化为零?
写 int arr[100] = {0};:没有列出的元素都会被初始化为零,所以一个零就能把整个数组清零。在 C23 中 int arr[100] = {}; 也可以。如果没有任何初始化器,局部数组的内容就是不确定的垃圾值。