前置知识: C

数组:连续内存与越界边界

17 min入门

用「存全班 40 个成绩」的问题掌握数组:五种初始化写法与剩余元素清零实验、下标从 0 开始的偏移量心智模型、越界访问的 stack-buffer-overflow 现场、sizeof 求元素个数与函数内失效的原因、VLA 的 C11 抉择与栈风险,以及数组不能整体赋值、比较、传值的三个「不能」与替代。

前置知识

  • 已完成 变量与常量:会声明变量,理解「初始化」与「赋值」的区别;
  • 已完成 运算符与表达式:会基本的整型运算,知道整型提升这回事。

for 循环的语法细节在控制流程,本文只用最基础的 for (int i = 0; i < n; i++) 形式,没读过也能跟上。

分工说明:数组相关内容在本模块分三篇。本篇是主线教学,解决「数组怎么用、哪里会出事」;指针与数组的区别 专门拆数组名与指针的身份差异——sizeof 为什么在函数里失效、&arr 是什么类型、传参退化到底发生了什么;动态内存 接手「大小要等程序跑起来才知道」的场景。本文凡是写「先混个眼熟」的地方,都会注明由哪篇讲透。

学习目标

读完本文你将能够:

  1. 用完全初始化、部分初始化、全零、省略长度、指定初始化器五种写法声明并初始化数组,说出每种写法里「没写到」的元素是什么值;
  2. 用「偏移量」心智模型解释下标为什么从 0 开始,并预测 a[i] 距数组首地址多少字节;
  3. 读懂 AddressSanitizer 的 stack-buffer-overflow 报告,指出越界写在哪一行、落在哪个变量上;
  4. 用 sizeof 写出「数组元素个数」公式,解释它在函数内部失效的原因,并说清「传参即退化」;
  5. 说出数组不能整体赋值、不能 == 比较、不能整体传值的三个「不能」,以及各自的替代写法。

预计 50 到 70 分钟,包含 5 组动手实验、2 道预测题与 1 道挑战题。

1. 问题引入:全班 40 个成绩

老师要存全班 40 个人的期末成绩。不用数组的话:

c
/* scores_nightmare.c:不用数组存成绩 */
#include <stdio.h>

int main(void) {
    int s0 = 90;
    int s1 = 75;
    int s2 = 60;
    /* ...还有 37 个变量等着声明... */
    printf("%d %d %d\n", s0, s1, s2);
    return 0;
}

写到第三个变量就该停手了:求平均分要连加 40 个变量;找最高分要写 39 次比较;人数从 40 变成 45,所有代码重写一遍。问题出在「每个成绩一个独立变量」——40 个变量之间没有任何联系,编译器也不知道它们是一伙的。

你需要的是:40 个同类型的格子连成一排,起一个共同的名字,用编号访问每一个。这就是数组(array):类型相同、内存连续、个数固定的一组元素。上面的三个需求(求平均、找最大、批量处理)在数组面前都只是同一个循环。

2. 声明与初始化

2.1 声明:类型、名字、个数

c
int scores[40];      /* 40 个 int */
double prices[10];   /* 10 个 double */
char name[20];       /* 20 个 char */

个数必须是编译期常量:字面量、由常量构成的表达式都行,普通变量不行(变量做长度是 VLA,第 6 节单讲)。

2.2 初始化:五种写法

c
int a[5] = {1, 2, 3, 4, 5};       /* 完全初始化 */
int b[5] = {1, 2, 3};             /* 部分初始化 */
int c[10] = {0};                  /* 全部清零的最惯用写法 */
int d[] = {10, 20, 30, 40};       /* 省略长度,编译器数出 4 个 */
int e[8] = { [2] = 5, [7] = 1 };  /* 指定初始化器(C99):下标 2 放 5,下标 7 放 1 */

关键问题:没写到初值的元素是什么?做实验:

c
/* init_probe.c:验证「没写到的元素」的值 */
#include <stdio.h>

