数组:连续内存与越界边界
用「存全班 40 个成绩」的问题掌握数组:五种初始化写法与剩余元素清零实验、下标从 0 开始的偏移量心智模型、越界访问的 stack-buffer-overflow 现场、sizeof 求元素个数与函数内失效的原因、VLA 的 C11 抉择与栈风险,以及数组不能整体赋值、比较、传值的三个「不能」与替代。
前置知识
for 循环的语法细节在控制流程,本文只用最基础的 for (int i = 0; i < n; i++) 形式,没读过也能跟上。
分工说明:数组相关内容在本模块分三篇。本篇是主线教学,解决「数组怎么用、哪里会出事」;指针与数组的区别 专门拆数组名与指针的身份差异——
sizeof为什么在函数里失效、&arr是什么类型、传参退化到底发生了什么;动态内存 接手「大小要等程序跑起来才知道」的场景。本文凡是写「先混个眼熟」的地方,都会注明由哪篇讲透。
学习目标
读完本文你将能够:
- 用完全初始化、部分初始化、全零、省略长度、指定初始化器五种写法声明并初始化数组,说出每种写法里「没写到」的元素是什么值;
- 用「偏移量」心智模型解释下标为什么从 0 开始,并预测
a[i]距数组首地址多少字节; - 读懂 AddressSanitizer 的 stack-buffer-overflow 报告,指出越界写在哪一行、落在哪个变量上;
- 用
sizeof写出「数组元素个数」公式,解释它在函数内部失效的原因,并说清「传参即退化」; - 说出数组不能整体赋值、不能
==比较、不能整体传值的三个「不能」,以及各自的替代写法。
预计 50 到 70 分钟,包含 5 组动手实验、2 道预测题与 1 道挑战题。
1. 问题引入:全班 40 个成绩
老师要存全班 40 个人的期末成绩。不用数组的话:
/* 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 声明:类型、名字、个数
int scores[40]; /* 40 个 int */
double prices[10]; /* 10 个 double */
char name[20]; /* 20 个 char */个数必须是编译期常量:字面量、由常量构成的表达式都行,普通变量不行(变量做长度是 VLA,第 6 节单讲)。
2.2 初始化:五种写法
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 */关键问题:没写到初值的元素是什么?做实验:
/* 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;
}gcc -Wall -Wextra -g init_probe.c -o init_probe
./init_probe一次典型输出(raw 那一行每次运行都可能不同):
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三条规则一次看清:
- 只要写了初始化列表(哪怕只写一部分),没覆盖到的元素一律为 0——这是标准保证,不是运气。所以
int c[10] = {0};是「全部清零」的惯用写法; - 指定初始化器(designated initializer,C99 引入)用
[下标] = 值跳着放,其余补 0,适合「绝大多数是默认值、个别项特殊」的配置表; - 完全不初始化就是垃圾值——变量与常量 讲过的规则对数组同样成立。
修改实验一:把 e 的初始化改成 { [7] = 1, [2] = 5, 9 }——指定项可以乱序,而它后面不带 [下标] = 的 9 从上一个指定下标的下一个位置继续放,即落在 e[3]。先预测再运行验证。
2.3 字符数组与字符串
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 事故现场。
还有一个新手必撞的差异:
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 开始的原因:下标不是「第几个」,是「偏移量」。
地址可以直接算出来:
&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 实录
/* 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 篇相同)再跑:
gcc -Wall -Wextra -g -fsanitize=address overflow.c -o overflow
./overflow预期输出(地址每次不同,关键行如下):
==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 问得到;除以单个元素的大小,就是元素个数:
/* 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;
}n = 6
bytes = 24sizeof 的结果类型是 size_t,printf 要用 %zu(见 数据类型详解)。
但这个公式只在数组还是数组的地方有效。把它搬进函数就翻车:
/* 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 位平台一次典型输出:
in main: 40
in func: 8原因一句话:数组作为参数传递时会退化为指向首元素的指针(decay),长度信息被丢掉,函数里的 sizeof a 量的是指针自己(8 字节)。所以 C 的惯例是长度总是作为额外参数跟着数组一起传。退化的完整机制、&a 与 a 的类型差异,见 指针与数组的区别。
修改实验四:把 broken 的参数写成 int a[10] 再跑一次,输出不变——参数方括号里的数字没有任何约束力,这正是 150 篇要拆的假象。
5. 多维数组:连续内存与按行遍历
5.1 二维数组:数组的数组
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;行数可以省略让编译器数,列数不行(列数决定每行多宽,编译器要靠它定位任何一行):
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)铺开:
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 按行遍历:缓存友好的直觉
既然内存按行铺开,「先走完一行再走下一行」的遍历顺序就与内存顺序一致:
/* 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;
}一次典型输出(数值与机器有关,相对差距稳定):
row order: 0.011000 s
col order: 0.031000 s同样的读写总量,按列遍历慢了好几倍:CPU 取内存不是一个字节一个字节取,而是把附近一整块(缓存行)一起搬进来。按内存顺序访问,每次搬运都被充分利用;跳着访问,每次搬运大多被浪费。「缓存友好」说的就是这件事,本实验先建立直觉即可,原理在体系结构课程深入。
5.4 多维数组作为参数
传二维数组时,除第一维外的所有维度必须写明:
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):
/* 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;
}三个必须知道的边界:
- VLA 不能用初始化列表(
int arr[n] = {0}编译错误),要清零就自己循环; - 栈溢出风险。VLA 在栈上分配,而普通栈通常只有 1 到 8 MB(内存深水区 的五段布局)。
int big[10 * 1000 * 1000]已经是 40 MB;VLA 更糟——它没有失败返回值,栈要不到不是返回 NULL,而是当场崩溃或悄悄踩坏别的内存; - C11 起它是可选特性。C99 曾强制要求支持 VLA,但嵌入式编译器普遍拒绝实现,C11 干脆把它降为可选:编译器若不支持,会定义宏
__STDC_NO_VLA__(MSVC 就不支持 VLA)。可移植代码应当探测:
#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 分钟):先写下答案再运行:
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 位平台输出哪两行?
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} 分别用冒泡排序与选择排序升序排列,两版都要写。
提示(思路方向):冒泡是「相邻两两比、大的往后冒」,每轮把当前最大值顶到末尾;选择是「每轮从未排序区挑最小值,与未排序区首位交换」。对照着数一数两者各交换几次——这是两种排序最直观的差异。
参考实现(先自己写,写完再对照,两版核心行如下):
/* 冒泡:相邻比较,逆序就交换 */
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 个,三步全用「相向双指针反转区间」这一个动作;每步之后打印中间结果自查。
参考实现(先自己写,写完再对照):
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 时外层跑列数。体会「二维数组是一片连续内存加一套读法约定」——转置换的是读法对应的下标,不是搬内存。
参考实现(先自己写,写完再对照):
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 两个下标与最大值同步维护,遇到「大于」(不是「大于等于」)才更新——想清楚「等于时更新」会怎样改变结果(取到靠后的那个)。
参考实现(先自己写,写完再对照):
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. 官方文档
- 数组声明(含 VLA 与
__STDC_NO_VLA__,cppreference C):https://en.cppreference.com/w/c/language/array - 初始化(含指定初始化器语义,cppreference C):https://en.cppreference.com/w/c/language/init
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 一族。
下一步
进入 结构体与联合:数组解决「多个同类型」,结构体解决「一组不同类型」——把姓名、学号、成绩打包成一个整体,并解答本文留下的悬念:为什么结构体可以整体赋值,而数组不行。