清风日常

Stay curious. Stay gentle.

听一会儿

4 首收藏
LOVE 2000 封面

LOVE 2000

遠野ひかる

0:004:23
首页/C Primer Plus/正文

C Primer Plus(第6版)· 第10章:数组和指针

数组把同一种类型的数据组织起来,指针则让程序能够通过地址访问这些数据。把数组交给函数处理时,需要同时说明数据从哪里开始、包含多少元素,以及函数能否修改它们。

文章目录 54 个章节

第10章:数组和指针

数组把同一种类型的数据组织起来,指针则让程序能够通过地址访问这些数据。把数组交给函数处理时,需要同时说明数据从哪里开始、包含多少元素,以及函数能否修改它们。

理解本章最重要的一点是:数组是存放元素的对象,指针是保存地址的对象;数组表达式在多数场合会转换为指向首元素的指针,但数组本身不是指针。

1. 数组的基本结构:类型、数量和连续存储

c
int scores[5];

这条声明创建一个包含 5 个 int 元素的数组。数组元素类型相同,依次排列在连续的存储空间中。

text
下标:       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 初始化列表与自动推导长度

c
int values[5] = {3, 6, 9, 12, 15};
int primes[] = {2, 3, 5, 7};

第二个声明省略了长度,编译器根据初始化列表确定它有 4 个元素。

省略长度可以减少重复,但不会检查业务上是否漏写了数据。如果原本需要 12 项,实际只写了 10 项,编译器会正常创建一个包含 10 项的数组。

2.2 部分初始化会补零

c
int first[5] = {7, 8};      // 7、8、0、0、0
int second[5] = {0};        // 全部为 0
double rates[3] = {0};      // 全部为 0.0

只要提供了初始化列表,未显式指定的元素就按规则初始化为零值。

但是下面的声明不同:

c
void task(void)
{
    int data[5];
    // 先给元素赋值,再读取它们。
}

函数内部没有 static 的这种自动数组,不会因为声明而自动清零。未初始化的元素具有不确定值,不能把读取结果当作某种可用的“随机数据”。

具有静态存储期的数组,例如文件作用域数组和用 static 声明的局部数组,未显式初始化时会被零初始化。不能把它们的规则直接套用到普通局部数组。

2.3 指定初始化器:直接指出下标

C99 可以在列表中使用 [下标] = 值:

c
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 初始化与后续赋值不同

声明后可以逐个修改元素:

c
int values[4];

for (int i = 0; i < 4; ++i) {
    values[i] = i * 3;
}

但不能把初始化列表直接当作整个数组的赋值语法,也不能用普通赋值运算复制数组:

c
int source[3] = {2, 4, 6};
int target[3];

// target = source;       // 错误:不能整体赋值数组。
// target = {2, 4, 6};    // 错误:这不是合法的数组赋值。

复制时需要逐个元素处理,或者使用适当的库函数。数组与数组之间不是通过 = 复制内容的。

3. 数组大小与访问边界

3.1 元素个数不等于最后一个下标

包含 n 个元素的数组,有效下标是 0 到 n - 1。

c
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 用数组本身计算元素个数

c
int data[] = {5, 10, 15, 20};
size_t count = sizeof data / sizeof data[0];

sizeof 的结果类型是 size_t,可以通过 <stddef.h> 等标准头文件取得该类型的声明;输出它使用 %zu。

c
printf("count = %zu\n", count);

这个计算成立的前提是:data 在这里确实是数组对象,而不是一个指针。

3.3 固定长度通常使用整型常量表达式

c
#define MONTHS 12
enum { DAYS = 7 };

int monthly[MONTHS];
int weekly[DAYS];
int buffer[2 * DAYS + 1];

标准 C 中,普通数组定义的长度不能为负数或零,也不能是浮点数。

还要区分 C 与 C++ 中 const 的规则。在本章采用的 C99/C11 规则下:

c
void task(void)
{
    const int count = 6;
    int data[count];   // 属于变长数组,需要实现支持 VLA。
    data[0] = 1;
}