int main(void) {
    int b[5] = {1, 2, 3};
    int e[8] = { [2] = 5, [7] = 1 };
    int raw[5];                       /* 故意不初始化,做对照 */
    for (int i = 0; i < 5; i++) printf("b[%d]=%d ", i, b[i]);
    printf("\n");
    for (int i = 0; i < 8; i++) printf("e[%d]=%d ", i, e[i]);
    printf("\n");
    for (int i = 0; i < 5; i++) printf("raw[%d]=%d ", i, raw[i]);
    printf("\n");
    return 0;
}
bash
gcc -Wall -Wextra -g init_probe.c -o init_probe
./init_probe

一次典型输出(raw 那一行每次运行都可能不同):

text
b[0]=1 b[1]=2 b[2]=3 b[3]=0 b[4]=0
e[0]=0 e[1]=0 e[2]=5 e[3]=0 e[4]=0 e[5]=0 e[6]=0 e[7]=1
raw[0]=4200896 raw[1]=6422336 raw[2]=49 raw[3]=0 raw[4]=98

三条规则一次看清:

  1. 只要写了初始化列表(哪怕只写一部分),没覆盖到的元素一律为 0——这是标准保证,不是运气。所以 int c[10] = {0}; 是「全部清零」的惯用写法;
  2. 指定初始化器(designated initializer,C99 引入)用 [下标] = 值 跳着放,其余补 0,适合「绝大多数是默认值、个别项特殊」的配置表;
  3. 完全不初始化就是垃圾值——变量与常量 讲过的规则对数组同样成立。

修改实验一:把 e 的初始化改成 { [7] = 1, [2] = 5, 9 }——指定项可以乱序,而它后面不带 [下标] = 的 9 从上一个指定下标的下一个位置继续放,即落在 e[3]。先预测再运行验证。

2.3 字符数组与字符串

c
char chars[5] = {'H', 'e', 'l', 'l', 'o'};      /* 只是 5 个字符,不是字符串 */
char str[6]  = {'H', 'e', 'l', 'l', 'o', '\0'}; /* 补上结尾的 '\0',这才是字符串 */
char s1[] = "Hello";   /* 惯用写法:自动补 '\0',长度是 6 不是 5 */

C 语言的字符串(string)不是一种独立类型,而是以 '\0' 结尾的字符数组。所有字符串函数(strlen、printf 的 %s)都靠找这个结尾符工作——少了它,函数会一路读到越界为止,下场就是第 3 节的 ASan 事故现场。

还有一个新手必撞的差异:

c
char a[] = "hi";   /* 数组:把 'h' 'i' '\0' 三个字符拷进自己的 3 字节 */
char *p  = "hi";   /* 指针:指向只读的字符串字面量,p 里存的是字面量的地址 */

两者 sizeof 一个是 3、一个是 8(64 位平台),且 p[0] = 'H' 是修改字符串字面量的未定义行为(变量与常量 讲过字面量为什么改不得)。这一对差异是「数组与指针」问题的预告片,完整拆解见 指针与数组的区别。

划界说明:本节只负责「字符数组作为数组」的声明、初始化与 '\0' 约定。字符串这一知识类别的完整体系——string.h 函数族逐个详解(strlen/strcpy/strtok 等)、sizeof 与 strlen 的混用陷阱、日志解析与配置切分实战——集中在专篇 C 字符串处理,需要处理文本时直接去那里。

修改实验二:打印 sizeof a 与 sizeof p(用 %zu),验证 3 与 8。

3. 下标从 0 开始:偏移量心智模型

a[i] 的真实含义是「距数组开头第 i 个元素」。第 0 个元素偏移为 0,正好在开头——这就是下标从 0 开始的原因:下标不是「第几个」,是「偏移量」。

地址可以直接算出来:

text
&a[i] 的字节地址 = 数组首地址 + i × sizeof(元素类型)

int a[5] 若首地址为 1000,则 a[3] 在 1000 + 3 × 4 = 1012。a[i] 在语言层面还有个恒等式:a[i] 完全等价于 *(a + i)——这句先混个眼熟即可,它为什么成立、a 在这里变成了什么,指针与数组的区别 讲透。

