1. 数组和指针:一对总被误解的“双胞胎”
1.1 数组名不是指针,但为什么大家都这么说
先问一个基础问题:数组和指针是一回事吗?答案是不,但很多人学完依然分不清。原因很现实——在绝大多数使用场景里,数组名和指针变量确实可以“互换着用”。比如声明int a[5]之后,a[i]和*(a+i)完全等价,传给函数时int arr[]和int *arr也几乎没差别。正因这种高频出现的等价性,初学者很容易得出“数组名就是指针”的错误结论。
但结论是错的。数组名是一个标识符,它代表一整块连续内存,类型是“长度为N的数组”,比如int [5]。指针是一个变量,它存储某个内存地址,类型是“指向int的指针”,比如int *。两者的本质区别,看sizeof就一目了然:
#include <stdio.h> int main(void) { int a[5] = {1, 2, 3, 4, 5}; int *p = a; printf("sizeof(a) = %zu\n", sizeof(a)); // 20 (5 * 4) printf("sizeof(p) = %zu\n", sizeof(p)); // 8 (64位系统上指针占8字节) return 0; }sizeof(a)返回整个数组占用的字节数,sizeof(p)返回指针本身占用的字节数。同一块内存,从“数组”的角度看是20字节,从“指针”的角度看是8字节。这说明数组名带的“数组类型”信息并没丢,它不是指针变量的别名。
那为什么几乎所有地方都可以互换?因为C语言设计了一条极其重要的规则:在表达式中,数组名会退化为指向首元素的指针。这条规则叫“数组退化”(array decay)。int a[5]在参与运算时,a会被转换成&a[0],类型变为int *。于是a[i]、*(a+i)、p[i]全都对上了。真正让人晕的是:退化不是绝对的,有三个场景数组名不退化,这也成了面试和笔试的高频考点。
1.2 三个不退化的关键时刻:sizeof、取地址、字符串字面量
第一个不退化场景就是刚说的sizeof(a)。这里a保留完整的数组类型,所以能算出总字节数。第二个场景是&a。&a的类型不是int *,而是int (*)[5],即“指向长度为5的int数组的指针”。从数值上看,&a和a地址相同,但类型完全不同,直接导致a + 1和&a + 1的移动步长不一样。
看这个例子:
#include <stdio.h> int main(void) { int a[5] = {1, 2, 3, 4, 5}; printf("a = %p\n", (void*)a); printf("a + 1 = %p\n", (void*)(a + 1)); printf("&a = %p\n", (void*)&a); printf("&a+1 = %p\n", (void*)(&a + 1)); return 0; }假设数组首地址是0x1000,那么a + 1是0x1004,因为a退化为int *,步长是4字节;而&a + 1是0x1014,因为它的类型是“指向int[5]的指针”,步长整整20字节。很多人用&a + 1去往后跳“一个元素”,结果直接跳出了整个数组,这是典型的踩坑点。
第三个场景是字符数组初始化。char *s = "hello"和char s[] = "hello"看起来都是字符串,但机制完全不同:前者是指针指向只读字符串字面量,字符串存在静态常量区,s[0] = 'H'在多数平台上是未定义行为(崩溃或静默失败);后者是数组,在栈上拷贝了一份字符串,可以安全修改。一个“可改”、一个“不可改”,写代码前必须分清楚。
从这三个不退化场景能提炼出什么?**退化发生在“需要值”的上下文里,而发生在“需要类型身份”的上下文里时,数组名就保留原型。**理解这条底层逻辑,比死记sizeof和&两个特例更有用。
1.3 数组名和指针在“赋值”上的根本不同
数组名不能自增、不能自减、不能被重新赋值,因为它是“常量地址”而非“指针变量”。a++编译不过,a = p也编译不过,但p = a、p++都是合法操作。这一点也解释了为什么函数内部无法通过arr++修改“数组本身”的指向——参数arr虽然看起来是数组,实际已经退化成指针,它可以自增,但改变的是指针变量本身,调用方的数组不受影响。
写一段代码验证:
void change(int arr[]) { arr[0] = 99; // 可以修改数组元素 arr++; // 可以,因为arr在这个函数里就是指针变量 } int main(void) { int a[3] = {1, 2, 3}; change(a); // a[0] 变成 99 return 0; }change的参数看似int arr[],实际上编译器和int *arr完全一致。所以“数组作为参数传递时,函数内不知道数组长度”这个经典问题出现了——sizeof(arr)在函数内算出来是8,不是12。怎么解决?没有魔法,标准做法是额外传一个长度参数,或者用结构体封装数组和长度,再或者(C++里)直接用std::array/std::vector。
把这种关系用一个表格记住,复习时一目了然:
| 场景 | 数组名 | 指针变量 |
|---|---|---|
| 存储本质 | 一段连续内存的标识符 | 保存地址的变量 |
| sizeof | 数组总字节数 | 指针本身字节数 |
| 参与运算 | 自动退化为首元素指针 | 直接使用 |
| 是否可自增 | 否 | 是 |
| 可重新赋值 | 否 | 是 |
| 作为函数参数 | 退化为指针 | 本身就是指针 |
2. 一维数组的指针视角:从下标到地址的换算
2.1a[i]的本质是*(a + i)
很多教材说“数组下标就是指针偏移的语法糖”,这句话至少帮我们看透一件事:a[i]这个写法在编译器眼里就是*(a + i)。它先把a退化成首元素地址,再按指针算术偏移i个元素,最后解引用。
于是可以出现一些违反直觉但完全合法的写法,比如i[a]。既然a[i]等价于*(a + i),而加法满足交换律,所以*(a + i)等价于*(i + a),也就是i[a]。我自己在给团队做培训时经常写2[a]来考大家,每次都能看到有人愣住——但道理说透了,这根本不是奇技淫巧,只是语法糖的必然结果。
这个等价关系也有很多实际意义。当你在写代码时想表达“从第 i 个位置开始”,指针写法往往比下标写法更直白:
int sum_from(int *start, int count) { int sum = 0; for (int i = 0; i < count; i++) { sum += *(start + i); } return sum; }调用时传a或者a + 2都行,数组退化为指针后完全统一。这种写法也能让你意识到:其实没有“特殊的数组下标运算”,所有下标都是指针算术,理解了这一点,后面学迭代器、学STL的随机访问迭代器都会顺畅很多。
2.2 指针加减运算的“步长”是类型决定的
指针加1到底加几个字节?答案取决于指针指向的类型。int *p加1跳过4字节,double *p加1跳过8字节,struct Large *p加1跳过整个结构体大小。这就是指针的“步长”概念,严格定义是:指针加 n,等于地址值加n * sizeof(指向类型)。
用代码体感一下:
#include <stdio.h> int main(void) { int a[4] = {10, 20, 30, 40}; int *p = a; printf("%d\n", *p); // 10 printf("%d\n", *(p + 1)); // 20 printf("%zu\n", (char*)(p + 1) - (char*)p); // 4 return 0; }把p + 1和p都转成char *再相减,得到4,证明步长是4字节。实际工程里,这种步长特性经常被用来遍历结构体数组,也经常因为“类型没对上”产生隐蔽bug。
常见错误是:想遍历char数据却用了int *,结果一次跳4字节,漏掉3/4的字节;或者反过来,用char *遍历int数组,一次跳1字节,读出来的数据不对。避免问题的核心原则是:指针的类型必须和它指向的对象的类型匹配,除非你有充分的理由做强制转换。
2.3 数组退化的副作用:函数内算不出数组长度
有经验的C程序员都遇到过这个场景:写了一个sort_array(int arr[]),想在函数里用sizeof(arr) / sizeof(arr[0])算长度,结果排序只处理了部分数据,或者干脆越界。原因就是参数退化成指针,sizeof(arr)是8,不是整个数组大小。
一个直观的现场还原:
#include <stdio.h> void print_length(int arr[]) { printf("inside function: %zu\n", sizeof(arr) / sizeof(arr[0])); } int main(void) { int arr[10]; printf("outside function: %zu\n", sizeof(arr) / sizeof(arr[0])); print_length(arr); return 0; }输出:
outside function: 10 inside function: 2在64位机器上,sizeof(arr)是8,除以4等于2,看起来就像数组长度是2,实则是两个指针的错觉。这个坑几乎每个C语言学习者都踩过。解决办法没有黑魔法,要么传入长度参数,要么用宏,要么在C++里用模板推导数组长度:
template <size_t N> void print_length(const int (&arr)[N]) { std::cout << N << std::endl; }这里const int (&arr)[N]是“数组的引用”,参数不会退化,所以能推导出真正的长度。从C++的角度看,这是最干净的写法,也是复习时值得留意的对比:C语言靠传长度,C++可以用引用保长度。
3. 二维数组与指针:最容易绕晕的迷宫
3.1 二维数组的内存布局其实是一维的
很多人初学二维数组时,会画成一张“表格”,行和列都清清楚楚。这个模型对理解下标有帮助,但若照搬到内存模型上,就容易误解——以为二维数组在内存里也是“按行分块、块内又连续”。实际上,C语言的二维数组在内存中完全是按行优先连续排列的一维线性空间。
比如int matrix[3][4],本质就是连续12个int,排列顺序是:第0行的4个、第1行的4个、第2行的4个。我们叫它“数组的数组”更准确:matrix是3个元素组成的数组,每个元素是int[4]类型的数组。
这个布局解释了很多现象:
matrix在表达式中退化为int (*)[4]类型,即“指向长度为4的int数组的指针”,而不是int **。matrix + 1跳过一整行,也就是4个int、16字节。*(matrix + 1) + 2指向第1行第2列的元素,*(*(matrix + 1) + 2)取出值。- 访问越界时,比如
matrix[2][5],很可能落到第3行之后的无关数据或崩溃,因为实际是把指针算到了第2行偏移5个int的位置。
用一张表记录几级解引用的含义,会很清晰:
| 表达式 | 类型 | 含义 |
|---|---|---|
matrix | int (*)[4] | 指向第0行(一个长度为4的数组) |
matrix + 1 | int (*)[4] | 指向第1行 |
*(matrix + 1) | int * | 第1行首元素地址 |
*(matrix + 1) + 2 | int * | 第1行第2个元素地址 |
*(*(matrix + 1) + 2) | int | 第1行第2个元素值 |
3.2int (*p)[N]与int *p[N]:到底谁是指针谁是数组
这是C语言里出了名的“左右之辩”。区别其实只在于运算符优先级。[]的优先级高于*:
int *p[N]:p先和[N]结合,说明p是数组,数组元素是int *,所以叫指针数组——一个数组,里面存指针。int (*p)[N]:括号让*先和p结合,说明p是指针,指向一个int[N]数组,所以叫数组指针——一个指针,指向数组。
理解这个区分,在实际代码里能避免很多编译告警。比如想用指针遍历二维数组,应该这样:
int matrix[3][4] = {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}}; int (*row)[4] = matrix; // 指向一行的指针 for (int i = 0; i < 3; i++) { for (int j = 0; j < 4; j++) { printf("%d ", row[i][j]); // 或 *(*(row + i) + j) } }但如果错误声明成int *row[4],编译器立刻报类型不匹配,因为int *[4]是“4个int指针组成的数组”,和int (*)[4]毫无关系。这个类型系统虽然繁琐,但强制你明确每一层的含义,一旦想清楚反而不容易错。
3.3 二级指针不等于二维数组
一个常见误区:用int **pp去指向int matrix[3][4],觉得“二级指针嘛,就是指向指针的指针,二维数组不就是指针的数组吗”——这是错误的。int **指向的对象是int *,也就是一个指向int的指针变量;而二维数组名退化后的类型是int (*)[4],两者底层布局完全不同。
为什么很多人混淆?因为动态分配二维数组时,确实会用到int **:
int **m = malloc(rows * sizeof(*m)); for (int i = 0; i < rows; i++) { m[i] = malloc(cols * sizeof(**m)); }这种“指针数组 + 每行单独分配”的结构,看起来像矩阵,但每行内存并不连续,行与行的地址也不一定是相邻的。它和真正的二维数组在内存布局上一个“每行独立分配、整体不连续”,一个“全部连续”。这个差异直接影响性能——连续版本对缓存友好,遍历速度快;分散版本可能每次切行都cache miss。
如果想让动态分配的二维数组也连续,可以这样:
int *m = malloc(rows * cols * sizeof(*m)); // 访问第i行第j列用 m[i * cols + j]用一个int *模拟二维下标,牺牲一点可读性,换来连续内存和高缓存命中率。这种写法在图像处理、矩阵运算里非常常见,复习数组和指针时值得留意。
4. 指针的高级形态:指针数组、函数指针与指针的指针
4.1 指针数组的实际用途:字符串管理和表驱动
指针数组最经典的应用是字符串管理。C语言没有真正的字符串类型,字符串只能用字符数组或字符指针表达。当你想管理多个字符串时,如果都用二维字符数组,列数必须固定为最长字符串的长度,既浪费空间又僵化。用指针数组则灵活得多:
const char *skill_names[] = { "C", "C++", "Python", "JavaScript", "SQL" };每个元素是const char *,指向一个字符串字面量,长度各不相同,互不干扰。按字典序排序时,只需要交换指针,不需要搬动字符串数据,成本低得多。这也直接解决了热搜里“c语言排序数组”和“指针数组存放字符串”的一个很现实的场景:数据量一大,搬数据代价高,换指针便宜。
另一个场景是表驱动编程。用指针数组把函数或配置集中管理:
typedef struct { const char *name; int (*handler)(int, int); } Command; Command commands[] = { {"add", add_handler}, {"sub", sub_handler}, {"mul", mul_handler}, };查表时通过名字找到对应处理函数,避免一长串if-else。这种写法在嵌入式命令解析、解释器、测试框架里很常见,是数组、指针、函数指针结合起来的典型用法。
4.2 函数指针:把函数当数据用
函数指针可能是“复习系列”里最容易被跳过、实战中又特别重要的一环。它的声明读法也用到那个顺序规则:先括号、再解引用、最后参数列表。
int (*func_ptr)(int, int);func_ptr先和*结合,是指针;接着是参数列表(int, int),表示它指向的函数接收两个int;最前面int是返回类型。读法是:func_ptr是指向“接收两个int、返回int的函数”的指针。
用函数指针干什么?最常用的是回调。比如C标准库的qsort:
#include <stdlib.h> #include <stdio.h> int compare_int(const void *a, const void *b) { int x = *(const int *)a; int y = *(const int *)b; return (x > y) - (x < y); } int main(void) { int arr[] = {5, 2, 9, 1, 7}; size_t n = sizeof(arr) / sizeof(arr[0]); qsort(arr, n, sizeof(int), compare_int); for (size_t i = 0; i < n; i++) { printf("%d ", arr[i]); } return 0; }qsort不认识int,它只知道每个元素占几个字节,排序规则由调用方通过函数指针传入。这种“算法与数据解耦”的思路,在C语言里全靠函数指针实现。理解了函数指针,再看面向对象里的回调、事件驱动、策略模式,都会觉得似曾相识。
4.3 指针的指针到底解决什么问题
int **pp最常见的含义是“指向指针的指针”。它的应用场景主要有两类:一是字符串数组(就是前面提到的argv,char **argv),二是“在函数里修改外部指针变量”。
第二类特别值得展开。假设你想让函数给一个指针重新分配内存,如果传的是指针本身,函数内部修改的是保护性拷贝,外部指针不会变:
#include <stdlib.h> void bad_alloc(int *p) { p = malloc(10 * sizeof(int)); // 只改了副本 } void good_alloc(int **p) { *p = malloc(10 * sizeof(int)); // 改的是外部指针变量 } int main(void) { int *p = NULL; bad_alloc(p); // p还是NULL good_alloc(&p); // p现在指向分配的内存 free(p); return 0; }看到区别了吗?bad_alloc传的是p的值,函数内新建了一个副本,给副本赋值,p本人毫无变化;good_alloc传的是p的地址,函数通过二级指针修改*p,也就是修改了调用方的p变量本身。这个模式在链表插入头节点、树插入节点时特别常用,如果不理解,头插法很容易写出“链断了”或“函数返回后节点丢失”的bug。
5. 数组和指针在实际项目中的典型应用
5.1 字符串数组的排序:地址交换和数据搬运的区别
热搜词里“c语言排序数组”出现频率很高。基础版是排int,进阶版就是排字符串数组。排字符串数组时,如果用二维字符数组,排序交换的是整行字符,内存拷贝量随字符串长度增长;如果用字符指针数组,排序只需交换指针,成本固定为sizeof(char *)。
方便理解,看两段思路对比:
// 易错版:二维字符数组排序 char names[][16] = {"banana", "apple", "cherry"}; // 交换时需要 char tmp[16]; strcpy(tmp, names[i]) ... // 字符串越长,交换越慢 // 推荐版:指针数组排序 const char *name_ptrs[] = {"banana", "apple", "cherry"}; // 交换时只需要 const char *tmp = name_ptrs[i];前者适合固定长度、少量数据;后者适合不定长字符串、数据量较大的情况。另外对qsort排字符串数组时,比较函数的参数类型是const void *,但指向的元素类型是char *,所以比较函数内要做两层转换:先把void *转成char **,再解引用拿char *字符串,最后套strcmp。这个细节特别容易错,很多人的排序结果不对,就是卡在这一层。
5.2 数组参数传递的三种姿态
数组作为参数,在C/C++里本质就是传地址,所以函数内可以修改实参数组元素。但“传整个数组”的方式有几种,整理下来便于对照:
- 退化成指针:
void f(int arr[], int n)最常用,配合长度参数。 - 数组引用(C++):
template<size_t N> void f(int (&arr)[N])保留长度,适合模板场景。 - 结构体封装:把数组和长度包进结构体,传递起来更像“一个对象”。
第三种在C语言里很常见,也是后来C++封装出std::vector的原型思路:
typedef struct { int *data; size_t length; } IntArray; void init_array(IntArray *arr, size_t len) { arr->data = malloc(len * sizeof(int)); arr->length = len; }这种“数据 + 元数据”的封装方式,让你不再一边传数组一边传长度,函数签名清晰得多。排查空指针、越界时,也更容易统一检查。
5.3 快慢指针在原地去重中的妙用
热搜里有“js快慢指针有序数组原地去重”,其实这个思路在C/C++里同样经典。给定一个升序数组,要求原地去重且不额外分配大块内存,用快慢指针一次遍历就能搞定:
int remove_duplicates(int *nums, int numsSize) { if (numsSize == 0) return 0; int slow = 1; for (int fast = 1; fast < numsSize; fast++) { if (nums[fast] != nums[slow - 1]) { nums[slow] = nums[fast]; slow++; } } return slow; }核心思想是:fast负责扫描,把不重复的元素往前搬;slow指向下一个不重复元素应放的位置。这个技巧底层用的还是数组下标和指针偏移的等价性,说明“数组理解了指针,算法才能玩得转”。
再看两个热搜词:“数组去重”和“所有n-1条最短路径可以用二维数组path”。前者是工程常见题,后者是图论算法里二维数组存路径的经典案例。这些热词共同指向一件事:数组和指针不是孤立知识点,而是所有数据结构算法的地基。链表里每个节点的next本质上就是个指针语义的数组链接;图论里用二维数组存邻接矩阵,本质是用连续内存表达关系。
5.4 数组转字符串、字符串数组参数的野路子与正经做法
“数组转字符串”这个热搜往往出现在C/C++新手群里。C语言没有内置这个操作,最常见的办法是循环加sprintf,或者用snprintf做格式化:
int numbers[] = {1, 2, 3, 4}; char buf[64] = {0}; size_t offset = 0; for (size_t i = 0; i < 4; i++) { int written = snprintf(buf + offset, sizeof(buf) - offset, "%d%c", numbers[i], i == 3 ? '\0' : ','); if (written < 0) break; offset += written; }注意snprintf返回的是“应写入的字符数”,不是实际写入数;缓冲区快满时要看返回值和剩余空间,否则会截断。用指针往后移动再拼字符串,是C风格字符串处理的基本功,也是一种“指针即游标”的实践。
“c语言字符串数组怎么定义”也是高频问题。两种方式务必分清:
char a[][8]:二维字符数组,每行固定8字节,可修改内容。char *a[]:字符指针数组,可以指向不同的字符串,灵活但要注意字面量只读问题。
同样声明成char *,是“指向只读字面量”还是“指向可写数组”,取决于初始化方式,这个坑我在面试里反复问过,仍然很多人中招。
6. 现代C++:还需要学原生数组和指针吗
6.1 std::array、std::vector 与原生数组的取舍
很多新手学完C语言的数组,进了C++就听说“用std::vector,别用原生数组”,于是彻底放弃理解数组。这个说法大方向没错,但不该变成“直接跳过基础”。恰恰相反,只有理解了原生数组的内存布局,才知道std::vector为什么在动态扩容时要搬数据、为什么std::array能保留栈上固定大小、为什么std::vector<int>的data()方法返回的指针可以兼容C接口。
取舍标准大致可以这样看:
| 需求 | 推荐 |
|---|---|
| 固定大小、高性能、栈上分配 | std::array |
| 需要动态增长 | std::vector |
| 与C接口交互、底层内存控制 | 原生数组或data() |
| 需要编译期长度推导 | std::array配合模板 |
#include <array> #include <vector> void process(const int *data, size_t n); int main() { std::array<int, 4> a = {1, 2, 3, 4}; std::vector<int> v = {1, 2, 3, 4}; process(a.data(), a.size()); process(v.data(), v.size()); return 0; }data()返回的就是指向底层数组首元素的裸指针,这也再次说明:不管外面包了多少层,底层仍然是数组和指针的模型。
6.2 智能指针是否意味着可以放弃裸指针
std::unique_ptr、std::shared_ptr、std::weak_ptr的出现,确实让很多以前用裸指针管理生命周期的问题得到解决。但“放弃裸指针”不等于“放弃理解指针”。智能指针的get()返回裸指针,operator->和operator*也模拟裸指针语义。如果你不理解指针的本质,用智能指针也容易出错,比如:
auto p = std::make_shared<int>(42); int *raw = p.get(); delete raw; // 错误!会导致双重delete或悬垂另一个典型问题是用shared_ptr管理数组时,默认删除器会调用delete而非delete[],除非显式指定:
std::shared_ptr<int> sp(new int[10], [](int *p) { delete[] p; });这类细节都要求你对“指针指向的对象类型”“所有权归谁”“释放方式是什么”有清晰认知。所以我的看法是:现代C++允许你少写裸指针,但绝不意味着可以不懂它。
6.3 复习阶段最该做的一次“指针自检”
复习数组和指针,最高效的方式不是一遍遍看书,而是自测几个“看起来简单、实际上容易翻车”的问题。我把它们列在下面,如果你都能答对并且能解释清楚,这部分基本过关了:
int a[5];中a与&a类型分别是什么?sizeof(a)和sizeof(&a[0])的结果一样吗?- 函数参数写成
int arr[]和int *arr有区别吗?为什么? int *p[3]和int (*p)[3]有什么区别,声明时怎么读?malloc返回void *,为什么C语言中可以直接赋给int *,C++却不行?char *s = "hello"和char s[] = "hello"修改s[0]会有什么不同?qsort的比较函数为什么参数是const void *,和函数指针是什么关系?int **pp能直接指向二维数组吗?如果不能,为什么?
这些问题覆盖了退化、步长、优先级、类型系统、函数指针、字符串字面量等核心考点。能把每个问题用代码验证一遍,比囫囵吞枣刷十道题有效。
我在给团队做Code Review时发现,很多线上bug表面上是逻辑问题,挖到底往往是某个数组越界、某个指针类型写错、某个字符串字面量被误修改。数组和指针这两个话题,入门时觉得“不过如此”,工作越久越觉得它们才是C/C++项目里最需要敬畏的地方:内存布局决定了性能,类型系统决定了正确性,而这两者恰好都压在数组和指针身上。把这部分复习扎实,后面再去看链表、树、图、动态规划、STL源码,都会轻松一大截。