这里的 count 虽然不能被修改,但一般不是 C 所要求的整型常量表达式。需要固定长度时,使用宏或枚举常量更清楚。

4. 数组与指针:先分清对象,再分析表达式

4.1 数组表达式通常转换为首元素指针

c
int data[4] = {10, 20, 30, 40};
int *p = data;

初始化 p 时,data 转换为指向 data[0] 的指针。这与下面的写法效果相同:

c
int *p = &data[0];

p 是一个独立的指针变量。修改 p 可以让它指向其他元素,但不会搬动数组,也不会改变数组名所代表的对象。

c
++p;       // p 现在指向 data[1]。
// ++data; // 错误:数组名不是可以递增的指针变量。

4.2 下标运算可以用指针运算解释

当下标有效时,以下写法等价:

c
data[i]       // 元素
*(data + i)   // 同一个元素

&data[i]      // 元素地址
data + i      // 同一个地址

所以指针变量也能使用下标:

c
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 指针对象的字节数

前面几个地址表达式描述的存储起点相同,但类型不同。类型决定后续运算如何解释地址:

c
data + 1    // 跨过一个 int。
&data + 1   // 跨过整个 int[4] 数组,到达其末后一位。

sizeof 和一元 & 是不发生通常数组到指针转换的典型场合。字符数组由字符串字面量初始化也是相关的特殊场合,字符串的具体规则在下一章展开。

不要把“数组在多数表达式中转换为指针”简化成“数组就是一个常量指针”。它们是不同类型的对象,sizeof 和取地址时尤其能看出差别。

5. 指针运算:以所指对象为单位移动

5.1 加 1 是移动一个元素

c
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:

c
ptrdiff_t distance = &data[3] - &data[1];
printf("distance = %td\n", distance);  // 2 个元素,而非 2 字节。

打印对象地址使用 %p,并将实参转换为 void *:

c
printf("p = %p, &p = %p\n", (void *)p, (void *)&p);

p 的值改变,不会让保存 p 的那个指针对象移动位置。

5.3 末后一位指针可以形成,不能解引用

c
int data[4] = {10, 20, 30, 40};
int *end = data + 4;

end 可以用来判断遍历何时结束,但不指向一个实际数组元素。

text
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,指针不动

还要区分:

c
*(p + 2)   // 移动两个元素的位置,再取值。
*p + 2     // 当前元素的值加 2。

刚开始学习时,可以把移动和取值拆成两条语句。不要在同一个复杂表达式中多次修改同一指针,再依赖未经保证的求值顺序。

5.5 声明指针不等于创建被指向的对象

c
int *p;
// *p = 10;  // 错误:p 尚未指向一个有效对象。

正确做法是先取得某个有效对象的地址:

c
int value = 0;
int *p = &value;
*p = 10;

也可以先把暂时没有目标的指针初始化为空指针,但空指针仍然不能解引用。访问前必须让它指向一个有效、仍在生存期内的对象。

6. 数组交给函数:地址与范围一起传递

6.1 数组形参会调整为指针形参

下面三种声明中的第一个形参,都会调整为 int *:

c
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 项。

调用时,函数接收的是地址值的副本,没有复制整个数组:

c
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 函数内部不能用形参恢复数组长度

c
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 两种常用范围接口

一种接口使用“起点和元素个数”:

c
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;
}

另一种接口使用“起点和结束位置”:

c
double sum_range(const double *begin, const double *end)
{
    double total = 0.0;

    while (begin != end) {
        total += *begin;
        ++begin;
    }

    return total;
}

第二种采用半开区间 [begin, end),包含起点,不包含结束位置。调用示意:

c
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 区分读取与修改

c
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 含义相同。

c
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 *,增加只读访问限制;不能通过普通隐式赋值去掉这个限制。

但不要把这条规则直接扩展到任意多级指针:

c
int value = 1;
int *p = &value;
int **pp = &p;

// const int **wrong = pp; // 类型不兼容,不能这样添加深层 const。

