C语言里有个概念,几乎所有教材都把它放在变量和循环之后讲,但每年还是有一大批初学者在它身上栽跟头,这就是数组。我见过太多人学指针、结构体、链表时自我感觉良好,回头却被一道“为什么函数里算不出数组长度”的题问得怀疑人生,根子往往就是数组的基础没打牢。这篇文章不打算绕弯子,只想把数组最基础、也最容易被忽略的部分彻底捋一遍——它解决什么问题、怎么声明、怎么初始化、怎么访问,以及内存里到底发生了什么。适合刚学完基本语法、准备开始啃数组的同学,也适合学了一段时间、想回来补基础的开发者。看这篇文章之前,你只要会变量、printf和for循环就够了,剩下的我会一层层拆开讲。
1. 从一个最简单的需求说起:为什么要引入数组
1.1 没有数组的日子:30个变量写到手软
设想一个特别常见的场景:你要统计班里30个人的C语言期末成绩,算平均分并找出最高分。如果没有数组,你得写成这样:
int s1, s2, s3, s4, s5, s6, ...;声明完变量之后还得逐个赋值,算总分时更是噩梦:sum = s1 + s2 + ... + s30,写30次加法,眼睛都花了。要是人数从30变成300,这套代码基本就得重写,复制粘贴都会把人逼疯。
我当年带新人时,经常让他们先试着用纯粹的变量完成这个需求,体验一下什么叫“重复代码写到想砸键盘”。这不是在刁难人,而是只有亲手写过那种又臭又长的变量列表,你才会真正感激数组这种结构的存在。数组做的事情非常朴素:把一群同类型的变量合并成一个名字,用编号区分它们,再配合循环批量处理。就像一栋宿舍楼,楼号是名字,房间号是下标,每间房里住的数据类型都一样。
1.2 数组的本质:同类型数据的连续集合
严格来说,数组是一组相同类型元素的集合,在内存中占据一块连续的空间。这句话里藏着三个关键点:
- 相同类型:int数组里全是int,double数组里全是double,不能混着放。想混着放需要结构体,那是另一个话题。
- 连续空间:元素一个挨一个,中间没有空隙,顺序紧紧相连。
- 固定顺序:每个元素都有自己唯一的位置编号,这个编号就是下标,从0开始。
这三个特点决定了数组的全部行为,也决定了它的优势和局限。优势是随机访问快——想拿第i个元素,编译器算一下地址就到手了,不用像链表那样从头遍历;局限是一旦创建,长度基本固定,不好动态伸缩。很多初学者没意识到“连续”这两个字意味着什么,其实后面所有跟数组有关的坑,几乎都能追溯到这段“连续内存”上去。
1.3 数组和普通变量的适用边界
搞清楚了数组是什么,还得知道什么时候该用它。我把数组和普通变量做了一张简单对比:
| 场景 | 普通变量方案 | 数组方案 |
|---|---|---|
| 存3个成绩 | int a, b, c; | int s[3]; |
| 存30个成绩 | 手写30个变量名 | int s[30]; |
| 计算总和 | sum = a + b + c + ... | for循环累加 |
| 找最大值 | 逐个比较,代码冗长 | for循环加一次比较 |
结论一句话:只要数据是“同一类型、数量较多、需要统一处理”,优先考虑用数组。反过来,如果数据类型各不相同,比如一个学生的姓名、学号、成绩,那定义成结构体更合适。这种边界感到后期写项目时会越来越明显,初学阶段先把数组用熟就好。
2. 数组声明:类型、名字、长度,这次把语法一次讲透
2.1 声明语法的拆解
数组声明的基本形式是:
类型 数组名[长度];实际写出来就是:
int scores[30]; // 30个int double weights[5]; // 5个double char name[20]; // 20个char这行代码可以拆成三部分看:
- 类型:决定每个元素占多大空间、能存什么值。int是4个字节,double是8个字节,char是1个字节。
- 数组名:和普通变量命名规则一样,字母数字下划线,不能以数字开头,不能和关键字冲突。
- 方括号里的长度:表示元素个数,必须是正整数。
比如int scores[30];的意思是向内存申请30个连续的int空间,每个空间都能放一个int值。这里有个容易忽略的细节:长度是元素个数,不是字节数。int scores[5]声明的是5个元素,占20个字节,而不是5个字节,新手经常把这两个数搞混,后面讲内存时还会再提到。
2.2 长度到底能不能是变量
这是基础阶段最容易被问住的一个问题:“老师,数组长度可以写变量吗?”标准答案是:在传统C89/C90里,长度必须是编译期常量;C99引入了变长数组(VLA),允许运行时才确定长度。
编译期常量的写法有这么几种:
int a[10]; // 直接写数字 #define SIZE 5 int b[SIZE]; // 用宏 int c[3 + 2]; // 常量表达式,编译时就算出5C99变长数组的写法则是这样:
int n; scanf("%d", &n); int arr[n]; // VLA,运行时确定长度但这里有个实操提醒:VLA不是所有编译器都支持,MSVC默认就不支持;就算支持的编译器,如果n特别大,栈空间不够,程序会在运行的一瞬间崩掉。而我见过太多初学者在栈上声明一个几百万长度的数组,然后来问为什么程序一启动就“炸”了,其实就是数组太大,把栈撑爆了。我现在教新人时会建议:先老老实实写常量或宏,把数组本身的基本功练扎实,等理解了内存分布和堆栈区别,再去碰VLA。
2.3 声明阶段最容易踩的三个坑
声明数组看起来简单,但实际写代码时有三类错误特别常见:
第一个坑:长度写成负数或0。int arr[-1];编译器直接报错,int arr[0];在很多编译器里是零长数组扩展,不是标准C,不要依赖它。
第二个坑:数组名和普通变量重名。同一个作用域里先写了int arr;再写int arr[5];,编译器会告诉你重复定义。命名时养成习惯,数组名尽量带复数含义,比如scores、weights,能减少不少麻烦。
第三个坑:试图整体赋值。数组之间不能直接赋值,int a[5]; int b[5]; b = a;编译不过。C语言只允许对数组的元素逐个操作,不允许把整个数组当普通值传来传去。这一点很多人直到写函数参数时才会真正体会到。
3. 初始化数组:给每个元素一个确定的初始值
3.1 五种常见的初始化写法
声明数组之后,下一步自然是往里面放值。直接初始化有五种常见写法,我把它们列出来:
int arr1[5] = {1, 2, 3, 4, 5}; // 完全初始化,5个元素都给了值 int arr2[5] = {1, 2}; // 部分初始化,剩下自动补0 int arr3[] = {1, 2, 3}; // 省略长度,编译器数出长度是3 int arr4[5] = {0}; // 全部清零,最常用的兜底写法 int arr5[5] = {[0] = 10, [2] = 30}; // 指定下标初始化,C99特性第五种写法很多人可能没见过,它允许你只给第0个和第2个元素赋值,其他元素自动补0。说实话,日常开发里这种写法用得不多,而且可移植性一般,我建议知道有这回事就行,主力记住前四种。
有一个细节要特别强调:省略长度初始化只在“同时提供初始化列表”时合法。int arr[];这种写作在编译期就是错的,编译器根本不知道该分配多少空间。
3.2 局部变量不初始化到底会发生什么
很多人想当然认为“数组不初始化,里面全是0”,这是初学者最大的误区之一。我在这里把规则说透:
- 全局数组:不初始化时自动为0。
- static局部数组:不初始化时也自动为0。
- 普通局部数组:不初始化时是“不确定值”,本质上是栈上残留的垃圾数据。
我在调试时经常看到有人定义一个局部数组,没初始化就直接输出,结果打印出巨大的随机数,然后一脸迷茫地怀疑编译器坏了。其实编译器没坏,是你没给初始值。
所以我的实战经验是:局部数组声明时如果还拿不准该填什么,至少给一个空初始化列表兜底:
int arr[100] = {0};这样至少保证数组内容可预期。另外,部分初始化后剩余元素自动补0是C标准明确保证的,不是碰巧,可以放心用。比如int arr[5] = {1, 2};后三个元素一定是0。
3.3 字符数组和字符串:一字之差,天壤之别
字符数组在所有数组类型里非常特殊,因为字符串的本质就是字符数组,但它还多了一个看不见的尾巴。看这几种写法:
char s1[] = "hello"; // 长度是6,最后有'\0' char s2[5] = "hello"; // 长度不够,'\0'被丢弃 char s3[] = {'h','e','l','l','o'}; // 5个字符,不是字符串 char s4[5] = {'h','e','l','l','o'}; // 没有'\0',不是字符串"hello"看着是5个字符,实际上内存里是6个字节,最后一个字节是不可见的'\0',这是字符串的结束标志。sizeof(s1)的结果是6,不是5,这个数字经常把新手坑到。
最有意思的风险在s2和s4上:数组里没有'\0',但它确实存了字符。你如果转头用printf("%s", s2)去打印,printf会从数组开头一直往后面的内存读,直到某处碰巧遇到一个0字节才停下来。这属于非常经典的越界读,输出内容完全不可控,可能后面是乱码,也可能直接崩溃。
初学阶段我送大家一个建议:只要遇到字符数组,一律按字符串的标准来写——长度多留1个字节给'\0',并且主动给最后一位赋'\0'。宁可多算一位,也别丢尾巴。
4. 下标访问与遍历:数组的正确打开方式
4.1 为什么下标从0开始
数组第一个元素的下标是0,不是1。很多新人别扭,但C语言这么设计是有底层原因的。数组元素在内存里连续排列,第i个元素的地址计算方式是:
第i个元素地址 = 起始地址 + i × 每个元素占用的字节数第一个元素到起始地址的偏移量是0,所以它理所当然排在0号位置。从0开始编号,CPU计算地址时不需要再做“减1”那一步,指令更简洁。更重要的是,C语言天生贴近内存模型,下标就是偏移量的直接表达。
如果你把“第5个元素”下意识写成arr[5],那到手的是第6个元素。记住一句话:下标是偏移量,不是序号。这个思维方式越早建立,后面学指针越容易。
4.2 用sizeof计算数组长度
访问数组前,先得知道数组有多少个元素。最经典的一种求长度方式是:
int arr[] = {10, 20, 30, 40, 50}; int len = sizeof(arr) / sizeof(arr[0]); // 结果是5sizeof(arr)在编译期求值,返回的是整个数组占用的字节数;sizeof(arr[0])返回一个元素占用的字节数。两者相除,就是元素个数。这个写法在数组定义所在的函数里百试百灵。
但这里埋着一个天坑:数组一旦作为函数参数传递,就会“退化”为指针,此时sizeof(arr)求出来的是指针大小,在64位机器上通常是8,而不是数组大小。很多人因此在函数里算出8 / 4 = 2,然后又想不通“明明传了5个元素,为什么函数里长度是2”。这个问题的完整原因,等讲到指针和函数传参时再展开,现在你只要记住:在定义处用sizeof没问题,在参数里别这么干。
4.3 遍历的经典写法与陷阱
遍历数组最标准的写法:
int arr[] = {10, 20, 30, 40, 50}; int len = sizeof(arr) / sizeof(arr[0]); for (int i = 0; i < len; i++) { printf("%d ", arr[i]); }循环条件i < len是无数老程序员验证过最稳妥的写法。常见的错误版本是把条件写成i <= len,多转了一圈,最后一次访问arr[len],而那个位置已经越界了。数组有效下标范围是0到len-1,循环条件用<就天然把这个范围圈住了。
另一个常见的低级错误是从1开始循环,比如有人写for (int i = 1; i <= len; i++),结果把第一个元素漏掉,还因为<=再次越界。我的经验是写遍历前先默念一遍“有效下标0到len-1”,再动笔写循环条件,这个习惯能帮你省掉大量调试时间。
5. 数组的内存本质:连续、定长、越界
5.1 数组名到底是地址还是指针
这个问题我每次讲数组都会被问到:“数组名是不是指针?”答案是:不完全一样。在绝大多数表达式里,数组名会“退化”为首元素地址,也就是等于&arr[0],比如你写printf("%p", arr),打印的就是首元素地址。但数组名本身不是指针变量,它没有自己的存储空间,也不能给它赋值。
更微妙的是&arr和arr的区别:
int arr[5] = {0}; arr + 1 // 指向第1个元素,跳过4个字节 &arr + 1 // 指向数组末尾的后面,跳过整个数组20个字节这种区别在以后做指针运算时特别容易踩坑。我不建议现在就把所有细节都塞进脑子,但请先建立一个概念:arr代表首元素地址,&arr代表整个数组的地址,两者类型都不一样。以后学指针时回头再看这段,你会觉得这层窗户纸捅破得非常及时。
5.2 元素大小、总大小和长度的换算
我用一张表总结数组和sizeof相关的常见表达式,方便你对照记忆:
| 表达式 | 含义 | 举例:int arr[5] |
|---|---|---|
sizeof(arr) | 整个数组占用的字节数 | 20字节(假设int为4字节) |
sizeof(arr[0]) | 单个元素占用的字节数 | 4字节 |
sizeof(arr) / sizeof(arr[0]) | 元素个数 | 5 |
&arr + 1 | 跳过整个数组的地址 | 比arr大20字节 |
这里有一个基础但关键的认知:sizeof的所有计算都在编译期完成,它不会在运行时去“数一遍”数组。所以如果数组长度本身是运行时才知道的VLA,sizeof的行为就需要单说;如果是普通数组,这个计算是零成本的,不需要担心性能。
5.3 越界读写为什么特别危险
C语言不会检查数组下标。你写arr[10]去访问一个长度为5的数组,编译能过,运行也大概率不报错。这种“不报错”恰恰是最危险的地方,因为程序会在你毫无察觉的时候悄然越界。
越界读的后果可能是读到相邻变量、栈上残留数据或者其他完全不相关的内存,输出乱码;越界写更凶,它可能直接覆盖掉相邻变量的值。比如你在函数里声明了数组a和变量b,编译器刚好把b放在a的紧后面,你越界写a[5] = 100时,b的值就被悄无声息改掉了,接下来程序所有用到b的地方都会出现莫名其妙的错误。
更要命的是,局部变量的布局由编译器决定,换一个编译优化等级,内存排列就变了。这种越界bug可能今天不出现、明天出现,调试器里还特别难复现。所以越界问题永远要靠程序员自己兜住,工具帮不了你太多。我的习惯是:循环里访问数组前,先问自己一句“i的最大值会不会超过len-1”。这个问题多问几次,比事后调试节省十倍时间。
6. 练习自查:用三个小例子把基础焊死
6.1 例一:求总分和平均分
学会了数组的声明、初始化和遍历,就可以解决开头那个成绩统计问题了:
#include <stdio.h> int main(void) { int scores[] = {78, 82, 91, 65, 88, 95}; int n = sizeof(scores) / sizeof(scores[0]); int sum = 0; for (int i = 0; i < n; i++) { sum += scores[i]; } printf("总分: %d\n", sum); printf("平均分: %.2f\n", (double)sum / n); return 0; }这段代码练习了两个核心点:一是用sizeof求数组长度,二是用for循环逐个访问元素。注意算平均分时我把sum强转成了double,否则两个整数相除结果还是整数,会丢掉小数部分。这是C语言整型运算的一个经典细节,写代码时一定要记得处理。
6.2 例二:找出最大值及它的位置
这道题在数组题里出现频率极高,也是面试和作业常客:
#include <stdio.h> int main(void) { int arr[] = {3, 8, -2, 10, 7, 4}; int n = sizeof(arr) / sizeof(arr[0]); int max = arr[0]; int pos = 0; for (int i = 1; i < n; i++) { if (arr[i] > max) { max = arr[i]; pos = i; } } printf("最大值: %d, 位置: %d\n", max, pos); return 0; }我特意把max初始化为arr[0]而不是0,这是很多人容易踩的坑。如果数组里全是负数,初始化为0就会导致最大值永远算不出来。正确思路是拿第一个元素当“候选人”,然后从第二个元素开始比较。
6.3 例三:倒序输出数组
倒序输出是理解下标的绝佳练习,因为它会强迫你反过来思考边界:
#include <stdio.h> int main(void) { int arr[] = {1, 2, 3, 4, 5}; int n = sizeof(arr) / sizeof(arr[0]); for (int i = n - 1; i >= 0; i--) { printf("%d ", arr[i]); } printf("\n"); return 0; }循环从n - 1开始,一直递减到0,输出顺序正好反转。这个题目虽然简单,但能把i >= 0这个边界条件刻进脑子里,以后写倒序遍历就不会手滑。
6.4 初学数组自查清单
文章最后,我把自己平时检查初学者代码时最常用的清单整理出来,你可以写完练习后逐项对照:
- 数组长度是否用了常量或宏,而不是含义不清的魔法数字?
- 普通局部数组是否已经初始化?至少给了
{0}兜底? - 字符数组是否给
'\0'留出了位置? - 循环条件用的是
< n还是<= n? sizeof求数组长度的代码,是否写在数组定义所在的作用域?- 有没有对数组名直接整体赋值?
- 访问数组时,心里是否清楚下标是从0开始、到len-1结束?
我个人带新人时最常说的一句话是:数组不是考点,是工具。基础阶段与其死背语法,不如多写几个数组小程序,把“同类型、连续、固定长度”这九个字在脑子里建立起画面感。等你哪一天用指针操作数组不再发怵,回头再看今天这篇文章,会发现所有内容都是顺着这个画面感长出来的。下一篇我会接着讲数组作为函数参数时那些更微妙的退化和传址行为,先把这一篇的代码亲手跑一遍,感受才算真的到位。