数组深入
在前面的速通篇和指针篇里,我们都碰过数组,但说实话一直停留在"会用"的层面。数组这东西用起来简单——声明、初始化、下标访问,谁不会?但一旦你开始追问"多维数组到底怎么排布的"、"为什么数组不能直接赋值"、"数组和指针到底什么时候是一样的什么时候不一样"——就会发现里面有不少值得拆解的细节。这些细节不只是理论上的东西,理解了数组的内存模型,后面学 C++ 的 std::array、std::vector、std::span 的时候就能清楚地知道它们各自在解决什么问题。
学习目标
完成本章后,你将能够:
- [ ] 掌握一维数组的各种初始化方式(含 C99 指定初始化器)
- [ ] 理解多维数组的内存布局和行优先存储
- [ ] 了解变长数组(VLA)的原理与局限性
- [ ] 明白数组的几个根本性限制
- [ ] 精确区分数组与指针的差异
环境说明
本篇所有代码基于 C99 标准,在 GCC 13.x / Clang 17.x 下测试通过,运行环境为 Linux x86-64。涉及变长数组(VLA)的部分需要编译器支持 C99(-std=c99 或 -std=c11)。如果你用的是 MSVC,请注意微软的 C 编译器对 C99 的支持不完整,部分 VLA 特性可能不可用——建议用 GCC 或 Clang。
第一步——掌握数组的各种初始化方式
数组的声明大家都会,int arr[10]; 就完事了。但初始化的细节其实比很多人想象的要丰富。我们先从最基本的开始,逐步看到 C99 引入的指定初始化器。
基本初始化
// 完全初始化——每个元素都给了值
int primes[] = {2, 3, 5, 7, 11}; // 大小自动推导为 5
// 部分初始化——没给值的元素自动填 0
int data[10] = {1, 2, 3}; // data[0]=1, data[1]=2, data[2]=3, data[3..9]=0
// 全零初始化——这是把数组清零最干净利落的写法
int zeros[100] = {0}; // 第一个元素显式为 0,其余自动填 0部分初始化这个行为很重要——C 标准规定,只要数组进行了初始化(哪怕只初始化了一个元素),所有未被显式赋值的元素都会被自动初始化为对应类型的零值。所以 {0} 就成了把数组清零的惯用写法,比你手动写个循环要干净得多。
指定初始化器(C99)
C99 引入了一个非常实用的特性:指定初始化器(designated initializer)。它允许你指定"哪个位置初始化为什么值",其余位置自动填零。这在处理稀疏数组、配置表、寄存器映射的时候特别方便:
// 只初始化特定的位置,其余自动为 0
int sparse[100] = {[5] = 10, [20] = 30, [99] = -1};
// sparse[5] = 10, sparse[20] = 30, sparse[99] = -1, 其余全部 0
// 可以乱序,也可以覆盖——后面的初始化覆盖前面的
int config[10] = {[3] = 100, [7] = 200, [3] = 999};
// config[3] = 999(被覆盖了), config[7] = 200
// 指定初始化器之后可以跟连续的普通初始化
int seq[10] = {[3] = 10, 20, 30};
// seq[3] = 10, seq[4] = 20, seq[5] = 30, 其余 0说实话,指定初始化器在嵌入式开发里用得非常多。比如你有一个中断向量表或者一个命令分发表,大部分入口都是空的,只有少数几个需要填——用指定初始化器写出来的代码既干净又不容易出错。C++ 也在 C++20 才正式支持了指定初始化器(而且有一些限制),所以这个特性在纯 C 代码里优势更明显。
第二步——理解多维数组的内存布局
多维数组本质上是"数组的数组"。int matrix[3][4] 声明的是一个有 3 个元素的数组,每个元素又是一个有 4 个 int 的数组。这句话听起来像绕口令,但它精确地描述了内存布局。
行优先存储
C 语言的多维数组在内存中是行优先(row-major)存储的,也就是说,最右边的下标变化最快。对于 int matrix[3][4],内存排列是这样的:
地址递增方向 →
matrix[0][0] matrix[0][1] matrix[0][2] matrix[0][3] ← 第 0 行
matrix[1][0] matrix[1][1] matrix[1][2] matrix[1][3] ← 第 1 行
matrix[2][0] matrix[2][1] matrix[2][2] matrix[2][3] ← 第 2 行
整个数组是连续的 12 个 int,没有间隙我们来验证一下:
#include <stdio.h>
int main(void) {
int matrix[3][4] = {
{0, 1, 2, 3},
{10, 11, 12, 13},
{20, 21, 22, 23}
};
// 用一维指针遍历整个二维数组
int* flat = &matrix[0][0];
for (int i = 0; i < 12; i++) {
printf("%d ", flat[i]);
}
// 输出: 0 1 2 3 10 11 12 13 20 21 22 23
return 0;
}你可以看到,内存完全是线性的——matrix[1][0] 紧挨在 matrix[0][3] 后面。理解这一点很重要,因为很多性能优化(比如缓存友好访问)就建立在这个基础上:按行遍历比按列遍历快得多,因为连续的内存访问能更好地利用 CPU 缓存行。
多维数组的初始化
初始化多维数组的方式和一维类似,只是多了嵌套的大括号:
// 完全初始化
int m1[2][3] = {
{1, 2, 3},
{4, 5, 6}
};
// 部分初始化——未给的元素自动为 0
int m2[2][3] = {
{1}, // 第 0 行: {1, 0, 0}
{4, 5} // 第 1 行: {4, 5, 0}
};
// 也可以用指定初始化器
int m3[3][4] = {
[0] = {1, 2, 3, 4},
[2] = {20, 21, 22, 23}
// 第 1 行全部为 0
};
// 甚至可以嵌套指定
int m4[3][4] = {
[0] = {[1] = 99},
[2] = {[0] = 88, [3] = 77}
};多维数组作为函数参数
二维数组传给函数的时候,编译器必须知道第二维(以及更高维)的大小,才能正确计算地址偏移。这是因为 matrix[i][j] 的地址计算公式是 base + i * cols + j,其中 cols 就是第二维的大小。如果编译器不知道 cols,就没办法生成正确的寻址代码:
// 必须指定列数
void print_matrix(int rows, int m[][4]) {
for (int i = 0; i < rows; i++) {
for (int j = 0; j < 4; j++) {
printf("%3d ", m[i][j]);
}
printf("\n");
}
}
// 等价写法——用数组指针
void print_matrix_v2(int rows, int (*m)[4]) {
// 完全一样的效果
}如果你想让函数能接受不同列数的二维数组,就得放弃直接的二维数组语法,改用一维数组+手动计算下标,或者用指针数组。这在灵活性和类型安全之间确实是个权衡。
第三步——认识变长数组(VLA)的利与弊
C99 引入了变长数组(Variable Length Array),允许用运行时变量作为数组的大小。注意这里的"变长"不是说数组大小可以动态改变——一旦创建,大小就固定了——而是说大小的确定推迟到了运行时:
#include <stdio.h>
int main(void) {
int n;
printf("Enter array size: ");
scanf("%d", &n);
int vla[n]; // 大小在运行时确定
for (int i = 0; i < n; i++) {
vla[i] = i * i;
}
// ...
return 0;
}VLA 也可以用在二维的情况,在函数参数中尤其方便:
// VLA 作为函数参数——行数和列数都是运行时确定的
void print_vla_matrix(int rows, int cols, int m[rows][cols]) {
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
printf("%3d ", m[i][j]);
}
printf("\n");
}
}
int main(void) {
int rows = 3, cols = 4;
int matrix[rows][cols]; // VLA 二维数组
// ... 填充数据
print_vla_matrix(rows, cols, matrix);
return 0;
}你看,print_vla_matrix 的参数列表里 m[rows][cols] 的大小依赖前面的参数 rows 和 cols。这解决了前面说的"二维数组传参必须固定列数"的问题。
VLA 的局限与争议
VLA 听起来很美好,但它有几个问题导致它在工业界并不太受待见。
首先,VLA 分配在栈上。栈空间通常是有限的(Linux 默认 8MB,嵌入式系统可能只有几 KB),如果用户输入了一个很大的数——比如 int vla[1000000]——你可能直接就把栈打爆了,而且没有任何恢复手段。不像 malloc 返回 NULL 你还能处理,栈溢出是直接未定义行为。
⚠️ 踩坑预警 VLA 分配在栈上,大小不可预测,分配失败没有恢复手段——直接就是未定义行为。在嵌入式领域,MISRA-C 明确禁止使用 VLA。如果你需要运行时确定大小的数组,用
malloc并检查返回值才是安全的做法。
其次,C11 把 VLA 从强制特性降级为了可选特性——编译器可以声称不支持 VLA,用一个宏 __STDC_NO_VLA__ 来告知。这意味着你不能指望 VLA 在所有 C11 编译器上都可用。
在嵌入式领域,VLA 基本上是禁止使用的。静态分析工具(如 MISRA-C)通常会明确禁止 VLA,因为它的大小不可预测,和实时性、确定性内存使用的需求完全冲突。
笔者的建议是:知道 VLA 的存在、能看懂别人写的 VLA 代码就好,自己写代码时优先用固定大小数组或 malloc。在需要灵活大小且能接受动态分配的场景下,malloc + 边界检查比 VLA 安全得多。
第四步——理解数组的根本性限制
数组在 C 语言中有几个根本性的限制,这些限制是理解后面 C++ 容器设计动机的关键。
数组不可赋值
声明两个数组之后,你不能把一个数组直接赋值给另一个:
int a[3] = {1, 2, 3};
int b[3];
// b = a; // 编译错误!数组不能直接赋值原因是数组名在赋值表达式中会退化为指向首元素的指针,而赋值运算符左边必须是一个可修改的左值(lvalue)——退化的指针是一个右值,不能被赋值。所以要拷贝数组,只能逐元素拷贝或者用 memcpy:
#include <string.h>
int a[3] = {1, 2, 3};
int b[3];
memcpy(b, a, sizeof(a)); // 正确的数组拷贝方式数组不可作为函数返回值
函数不能返回数组类型。你写不了 int[10] foo(void) 这样的签名。如果你想从函数里"返回"一个数组,有三种常见做法:返回指针(指向静态数组或动态分配的数组)、通过参数传出一个数组、或者把数组包裹在结构体里返回。最后一种方法其实很实用——C 语言允许结构体赋值和作为返回值,而结构体里可以包含数组:
typedef struct {
int data[10];
} IntArray10;
IntArray10 make_array(void) {
IntArray10 result = {.data = {1, 2, 3, 4, 5}};
return result; // 合法!结构体可以返回
}这个技巧在 C 标准库的数学函数里也能看到(返回复数、返回 div_t 之类的结构)。
数组大小必须编译期常量(VLA 除外)
普通数组的大小在编译时就必须确定。int arr[n](n 是变量)在 C89 中是非法的——只有 C99 的 VLA 才允许这样做。而 VLA 又有上面说的那些问题。这意味着在 C89 或不支持 VLA 的环境下,如果你想根据运行时数据创建不同大小的数组,只能用 malloc。
第五步——精确区分数组和指针
在速通篇和指针篇里我们都说过"数组名退化为指针"。这句话没问题,但它容易让人以为"数组就是指针"——这是不对的。数组是数组,指针是指针,它们只在特定情况下可以互相转换。
什么时候数组名退化为指针
数组名在以下场景中会退化为指向首元素的指针:作为函数参数传递、用于算术运算、在表达式中使用(大多数情况)。但有三个例外——数组名在 sizeof、_Alignof(C11)和取地址运算符 & 的操作数中不会退化:
int arr[10] = {0};
// sizeof 对数组名——得到整个数组的大小
printf("%zu\n", sizeof(arr)); // 40(10 * sizeof(int),假设 int 为 4 字节)
// & 对数组名——得到指向整个数组的指针,类型是 int (*)[10]
int (*ptr_to_array)[10] = &arr;
// 注意:ptr_to_array + 1 跳过整个数组(40 字节)
// 数组名在表达式中——退化为 int*
int* p = arr; // 等价于 int* p = &arr[0];
printf("%zu\n", sizeof(p)); // 8(指针本身的大小,64 位系统)sizeof(arr) 返回 40 而 sizeof(p) 返回 8——这就是数组不是指针的最直接证据。
指针算术 vs 数组下标
arr[i] 和 *(arr + i) 是完全等价的——C 语言的数组下标运算符 [] 本质上就是指针算术的语法糖。而且这个等价关系是可交换的:arr[i] 等价于 i[arr]。是的,3[arr] 是合法的 C 代码,和 arr[3] 完全等价。这种写法在实际项目中当然不要用——它除了炫技之外没有任何好处,还会让同事血压飙升。
二维数组 vs 指针数组
这是一个非常经典的混淆点。int m[3][4] 和 int** pp 看起来都能用 m[i][j] 和 pp[i][j] 来访问,但内存模型完全不同:
int m[3][4]:
连续的 12 个 int
m[i][j] 的地址 = base + i*4 + j
int** pp:
pp → [ptr0, ptr1, ptr2] ← 指针数组(不连续)
│ │ │
▼ ▼ ▼
[....] [....] [....] ← 每行各自的内存二维数组是一块连续的内存,编译器通过行列公式直接算地址。指针数组是一个间接层——先找到第 i 行的指针,再用这个指针找到第 j 个元素。所以 int m[3][4] 不能传给接受 int** 参数的函数,反过来也一样。它们的类型不兼容,强行转换会导致未定义行为。
⚠️ 踩坑预警
int m[3][4]和int** pp虽然都能用m[i][j]/pp[i][j]访问,但内存模型完全不同——前者是连续内存,后者有间接层。绝不能把二维数组传给int**参数,编译器可能会放过你,但运行时地址计算会完全错误。
C++ 衔接
理解了 C 数组的这些限制之后,我们来看 C++ 是怎么一个一个解决它们的。
std::array——可赋值的固定大小数组
std::array 是 C++11 引入的固定大小数组容器,它在栈上分配内存(和 C 数组一样),但补上了 C 数组缺失的功能:可以赋值、可以拷贝、可以作为函数返回值、知道自己的大小:
#include <array>
#include <algorithm>
int main() {
std::array<int, 5> a = {1, 2, 3, 4, 5};
std::array<int, 5> b;
b = a; // 直接赋值!C 数组做不到
// 知道自己的大小
for (std::size_t i = 0; i < b.size(); i++) {
// b[i] ...
}
// 还支持 fill、swap、比较运算等
b.fill(0); // 全部填 0
}std::array 的开销为零(zero overhead)——它不会引入额外的内存或运行时成本,编译器优化后和裸 C 数组一样快。如果你在 C++ 里需要一个固定大小的数组,没有理由不用 std::array 而用裸数组。
std::vector——动态大小数组
std::vector 解决的是"大小在运行时才能确定"的问题。它在堆上分配内存,可以动态增长和缩小,自动管理内存生命周期:
#include <vector>
int main() {
int n;
std::cin >> n;
std::vector<int> vec(n); // 运行时确定大小,类似 VLA 但安全得多
for (int i = 0; i < n; i++) {
vec[i] = i * i;
}
vec.push_back(999); // 还能追加元素
// 离开作用域自动释放内存
}std::vector 可以看作 VLA 的安全替代——大小可变、分配失败会抛异常(而不是栈溢出的未定义行为)、有边界检查(at() 方法)、自动释放内存。唯一的代价是少量堆分配的开销,但在绝大多数场景下这点开销完全可以接受。
范围 for 遍历
C 数组的遍历要么用下标,要么用指针算术,都需要手动管边界。C++11 引入的范围 for 循环让遍历变得非常简洁,而且 std::array 和 std::vector 都支持:
#include <array>
#include <vector>
int main() {
std::array<int, 5> arr = {10, 20, 30, 40, 50};
std::vector<int> vec = {1, 2, 3};
// 范围 for——不需要管下标
for (const auto& elem : arr) {
// elem 是 arr 中每个元素的 const 引用
}
for (auto& elem : vec) {
elem *= 2; // 可以修改元素
}
}值得注意的是,范围 for 也能用于裸 C 数组(只要数组的大小在当前作用域可见),但它的使用场景比较有限——数组退化为指针之后就丢失了大小信息,范围 for 就用不了了。这也是 std::array 相对于裸数组的又一个优势。
小结
数组的内存模型其实并不复杂——就是一段连续的、相同类型的元素排列。一维数组的初始化方式多样,C99 的指定初始化器在处理稀疏数据时特别好用。多维数组是"数组的数组",按行优先存储,理解这一点对性能优化很重要。VLA 虽然方便但存在栈溢出风险,在工业界和嵌入式领域基本不推荐使用。数组有几个根本性的限制——不能赋值、不能作为函数返回值——这些限制在 C++ 中被 std::array 完美解决了。而数组和指针虽然在大多数场景下可以互换,但它们本质上是不同的类型——sizeof 和 & 是最容易暴露差异的地方。理解了这些底层细节,后面学 C++ 容器的时候就能体会到每一个设计决策背后的动机。
关键要点
- [ ] 部分初始化时未给出的元素自动填零,
{0}是清零数组的惯用写法 - [ ] C99 指定初始化器允许按位置初始化,适合稀疏数据和配置表
- [ ] 多维数组按行优先连续存储,
m[i][j]的地址为base + i * cols + j - [ ] VLA 分配在栈上,大小不可预测,C11 降级为可选特性
- [ ] 数组不能赋值、不能作为函数返回值,但包裹在结构体中可以
- [ ] 数组名在
sizeof和&的操作数中不退化为指针 - [ ]
std::array是零开销的固定大小容器,支持赋值和拷贝 - [ ]
std::vector是动态大小容器,是 VLA 的安全替代
练习
练习 1:矩阵运算
实现以下三个函数,完成基本的矩阵操作。矩阵使用普通的 C 二维数组表示,请自行实现矩阵转置和矩阵乘法:
#define kMaxRows 10
#define kMaxCols 10
/// @brief 转置矩阵,将 src 的转置结果写入 dst
/// @param rows src 的行数
/// @param cols src 的列数
/// @param src 源矩阵
/// @param dst 目标矩阵(调用者保证大小为 cols x rows)
void matrix_transpose(int rows, int cols,
const int src[rows][cols],
int dst[cols][rows]);
/// @brief 矩阵乘法,计算 a x b 的结果写入 c
/// @param m a 的行数
/// @param n a 的列数 / b 的行数
/// @param p b 的列数
/// @param a 左矩阵 (m x n)
/// @param b 右矩阵 (n x p)
/// @param c 结果矩阵 (m x p)
void matrix_multiply(int m, int n, int p,
const int a[m][n],
const int b[n][p],
int c[m][p]);
/// @brief 打印矩阵
/// @param rows 行数
/// @param cols 列数
/// @param mat 矩阵
void matrix_print(int rows, int cols, const int mat[rows][cols]);提示:转置的核心是 dst[j][i] = src[i][j]。乘法的核心是三重循环——c[i][j] 是 a 的第 i 行和 b 的第 j 列的点积。这里函数参数用了 VLA 语法,让列数可以动态指定。
练习 2:对比 VLA 与 malloc
编写一个程序,分别用 VLA 和 malloc 分配一个大小由用户输入决定的整型数组,然后对比两者的行为差异:
#include <stdio.h>
#include <stdlib.h>
/// @brief 用 VLA 方式分配并填充数组
/// @param n 数组大小
/// @param out 输出数组的指针(VLA 版本需要调用者传入栈数组)
void fill_with_vla(int n, int arr[n]);
/// @brief 用 malloc 方式分配并填充数组
/// @param n 数组大小
/// @return 指向动态分配数组的指针,失败返回 NULL
int* fill_with_malloc(int n);请自行实现这两个函数和 main 函数。思考以下问题:如果用户输入一个非常大的数字(比如 100000000),两种方式分别会发生什么?哪种方式可以优雅地处理分配失败的情况?在嵌入式系统中你会选择哪种方式?