如果允许这种转换,就可能通过 wrong 把某个真正的 const int 的地址写进 p,再经由 p 尝试修改常量对象。多级指针必须检查每一层的类型,不能靠强制转换掩盖问题。

8. 多维数组:把“元素”理解成一整行

8.1 二维数组是数组的数组

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

可以分两层理解:

  • table 有 2 个元素,每个元素都是包含 3 个 int 的数组。
  • table[0] 是第一行,table[1] 是第二行。
text
table[0] → [1] [2] [3]
table[1] → [4] [5] [6]

内存顺序:第一行的元素 → 第二行的元素

table[1][2] 表示第二行第三列的值,即 6。

三维数组可继续使用相同方法理解:

c
int box[2][3][4];

box 有 2 个元素,每个元素是一个 3 × 4 的二维数组。

8.2 内层花括号会影响部分初始化的结果

c
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]:

c
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] 指向整个二维数组

因此:

c
table + 1      // 移动一行,即跨过 3 个 int。
table[0] + 1   // 在第一行内移动一个 int。

即使转换成 void * 后打印出的起始地址相同,也不能互换它们的指针类型。

9.2 逐层拆开下标

c
table[r][c]
*(*(table + r) + c)

两者表示同一个元素。可以分成四步:

  1. table + r:指向第 r 行。
  2. *(table + r):得到这一行数组,在后续加法中转换为行首元素指针。
  3. *(table + r) + c:指向这一行的第 c 个元素。
  4. 再解引用:访问这个整数。

实际编写程序时,table[r][c] 通常更清楚。展开表达式是为了理解类型和寻址过程。

9.3 数组指针与指针数组

c
int (*row)[3];  // 一个指针,指向包含 3 个 int 的数组。
int *items[3];  // 一个数组,里面有 3 个 int * 元素。

声明中的 [] 比 * 结合得更紧,因此第一种需要括号。

c
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 **

c
int table[2][3];
int (*rows)[3] = table;  // 类型匹配。
// int **wrong = table; // 类型不匹配。

二维数组的行里直接存放整数,没有在每行开头额外储存一个指针。int ** 则要求先找到一个 int * 对象,再通过那个指针访问整数。

如果确实创建了指针数组,它在通常表达式中才会转换为指向指针的指针:

c
int first[2] = {1, 2};
int second[3] = {3, 4, 5};
int *groups[2] = {first, second};
int **p = groups;

这里的两组整数可以位于不同位置、拥有不同长度,与连续的规则二维数组是不同布局。

10. 把多维数组传给函数

10.1 固定列数,显式传入行数

c
enum { COLS = 3 };

int sum_matrix(int data[][COLS], size_t rows);
int sum_matrix(int (*data)[COLS], size_t rows);

这两种原型等价,形参都是指向一行的指针。

最左边的数组长度可以省略,因为它会被调整掉;后面的长度要确定,用来描述一行的类型与大小。

c
// int sum_matrix(int data[][], size_t rows); // 错误:缺少行宽信息。

写成 int data[20][COLS] 也不会创建形参数组,普通写法中的 20 不会自动保证传入了 20 行。

对于更多维数组,同样保留后续维度:

c
void process_box(int data[][3][4], size_t layers);
// 等价的指针形参是 int (*data)[3][4]。

10.2 行与列对应不同的遍历顺序

行求和:固定行,遍历列;列求和:固定列,遍历行。

下面是一个完整程序:

c
#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);
}

输出:

text
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 可以复用处理一行的函数

二维数组的某一行仍然是一维数组,因此可以调用已有的一维函数:

c
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 “变长”指创建时长度可由运行时值决定

c
void task(int count)
{
    if (count <= 0 || count > 100) {
        return;
    }

    int data[count];

    for (int i = 0; i < count; ++i) {
        data[i] = i;
    }
}

每次执行到声明时,可以使用不同的 count 创建数组。但数组创建以后,它的长度就固定了:

c
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 让二维函数同时接收行数和列数

c
int sum_vla(size_t rows, size_t cols, int data[rows][cols]);

尺寸参数放在数组参数前面,让后面的声明能够引用它们。

