
1. 为什么数组值得单独拎出来讲C 学到数组这一章很多人会下意识觉得“这不就是一堆相同类型的数据放一起嘛”然后翻两页就过去了。但我带过不少刚入门的同学真正写代码时翻车最多的地方恰恰就是数组。比如声明了一个int arr[5]转头去访问arr[5]程序不报错跑出来一个莫名其妙的数字又比如把数组传给函数在函数里用sizeof(arr)算长度结果永远得到 8 或者 4怎么算都不对。这些坑不是语法没背熟而是对数组在内存里到底长什么样、名字到底代表什么没有建立起直觉。这篇笔记就围绕数组的四个核心动作展开声明、初始化、访问、修改。这四个词看着简单但每一个背后都有一堆细节。我会把每个环节拆开讲清楚包括内存布局、下标越界的真实后果、初始化列表的规则、以及遍历和修改数组元素时那些容易忽略的边界情况。内容适合刚接触 C 数组的初学者也适合学了一段时间但总觉得“数组用起来心里没底”的朋友。读完之后你至少能做到看到一个数组声明脑子里能浮现出它在内存里的样子写循环访问数组时清楚边界在哪里遇到数组相关的报错或诡异输出知道从哪几个方向去排查。我尽量不堆术语能用生活化的例子就用例子。比如把数组想象成一排连续的储物柜每个柜子有编号编号从 0 开始每个柜子大小一样只能放同一种东西。这个类比贯穿全文帮助你建立空间感。下面正式进入正题。2. 数组到底是什么从内存布局说起2.1 一排连续的储物柜数组的本质是一块连续的内存空间里面存放着相同类型的若干个元素。这句话里有两个关键词连续、相同类型。连续意味着元素一个挨着一个中间没有空隙相同类型意味着每个元素占用的字节数一样这样编译器才能通过“首地址 下标 × 单个元素大小”快速算出任意元素的地址。打个比方你去游泳馆存包前台给你一排连着的柜子编号 0、1、2、3、4。每个柜子大小完全一样只能放同一种规格的包。你想找 3 号柜子不需要从头一个个数直接走到第 4 个位置就行。数组的随机访问之所以是常数时间 O(1)就是因为这个“直接算地址”的机制。用代码表示就是int arr[5]; // 5 个 int 类型的储物柜编号 0 到 4在常见的 32 位或 64 位平台上一个int通常占 4 个字节所以这 5 个元素一共占 20 个字节在内存里是紧挨着的。假设首地址是0x1000那么arr[0]在0x1000arr[1]在0x1004arr[2]在0x1008以此类推。编译器计算arr[i]的地址公式就是首地址 i * sizeof(int)。注意不同平台、不同编译器下int的大小可能不同标准只保证int至少 16 位。实际开发中常见的是 4 字节但写可移植代码时不要硬编码这个假设。2.2 数组名不是指针但会“退化”成指针初学者最容易混淆的一点数组名arr到底是指针还是数组答案是arr的类型是“含有 5 个 int 的数组”它本身不是指针。但在大多数表达式里数组名会自动转换退化为指向首元素的指针也就是int*。这就是为什么你可以写int* p arr;但不能写arr p;——数组名不是一个可以重新赋值的变量。这个退化特性带来一个经典陷阱把数组传给函数时函数参数里的数组其实是指针。所以下面两种写法完全等价void func(int arr[5]); // 看起来是数组 void func(int* arr); // 实际上是指针 void func(int arr[]); // 也是指针在函数内部sizeof(arr)得到的是指针的大小64 位系统上通常是 8 字节而不是整个数组的大小。这就是为什么在函数里算不出数组长度必须额外传一个长度参数。这个坑我在第 5 节还会展开讲。2.3 为什么不用std::vector而要用原生数组有同学会问既然std::vector这么方便为什么还要学原生数组原因有几个。第一原生数组没有额外开销不需要堆分配不需要管理容量和大小在性能敏感或内存受限的场景下更可控。第二很多底层接口、硬件寄存器映射、协议数据包解析用的就是原生数组或指针加长度。第三理解原生数组是理解指针、内存布局、缓存友好的基础这些概念在学std::vector、std::array、甚至其他语言时都会反复用到。所以我的建议是日常业务代码优先用std::vector或std::array但原生数组的机制必须搞懂否则你调库、看底层代码、排查内存问题时都会卡壳。3. 数组的声明语法、规则与常见误区3.1 声明的基本语法声明一个数组的语法是类型 数组名[元素个数];例如int scores[10]; // 10 个 int double prices[5]; // 5 个 double char name[20]; // 20 个 char这里有几个硬性规则。第一元素个数必须是编译期常量表达式不能是运行时才确定的变量C99 的变长数组在标准 C 里不支持虽然某些编译器作为扩展支持。第二元素个数必须是正整数不能是 0 或负数。第三数组名必须符合标识符命名规则。int n 10; int arr[n]; // 在标准 C 中不合法n 不是编译期常量如果你需要运行时确定大小用std::vectorint n; std::cin n; std::vectorint arr(n); // 合法大小在运行时确定3.2 元素个数与下标范围的关系这是初学者最容易搞错的地方。声明int arr[5]元素个数是 5但合法下标是 0 到 4不是 1 到 5。因为下标从 0 开始最后一个元素的下标是“元素个数减一”。声明元素个数合法下标范围最后一个元素int a[5]50 ~ 4a[4]int b[1]10b[0]int c[100]1000 ~ 99c[99]访问arr[5]是越界访问属于未定义行为。未定义行为的意思是标准不规定会发生什么可能崩溃可能输出垃圾值可能看起来“正常”但悄悄破坏了别的数据。这是最危险的一类 bug因为它不一定立刻暴露。3.3 声明时不要忘了初始化声明数组时如果不初始化对于局部数组在函数内部声明的元素的值是不确定的也就是内存里原来残留什么就是什么。全局数组和静态数组则会被自动初始化为 0。void demo() { int local[5]; // 5 个不确定的值可能是任何东西 static int s[5]; // 全部为 0 int global_arr[5]; // 如果在全局作用域全部为 0 }我见过太多人声明了局部数组就直接用结果打印出一堆奇怪的数字还以为是编译器坏了。养成习惯声明数组时顺手初始化哪怕只是int arr[5] {0};。实操心得调试时如果看到数组里有莫名其妙的大数比如 32767、-858993460 这类第一反应就是“没初始化”。这类值往往是调试模式下编译器填充的标记值用来帮你发现未初始化内存。4. 数组的初始化各种写法与背后规则4.1 逐个初始化与列表初始化最直观的初始化方式是列出每个元素的值int arr[5] {1, 2, 3, 4, 5};这里arr[0]1arr[1]2依次类推。如果列表里的值少于元素个数剩下的元素会被初始化为 0int arr[5] {1, 2}; // arr {1, 2, 0, 0, 0}这个特性非常实用。想快速把整个数组清零写int arr[100] {0};就行比写循环简洁而且编译器会优化成高效的内存清零操作。如果列表里的值多于元素个数编译器会报错int arr[3] {1, 2, 3, 4}; // 错误初始化值太多4.2 省略元素个数让编译器自己数如果初始化列表里给了所有元素的值可以省略方括号里的数字编译器会自动推断int arr[] {10, 20, 30, 40}; // 编译器推断为 int arr[4]这种写法在写测试数据、查表法lookup table时特别方便。但要注意一旦省略了大小后续就不能再改变数组长度了因为长度已经由初始化列表固定下来。const char vowels[] {a, e, i, o, u};4.3 字符串数组的特殊初始化字符数组可以用字符串字面量初始化这是唯一一种“看起来元素个数和列表长度不一致”的情况char str1[] hello; // 大小是 6包含结尾的 \0 char str2[6] hello; // 合法正好放下 char str3[5] hello; // 错误放不下结尾的 \0hello看起来是 5 个字符但字符串字面量在内存里末尾还有一个空字符\0所以实际需要 6 个字节。用char str[] hello时编译器会自动把大小定为 6。如果你手动指定大小必须至少留出\0的位置否则编译报错。注意char str[5] {h,e,l,l,o};是合法的它创建的是一个没有结尾空字符的字符数组不是字符串。用printf(%s, str)打印它会越界读取直到内存里偶然遇到一个 0 才停。这是经典的安全隐患。4.4 C11 起的统一初始化C11 引入了花括号统一初始化数组也支持int arr[5]{1, 2, 3, 4, 5}; // 省略等号 int arr2[5] {}; // 全部初始化为 0用花括号初始化还有一个好处禁止窄化转换。比如把double放进int数组用等号可能只是警告用花括号会直接报错int arr[] {1.5, 2.5}; // 可能只是警告值被截断 int arr2[]{1.5, 2.5}; // 编译错误窄化转换这个特性帮你更早发现类型不匹配的问题推荐在能用的地方尽量用花括号初始化。4.5 初始化方式对比速查写法效果适用场景int a[5];局部数组值不确定不推荐除非马上赋值int a[5] {0};全部为 0清零最常用int a[5] {1,2};前两个为 1、2其余为 0部分初始化int a[] {1,2,3};大小自动推断为 3已知全部元素int a[5]{};全部为 0C11 风格清零char s[] abc;大小 4含\0字符串5. 数组的访问下标、遍历与越界的真相5.1 下标运算符的本质访问数组元素用下标运算符[]int arr[5] {10, 20, 30, 40, 50}; int x arr[2]; // x 30arr[2]在底层等价于*(arr 2)。也就是说下标运算本质上是指针运算加解引用。这也解释了为什么arr[2]和2[arr]在 C 里居然都能编译通过——因为*(arr 2)和*(2 arr)是一样的。当然没人会真的写2[arr]这里只是帮你理解机制。理解了这个等价关系你就能明白下标访问不做边界检查。编译器不会帮你验证i是否在 0 到 4 之间它只是老老实实算地址然后取值。这就是越界访问危险的根源。5.2 用循环遍历数组最常见的访问方式是用for循环int arr[5] {10, 20, 30, 40, 50}; for (int i 0; i 5; i) { std::cout arr[i] ; }注意循环条件是i 5不是i 5。写成i 5会访问arr[5]越界。这个“差一错误”off-by-one error是数组相关 bug 的头号来源。C11 起可以用范围 for 循环更简洁也更安全for (int val : arr) { std::cout val ; }范围 for 会自动遍历所有元素不需要关心下标和边界。如果只是想读取用int val如果想修改用引用int valfor (int val : arr) { val * 2; // 每个元素翻倍 }实操心得范围 for 循环的底层其实也是用迭代器或指针遍历它不会做额外的边界检查但因为它自己管理边界所以不会出现手写下标时的差一错误。能用范围 for 就用范围 for尤其是遍历整个数组时。5.3 越界访问到底会发生什么越界访问是未定义行为实际表现取决于内存布局和运行环境。常见的情况有读到相邻变量的值数组后面的内存可能正好是另一个变量你读到的是它的数据。读到垃圾值那块内存没被初始化输出一个随机数。程序崩溃如果越界到了未映射的页触发段错误。悄悄破坏数据写越界时可能覆盖了相邻变量的值导致程序在别处出错排查起来非常困难。int arr[3] {1, 2, 3}; int secret 999; arr[3] 0; // 未定义行为可能覆盖了 secret std::cout secret; // 可能输出 0也可能输出 999取决于编译器布局这就是为什么数组越界被称为“沉默的杀手”。它不一定立刻报错但会在你最意想不到的地方引发问题。5.4 用std::array获得边界安全如果你想要数组的性能又想要一点安全性可以用 C11 的std::array#include array std::arrayint, 5 arr {1, 2, 3, 4, 5}; std::cout arr.at(2); // 带边界检查越界抛异常 std::cout arr[2]; // 不带检查和原生数组一样std::array是固定大小的没有堆分配开销但提供了size()、at()、迭代器等便利接口。日常写业务代码我基本优先用它替代原生数组。6. 修改数组元素赋值、批量操作与陷阱6.1 单个元素的修改修改数组元素就是给它赋值int arr[5] {1, 2, 3, 4, 5}; arr[0] 100; // 第一个元素变成 100 arr[4] 500; // 最后一个元素变成 500下标可以是变量也可以是表达式只要结果在合法范围内int i 2; arr[i] 30; arr[i 1] 40;6.2 批量修改循环是主力要把所有元素改成同一个值或者按规律修改用循环// 全部设为 0 for (int i 0; i 5; i) { arr[i] 0; } // 每个元素加 10 for (int val : arr) { val 10; }注意范围 for 里要用引用int否则修改的是副本原数组不变。这是初学者常犯的错误for (int val : arr) { val * 2; // 错误修改的是副本arr 没变 }6.3 数组不能整体赋值这是和std::array、std::vector的一个重要区别原生数组不支持整体赋值。int a[5] {1, 2, 3, 4, 5}; int b[5]; b a; // 错误数组不能整体赋值要复制数组得用循环或std::copy#include algorithm std::copy(std::begin(a), std::end(a), std::begin(b));或者用memcpy仅限简单类型#include cstring std::memcpy(b, a, sizeof(a));注意memcpy对包含指针或非平凡构造函数的类型不安全会做浅拷贝。对于int、double这类简单类型没问题对于std::string数组就会出大问题。现代 C 推荐用std::copy或std::copy_n。6.4 修改时的边界检查习惯修改比读取更危险因为写越界会破坏其他数据。我的习惯是只要下标来自计算或用户输入就先检查范围。int index; std::cin index; if (index 0 index 5) { arr[index] 100; } else { std::cerr 下标越界\n; }如果用的是std::array或std::vector直接用at()越界会抛std::out_of_range异常比手动检查更省心。7. 常见问题与排查技巧实录7.1 数组长度算不对现象在函数里用sizeof(arr) / sizeof(arr[0])算长度结果永远是 1 或 2。原因数组作为函数参数时会退化为指针sizeof(arr)得到的是指针大小不是数组大小。解决把长度作为参数传进去或者用模板推导template typename T, size_t N size_t arraySize(T ()[N]) { return N; }或者直接用std::array它有size()成员函数。7.2 输出一堆乱码或奇怪数字现象打印数组元素出现 32767、-858993460 之类的值。原因局部数组未初始化读到的是内存残留值。解决声明时初始化int arr[10] {0};。7.3 程序在别处崩溃排查发现是数组越界现象程序在某个不相关的函数里崩溃或者某个变量莫名其妙变了值。原因之前某处数组写越界覆盖了相邻内存。解决用地址消毒工具如编译器的-fsanitizeaddress跑一遍它会精确定位越界写入的位置。这是排查内存问题的利器强烈建议在开发和测试阶段开启。7.4 常见问题速查表问题可能原因排查方向数组元素值不确定局部数组未初始化声明时加 {0}函数内算不出数组长度数组退化为指针传长度参数或用std::array程序随机崩溃数组越界写开地址消毒工具定位修改数组无效范围 for 没用引用改成int val字符串打印乱码字符数组缺\0确保留出结尾空字符位置数组整体赋值报错原生数组不支持用循环或std::copy7.5 几个我踩过的坑第一个坑是char buf[10]; strcpy(buf, hello world);。hello world是 11 个字符加结尾空字符共 12 字节塞进 10 字节的数组直接缓冲区溢出。这种错误在 C 风格字符串处理里非常常见现代 C 尽量用std::string。第二个坑是循环条件写成i n。这个差一错误我到现在偶尔还会犯尤其是在赶时间的时候。后来养成的习惯是写循环时先想清楚“最后一个合法下标是多少”然后条件用或!。第三个坑是在函数里返回局部数组的指针。局部数组在函数返回后就被销毁了返回它的指针是悬空指针用的时候行为未定义。要返回数组用std::vector、std::array或者让调用方传入缓冲区。实操心得调试数组问题时先把数组的地址和每个元素的地址打印出来看看是不是连续的、间隔是不是等于sizeof(元素类型)。这一步能帮你确认内存布局是否符合预期也能快速发现越界访问到了哪里。8. 从原生数组到现代 C 的平滑过渡学完原生数组的声明、初始化、访问和修改你其实已经掌握了 C 内存模型的一块基石。但日常写代码我建议逐步向std::array和std::vector迁移。std::array适合大小固定的场景零开销且带边界检查接口std::vector适合大小可变的场景自动管理内存。它们都支持范围 for、size()、at()能帮你避开原生数组的大部分坑。不过原生数组的机制不能跳过。你调用的很多底层库、看到的很多系统代码用的还是原生数组加长度参数的模式。理解数组名退化、下标运算本质、越界后果这些知识在排查底层问题时无可替代。我的建议是新代码优先用标准容器读老代码和底层代码时能看懂原生数组两者不矛盾。最后分享一个我常用的初始化技巧如果要把一个数组全部设成某个非零值 {0}只能清零其他值得用std::fill#include algorithm int arr[100]; std::fill(std::begin(arr), std::end(arr), 42); // 全部设为 42这个比手写循环清晰也不容易写错边界。数组这东西细节都在边界上把边界守住了基本就不会出大问题。