偏移量模型立刻解释了越界为什么危险:a[5] 的意思是「开头往后第 5 个元素的位置」——那块内存不属于这个数组,可能是别的变量,可能是函数自己的账本(返回地址),也可能是没人管的地带。C 编译器不做下标检查,越界访问是未定义行为(undefined behavior,UB):可能崩溃,可能悄悄改坏别的数据,也可能碰巧没事——标准对结果不做任何承诺(UB 的常见条目见 运算符与表达式;越界引发的缓冲区溢出作为一类安全漏洞,在安全函数与边界检查 展开)。

3.1 越界事故现场:ASan 实录

c
/* overflow.c:经典 off-by-one,<= 应为 < */
#include <stdio.h>

int main(void) {
    int scores[5] = {0};
    for (int i = 0; i <= 5; i++) {   /* i=5 时越界 */
        scores[i] = i * 10;
    }
    printf("scores[4] = %d\n", scores[4]);
    return 0;
}

先直接编译运行:大概率正常打印 scores[4] = 40,一切看起来没事——这正是 UB 的阴险之处,没出事不等于没错,只是这次运气好。换 ASan(AddressSanitizer,内存错误检查器,编译开关与 210 篇相同)再跑:

bash
gcc -Wall -Wextra -g -fsanitize=address overflow.c -o overflow
./overflow

预期输出(地址每次不同,关键行如下):

text
==24510==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffe4a2c1a58
WRITE of size 4 at 0x7ffe4a2c1a58 thread T0
    #0 0x5a3c21b0d1f2 in main overflow.c:7
...
Address 0x7ffe4a2c1a58 is located in stack of thread T0 at offset 52 in frame
    #0 0x5a3c21b0d148 in main
  This frame has 1 object(s):
    [32, 52) 'scores' <== Memory access at offset 52 overflows this variable

逐行读:第一行点名事故类型 stack-buffer-overflow——栈上的缓冲区越界;WRITE of size 4 说明这是一次 4 字节写入(一个 int);overflow.c:7 指出出错行;最后的对象清单更直白:scores 占据 [32, 52) 这 20 个字节(5 个 int),而这次访问落在偏移 52——刚好越过一格。把 i <= 5 改成 i < 5,报告消失。

修改实验三:把写入改成 scores[100000] 这样的大偏移再跑 ASan,观察报告里 offset 数字的变化;再想想为什么「恰好越过一格」的小越界在真实程序里最难被发现。

4. sizeof 与元素个数

数组自己知道总字节数,sizeof 问得到;除以单个元素的大小,就是元素个数:

c
/* count.c:元素个数公式 */
#include <stdio.h>

int main(void) {
    int a[] = {1, 2, 3, 4, 5, 6};
    size_t n = sizeof a / sizeof a[0];
    printf("n = %zu\n", n);
    printf("bytes = %zu\n", sizeof a);
    return 0;
}
text
n = 6
bytes = 24

sizeof 的结果类型是 size_t,printf 要用 %zu(见 数据类型详解)。

但这个公式只在数组还是数组的地方有效。把它搬进函数就翻车:

c
/* decay.c:函数内的 sizeof */
#include <stdio.h>

void broken(int a[]) {           /* 写 int a[] 只是习惯,实际参数是 int *a */
    printf("in func:  %zu\n", sizeof a);   /* 指针的大小,不是数组的大小 */
}

int main(void) {
    int a[10];
    printf("in main:  %zu\n", sizeof a);   /* 40:还是完整的数组 */
    broken(a);
    return 0;
}

64 位平台一次典型输出:

text
in main:  40
in func:  8

原因一句话:数组作为参数传递时会退化为指向首元素的指针(decay),长度信息被丢掉,函数里的 sizeof a 量的是指针自己(8 字节)。所以 C 的惯例是长度总是作为额外参数跟着数组一起传。退化的完整机制、&a 与 a 的类型差异,见 指针与数组的区别。