函数中的 data 仍然调整为指针,指向一个包含 cols 个整数的行数组,没有复制整个二维数组。data + 1 跨过多少元素,由本次调用的 cols 决定。

下面的程序同时处理固定数组和 VLA:

c
#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;
}

输出:

text
fixed: 21
vla: 21

调用者必须提供与真实布局一致的列数,以及不超过实际可访问范围的行数。把列数传错,会让函数使用错误的行跨度;函数不会从指针里自动识别真实尺寸。

原型还可以写成下面这样,使用 * 表示原型中的变长维度:

c
int sum_vla(size_t, size_t, int data[*][*]);

定义中仍需提供函数体使用的参数名和相应维度表达式。通常保留有含义的参数名更易读。

12. 复合字面量:创建一个匿名数组对象

12.1 语法与用途

C99 支持通过“括号内的类型 + 花括号初始化列表”创建匿名对象:

c
(int[3]){3, 6, 9}
(double[]){1.5, 2.5, 3.5}

这不是把某个值强制转换为数组,而是在创建一个实际存在的数组对象。数组长度可以明确写出,也可以由初始化列表推导,但不能使用 VLA 类型。

可以用指针保存它的首元素地址,也可以直接作为函数实参:

c
int *p = (int[]){3, 6, 9};
p[1] = 8;  // 对象没有 const 限定,可以修改。

double total = sum_array((double[]){1.5, 2.5, 3.5}, 3);

它并非只能在创建的那一条语句中使用。只要匿名对象仍在生存期内,就可以通过保存的指针继续访问。

12.2 多维复合字面量

c
int (*rows)[2] = (int[][2]){
    {1, 2},
    {3, 4},
    {5, 6}
};

printf("%d\n", rows[2][1]);  // 6

最左边的长度可以由初始化列表推导,后面的长度仍然需要给出,以确定每行的结构。

12.3 生存期由出现位置决定

在 C99/C11 的规则下:

出现位置 匿名对象的存储期
函数外部 静态存储期
函数体内的块中 与相应块执行相关的自动存储期

块内的复合字面量不会在语句分号处立即消失,但离开相应块后,不能继续通过指针访问它。

c
int *p = NULL;

{
    p = (int[]){2, 4, 6};
    printf("%d\n", p[1]);  // 此时对象仍然存在。
}

// 此时不能再使用 *p 或 p[1]。

同理,不能从函数返回指向其局部复合字面量的指针,然后指望调用方继续使用那些元素。

13. 常见数组算法与接口约定

数组算法常常只有一个循环,但循环之前的约定很重要:处理多少元素、是否允许空范围、目标空间是否足够,以及输入输出是否可以重叠。

13.1 复制与选择子区间

c
void copy_array(double target[], const double source[], size_t count)
{
    for (size_t i = 0; i < count; ++i) {
        target[i] = source[i];
    }
}

这里约定源和目标是不重叠的有效范围,且都至少能访问 count 个元素。这个简单的正向循环不能一般性地处理所有重叠情况。

要复制一部分,不需要重写函数:

c
double source[6] = {10, 20, 30, 40, 50, 60};
double target[3];

copy_array(target, source + 2, 3);
// target 得到 30、40、50。

“地址 + 个数”可以表示整个数组,也可以表示其中的连续片段。

13.2 最大值与最大值下标

c
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 原地逆序

c
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 对应元素相加

c
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,修改函数接收可写数组,复制函数同时包含输入范围和输出范围。

c
#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');
}

输出:

text
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 又会跨过多大的对象。把这些问题回答清楚,再检查访问范围和生存期,数组与指针的关系就会更容易把握。

Aron 的清风日常

作者 Aron · 发布于 2026.08.04

愿这份记录能为你提供一点参考。发现问题,欢迎通过 GitHub 或 邮件交流。

如果这篇记录对你有用,留下一份喜欢。

← 所有文章继续阅读C Primer Plus(第6版)· 附录整理:语法与标准库速查 →
SEARCH MY NOTES

在记录里,找一点答案

搜索标题、标签与全文 · 按 Esc 关闭