1. 为什么指针学到第三篇,反而觉得前两篇白学了?
能坚持到C语言指针系列的第三篇,说明前面基础部分的底子你已经大致铺开了。可奇怪的是,很多人在这个阶段反而比刚入门时更没底:声明、取地址、解引用单独拿出来都认识,一旦代码里出现const char **、int (*p)[5]这种组合,就得捧着书猜半天;程序一崩溃,第一反应不是查指针,而是怀疑编译器出了毛病。
C语言指针基础三这篇,我想解决的不是"指针是什么",而是"指针为什么能组合出这么多花样"。const 加进来该怎么读?字符串到底用char*还是char[]?二维数组和二级指针是不是同一回事?函数里想改外部的指针又该怎么传参?如果你被这几个问题卡过,这篇文章就是给你准备的。
我见过不少学完基础篇的同学,术语背得滚瓜烂熟,一写真实代码就露怯。这不怪你——指针本来就不是靠背定义能拿下的,它是一套需要反复对照内存去理解的心智模型。第三篇的内容,就是用几组最容易碰到的"组合场景",把这些知识点真正拧在一起。
下面按四条线展开:const 与指针的搭配、字符指针和字符数组的区别、数组指针与指针数组的辨析、二级指针的出现时机。每条线后面都有实际工程里高频出现的例子,以及我个人踩过之后总结出来的判读技巧。建议手边备一支笔,遇到不明确的声明,先在纸上画一画它到底指向哪里。
2. const 与指针的四种组合:别死背口诀,先搞懂"锁住谁"
很多教程喜欢把 const 指针声明总结成口诀,背是能背,换个场景就忘。我的建议是直接从修饰关系去理解:const 始终修饰紧挨着它右侧的东西。当右侧是*时,它修饰"通过解引用得到的值";当右侧是指针变量名时,它修饰"指针变量本身"。
看一段最直观的代码:
int a = 10, b = 20; const int *p = &a; // const 修饰 *p,即被指数据只读 p = &b; // 合法:p 本身可改 // *p = 5; // 非法:*p 不能写 int *const q = &a; // const 修饰 q,即指针变量只读 *q = 5; // 合法:a 被改成 5 // q = &b; // 非法:q 不能指向别处2.1 const 放在星号左边还是右边,含义完全相反
对齐上面代码看,const int *p的 const 在*左边,锁住的是解引用后的数据;int *const q的 const 在*右边,锁住的是指针变量自己。哪怕你什么都不记,只要看到 const,迅速问一句:它右边跟的是什么?跟的是*,锁数据;跟的是变量名,锁指针。
好多人不知道的是,const int *p和int const *p其实完全等价,因为把 const 挪到int前面并不会改变修饰关系。真正能改变含义的,是 const 和星号之间的相对位置。这个细节在阅读旧代码时特别重要,因为不同开发者写 const 的习惯不一样,看懂等价的两种形式,才不会把同一种声明理解成两回事。
2.2 四种组合的判读速查表
| 声明 | 指针本身 | 被指向的数据 | 常见叫法 |
|---|---|---|---|
const int *p或int const *p | 可改 | 只读 | 指向常量的指针 |
int *const p | 只读 | 可改 | 常量指针 |
const int *const p | 只读 | 只读 | 指向常量的常量指针 |
int *p | 可改 | 可改 | 普通指针 |
判读时最忌讳跳跃。先让标识符p站出来,看它右边有没有 const;再看解引用后的数据左边有没有 const。两次检查完,结果就出来了。我平时读复合声明都按这个顺序来,宁可慢一点,也不靠感觉猜。
2.3 const 在工程接口里为什么值得写
写小项目时容易觉得 const 可有可无,反正自己不会乱改。一旦代码变成多人维护,const 的价值立刻显现:它把"这块数据不允许修改"从注释里的口头约定,变成了编译器的强制检查。调用方某天试图修改,编译阶段就会报错,省下大量联调时间。
size_t my_strlen(const char *s) { size_t n = 0; while (s[n] != '\0') n++; return n; }函数内部只需要读取字符,参数写成const char *,等于给所有调用者一个明确承诺:这个函数不会改你的字符串。反过来,如果有人想传字符串常量进来,这个签名也完全兼容。我在自己的项目里有一条硬性规定:凡是函数不需要修改的外部数据,参数一律加 const。前期的确多敲几个字符,后期能省下一整晚排查"谁把数据改坏了"的时间。
需要提醒的是:const 是编译期约束,它只拦截直接通过这个指针进行的修改,挡不住恶意强转,也管不了别的指针从旁边改。它解决的是工程规范问题,不是安全墙。理解到这一层,就比单纯背口诀的人高出一截了。
3. 字符串这块,char* 和 char[] 的差别能毁掉一整天
不少初学者以为字符串就是char*,随手写char *str = "hello"。两种写法在很多操作上确实一模一样:printf 能打印、strlen 能测长、下标能取字符。但它们的本质差异,是 C 语言里最常触发野指针和段错误的隐藏陷阱之一。
3.1 同样写着 "hello",内存里的命运完全不同
char s[] = "hello"; // 栈上一个可修改的数组,长度 6 字节 char *p = "hello"; // 指针变量指向只读的字符串字面量第一条语句定义了一个数组,编译器把"hello"的 6 个字节复制到栈上,以后s的使用都发生在那块独立内存上。数组名s被绑定在起始地址,你不能写s++,但可以通过s[0]修改内容。第二条语句定义的只是一个指针变量,里面保存的是字符串字面量在内存中的起始地址,而字面量在多数系统上放在只读数据段,一旦尝试通过p去写,轻则没效果,重则直接段错误。
标准的说法是:修改字符串字面量属于未定义行为。未定义的意思不是"一定会崩也不一定不崩",而是语言标准不保证任何结果。C 标准把字面量当作普通字符数组,行为交给实现决定,C++ 则更严格地把它当成 const 字符数组处理。两个语言的保护力度不同,但结论一致:别去改字面量。
3.2 一个让新手头皮发麻的段错误场景
很多人排查段错误时,怀疑 malloc 用错、怀疑指针越界,很少想到问题出在"改了一个不该改的字符串常量"。我帮 A 同学调试过一个字符串替换函数,函数参数是char *origin,逻辑很好,直接在原字符串上做原地替换。日常传入的都是 malloc 出来的字符串,一切正常。某次调用顺手传入一个字符串常量,就出现随机崩溃,而且崩溃位置还不固定,特别难抓。
最终定位下来,就是函数内部对只读区域做了写操作。把参数类型改成const char *,调用处如果还想传可变字符串原本没问题,而任何试图修改的路径都会在编译期暴露。这就是 const 和字符串字面量规则叠加在一起的价值——它们不是孤立的语法点,而是共同构成一套内存安全边界。
3.3 到底该选 char[] 还是 char *,我的三条标准
判断依据其实很朴素:
- 字符串内容需要修改,或者生命周期由当前栈管理,用数组
char s[]。 - 只想保存一个指向某段字符串的指针,将来想换一个地方指,用
char *p。 - 需要一个真正可写的字符串缓冲区,先
malloc一块内存,再往里面拷贝。
char *p = "first"; p = "second"; // 合法:p 换个地方指 char *buf = malloc(1024); strcpy(buf, "second"); // 这才是可写字符串 free(buf);最后一条特别多人踩坑:有人写下char *buf = "...",然后紧跟着strcpy(buf, ...),结果就是往只读区域写数据。缓冲区一旦需要被修改,第一件事就是保证它指向的是一块可写内存,而不是字面量地址。这个习惯建立起来之后,字符串相关的崩溃能少掉一大半。
4. 二维数组传参数:为什么是 int (*p)[N],而不是 int **p
二维数组的传参问题,简直是每届初学者必掉的坑。int *a[5]和int (*a)[5]只差一对括号,含义却从"5 个指针组成的数组"变成了"指向一个含 5 个 int 的数组的指针"。搞懂这一对,才算真正摸到复合声明的门槛。
4.1 [] 的优先级高于 *,类型就完全不一样
关键在运算符优先级:[]的优先级高于取内容运算符*。所以没有括号时,a先和[5]结合,它首先是一个数组,每个元素再去乘星号成为指针;加了括号后,(*a)先结合,a首先是一个指针,再借用[5]描述它指向的目标。
int *a[5]; // 数组:5 个元素,每个是 int* int (*a)[5]; // 指针:一个指针,指向含 5 个 int 的数组这两个声明在内存布局上也不同。a[5]是连续存放的 5 个指针变量,每个占 8 字节(64 位系统),总共占 40 字节;(*a)[5]只是一个指针变量,占 8 字节,它指向的那 5 个 int 是另行分配的。画一下草图就非常清楚。
4.2 用"右左法则"读复杂声明
遇到更复杂的声明,我推荐右左法则:从标识符开始,向右找[]或(),再折返回左边找*,一层层剥开。
以int (*p)[5]为例:从p开始,右边是),没有别的,向左看是*,所以p是指针;跳出这层括号,p右边是[5],说明这个指针指向一个包含 5 个 int 的数组。再读int (*funcp)(int):funcp右边是)、左边是*,是指针;跳出括号后右边是(int),表示参数列表,整个声明读作"指向一个参数为 int、返回值为 int 的函数指针"。
右左法则应付不了 C 语言里 90% 的复合声明。遇到读不出来的,先写出来推一遍,而不是赌自己猜得对。读声明和读英文句子一样,有固定语序,练几次就有肌肉记忆了。
4.3 二维数组名退化后的真实类型
很多人想当然:int arr[3][5]有行有列,数组名退化成指针,那不就是int*吗?于是写成int **p = arr。这里有一个必须正视的事实:数组名作参数传递时退化的是"指向第一个元素的指针"。arr的第一个元素不是 int,而是一个int[5]数组,所以退化成int (*)[5],也就是上一节说的数组指针。
那int **到底对应什么?它是指针的指针,一般用来指向一个int*变量,或者指向一个指针数组。int **p想通过p[i][j]双重下标访问数据,前提是p[i]拿到的是int*;而二维数组arr[i]拿到的是整个int[5],类型不匹配。强行赋值,编译器会毫不客气地报警告或报错。
标准传参写法是:
void print_matrix(int (*mat)[5], int rows) { int i, j; for (i = 0; i < rows; i++) { for (j = 0; j < 5; j++) printf("%d ", mat[i][j]); printf("\n"); } }这里第二维大小 5 必须写清楚,因为编译器只有知道每行多长,才能算mat[i] + j的步长。C99 之后还可以用变长数组,写成void print_matrix(int rows, int cols, int mat[rows][cols]),本质还是行指针,只是把长度一并传进来,灵活处理不同列数。
4.4 指针数组和数组指针,工程上分别用来干什么
指针数组最经典的用途是装一组字符串,例如菜单项、命令词表:
const char *cmds[] = {"add", "delete", "search", "quit"};字符串长短不一,用二维字符数组会浪费空间,还要约定最大长度;换成指针数组,每个元素只是 8 字节的char*,各自指向对应的字符串常量,轻巧又直观。遍历时只需要数数组元素个数。
数组指针则适合固定形状的数据,比如矩阵、图像像素、网格状态。处理这类数据通常要传int (*)[N],让函数清楚"一行有多少列"。如果行和列都是动态的,我个人的建议是干脆用一维数组加手动下标换算:idx = i * width + j,很多底层库也偏爱这种紧凑的连续内存布局,访问速度还更快。
5. 二级指针的存在价值:函数想改的不只是值,而是指针本身
二级指针让不少初学者望而生畏,但实际上它的出现条件非常单一:当你确实需要修改某个指针变量本身时,自然就需要再往上一层,把这个指针的地址传进来。
5.1 一个经典问题:函数里 malloc,调用者却拿不到
写封装函数时,经常想把"分配内存"这个动作包起来。初学者最容易写成这样:
void alloc_buf(int *buf) { buf = malloc(10 * sizeof(int)); }然后发现调用处buf仍然是 NULL。原因就藏在参数传递里:int *buf传进来的是指针的值,函数内部重新给buf赋值,只是让局部副本指向了新内存,调用者的指针变量没有受到任何影响。函数返回后,那块新分配的内存既没有地址被保存,也没有被释放,白白泄漏。
想修改调用者的变量本身,就必须把这块变量的地址传进来。调用者的变量是指针int*,它的地址自然就是int**:
void alloc_buf(int **buf) { *buf = malloc(10 * sizeof(int)); }这里的*buf等价于调用者的那个指针变量。给*buf赋值,等于直接修改调用方手里的指针。想通这一点,二级指针的窗户纸就捅破了。
5.2 链表插入的哨兵模式,其实用的是同一个套路
链表的头插、删除头节点这类操作,也需要二级指针。比如头插一段新节点后,链表的头指针本身要变,如果函数只传Node *head,内部让head指向新节点,外面的头指针还是旧地址,链表就"断"了。正确写法是传Node **head,在函数内用*head = new_node,让外部头指针真正更新。
单链表的删除、二叉树的指针调整、各种容器结构的修改接口,全都是这个模式。可以说,二级指针不是 C 语言里独立的神秘语法,而是"你要修改的东西本身是个指针"时,自然的推导结果。在判断要不要用二级指针时,只问一句:这个函数结束之后,调用者的指针变量需不需要变?需要,就传地址;不需要,传值就够了。
5.3 argv 这个每天都见的二级指针
int main(int argc, char **argv)就是最顺手的二级指针实例。argv的类型是char**,它指向一个指针数组的首元素,那个指针数组的每个元素是char*,装着启动参数字符串。平时遍历argv[1]到argv[argc-1],本质上就是在遍历一个字符串列表。
int main(int argc, char **argv) { int i; for (i = 1; i < argc; i++) printf("%s\n", argv[i]); return 0; }很多人分不清char *argv[]和char **argv,其实在函数参数列表里二者等价,因为数组形参会退化成指针。char *argv[]作为形参时,真实类型就是char **argv。二级指针藏在这样一层等价关系后面,所以看起来抽象,实际每天都在用。
5.4 解引用层级最容易看花眼
使用二级指针前,先在纸上分清两层:p是二级指针,*p是一级指针,**p才是真正的数据。&p的类型是int***,逐层往下传,每一次解引用,就向数据靠近一步。出现类型不匹配的报错时,多半是解引用层级没有对齐。
我调试这类问题的习惯,是在可疑代码前插临时打印:
printf("p=%p *p=%p **p=%d\n", (void*)p, (void*)*p, **p);一次看清三个量。如果*p是0x0,说明第一层还没初始化;如果**p突然变成乱码,多半是第二层越界。指针调试,本质就是在打印地址和值的过程中,把脑中的内存模型一点点校准。这一步熟练之后,二级指针就不再是玄学,而只是又一个需要小心对待的普通层级。
6. 用几道判断题给这篇收个尾:你的指针直觉准不准
讲完这些概念,我准备了六道小判断题,全部围绕这篇的核心内容。每一道我都亲眼见过有人答错,而且错法还各有各的精彩。建议你先别急着看答案,拿纸写下自己的判断,再往下对答案。答错不丢人,答错之后的纠正往往比全对更有价值。
// 题1 const int a = 1; int *p = &a; // 能通过编译吗? // 题2 char s[] = "hello"; char *p = s; p++; // 合法吗? printf("%c\n", *p); // 输出什么? // 题3 char *str = "hello"; str[0] = 'H'; // 安全吗? // 题4 int a[3] = {1, 2, 3}; a++; // 合法吗? // 题5 int *p[5]; // p 到底是什么? int (*q)[5]; // q 到底是什么? // 题6 void set_ptr(int *p) { p = NULL; } int main(void) { int v = 42; int *x = &v; set_ptr(x); // 这里 x 等于什么? }题1答案:不能。int*指向const int会丢弃 const 限定符,编译器至少给出警告,严谨的工程环境会直接报错。正确写法是const int *p = &a;,也就是第 2 节讲的锁数据。
题2答案:合法。p++让指针移动到数组第二个元素,输出是字符e。数组名s本身不能自增,但它赋值给指针后,指针是自由变量,移动完全合法。
题3答案:不安全,属于未定义行为。str指向字符串字面量,修改str[0]就是在写只读区域。正确做法是定义成char str[] = "hello";,让编译器在栈上复制一份可写副本。
题4答案:不合法。数组名不是可修改的左值,a++编译不过。记住数组名在大多数表达式中会退化成指针,但它本身并不是变量,不能被重新赋值。
题5答案:p是指针数组,包含 5 个int*元素,共 40 字节;q是数组指针,指向一个包含 5 个 int 的数组。区别全部来自[]优先级高于*,加不加括号决定先和谁结合。
题6答案:x仍然是&v,函数内p = NULL修改的只是形参副本。想让外部x变成 NULL,必须把参数写成int **p,函数内执行*p = NULL。这正是第 5 节的核心。
如果你把上面六道题都弄明白了,这篇的基础就已经内化得差不多。我始终觉得,指针需要的不只是记忆,而是一套稳定的判断流程:先分清它是数组还是指针,再确认 const 锁住的是哪一层,最后看传参时地址走了几层。
多年之后回看 C 语言里的指针,真正困难的从来不是语法,而是识别"这个地址到底能不能改、这块内存到底归谁管"。写代码之前多问自己这两个问题,比背一百个技巧都管用。基础三走到这里,下一步无论是深入函数指针还是研究链表结构,脚下这块地基都已经踩实了。