修改实验四:把 broken 的参数写成 int a[10] 再跑一次,输出不变——参数方括号里的数字没有任何约束力,这正是 150 篇要拆的假象。

5. 多维数组:连续内存与按行遍历

5.1 二维数组:数组的数组

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

matrix[2][3] 是「2 行,每行 3 个 int」。可以理解为:matrix 是一个长度为 2 的数组,每个元素又是一行 int[3]。访问用 matrix[行][列],如 matrix[1][2] 是 6。

初始化规则与一维一致:没写到的补 0;行数可以省略让编译器数,列数不行(列数决定每行多宽,编译器要靠它定位任何一行):

c
int m1[2][3] = {{1, 2}, {4}};          /* 部分初始化:m1 是 {{1,2,0},{4,0,0}} */
int m2[][3]  = {{1, 2, 3}, {4, 5, 6}}; /* 行数自动推断为 2 */

三维及以上同理:int cube[2][2][2] 就是「数组的数组的数组」。

5.2 内存里没有「行」:只有连续的 6 个 int

二维数组在内存中仍然是连续一排,行优先(row-major)铺开:

text
matrix 的 24 字节(首地址记为 1000):
地址    1000 1004 1008 | 1012 1016 1020
内容       1    2    3 |    4    5    6
           [---- 第 0 行 ----][---- 第 1 行 ----]

matrix[i][j] 的地址同样能算:首地址 + (i × 列数 + j) × sizeof(int)——这也是为什么函数参数必须写明列数(5.4 节)。

5.3 按行遍历:缓存友好的直觉

既然内存按行铺开,「先走完一行再走下一行」的遍历顺序就与内存顺序一致:

c
/* row_major.c:两种遍历顺序,只差循环嵌套的先后 */
#include <stdio.h>
#include <time.h>

enum { N = 3000 };
int big[N][N];

int main(void) {
    clock_t t0 = clock();
    for (int i = 0; i < N; i++)            /* 外层走行:与内存顺序一致 */
        for (int j = 0; j < N; j++)
            big[i][j] = 1;
    printf("row order: %f s\n", (double)(clock() - t0) / CLOCKS_PER_SEC);

    t0 = clock();
    for (int j = 0; j < N; j++)            /* 外层走列:每一步都跳一整行 */
        for (int i = 0; i < N; i++)
            big[i][j] = 1;
    printf("col order: %f s\n", (double)(clock() - t0) / CLOCKS_PER_SEC);
    return 0;
}

一次典型输出(数值与机器有关,相对差距稳定):

text
row order: 0.011000 s
col order: 0.031000 s

同样的读写总量,按列遍历慢了好几倍:CPU 取内存不是一个字节一个字节取,而是把附近一整块(缓存行)一起搬进来。按内存顺序访问,每次搬运都被充分利用;跳着访问,每次搬运大多被浪费。「缓存友好」说的就是这件事,本实验先建立直觉即可,原理在体系结构课程深入。

5.4 多维数组作为参数

传二维数组时,除第一维外的所有维度必须写明:

c
void print_matrix(int m[][3], int rows) {
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < 3; j++) printf("%d ", m[i][j]);
        printf("\n");
    }
}

原因用 5.2 节的公式就能想通:函数要算第 i 行的地址,必须知道每行多宽;第一维(几行)则无所谓——所以行数习惯上单独作为参数传。三维及以上照此办理:int cube[2][2][2] 传参写成 int cube[][2][2]。

6. 变长数组:大小运行时才知道

数组长度必须编译期确定,可有些场景长度要等程序跑起来才知道(读入人数、文件行数)。C99 给了变长数组(VLA,variable length array):

c
/* vla.c:长度由参数决定 */
#include <stdio.h>

void fill(int n) {
    int arr[n];                 /* VLA:长度是运行时的 n */
    for (int i = 0; i < n; i++) arr[i] = i + 1;
    for (int i = 0; i < n; i++) printf("%d ", arr[i]);
    printf("\n");
}

