1. 为什么数组值得单独拎出来讲C 学到数组这一章很多人会觉得“这不就是一堆相同类型的数据放一起吗”然后翻两页就过去了。但我带过的新人里十个有八个在数组上栽过跟头而且栽的方式五花八门有人把数组长度写成变量结果编译报错有人访问越界把隔壁变量的值改了还浑然不知有人在函数里用sizeof(arr)/sizeof(arr[0])算长度结果算出来是 1。这些问题单看都不复杂但凑在一起就特别消耗调试时间。数组的本质是一块连续的内存空间里面存放着相同类型的元素。这句话听起来像教科书定义但它解释了很多现象为什么数组下标从 0 开始、为什么越界访问不会立刻报错、为什么数组名在很多场景下会“退化”成指针。理解了这个本质后面学指针、学动态内存、学 STL 容器都会顺很多。这篇笔记面向的是刚接触 C 数组的读者也适合学了一段时间但总觉得数组这块“差点意思”的人。我会从声明、初始化、访问、修改四个环节逐一拆解每个环节都配上能直接跑的代码和实际踩坑记录。你不需要有编程基础但需要有一台能编译 C 的环境后面所有示例我都建议你亲手敲一遍。2. 数组的声明先把“类型、名字、长度”三件事说清楚2.1 声明语句的每个部分都在干什么C 里声明一个数组的基本语法是这样的类型 数组名[长度];比如int scores[5];这行代码做了三件事告诉编译器我要存int类型的数据这块空间叫scores里面能放 5 个元素。编译器看到这行之后会在栈上分配5 * sizeof(int)字节的连续空间。在常见的 64 位环境下sizeof(int)是 4 字节所以这块空间一共 20 字节。这里有个关键点长度必须是编译期常量。也就是说下面这种写法在标准 C 里是不合法的int n 5; int arr[n]; // 错误n 不是编译期常量为什么因为编译器需要在编译阶段就确定要为这个数组分配多少栈空间。如果长度是运行时才能确定的变量编译器就没法安排内存布局了。有些编译器比如 GCC 的某些版本支持变长数组作为扩展但这是非标准行为换一个编译器可能就编译不过。正确的做法是用const或constexpr修饰const int n 5; int arr[n]; // 合法n 是编译期常量注意如果你确实需要运行时确定长度应该用std::vector而不是硬扛着用数组。这一点在后面讲动态数组时会展开。2.2 长度到底该用常量还是宏实际项目里数组长度经常需要被多处引用。比如你定义了一个存学生成绩的数组遍历的时候要用长度打印的时候也要用长度。如果到处写裸数字5哪天要改成 10 就得满文件搜索替换很容易漏改。常见的做法有两种。一种是用const intconst int kMaxStudents 50; int scores[kMaxStudents];另一种是用宏#define MAX_STUDENTS 50 int scores[MAX_STUDENTS];我个人更推荐const int或constexpr int原因是它有类型信息编译器能做类型检查调试时也能看到符号名。宏是预处理阶段的文本替换出错了报错信息往往指向宏展开后的位置排查起来更费劲。不过在纯 C 项目或者需要兼容老代码的场景下宏仍然很常见看到也不用惊讶。2.3 数组名到底代表什么这是数组里最容易让人困惑的地方。int arr[5];声明之后arr这个名字在不同场景下含义不一样在sizeof(arr)里arr代表整个数组结果是 20假设 int 是 4 字节。在arr里arr代表整个数组取地址得到的是“指向含 5 个 int 的数组的指针”。在其他大多数场景下比如传给函数、参与算术运算arr会退化成指向首元素的指针类型是int*。这个退化行为是很多 bug 的根源。比如你把数组传给一个函数函数内部再用sizeof算长度算出来的就是指针大小64 位环境下是 8而不是数组总字节数。所以数组长度必须额外传参这是 C 风格数组的硬伤也是后来std::array和std::vector被发明出来的原因之一。void printSize(int arr[]) { // 这里的 arr 已经退化成指针了 std::cout sizeof(arr) std::endl; // 输出 8不是 20 }3. 数组的初始化几种写法背后的区别3.1 完全初始化与部分初始化数组初始化有好几种写法每种的行为都不一样值得逐个说清楚。第一种是完全初始化把每个元素的值都列出来int arr[5] {1, 2, 3, 4, 5};第二种是部分初始化只给前几个元素赋值int arr[5] {1, 2, 3}; // 结果是 {1, 2, 3, 0, 0}这里有个很重要的规则剩余元素会被自动初始化为 0。这个特性在实际编码里很有用比如你要统计字符出现次数直接int count[256] {0};就能把整个数组清零比写循环方便得多。第三种是省略长度让编译器自己数int arr[] {1, 2, 3, 4, 5}; // 编译器推断长度为 5这种写法在初始化列表很长的时候特别省事而且以后增删元素不用同步改长度。但要注意只有带初始化列表的时候才能省略长度int arr[];是编译不过的。3.2 全零初始化的几种写法对比把数组清零是极常见的需求写法有好几种效果和适用场景不太一样写法效果适用场景int arr[5] {0};首元素为 0其余自动为 0最常用简洁int arr[5] {};所有元素为 0C11 起支持更直观memset(arr, 0, sizeof(arr));按字节清零大数组或需要重置时std::fill(arr, arr5, 0);逐个赋值为 0需要赋非零值时更通用memset这里要特别小心。它是按字节操作的把每个字节都设成 0对于int、float这类用全零字节表示 0 的类型是没问题的。但如果你想把数组设成其他值比如全设成 1memset(arr, 1, sizeof(arr))得到的结果是每个字节都是0x01一个 int 会变成0x01010101也就是 16843009完全不是你想要的结果。这个坑我见过不止一个人踩。提示memset只适合把内存设成全零字节或者对char数组设任意值。其他情况老老实实用循环或std::fill。3.3 字符数组的特殊之处字符数组有个特殊规则用字符串字面量初始化时会自动在末尾加一个\0char str1[] hello; // 长度是 6包含 \0 char str2[5] hello; // 错误没有空间放 \0 char str3[6] hello; // 正确str1的长度是 6 而不是 5因为编译器会自动补一个结束符。这个结束符是 C 风格字符串的基础所有字符串处理函数strlen、strcpy等都靠它判断字符串在哪里结束。如果你手动构造字符数组忘了加\0用strlen去量长度就会一直往后读直到内存里碰巧遇到一个 0 字节结果完全不可预测。char bad[3] {a, b, c}; // 没有 \0 // strlen(bad) 的行为是未定义的4. 访问数组元素下标、越界与遍历4.1 下标从 0 开始的真正原因数组下标从 0 开始这件事初学者经常记混。其实原因很直接数组名在参与运算时会退化成指向首元素的指针而arr[i]本质上等价于*(arr i)。也就是说下标i表示的是“从首元素往后偏移 i 个元素”。首元素自己的偏移量是 0所以它的下标就是 0。int arr[5] {10, 20, 30, 40, 50}; // 下面两种写法完全等价 int a arr[2]; int b *(arr 2); // a 和 b 都是 30理解了这层等价关系很多看似奇怪的现象就说得通了。比如arr[-1]在语法上是合法的等价于*(arr - 1)只是访问了数组前面的内存行为未定义。编译器不会拦你因为从它的角度看这就是一次普通的指针运算。4.2 越界访问为什么危险C 不会对数组下标做边界检查这是它追求性能的代价之一。越界访问的后果分几种情况读越界读到的是相邻内存里的垃圾值程序可能继续跑但结果是错的。写越界改掉了相邻变量的值可能引发莫名其妙的 bug甚至覆盖函数返回地址导致崩溃。越界很多直接触发段错误程序当场挂掉。最麻烦的是第二种因为它不会立刻报错。我遇到过这样一个案例一个循环写成了for (int i 0; i 5; i) arr[i] i;数组长度是 5循环跑了 6 次。多出来的那次写到了arr[5]恰好是另一个局部变量的位置把那个变量的值改成了 5。程序跑起来结果不对但调试器里看那个变量又“莫名其妙”变成了 5查了半天才定位到是数组越界。注意写循环的时候条件一定要用而不是。这是数组越界最常见的来源没有之一。4.3 遍历数组的几种方式遍历数组最常用的是for循环配合下标int arr[5] {1, 2, 3, 4, 5}; for (int i 0; i 5; i) { std::cout arr[i] ; }C11 之后可以用范围forfor (int x : arr) { std::cout x ; }范围for更简洁也不容易写出越界。但它有个限制在范围for里拿不到下标。如果你需要下标比如要打印“第几个元素”还是得用传统for。另外范围for遍历时如果元素类型较大比如结构体建议用引用避免拷贝for (const auto item : arr) { // 用 const 引用既不拷贝也不修改 }如果要在遍历中修改元素就用非 const 引用for (auto x : arr) { x * 2; // 每个元素翻倍 }这里有个细节范围for里的auto和auto差别很大。用auto是拷贝一份改它不影响原数组用auto才是直接操作原元素。这个区别在初学阶段特别容易搞混我建议你写个小例子亲手验证一下。5. 修改数组元素赋值、批量操作与常见陷阱5.1 单个元素的修改修改单个元素就是直接赋值int arr[5] {1, 2, 3, 4, 5}; arr[0] 100; // 第一个元素变成 100 arr[4] 500; // 最后一个元素变成 500这里没什么特别的但要注意数组不能整体赋值。下面这种写法是错的int a[5] {1, 2, 3, 4, 5}; int b[5]; b a; // 错误数组名不能作为赋值目标原因还是数组名退化成指针b a相当于试图把指针赋给数组语法上不成立。要复制数组内容得用循环、std::copy或memcpystd::copy(std::begin(a), std::end(a), std::begin(b)); // 或者 memcpy(b, a, sizeof(a));5.2 批量修改的几种手段批量修改数组元素常见的有这几种方式循环逐个改是最直观的for (int i 0; i 5; i) { arr[i] i * i; }std::fill适合把整个数组设成同一个值std::fill(std::begin(arr), std::end(arr), 42);std::iota适合生成递增序列std::iota(std::begin(arr), std::end(arr), 1); // 结果是 {1, 2, 3, 4, 5}std::transform适合对每个元素做同一个变换std::transform(std::begin(arr), std::end(arr), std::begin(arr), [](int x) { return x * 2; });这几种方式各有适用场景。简单循环最灵活std::fill和std::iota语义最清晰std::transform在配合 lambda 做复杂变换时最方便。实际项目里我倾向于优先用标准库算法因为它们不容易写出越界而且意图更明确。5.3 修改时的边界问题修改数组元素时边界问题比读取时更危险因为写越界会破坏其他数据。有几个高频出错点第一个是循环条件写错。前面提过的问题这里再强调一次。写循环的时候脑子里要清楚“数组有效下标是 0 到 length-1”条件用i length。第二个是用变量做下标时没检查范围。比如从用户输入读一个下标去修改数组int index; std::cin index; arr[index] 100; // 如果 index 是 100 呢这种代码在真实项目里很常见但必须加范围检查if (index 0 index 5) { arr[index] 100; } else { std::cout 下标越界 std::endl; }第三个是在函数里修改数组时长度传错。因为数组退化成指针后函数内部拿不到真实长度全靠调用方传参。如果调用方传错了长度函数里的循环就可能越界。这种 bug 特别隐蔽因为函数本身看起来没问题。6. 常见问题与排查技巧实录6.1 数组相关的典型报错与解决下面这张表整理了我实际遇到过的数组相关问题以及对应的排查思路现象可能原因排查方法编译报错“variable-sized object may not be initialized”用变量做数组长度改用const或constexpr程序运行结果随机变化越界读写了相邻内存检查所有循环边界和下标计算sizeof(arr)在函数里结果不对数组退化成指针额外传长度参数字符数组strlen结果异常缺少\0结束符手动补\0或用std::stringmemset后值不对按字节设置非零值改用循环或std::fill数组整体赋值编译失败数组名不能作为赋值目标用std::copy或memcpy6.2 几个我踩过的坑坑一以为int arr[5] {1};是把所有元素设成 1。实际上只有第一个元素是 1其余是 0。这个规则和“部分初始化剩余补零”是同一条但很多人第一次看到会误解。坑二在函数里用sizeof算数组长度。这个前面说过但值得再强调。我建议养成习惯只要数组作为参数传递就同时传长度别在函数里算。坑三用char数组存字符串忘了留结束符的位置。char str[5] hello;编译不过因为 hello 需要 6 个字节。这种错误编译器会拦但如果是手动逐个字符赋值编译器就不管了运行时才出问题。坑四二维数组的初始化搞混。二维数组可以写成int arr[2][3] {{1,2,3},{4,5,6}};也可以写成int arr[2][3] {1,2,3,4,5,6};后者会按行填充。但如果你写成int arr[2][3] {{1},{4}};结果是{{1,0,0},{4,0,0}}。这个规则和一位数组一致但嵌套之后容易看花眼。6.3 调试数组问题的实用技巧数组问题难查很大原因是越界不会立刻报错。有几个技巧能帮你快速定位第一用调试器观察内存。在关键位置打断点查看数组所在内存区域的值看看有没有被意外修改。大多数 IDE 的调试器都支持查看连续内存。第二加边界断言。在访问数组前加一句检查assert(index 0 index 5);断言在 Debug 模式下会检查Release 模式下会被去掉不影响性能。这是性价比很高的防御手段。第三用std::array或std::vector替代裸数组。它们自带at()方法越界会抛异常比裸数组安全得多。如果你的项目允许用标准库强烈建议优先考虑它们。第四把数组长度定义成常量并统一使用。这样改长度的时候只改一处减少不一致的风险。constexpr int kSize 5; int arr[kSize]; for (int i 0; i kSize; i) { /* ... */ }7. 从裸数组到现代 C什么时候该换工具裸数组C 风格数组有它的历史地位但在现代 C 里大多数场景都有更好的替代品。这不是说裸数组不能用而是说你要清楚每种工具的适用边界。std::array是固定长度的数组封装大小在编译期确定但带了size()、at()、迭代器等接口用起来比裸数组方便也更安全。它没有额外开销本质上就是对裸数组的零成本封装。如果你需要一个编译期确定长度的数组std::array基本是首选。std::vector是动态长度的数组大小可以在运行时确定也能随时增删元素。它内部管理一块堆内存会自动扩容。代价是有一定的内存和性能开销但换来的是极大的灵活性。绝大多数需要“一组同类型数据”的场景std::vector都是合理选择。那裸数组还有什么用主要在几个场景一是和 C 接口交互时很多底层 API 要求传裸指针二是对性能极度敏感、需要精确控制内存布局的场合三是教学场景理解裸数组有助于理解指针和内存模型。除此之外能用std::array或std::vector就用它们。我个人的经验是初学阶段一定要把裸数组学扎实因为它是理解内存、指针、越界这些概念的基础。但实际写项目时优先用标准库容器把精力放在业务逻辑上而不是手动管理内存边界。这两件事不矛盾前者是内功后者是效率。数组这块内容看起来简单但真正写对、写稳需要不少练习。建议你把文中的每个示例都亲手敲一遍特别是那些“错误示范”亲眼看看编译器报什么错、运行时出什么结果比只看正确代码印象深得多。