【C语法硬核20讲】02 数组声明到越界避坑
第 01 讲聊了指针。这一讲,我们把“最容易和指针搅在一起”的数组彻底讲清楚:从声明与初始化,到退化(decay)、多维数组、函数形参写法,再到**越界与未定义行为(UB)**的实战避坑。内容偏硬核,但保证能直接落地。
一、数组声明速通:看懂这几行就够了
- 一维数组:类型 名字[元素个数];
- int a[4]; // 4 个 int double x[3] = {0}; // 全部置零
- 多维数组(行主序 row-major):int m[行][列];
- int m[2][3] = { {1,2,3}, {4,5,6} };
- 区分“指针数组 vs. 数组指针”
- int *pa[10]; // 指针数组:10 个 int* 元素 int (*ap)[10]; // 数组指针:指向“含 10 个 int 的数组”的指针
- 口诀:[] 与 () 谁先“贴”到变量名,谁就先结合。
- 变长数组(VLA, variable length array,C99 起、C11/C17 为可选特性)
- void f(size_t n) { int v[n]; // 运行期决定大小,注意栈空间与可移植性 }
二、初始化:指定初始化器、字符串、零填充
- 部分初始化自动补零
- int a[5] = {1, 2}; // 等价于 {1,2,0,0,0}
- 指定初始化器(designated initializer, C99)
- int a[8] = {[2]=10, [7]=99}; // 其余元素为 0
- 字符数组 vs. 字符串字面量
- char s1[] = "abc"; // 含隐含 '\0',长度 4 char *s2 = "abc"; // 指向只读存储(多数实现);修改 → UB // s1[0]='A'; 合法 s2[0]='A'; 未定义行为
- 要点:想改内容就用数组;char* 指向字符串字面量通常不可改。
三、sizeof 与长度:别把指针当数组
- 在定义处拿长度(仅在同一作用域有效)
- int a[5]; size_t n = sizeof a / sizeof a[0]; // 5
- 指针不是数组
- int *p = a; sizeof p // 指针大小(64 位常见为 8),不是数组大小
- 多维数组的宽度
- int m[3][4]; size_t rows = sizeof m / sizeof m[0]; // 3 size_t cols = sizeof m[0] / sizeof m[0][0]; // 4
- 字符串字面量长度
- sizeof "hello" // 6(含结尾 '\0')
结论:凡是离开数组的定义处(比如传给函数),就丢失了长度信息,要么传长度,要么用更安全的形参写法(见下一节)。
四、数组“退化(decay)”与函数形参的三种写法
规则:除 sizeof、_Alignof、& 以及字符串字面量初始化外,数组会退化为指向首元素的指针。这会导致函数里看不到数组的真实长度。
1)最常用:指针 + 显式长度
void sum_ints(const int *a, size_t n, long long *out) {
long long s = 0;
for (size_t i = 0; i < n; ++i) s += a[i];
*out = s;
}
2)固定“列数”的二维数组(指针指向数组)
void scale_rows(size_t rows, int (*mat)[4], int k) { // 每行 4 列
for (size_t i = 0; i < rows; ++i)
for (size_t j = 0; j < 4; ++j) mat[i][j] *= k;
}
调用:
int m[2][4] = {0};
scale_rows(2, m, 2); // OK:m 自动转为 int (*)[4]
3)使用 VLA 形参(C99,可选特性)
void add_mm(size_t r, size_t c, int A[r][c], const int B[r][c]) {
for (size_t i=0;i<r;++i)
for (size_t j=0;j<c;++j)
A[i][j]+=B[i][j];
}
优点:形参自带“宽度信息”。缺点:受实现与栈大小限制。
常见误区:int ** 不是 int a[M][N] 的等价形参。int a[M][N] 在内存中是一块连续区域;而 int ** 通常是“指向指针的指针”,两者布局与寻址不同。
五、多维数组与内存布局:行主序、等价地址
- C 的多维数组是数组的数组,采用行主序(row-major):
- int a[2][3] = { {1,2,3}, {4,5,6} }; // &a[0][0]、&a[0][1]、&a[0][2]、&a[1][0]、...
- 等价表达式:
- *(*(a + i) + j) // 等价于 a[i][j]
- 动态二维数组的正确分配(连续内存版)
- size_t R=3, C=4; int (*mat)[C] = malloc(sizeof *mat * R); // 连续 R*C 个 int // 使用 mat[r][c] 访问 free(mat);
- 别用 int **p + 多次 malloc 企图模拟 int[R][C] 的连续布局;那是**“锯齿形(jagged)”**,缓存局部性与寻址都不同。
六、越界 = 未定义行为(UB):十个高频坑
- <= 写成边界条件
- for (size_t i=0; i<=n; ++i) a[i]=0; // 越界(最后一次 i==n)
- 负索引或“从 -1 开始”
- a[-1] = 7; // UB,即使在某些平台“看起来没事”
- 指针走过头
- int *end = a + n; // 这是“尾后指针”,可比较,不可解引用 *end = 0; // UB
- memcpy 长度写错
- memcpy(dst, src, sizeof(src)); // src 已退化为指针?→ 只拷 8 字节(64 位)
- 字符串未以 '\0' 结尾
- char s[3] = {'o','k'}; puts(s); // UB,未终止
- 把“指针数组”当“二维数组”用
- int *pa[3]; pa[0] = a; pa[1] = a+3; // pa[i][j] 没有统一的连续布局
- 把 int ** 当 int[][N] 用
- void f(int **p); // 不能接收 int a[M][N]
- 混用 realloc 后的旧指针
- p = realloc(p, new_sz); // 失败返回 NULL,成功返回“可能移动过”的新块 // 错误写法:if(realloc(p,...)) { ... 使用 p ... } // 泄漏/悬空
- VLA 太大撑爆栈
- void g(size_t n){ int a[n]; } // n 来自外部输入 → 风险
- 修改字符串字面量
- char *s = "abc"; s[0]='A'; // UB(多数实现只读)
七、字符串与字符数组:容量、长度与安全接口
- 容量(capacity):数组可容纳的元素数
- 长度(length):实际字符数,不含 '\0'
安全模板:
char buf[16];
snprintf(buf, sizeof buf, "%s-%d", tag, id); // 自动截断 + 结尾 '\0'
size_t len = strnlen(buf, sizeof buf); // 最多扫到容量大小
避免使用:gets(已移除)、不加边界的 strcpy/strcat/sprintf。
八、实用模板与习惯用法(拿走即用)
- 计算数组元素个数(仅在定义处使用)
- #define COUNT_OF(a) (sizeof(a)/sizeof (a)[0])
- 遍历模板
- for (size_t i = 0; i < COUNT_OF(a); ++i) { /* ... */ }
- 函数签名三选一
- void f(const int *a, size_t n); // 一维 void g(size_t rows, int (*mat)[COLS]); // 固定列数二维 void h(size_t r, size_t c, int A[r][c]); // VLA(实现可选)
- 动态二维(连续块)
- int (*m)[C] = malloc(sizeof *m * R); // 使用 m[r][c] free(m);
- 指定初始化器
- int z[10] = {[0]=1, [3]=7}; // 其余为 0
九、迷你练习(答案要点在注释)
- 下面输出是什么?
int a[3] = {1,2,3};
int *p = a;
printf("%zu %zu\n", sizeof a, sizeof p); // 12(假设 4B int),8(64 位指针)
- 哪个是“指向 8 个 int 的数组的指针”?
int (*ap)[8]; //
int *pa[8]; // 指针数组
- 为 3×5 的矩阵申请连续内存并置零?
int (*m)[5] = calloc(3, sizeof *m); //
- 写一个 safe_copy:把 src 拷贝进 dst,保证以 '\0' 结尾
void safe_copy(char *dst, size_t cap, const char *src){
if (cap==0) return;
size_t n = strnlen(src, cap-1);
memcpy(dst, src, n);
dst[n]='\0';
}
十、收尾与要点回顾
- 数组 ≠ 指针;离开定义处数组就退化为指针
- 函数形参:用“指针+长度”、或“指向数组的指针”、或“VLA 形参”
- 多维数组是连续行主序;不要用 int ** 代替
- 越界 = UB,不要赌运气
- 字符串安全:容量与长度分离,优先 snprintf/strnlen
把这篇做成你的数组速查表:声明、初始化、sizeof、函数形参、二维分配、越界避坑,一把梭!