int main(void) {
    fill(5);
    return 0;
}

三个必须知道的边界:

  1. VLA 不能用初始化列表(int arr[n] = {0} 编译错误),要清零就自己循环;
  2. 栈溢出风险。VLA 在栈上分配,而普通栈通常只有 1 到 8 MB(内存深水区 的五段布局)。int big[10 * 1000 * 1000] 已经是 40 MB;VLA 更糟——它没有失败返回值,栈要不到不是返回 NULL,而是当场崩溃或悄悄踩坏别的内存;
  3. C11 起它是可选特性。C99 曾强制要求支持 VLA,但嵌入式编译器普遍拒绝实现,C11 干脆把它降为可选:编译器若不支持,会定义宏 __STDC_NO_VLA__(MSVC 就不支持 VLA)。可移植代码应当探测:
c
#ifdef __STDC_NO_VLA__
/* 这个编译器不支持 VLA,改走固定大小或 malloc 的路线 */
#endif

(C23 起口径又动了:可变修改类型与堆上 VLA 重新成为必选,栈上自动存储期的 VLA 仍允许编译器缺席,细节见文末官方文档。)

那什么时候用 VLA、什么时候用 malloc?经验法则:小、临时、随函数进出的数据用 VLA 省事;大、要活着跨出函数、要扩容的数据交给 malloc——它有判 NULL 的机会,能 realloc 扩容,能 free 归还。malloc 四件套的完整教学在 动态内存。

7. 数组的三个「不能」与替代

数组有一组整齐的限制,根源是同一个:数组在表达式中代表的是「一排连续内存的首地址」,它自己不是一个可以整体操作的值。

