第10章:数组和指针
数组把同一种类型的数据组织起来,指针则让程序能够通过地址访问这些数据。把数组交给函数处理时,需要同时说明数据从哪里开始、包含多少元素,以及函数能否修改它们。
理解本章最重要的一点是:数组是存放元素的对象,指针是保存地址的对象;数组表达式在多数场合会转换为指向首元素的指针,但数组本身不是指针。
1. 数组的基本结构:类型、数量和连续存储
int scores[5];
这条声明创建一个包含 5 个 int 元素的数组。数组元素类型相同,依次排列在连续的存储空间中。
下标: 0 1 2 3 4
元素: scores[0] scores[1] scores[2] scores[3] scores[4]
| 信息 | 含义 |
|---|---|
int |
每个元素的类型 |
scores |
数组名 |
5 |
元素个数 |
scores[2] |
下标为 2 的元素,即第 3 个元素 |
sizeof scores |
整个数组占用的字节数 |
sizeof scores[0] |
一个元素占用的字节数 |
如果一个实现中的 int 占 4 字节,数组总共占 20 字节。不过,编写代码时应使用 sizeof,不要把某台计算机上的类型大小写成通用规则。
元素类型还可以是 double、char,甚至另一个数组。多维数组就是在最后一种情况上建立起来的。
2. 初始化:先确定数组中有哪些值
2.1 初始化列表与自动推导长度
int values[5] = {3, 6, 9, 12, 15};
int primes[] = {2, 3, 5, 7};
第二个声明省略了长度,编译器根据初始化列表确定它有 4 个元素。
省略长度可以减少重复,但不会检查业务上是否漏写了数据。如果原本需要 12 项,实际只写了 10 项,编译器会正常创建一个包含 10 项的数组。
2.2 部分初始化会补零
int first[5] = {7, 8}; // 7、8、0、0、0
int second[5] = {0}; // 全部为 0
double rates[3] = {0}; // 全部为 0.0
只要提供了初始化列表,未显式指定的元素就按规则初始化为零值。
但是下面的声明不同:
void task(void)
{
int data[5];
// 先给元素赋值,再读取它们。
}
函数内部没有 static 的这种自动数组,不会因为声明而自动清零。未初始化的元素具有不确定值,不能把读取结果当作某种可用的“随机数据”。
具有静态存储期的数组,例如文件作用域数组和用 static 声明的局部数组,未显式初始化时会被零初始化。不能把它们的规则直接套用到普通局部数组。
2.3 指定初始化器:直接指出下标
C99 可以在列表中使用 [下标] = 值:
int flags[8] = {[2] = 5, [6] = 9};
// 结果:0、0、5、0、0、0、9、0
int steps[] = {[3] = 10, 20, [7] = 40};
// 结果:0、0、0、10、20、0、0、40
理解这种初始化时,记住三个规则:
- 普通值接着最近一次指定的位置向后填入。
- 没有显式初始化的元素补零。
- 省略长度时,长度由最终涉及的最大下标加 1 确定。
如果一个元素被初始化多次,最终使用后一次指定的初始化值。为了保持可读性,不宜故意重复指定同一个元素,也不要在这些初始化表达式中安排有副作用的操作。
2.4 初始化与后续赋值不同
声明后可以逐个修改元素:
int values[4];
for (int i = 0; i < 4; ++i) {
values[i] = i * 3;
}
但不能把初始化列表直接当作整个数组的赋值语法,也不能用普通赋值运算复制数组:
int source[3] = {2, 4, 6};
int target[3];
// target = source; // 错误:不能整体赋值数组。
// target = {2, 4, 6}; // 错误:这不是合法的数组赋值。
复制时需要逐个元素处理,或者使用适当的库函数。数组与数组之间不是通过 = 复制内容的。
3. 数组大小与访问边界
3.1 元素个数不等于最后一个下标
包含 n 个元素的数组,有效下标是 0 到 n - 1。
enum { COUNT = 6 };
int data[COUNT] = {0};
for (int i = 0; i < COUNT; ++i) {
data[i] = i;
}
循环条件使用 i < COUNT。写成 i <= COUNT,最后一次就会访问不存在的 data[COUNT]。
越界访问属于未定义行为,可能破坏其他数据、导致崩溃,也可能暂时看不出异常。某次运行看起来成功,不能证明访问合法;更不能根据一次实验推断相邻变量在内存中的固定排列。
3.2 用数组本身计算元素个数
int data[] = {5, 10, 15, 20};
size_t count = sizeof data / sizeof data[0];
sizeof 的结果类型是 size_t,可以通过 <stddef.h> 等标准头文件取得该类型的声明;输出它使用 %zu。
printf("count = %zu\n", count);
这个计算成立的前提是:data 在这里确实是数组对象,而不是一个指针。
3.3 固定长度通常使用整型常量表达式
#define MONTHS 12
enum { DAYS = 7 };
int monthly[MONTHS];
int weekly[DAYS];
int buffer[2 * DAYS + 1];
标准 C 中,普通数组定义的长度不能为负数或零,也不能是浮点数。
还要区分 C 与 C++ 中 const 的规则。在本章采用的 C99/C11 规则下:
void task(void)
{
const int count = 6;
int data[count]; // 属于变长数组,需要实现支持 VLA。
data[0] = 1;
}
这里的 count 虽然不能被修改,但一般不是 C 所要求的整型常量表达式。需要固定长度时,使用宏或枚举常量更清楚。
4. 数组与指针:先分清对象,再分析表达式
4.1 数组表达式通常转换为首元素指针
int data[4] = {10, 20, 30, 40};
int *p = data;
初始化 p 时,data 转换为指向 data[0] 的指针。这与下面的写法效果相同:
int *p = &data[0];
p 是一个独立的指针变量。修改 p 可以让它指向其他元素,但不会搬动数组,也不会改变数组名所代表的对象。
++p; // p 现在指向 data[1]。
// ++data; // 错误:数组名不是可以递增的指针变量。
4.2 下标运算可以用指针运算解释
当下标有效时,以下写法等价:
data[i] // 元素
*(data + i) // 同一个元素
&data[i] // 元素地址
data + i // 同一个地址
所以指针变量也能使用下标:
int *p = data;
printf("%d\n", p[2]); // 30
使用 [] 不代表对象一定是数组,使用 * 也不代表就比下标写法更高效。选择能清楚表达操作目的的写法即可。
4.3 data、&data[0] 与 &data 的区别
对于 int data[4]:
| 表达式 | 类型或转换后的类型 | 含义 |
|---|---|---|
data |
多数表达式中转换为 int * |
指向第一个整数 |
&data[0] |
int * |
指向第一个整数 |
&data |
int (*)[4] |
指向整个数组 |
sizeof data |
结果为 size_t |
整个数组的字节数 |
sizeof p,其中 p 为 int * |
结果为 size_t |
指针对象的字节数 |
前面几个地址表达式描述的存储起点相同,但类型不同。类型决定后续运算如何解释地址:
data + 1 // 跨过一个 int。
&data + 1 // 跨过整个 int[4] 数组,到达其末后一位。
sizeof 和一元 & 是不发生通常数组到指针转换的典型场合。字符数组由字符串字面量初始化也是相关的特殊场合,字符串的具体规则在下一章展开。
不要把“数组在多数表达式中转换为指针”简化成“数组就是一个常量指针”。它们是不同类型的对象,sizeof 和取地址时尤其能看出差别。
5. 指针运算:以所指对象为单位移动
5.1 加 1 是移动一个元素
double readings[4] = {1.5, 2.5, 3.5, 4.5};
double *p = readings;
++p;
递增后,p 指向 readings[1]。移动距离由 sizeof(double) 决定,不是固定移动一个字节。
同样的规则适用于指向数组的指针:如果所指对象是一整行,加 1 就跨过一整行。
5.2 常用操作与条件
| 操作 | 结果 | 必须注意 |
|---|---|---|
p = &data[2] |
保存地址 | 指针类型应匹配 |
*p |
访问所指对象 | 必须指向仍然存在的有效对象 |
&p |
取得指针变量自身的地址 | 与 p 保存的地址含义不同 |
p + k、p - k |
得到移动后的指针 | 结果须在同一数组内或其末后一位 |
++p、--p |
修改指针变量 | 同样受数组边界限制 |
q - p |
相隔的元素个数 | 两者须属于同一数组或其末后一位,结果还须可表示 |
p < q |
比较前后位置 | 遍历数组时,应比较同一数组范围内的指针 |
p == q |
判断是否相等 | 不要由此推导无关对象之间的排列顺序 |
两个指针不能相加,也不能相乘。无关数组的指针不能通过相减来计算所谓的“间隔”。
指针差的类型是 ptrdiff_t,由 <stddef.h> 提供,输出使用 %td:
ptrdiff_t distance = &data[3] - &data[1];
printf("distance = %td\n", distance); // 2 个元素,而非 2 字节。
打印对象地址使用 %p,并将实参转换为 void *:
printf("p = %p, &p = %p\n", (void *)p, (void *)&p);
p 的值改变,不会让保存 p 的那个指针对象移动位置。
5.3 末后一位指针可以形成,不能解引用
int data[4] = {10, 20, 30, 40};
int *end = data + 4;
end 可以用来判断遍历何时结束,但不指向一个实际数组元素。
data data+1 data+2 data+3 data+4
↓ ↓ ↓ ↓ ↓
[10] [20] [30] [40] 结束位置
不可读取
因此,不能读取 *end 或 data[4],也不能继续形成 data + 5 这样的指针。并不是“只要不解引用,任意地址运算都合法”。
5.4 分清移动指针与修改元素
假设 p 指向一个仍有后续元素的可修改数组:
| 表达式 | 本次表达式的值 | 改变什么 |
|---|---|---|
*p++,即 *(p++) |
原位置的元素值 | 指针向后移动 |
*++p,即 *(++p) |
移动后位置的元素值 | 指针先向后移动 |
(*p)++ |
元素递增前的值 | 元素加 1,指针不动 |
++*p,即 ++(*p) |
元素递增后的值 | 元素先加 1,指针不动 |
还要区分:
*(p + 2) // 移动两个元素的位置,再取值。
*p + 2 // 当前元素的值加 2。
刚开始学习时,可以把移动和取值拆成两条语句。不要在同一个复杂表达式中多次修改同一指针,再依赖未经保证的求值顺序。
5.5 声明指针不等于创建被指向的对象
int *p;
// *p = 10; // 错误:p 尚未指向一个有效对象。
正确做法是先取得某个有效对象的地址:
int value = 0;
int *p = &value;
*p = 10;
也可以先把暂时没有目标的指针初始化为空指针,但空指针仍然不能解引用。访问前必须让它指向一个有效、仍在生存期内的对象。
6. 数组交给函数:地址与范围一起传递
6.1 数组形参会调整为指针形参
下面三种声明中的第一个形参,都会调整为 int *:
void process(int data[], size_t count);
void process(int *data, size_t count);
void process(int data[10], size_t count);
第三种写法中的 10 不会创建一个包含 10 个元素的形参数组,也不会自动检查实参数组是否有 10 项。
调用时,函数接收的是地址值的副本,没有复制整个数组:
void add_offset(int data[], size_t count, int offset)
{
for (size_t i = 0; i < count; ++i) {
data[i] += offset;
}
}
修改 data[i] 会修改调用方的元素。修改形参指针本身,例如 ++data,则只改变函数自己的指针副本。
这仍然遵循 C 按值传参的规则,只是本次传递的值是地址。
6.2 函数内部不能用形参恢复数组长度
void process(int data[], size_t count)
{
// sizeof data 得到的是指针大小。
// sizeof data / sizeof data[0] 不能求出实参数组的长度。
for (size_t i = 0; i < count; ++i) {
data[i] = 0;
}
}
长度应在调用处从实际数组计算,再显式传入。即使某个平台上指针大小刚好等于几个元素的大小,也不能把这个巧合当作数组长度。
6.3 两种常用范围接口
一种接口使用“起点和元素个数”:
double sum_array(const double data[], size_t count)
{
double total = 0.0;
for (size_t i = 0; i < count; ++i) {
total += data[i];
}
return total;
}
另一种接口使用“起点和结束位置”:
double sum_range(const double *begin, const double *end)
{
double total = 0.0;
while (begin != end) {
total += *begin;
++begin;
}
return total;
}
第二种采用半开区间 [begin, end),包含起点,不包含结束位置。调用示意:
double data[] = {1.0, 2.0, 3.0, 4.0};
double all = sum_array(data, 4);
double middle = sum_range(data + 1, data + 3); // 2.0 + 3.0
sum_range 要求两个指针来自同一数组的有效范围,且起点不在结束位置之后;结束位置也可以是数组的末后一位。调用方必须保证函数能从起点合法地走到终点。
6.4 用 const 区分读取与修改
double sum_array(const double data[], size_t count);
void scale_array(double data[], size_t count, double factor);
第一个接口表明函数通过该形参只读取元素;第二个接口允许修改元素。
const double data[] 调整后是 const double *data,不表示形参指针本身不能移动,也不要求调用者只能传入 const 数组。
普通一维数组和 const 一维数组都能交给读取接口;const 数组不能交给要求可修改元素的接口。
const 限制的是通过这个访问方式进行修改。若原对象可修改,程序仍可能通过另一个合法的非 const 指针修改它;它不会自动冻结所有别名。
7. const 放在哪里,就限制哪里
假设声明时都已经提供了有效地址:
| 声明 | 能否让 p 指向别处 |
能否通过 p 修改所指整数 |
|---|---|---|
int *p |
能 | 能 |
const int *p |
能 | 不能 |
int *const p |
不能 | 能 |
const int *const p |
不能 | 不能 |
int const *p 与 const int *p 含义相同。
int data[3] = {1, 2, 3};
const int *reader = data;
int *const fixed = data;
++reader; // 可以改变 reader。
// *reader = 8; // 不能通过 reader 修改元素。
*fixed = 8; // 可以修改 data[0]。
// ++fixed; // 不能改变 fixed。
对于一级指针,可以把 int * 转换为 const int *,增加只读访问限制;不能通过普通隐式赋值去掉这个限制。
但不要把这条规则直接扩展到任意多级指针:
int value = 1;
int *p = &value;
int **pp = &p;
// const int **wrong = pp; // 类型不兼容,不能这样添加深层 const。
如果允许这种转换,就可能通过 wrong 把某个真正的 const int 的地址写进 p,再经由 p 尝试修改常量对象。多级指针必须检查每一层的类型,不能靠强制转换掩盖问题。
8. 多维数组:把“元素”理解成一整行
8.1 二维数组是数组的数组
int table[2][3] = {
{1, 2, 3},
{4, 5, 6}
};
可以分两层理解:
table有 2 个元素,每个元素都是包含 3 个int的数组。table[0]是第一行,table[1]是第二行。
table[0] → [1] [2] [3]
table[1] → [4] [5] [6]
内存顺序:第一行的元素 → 第二行的元素
table[1][2] 表示第二行第三列的值,即 6。
三维数组可继续使用相同方法理解:
int box[2][3][4];
box 有 2 个元素,每个元素是一个 3 × 4 的二维数组。
8.2 内层花括号会影响部分初始化的结果
int first[2][3] = {{1, 2}, {3, 4}};
// 第一行:1、2、0
// 第二行:3、4、0
int second[2][3] = {1, 2, 3, 4};
// 第一行:1、2、3
// 第二行:4、0、0
内层花括号明确了每行的初始化范围。省略它们时,值按行依次填入。
通常保留各行的花括号更容易看出数据结构,也更容易发现某一行缺项。
8.3 多维数组的 sizeof
对于 int table[2][3]:
size_t rows = sizeof table / sizeof table[0];
size_t cols = sizeof table[0] / sizeof table[0][0];
sizeof table[0] 得到整行的大小,sizeof table[0][0] 得到一个整数的大小。
仍然要注意:这种方法用于实际数组对象;函数中调整后的指针形参不保留整个数组的大小。
9. 二维数组的指针类型与寻址过程
9.1 相同起点不代表相同类型
对于 int table[2][3]:
| 表达式 | 类型或通常转换后的类型 | 含义 |
|---|---|---|
table |
int (*)[3] |
指向第一行 |
table[0] |
int * |
指向第一行的第一个整数 |
&table[0][0] |
int * |
指向第一个整数 |
&table[0] |
int (*)[3] |
指向第一行 |
&table |
int (*)[2][3] |
指向整个二维数组 |
因此:
table + 1 // 移动一行,即跨过 3 个 int。
table[0] + 1 // 在第一行内移动一个 int。
即使转换成 void * 后打印出的起始地址相同,也不能互换它们的指针类型。
9.2 逐层拆开下标
table[r][c]
*(*(table + r) + c)
两者表示同一个元素。可以分成四步:
table + r:指向第r行。*(table + r):得到这一行数组,在后续加法中转换为行首元素指针。*(table + r) + c:指向这一行的第c个元素。- 再解引用:访问这个整数。
实际编写程序时,table[r][c] 通常更清楚。展开表达式是为了理解类型和寻址过程。
9.3 数组指针与指针数组
int (*row)[3]; // 一个指针,指向包含 3 个 int 的数组。
int *items[3]; // 一个数组,里面有 3 个 int * 元素。
声明中的 [] 比 * 结合得更紧,因此第一种需要括号。
int table[2][3] = {{1, 2, 3}, {4, 5, 6}};
int (*row)[3] = table;
printf("%d\n", row[1][2]); // 6
row 可以递增,因为它是指针变量;table 不能递增,因为它是数组名。
9.4 二维数组不能直接交给 int **
int table[2][3];
int (*rows)[3] = table; // 类型匹配。
// int **wrong = table; // 类型不匹配。
二维数组的行里直接存放整数,没有在每行开头额外储存一个指针。int ** 则要求先找到一个 int * 对象,再通过那个指针访问整数。
如果确实创建了指针数组,它在通常表达式中才会转换为指向指针的指针:
int first[2] = {1, 2};
int second[3] = {3, 4, 5};
int *groups[2] = {first, second};
int **p = groups;
这里的两组整数可以位于不同位置、拥有不同长度,与连续的规则二维数组是不同布局。
10. 把多维数组传给函数
10.1 固定列数,显式传入行数
enum { COLS = 3 };
int sum_matrix(int data[][COLS], size_t rows);
int sum_matrix(int (*data)[COLS], size_t rows);
这两种原型等价,形参都是指向一行的指针。
最左边的数组长度可以省略,因为它会被调整掉;后面的长度要确定,用来描述一行的类型与大小。
// int sum_matrix(int data[][], size_t rows); // 错误:缺少行宽信息。
写成 int data[20][COLS] 也不会创建形参数组,普通写法中的 20 不会自动保证传入了 20 行。
对于更多维数组,同样保留后续维度:
void process_box(int data[][3][4], size_t layers);
// 等价的指针形参是 int (*data)[3][4]。
10.2 行与列对应不同的遍历顺序
行求和:固定行,遍历列;列求和:固定列,遍历行。
下面是一个完整程序:
#include <stddef.h>
#include <stdio.h>
enum { ROWS = 2, COLS = 3 };
void show_totals(const int data[][COLS], size_t rows);
int main(void)
{
const int data[ROWS][COLS] = {
{2, 4, 6},
{1, 3, 5}
};
show_totals(data, ROWS);
return 0;
}
void show_totals(const int data[][COLS], size_t rows)
{
int total = 0;
for (size_t r = 0; r < rows; ++r) {
int row_total = 0;
for (size_t c = 0; c < COLS; ++c) {
row_total += data[r][c];
}
printf("row %zu: %d\n", r, row_total);
total += row_total;
}
for (size_t c = 0; c < COLS; ++c) {
int col_total = 0;
for (size_t r = 0; r < rows; ++r) {
col_total += data[r][c];
}
printf("col %zu: %d\n", c, col_total);
}
printf("total: %d\n", total);
}
输出:
row 0: 12
row 1: 9
col 0: 3
col 1: 7
col 2: 11
total: 21
行内的累加器要在每次处理新的一行时清零;全局总和的累加器则放在外层循环之外。
这里的数据数组与形参都使用了 const。在严格的 C99/C11 规则下,不能把一维数组中 int * 到 const int * 的转换规则直接套到指向数组的指针上。普通 int [R][C] 传给 const int (*)[C] 的写法可能被编译器作为扩展接受,但不宜据此认为它在这些标准下完全可移植。
10.3 可以复用处理一行的函数
二维数组的某一行仍然是一维数组,因此可以调用已有的一维函数:
double matrix[2][3] = {
{1.0, 2.0, 3.0},
{4.0, 5.0, 6.0}
};
double total = 0.0;
for (size_t r = 0; r < 2; ++r) {
total += sum_array(matrix[r], 3);
}
这种方法也适用于逐行复制、逐行求平均等任务。
虽然二维数组的行连续排列,仍应按行操作或使用正确的数组指针。不要把第一行的 int * 当作一个可以任意越过所有行边界的一维数组指针;内存连续并不会消除各个子数组自身的边界规则。
11. 变长数组:创建时确定大小
11.1 “变长”指创建时长度可由运行时值决定
void task(int count)
{
if (count <= 0 || count > 100) {
return;
}
int data[count];
for (int i = 0; i < count; ++i) {
data[i] = i;
}
}
每次执行到声明时,可以使用不同的 count 创建数组。但数组创建以后,它的长度就固定了:
int count = 3;
int data[count];
count = 8;
// data 仍然只有 3 个元素,没有扩展为 8 个。
“运行时确定大小”与“创建后自动扩容”是两件事。
11.2 使用条件
在本章采用的 C99/C11 范围内:
- VLA 的运行时长度必须大于零,并且要控制所需存储空间。
- 实际的局部 VLA 对象不能用
static或extern声明。 - 不能在声明 VLA 时提供初始化列表,应在创建后逐个赋值。
- VLA 对象的生存期与所在块的执行相关,离开相应作用域后不能继续访问。
- C99 引入 VLA;C11 将其支持设为可选,使用前需要确认实现是否支持。
对于实际的 VLA 对象,sizeof 可以在运行时得到整个数组的大小;对于调整后的 VLA 形参,sizeof 仍然得到指针大小。
11.3 让二维函数同时接收行数和列数
int sum_vla(size_t rows, size_t cols, int data[rows][cols]);
尺寸参数放在数组参数前面,让后面的声明能够引用它们。
函数中的 data 仍然调整为指针,指向一个包含 cols 个整数的行数组,没有复制整个二维数组。data + 1 跨过多少元素,由本次调用的 cols 决定。
下面的程序同时处理固定数组和 VLA:
#include <stddef.h>
#include <stdio.h>
int sum_vla(size_t rows, size_t cols, int data[rows][cols]);
int main(void)
{
int fixed[2][3] = {
{1, 2, 3},
{4, 5, 6}
};
size_t rows = 3;
size_t cols = 2;
int dynamic[rows][cols];
for (size_t r = 0; r < rows; ++r) {
for (size_t c = 0; c < cols; ++c) {
dynamic[r][c] = (int)(r * cols + c + 1);
}
}
printf("fixed: %d\n", sum_vla(2, 3, fixed));
printf("vla: %d\n", sum_vla(rows, cols, dynamic));
return 0;
}
int sum_vla(size_t rows, size_t cols, int data[rows][cols])
{
int total = 0;
for (size_t r = 0; r < rows; ++r) {
for (size_t c = 0; c < cols; ++c) {
total += data[r][c];
}
}
return total;
}
输出:
fixed: 21
vla: 21
调用者必须提供与真实布局一致的列数,以及不超过实际可访问范围的行数。把列数传错,会让函数使用错误的行跨度;函数不会从指针里自动识别真实尺寸。
原型还可以写成下面这样,使用 * 表示原型中的变长维度:
int sum_vla(size_t, size_t, int data[*][*]);
定义中仍需提供函数体使用的参数名和相应维度表达式。通常保留有含义的参数名更易读。
12. 复合字面量:创建一个匿名数组对象
12.1 语法与用途
C99 支持通过“括号内的类型 + 花括号初始化列表”创建匿名对象:
(int[3]){3, 6, 9}
(double[]){1.5, 2.5, 3.5}
这不是把某个值强制转换为数组,而是在创建一个实际存在的数组对象。数组长度可以明确写出,也可以由初始化列表推导,但不能使用 VLA 类型。
可以用指针保存它的首元素地址,也可以直接作为函数实参:
int *p = (int[]){3, 6, 9};
p[1] = 8; // 对象没有 const 限定,可以修改。
double total = sum_array((double[]){1.5, 2.5, 3.5}, 3);
它并非只能在创建的那一条语句中使用。只要匿名对象仍在生存期内,就可以通过保存的指针继续访问。
12.2 多维复合字面量
int (*rows)[2] = (int[][2]){
{1, 2},
{3, 4},
{5, 6}
};
printf("%d\n", rows[2][1]); // 6
最左边的长度可以由初始化列表推导,后面的长度仍然需要给出,以确定每行的结构。
12.3 生存期由出现位置决定
在 C99/C11 的规则下:
| 出现位置 | 匿名对象的存储期 |
|---|---|
| 函数外部 | 静态存储期 |
| 函数体内的块中 | 与相应块执行相关的自动存储期 |
块内的复合字面量不会在语句分号处立即消失,但离开相应块后,不能继续通过指针访问它。
int *p = NULL;
{
p = (int[]){2, 4, 6};
printf("%d\n", p[1]); // 此时对象仍然存在。
}
// 此时不能再使用 *p 或 p[1]。
同理,不能从函数返回指向其局部复合字面量的指针,然后指望调用方继续使用那些元素。
13. 常见数组算法与接口约定
数组算法常常只有一个循环,但循环之前的约定很重要:处理多少元素、是否允许空范围、目标空间是否足够,以及输入输出是否可以重叠。
13.1 复制与选择子区间
void copy_array(double target[], const double source[], size_t count)
{
for (size_t i = 0; i < count; ++i) {
target[i] = source[i];
}
}
这里约定源和目标是不重叠的有效范围,且都至少能访问 count 个元素。这个简单的正向循环不能一般性地处理所有重叠情况。
要复制一部分,不需要重写函数:
double source[6] = {10, 20, 30, 40, 50, 60};
double target[3];
copy_array(target, source + 2, 3);
// target 得到 30、40、50。
“地址 + 个数”可以表示整个数组,也可以表示其中的连续片段。
13.2 最大值与最大值下标
size_t max_index(const double data[], size_t count)
{
size_t best = 0;
for (size_t i = 1; i < count; ++i) {
if (data[i] > data[best]) {
best = i;
}
}
return best;
}
该函数要求 count > 0,且数据是可正常比较的数值。使用首元素作为初始候选,才能正确处理所有元素都为负数的情况。
使用严格的 > 会保留第一个最大值的下标;改成 >= 则会保留最后一个。
在此基础上,同时记录最小值,就能计算最大值与最小值的差。求平均值还需要明确非空条件,避免除以零。
13.3 原地逆序
void reverse_array(double data[], size_t count)
{
for (size_t i = 0; i < count / 2; ++i) {
size_t opposite = count - 1 - i;
double temp = data[i];
data[i] = data[opposite];
data[opposite] = temp;
}
}
每次交换两端的一对元素,只需循环一半次数。对于零元素范围,循环体不会执行,因此不会计算 count - 1,避免了无符号长度减 1 的问题。
13.4 对应元素相加
void add_arrays(double out[], const double left[],
const double right[], size_t count)
{
for (size_t i = 0; i < count; ++i) {
out[i] = left[i] + right[i];
}
}
三个范围都必须有足够的元素。最简单的使用约定是输出范围与两个输入范围互不重叠,避免写入影响尚未读取的输入。
把这些操作组合起来,就能完成“逐行统计、所有数据求平均、查找整体最大值”等任务。先写好处理一维数据的函数,再让二维函数负责逐行调用,可以减少重复。
14. 完整示例:复制、统计并修改一维数组
下面的程序展示三类接口:读取函数使用 const,修改函数接收可写数组,复制函数同时包含输入范围和输出范围。
#include <stddef.h>
#include <stdio.h>
double sum_array(const double data[], size_t count);
void copy_array(double target[], const double source[], size_t count);
void scale_array(double data[], size_t count, double factor);
void show_array(const double data[], size_t count);
int main(void)
{
const double source[] = {2.0, 4.0, 6.0, 8.0};
enum { COUNT = sizeof source / sizeof source[0] };
double work[COUNT];
copy_array(work, source, COUNT);
scale_array(work, COUNT, 1.5);
printf("source: ");
show_array(source, COUNT);
printf("work: ");
show_array(work, COUNT);
printf("average: %.1f\n", sum_array(work, COUNT) / COUNT);
printf("literal sum: %.1f\n",
sum_array((double[]){1.0, 2.0, 3.0}, 3));
return 0;
}
double sum_array(const double data[], size_t count)
{
double total = 0.0;
for (size_t i = 0; i < count; ++i) {
total += data[i];
}
return total;
}
void copy_array(double target[], const double source[], size_t count)
{
for (size_t i = 0; i < count; ++i) {
target[i] = source[i];
}
}
void scale_array(double data[], size_t count, double factor)
{
for (size_t i = 0; i < count; ++i) {
data[i] *= factor;
}
}
void show_array(const double data[], size_t count)
{
for (size_t i = 0; i < count; ++i) {
printf("%s%.1f", i == 0 ? "" : " ", data[i]);
}
putchar('\n');
}
输出:
source: 2.0 4.0 6.0 8.0
work: 3.0 6.0 9.0 12.0
average: 7.5
literal sum: 6.0
原始数据与工作数据分别存放在两个数组中。函数没有隐式复制数组,复制行为由 copy_array 的循环明确完成。
15. 容易混淆的规则对照
| 容易产生的理解 | 应当掌握的规则 |
|---|---|
| 数组名就是指针变量 | 数组表达式通常转换为首元素指针,数组本身仍是数组对象 |
声明 int *p 就能写入 *p |
必须先让 p 指向可写、有效且仍然存在的对象 |
包含 n 个元素,所以能访问 a[n] |
最后一个元素是 a[n - 1] |
| 局部数组没有初始化就全为零 | 普通自动数组的未初始化元素具有不确定值 |
函数里的 int a[10] 是独立的数组副本 |
形参会调整为指针,普通写法中的 10 不保证长度 |
用 sizeof 总能求出实参数组长度 |
数组形参已经是指针,长度需要另外传入 |
| 指针加 1 就是地址加一个字节 | 移动一个所指类型对象的大小 |
| 末后一位指针合法,因此可以读取它 | 可以用于表示结束位置,不能解引用 |
二维数组转换为 int ** |
它转换为指向一行数组的指针,例如 int (*)[3] |
const int *p 表示指针不能改变 |
它限制通过 p 修改整数,p 本身仍可改变 |
| VLA 会跟着长度变量自动扩容 | 只在创建时使用该变量确定长度 |
| 复合字面量在分号处失效 | 块内对象的生存期与所在块相关,仍不能越过该生存期使用 |
理解复杂声明和表达式时,可以先问:当前对象是什么类型,解引用一次得到什么,加 1 又会跨过多大的对象。把这些问题回答清楚,再检查访问范围和生存期,数组与指针的关系就会更容易把握。