不能整体赋值。 b = a 直接编译错误(数组不是可修改的值)。替代:循环逐个赋,或 memcpy(b, a, sizeof a);(#include <string.h>,按字节数整块拷贝)。

不能 == 比较。 a == b 居然能编译——但比较的是两个数组退化的首地址,结果永远是「不等」,纯属误导。替代:循环逐个比;字符串用 strncmp。

不能整体传值。 传给函数就退化成指针,长度丢掉(第 4 节)。替代:额外传一个长度参数。

字符数组(字符串)的替代品有现成函数(都来自 <string.h>):

需求数组直接写字符串用
赋值/拷贝b = a 编译错误strcpy(dest, src),带界的 strncpy
比较a == b 比的是地址strcmp(s1, s2) / strncmp(s1, s2, n),返回 0 表示相等
求长度sizeof 是含 '\0' 的总字节数strlen(s),数到 '\0' 为止,不含结尾符

注意 strcpy/strcat 不检查目标缓冲区大小——这正是历史上一大类安全漏洞的来源,安全替代与边界习惯见安全函数与边界检查;键盘输入一律用 fgets(自动留 '\0'、带长度上限),gets 因无法防溢出已被 C11 标准废除,行输入的细节见 标准库文件 IO。

预告一个反转:三个「不能」里的前两个,在结构体那里恰好反过来——结构体可以整体赋值,而且数组元素会被一起拷走。下一课的主角就是它。

8. 实际项目中的使用场景

  • 配置与查找表:指定初始化器把「默认值加个别覆盖」写成一张可读的表,下标即含义;
  • 各类缓冲区:串口、网络、文件的接收缓冲区都是定长字符数组,使用纪律是「写多少、判多少」,见 标准库文件 IO;
  • 网格与矩阵:棋盘、地图、灰度图都是二维数组,遍历方向直接决定性能(5.3 节);
  • 嵌入式固件:很多小型 MCU 没有堆或禁用 malloc,定长数组是唯一选择,容量按最坏情况预留,见 嵌入式 C 编程;
  • 数量上界未知:换 动态内存 的 malloc 与 realloc。

9. 小练习

预测题一(5 分钟):先写下答案再运行:

c
int a[6] = { [2] = 7, 8, 9 };

a[0] 到 a[5] 各是多少?

参考答案(先写再看):0 0 7 8 9 0。指定项落在下标 2;它后面不带 [下标] = 的值从下一个位置继续,所以 8 到下标 3、9 到下标 4;其余补 0。

预测题二(5 分钟):下面的片段在 64 位平台输出哪两行?

c
void count(int arr[]) { printf("A %zu\n", sizeof arr); }
int a[10];
count(a);
printf("B %zu\n", sizeof a);

参考答案(先写再看):A 8、B 40。参数位置的 arr 已退化为指针;main 里的 a 还是数组。

挑战题(30 分钟,不看答案先动手):成绩单统计器。用数组存 8 个成绩(初始化器给出),一遍循环同时求出最高分、最低分、平均分、及格(不低于 60)人数,最后逆序打印成绩。提示两级如下。

提示(思路方向):最高分、最低分、总和、及格数共四个变量与循环同步维护;平均分记得 (double)sum / n,否则整数除法截断(运算符与表达式)。

展开(关键点):元素个数用 sizeof scores / sizeof scores[0] 求,不要手写 8;逆序打印从 n - 1 走到 0;%zu 配 size_t。

验收清单:编译无警告;改一个成绩重新运行,四个统计量全部正确变化;平均分带小数。

练习库:数组专题五题(经典课堂题组,先纸上后上机)

题一(冒泡与选择排序同题对照,25 分钟):把 {64, 25, 90, 12, 22} 分别用冒泡排序与选择排序升序排列,两版都要写。 提示(思路方向):冒泡是「相邻两两比、大的往后冒」,每轮把当前最大值顶到末尾;选择是「每轮从未排序区挑最小值,与未排序区首位交换」。对照着数一数两者各交换几次——这是两种排序最直观的差异。

参考实现(先自己写,写完再对照,两版核心行如下):

c
/* 冒泡:相邻比较,逆序就交换 */
for (size_t i = 0; i + 1 < n; i++)
    for (size_t j = 0; j + 1 < n - i; j++)
        if (a[j] > a[j + 1]) { int t = a[j]; a[j] = a[j + 1]; a[j + 1] = t; }

/* 选择:每轮找最小,与未排序区首位交换(每轮至多换一次) */
for (size_t i = 0; i + 1 < n; i++) {
    size_t min = i;
    for (size_t j = i + 1; j < n; j++)
        if (a[j] < a[min]) min = j;
    if (min != i) { int t = a[i]; a[i] = a[min]; a[min] = t; }
}

易错点两处:内层边界 n - i 写成 n 会重复比较已归位区(不崩但白跑);size_t 做减法会下溢,所以循环条件写 i + 1 < n 而不是 i < n - 1(n 为 0 时后者回绕成天文数字)。工程排序直接用标准库 qsort(见 C 标准库),手写两版的价值是看清「交换」这一动作的两种组织方式。

题二(Fibonacci 前 20 项,10 分钟):用数组存斐波那契数列前 20 项(1, 1, 2, 3, 5, …),再正序打印。 提示(思路方向):fib[0] = fib[1] = 1; for (i = 2; i < 20; i++) fib[i] = fib[i-1] + fib[i-2];——数组在这里的价值是「算过的项留下」,与只用两个变量滚动计算对照,体会「以空间换可回溯」。

题三(数组循环右移,15 分钟):把 {1,2,3,4,5,6,7,8,9,10} 循环右移 3 位,得到 {8,9,10,1,2,3,4,5,6,7}。要求原地完成。 提示(思路方向):三段反转法——先整体反转得 {10,...,1},再反转前 k 个、反转后 n-k 个,三步全用「相向双指针反转区间」这一个动作;每步之后打印中间结果自查。

参考实现(先自己写,写完再对照):

c
static void reverse_range(int *a, size_t lo, size_t hi) {   /* 反转 [lo, hi] 闭区间 */
    while (lo < hi) { int t = a[lo]; a[lo] = a[hi]; a[hi] = t; lo++; hi--; }
}

void rotate_right(int *a, size_t n, size_t k) {
    if (n == 0) return;
    k %= n;                              /* 右移 n 位等于没移:先取模 */
    if (k == 0) return;
    reverse_range(a, 0, n - 1);
    reverse_range(a, 0, k - 1);
    reverse_range(a, k, n - 1);
}

易错点:k %= n 那行——右移 12 位在长度 10 的数组上等价于右移 2 位,漏了取模,下标 k - 1 直接越界。

题四(二维行列互换,15 分钟):把 int src[2][3] = {{1,2,3},{4,5,6}} 转置成 int dst[3][2],使 dst[j][i] == src[i][j]。 提示(思路方向):双重循环 dst[j][i] = src[i][j],两层下标换位即可;打印 dst 时外层跑列数。体会「二维数组是一片连续内存加一套读法约定」——转置换的是读法对应的下标,不是搬内存。

参考实现(先自己写,写完再对照):

c
int src[2][3] = {{1,2,3},{4,5,6}};
int dst[3][2];
for (int i = 0; i < 2; i++)
    for (int j = 0; j < 3; j++)
        dst[j][i] = src[i][j];

题五(求最大元素及行列号,10 分钟):在 int grid[3][4] 里找最大值,同时报出行号与列号。 提示(思路方向):row_best/col_best 两个下标与最大值同步维护,遇到「大于」(不是「大于等于」)才更新——想清楚「等于时更新」会怎样改变结果(取到靠后的那个)。

参考实现(先自己写,写完再对照):

c
int best = grid[0][0];
int row_best = 0, col_best = 0;
for (int i = 0; i < 3; i++)
    for (int j = 0; j < 4; j++)
        if (grid[i][j] > best) {
            best = grid[i][j];
            row_best = i;  col_best = j;
        }
printf("max=%d at (%d,%d)\n", best, row_best, col_best);

10. 与之前和之后的知识的关系

  • 往前:变量与常量 的「未初始化即垃圾值」在 2.2 节的 raw 数组上重演,而「写了初始化列表则剩余清零」是数组特有的强保证;运算符与表达式 的整数除法规则解释了挑战题里为什么要 (double)sum / n;
  • 旁支:数组名与指针的身份差异是 指针与数组的区别 的全部主题;memcpy/strncmp 的安全用法在安全函数与边界检查;
  • 往后:数组把「多个同类型」连成一行;当每个数据项的类型各不相同(姓名是字符数组、学号是整数、成绩是浮点数),就需要把不同成员打包成一个整体——结构体与联合 接棒,并兑现本文埋下的反转:数组包进结构体就能整体赋值。

11. 官方文档

12. 自我检查

  • 能默写五种初始化写法,并说清每种写法里「没写到」的元素是什么值;
  • 能用偏移量模型解释下标为什么从 0 开始,并算出 a[i] 的字节地址;
  • 拿到一份 stack-buffer-overflow 报告,能在三行内指出出错行、写入大小与被越界的变量;
  • 能向同事讲清 sizeof a / sizeof a[0] 为什么进了函数就失效,以及三个「不能」各自的替代写法。

本章总结

数组是类型相同、内存连续、个数固定的一排元素:下标从 0 开始,因为它是偏移量,a[i] 就是首地址往后第 i 个元素的位置。初始化列表只要出现,没覆盖的元素保证清零;指定初始化器允许跳位;字符串是 '\0' 结尾的字符数组。越界是未定义行为,ASan 能把「碰巧没事」变成当场点名;sizeof a / sizeof a[0] 只在数组还是数组的地方有效——传参即退化,长度必须额外传。VLA 把长度推迟到运行时,C11 起可选且有栈风险,大而长寿的数据交给 malloc。数组不能整体赋值、不能 == 比较、不能整体传值,替代是循环、逐个比较与长度参数;字符串有 strcpy/strncmp/strlen 一族。

下一步

进入 结构体与联合:数组解决「多个同类型」,结构体解决「一组不同类型」——把姓名、学号、成绩打包成一个整体,并解答本文留下的悬念:为什么结构体可以整体赋值,而数组不行。