函数中指针的学习,这个话题我太有发言权了。我自己当年在函数这块栽过的跟头、后来带身边人时看他们重复踩的坑,加起来能绕教室一圈。指针本身就已经够让人头大了,一旦钻进函数里,它就开始表演"隐身术"——你以为你传了个指针进去就能改数据,结果出来一看,原封不动;你以为函数返回了个指针就能拿着用,结果过一会儿数据就变成了一堆乱码。这篇文章我就把"函数中指针"这条线彻底捋清楚,从值传递的本质、指针参数的内存原理、返回指针的雷区,到二级指针和函数指针的实际应用,全程配上我实际跑过的代码和使用体会。适合刚学完指针基础、开始被函数折磨的初学者,也适合那些会写但说不清原理的开发者,看完之后你至少能明白一件事:函数里到底什么时候该用指针,用了之后内存和生活会发生什么变化。
我尽量不堆砌教科书式的抽象概念,所有结论都用能实际运行的例子说话。会拆到"函数调用瞬间内存里发生了什么"这种粒度,因为这些底层动作才是理解指针的关键。
1. 先搞清楚本质:函数传参到底是传"值",不是传"东西"
1.1 传值调用里那个让人抓狂的副本
很多初学者学指针卡壳,根源在于对函数参数传递机制没有一个画面感。C和C++里,函数调用时实参到形参的传递,本质就是一次赋值操作,而这个赋值操作传递的是"值",不是"变量本身"。
什么意思?举个例子,你有个变量int a = 10;,把它传给一个函数void func(int x)。在调用那一刻,系统在栈上重新开辟一块空间给形参x,然后把a的值 10 拷贝进去。从此以后,函数体内操作的是x这块新空间,它和a只是"值相等"而已,地址完全不同。
void change(int x) { x = 100; } int main() { int a = 10; change(a); printf("%d\n", a); // 输出 10 return 0; }这段代码输出一定是 10,不是 100。因为 change 函数里头改的是局部形参 x,出了函数就没了。用生活类比来解释,这就好比你把自己的身份证复印件交给别人,委托他去派出所改你的住址信息——他拿到的只是复印件,改得再起劲,原件上的信息纹丝不动。函数传值,就是递复印件。
那这不是很憋屈吗?我需要函数帮我改掉主调函数里的某个变量,怎么办?答案就是:把原件的"存放地址"传过去。
1.2 指针参数是在传"你可以找到我的线索"
把参数类型改成指针,传递的东西就变了。void change(int *p)这种形式,实参传进来的是指针的值,也就是目标变量的地址。注意,指针本身依然按值传递,函数会得到形参p的一份拷贝,但这份拷贝里面存着和实参一模一样的地址。
void change(int *p) { *p = 100; } int main() { int a = 10; change(&a); printf("%d\n", a); // 输出 100 return 0; }这里change(&a)把 a 的地址传了进去,函数内通过*p解引用,沿着地址找到了 a 本体,直接修改它的值。这时候你交出去的不是复印件,而是一张写着你家详细门牌号的便签。拿到便签的人顺着门牌号找上门,把你家里的家具重新摆了一遍——你家的地址没变,家具确实被动了。
这是理解所有"函数中指针"用法的最底层逻辑。不管后面引申出多复杂的场景——数组、字符串、结构体、回调函数——底子都是这一句话:传指针,是为了让函数通过地址找到主调方的数据本体,从而修改它或者避免大面积拷贝。
1.3 我当时学到这里最容易混的一个点
我记得很多同学,包括当年的我,都会困惑:既然指针是按值传递的,我在函数里让指针本身指向别的地方,外面的指针为什么没变?
void point_to_new(int *p) { int new_val = 20; p = &new_val; // 只是改了形参 p 的指向 } int main() { int a = 10; int *p = &a; point_to_new(p); printf("%d\n", *p); // 还是 10 return 0; }这段代码里,外面 p 依然指向 a,打印出来是 10。道理和普通值传递一模一样:内层 p 是拷贝,你修改拷贝的指向,跟外层没有半毛钱关系。要想修改外层指针本身的指向,就得用二级指针int **p,把外层指针的地址传进去。这个我在后面的章节详细展开,因为它是一个特别经典又特别实用的岔路口。
2. 指针参数的内存真相:栈帧、地址拷贝与解引用链路
2.1 函数调用瞬间,栈上发生了什么
要给初学者讲明白指针传参,就必须聊聊函数调用时的内存布局。很多讲法只停留在"指针可以修改值",但没讲清楚为什么能修改,导致读者一旦遇到复杂情况又懵了。
每一个函数被调用时,系统会为它在内存的栈区分配一块连续的存储区域,叫做栈帧。栈帧里存着这个函数的局部变量、形参、返回地址等等。当 main 调用 foo 时,流程是这样的:
- 把实参的值压栈,也就是为形参准备初始化数据。
- 跳转到 foo 的指令入口。
- 为 foo 的局部变量在栈上分配空间。
- 执行 foo 的函数体。
- 销毁 foo 的栈帧,回到 main 接着往下走。
关键在第一步。main 调用foo(&a),压栈的是 a 的地址值,比如 0x7ffc12345678。foo 的形参int *p拿到的是这个地址,放在自己的栈帧里。之后 foo 访问*p,程序就从 p 中取出地址 0x7ffc12345678,去这块内存上读写数据。这块内存不属于 foo 的栈帧,而属于 main 的栈帧,所以 foo 才能"越界操作"main 的家当。
2.2 解引用就是沿着门牌号找房子
*p这个操作在机器层面是什么?简单说,它做两件事:第一,从 p 里取地址;第二,按照这个地址发起一次内存读写。在 C 语言层面这是"取指针指向的内容",在汇编层面就是一个基于寻址的 load 或 store 指令。
理解了这一点,你就明白为什么指针参数能实现"修改外部变量"。因为 p 里存的地址指向的是外部变量所在的内存单元,store 指令直接写那个单元。而普通值传递时,x 的地址是函数自己栈帧里的,写 x 只是写自己的局部空间。
为了加深感受,可以看一个带地址输出的例子:
void show_addr(int *p) { printf("形参 p 自己所在: %p\n", &p); printf("p 指向的地址: %p\n", p); } int main() { int a = 0; int *p = &a; printf("实参 p 自己所在: %p\n", &p); printf("p 指向的地址: %p\n", p); show_addr(p); return 0; }你会看到,两次输出的p变量地址不同,但两次输出的"p 指向的地址"完全相同。这直观地证明了"形参是实参的拷贝,但拷贝里存着同一份地址"。以此为基础,任何复杂的指针传参你都能推演出来。
2.3 数组参数为何自动"堕落"成指针
函数中指针的一大高频场景是数组。很多人一开始会以为数组作为参数是"整个传进去的",因此写出void print(int arr[])就觉得放心了。但真相是:数组名在表达式中会被隐式转换为指向首元素的指针,作为参数传递时,传的也是这个指针值。
void print_size(int arr[]) { printf("sizeof(arr) = %zu\n", sizeof(arr)); // 8,在64位系统上是指针大小 } int main() { int arr[10]; printf("sizeof(arr) = %zu\n", sizeof(arr)); // 40,10个int的大小 print_size(arr); return 0; }同样的arr,在 main 里是数组类型,sizeof为 40;进入了函数形参列表,它就退化成了int *,sizeof变成 8。这也是初学者最容易崩溃的地方。C 语言这种设计一开始让人觉得坑,但反过来看,它大大提高了大数组的传递效率——不用把整个数组拷贝进栈里,只要传一个起始地址就够了。代价是函数里不再知道数组长度,所以一般都要配套传入一个int len参数,这是 C 语言写函数的常见惯例。
3. 函数返回指针:光鲜背后全是雷区
3.1 最大的坑:返回局部变量的地址
如果说指针作为入参是很多人的第一道坎,那指针作为返回值就是第二道坎,而且这道坎摔得人更疼,因为它的问题通常不会立即暴露。你写了这样一个函数:
int *get_value() { int local = 42; return &local; } int main() { int *p = get_value(); printf("%d\n", *p); return 0; }这段代码在部分编译器上可能打印出 42,部分编译器上打印出垃圾值,还可能在打印前就崩溃。原因在于local是函数栈帧里的局部变量,函数返回时,栈帧被销毁,这块内存就不再属于你。但它上面的旧数据可能还没被覆盖,所以侥幸输出 42;一旦其他代码又压栈使用这块区域,数据就被篡改。这是典型的悬空指针,即指针所指向的内存已被系统回收。
正确的返回方式有这么几种:
- 返回静态变量的地址:
static int local = 42;,生命周期到程序结束。 - 在堆区分配,返回堆地址。例如
int *p = malloc(sizeof(int)); *p = 42; return p;,但要求调用者自行free,这就引出了"谁分配谁释放"的约定。 - 通过指针参数在外部传入缓冲区,函数只往里面写数据,不返回地址。
3.2 一个从"能跑"到"随机崩"的完整排查经历
我印象特别深,有段时间我写一个字符串处理的小工具,里面有个函数负责把两个短字符串拼接成新串。当时为了省事,直接写成了返回一个局部数组。第一版跑起来一切正常,我甚至一度以为局部变量返回也没什么。直到某一天,我在调用它前后多了一行负责任的日志代码,程序就开始随机崩溃或者输出乱码。
那次的排查过程让我彻底养成了看汇编和看栈的习惯。我先是在返回语句前打印地址,再在调用方打印同一地址的数据,发现每次的值时好时坏。后来我终于反应过来,是新增的日志函数及其参数压栈,把原来存字符串的那块栈内存覆盖掉了。而崩溃或乱码完全看"新压栈内容"和"函数返回后到使用前的时间窗",所以具有随机性。
从那以后,我给自己的代码定了一条死规矩:凡是函数内部需要生成数据并交到外部继续使用的,要么由调用方提供足够大的缓冲区,要么用堆内存并明确注释由调用方释放。这一条能挡掉至少一半的指针悬空事故。
3.3 返回"指向堆内存的指针"时的注意事项
堆内存是另一条路。malloc分配的内存在堆区,生命周期由开发者控制,函数返回后依然有效。但它的坑在于内存泄漏。如果你写了一个模块专门生成一堆返回值,调用者忘记释放,程序跑久了内存占用会像滚雪球一样膨胀。
char *concat(const char *a, const char *b) { size_t len_a = strlen(a); size_t len_b = strlen(b); char *result = (char *)malloc(len_a + len_b + 1); if (result == NULL) { return NULL; } strcpy(result, a); strcat(result, b); return result; }这种写法本身没问题,实践上也很常见,但一定要配套明确的文档注释或接口命名,比如create_xxx()、make_xxx(),让人一眼看出它返回的是需要释放的资源。最怕的是那种"看起来不需要管,但其实需要 free"的接口,几乎所有经历过内存泄漏煎熬的人都对这种设计恨之入骨。
4. 进阶岔路:二级指针和指向指针的指针
4.1 为什么修改指针本身需要二级指针
返回第一个章节留下的问题:如果你需要在函数里让外部的指针指向一个新的地址,该怎么做?比如你写了一个初始化函数,希望它做完之后,外部的链表头指针指向一个新创建的节点。如果只传一级指针:
void init_node(int *head) { static int cell; head = &cell; // 白搭,改的是形参的指向 } int main() { int *head = NULL; init_node(head); if (head == NULL) { printf("还是NULL\n"); } return 0; }head的值不会变。因为函数形参里的int *head是一份拷贝,你让拷贝指向一边,原来的还是原来的。这时你需要的是"指向指针的指针"——把外层指针的地址传进去,函数在解引用一层后,拿到外层的指针变量本体,从而修改它的值。
void init_node(int **head) { static int cell; static int calls = 0; cell = 100 + calls++; *head = &cell; // 通过二级指针,修改外部指针的指向 } int main() { int *head = NULL; init_node(&head); if (head != NULL) { printf("%d\n", *head); } return 0; }二级指针的使用逻辑用一句话包装:一级指针指向"数据",二级指针指向"指向数据的指针"。当你需要在函数中修改调用者的指针变量时,就必须把这个指针变量的地址传进去。如果你的参数是想修改"某个内存区域里的内容",一级指针就够了;你的参数是想让"调用者的指针指向一块全新区域",就得二级指针。
4.2 链表、树这些结构的插入删除都是典型应用
动态数据结构的增删操作,尤其是链表的头插和树的旋转,是二级指针最具说服力的应用场景。头插法里,新节点要成为新的头指针指向的开头,所以insert_head(Node **head, Node *new_node)是标准写法。如果不传二级指针而是通过返回值更新头指针,代码也能写,但容易遗漏,一遗漏就丢链表。传二级指针,函数内部自动更新头节点,调用方的head变量会在函数返回后自动指向新节点,这种直接的"副作用"是许多工业代码偏爱二级指针的原因。
树的节点旋转则需要修改某个祖父节点的左指针或右指针字段,这时如果你使用void insert(Node *root, Node *node),当需要更新根节点时你做不到。很多库函数干脆在接口里返回新的根节点:root = insert(root, node),这也是合理的。但一旦返回值和调用点之间隔着多层跳转,维护成本升高,二级指针的价值就体现出来了。
我在实际写代码时的一个偏好是:二叉树这种结构尽量用返回值方式管理根节点,链表这种结构用二级指针管理头节点。前者能写出一目了然的递归,后者能省去大量p = insert(p, q)的重复赋值。
4.3 二维数组、指针数组与二级指针的边界
很多同学学到二维数组,会想当然地认为int **和int a[3][4]通用。但这是一个经典陷阱。
int a[3][4]的本质是"长度为3的数组,每个元素是长度为4的 int 数组"。作为参数传递时,它会退化成指向长度为4的int数组的指针,也就是int (*p)[4],而不是int **。如果你强行用一个int **去接,编译器会告警或崩溃,因为二维数组的内存布局是连续的 12 个 int,而int **期望的是"指向一个元素,该元素本身又是指针",两者布局完全不同。
我用一个简单例子说明:
void func(int (*p)[4], int rows) { // p[i][j] 访问方法成立 } // 这不对: // void wrong_func(int **p) { // // p[i][j] 极可能会崩,因为 p 被要求是"指针的指针" // }正确理解各种"指针的指针"和"数组的指针",最好的方式是从内存布局出发,而不是死记硬背。二维数组名在表达式里时而表现为数组,时而表现为行指针,这个边界一定要亲手写段代码验证一遍,比看十遍定义都有效。
5. 函数指针:把函数当数据用的底层乐趣
5.1 函数也有地址:声明与调用拆解
函数指针是"C 语言函数学"里最容易被忽略的一章,但它其实特别能打通思路。每个函数编译之后,在内存中占据一块区域,函数名就是这个区域的起始地址。所以和变量一样,函数也有地址,也就可以被存放在指针变量里。
声明一个指向函数的指针,语法看起来劝退:int (*func_ptr)(int, int);。要读这种声明,一个核心口诀是"从变量名出发,由内向外读":func_ptr先被*修饰,说明它是一个指针;然后被(int, int)修饰,说明它指向一个有两个 int 形参的函数;最前面int是那个函数的返回类型。所以它表示"指向‘接收两个 int 参数,返回 int 的函数’的指针"。
简单用法:
int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } int main() { int (*op)(int, int) = add; printf("%d\n", op(3, 4)); // 7 op = sub; printf("%d\n", op(3, 4)); // -1 return 0; }在给函数指针赋值时,可以直接用函数名(它会隐式转换成指针),调用时op(3, 4)和(*op)(3, 4)在功能上是等价的。现代 C 程序员普遍直接用op(3, 4),看着更清爽。
5.2 函数指针作为参数:回调函数是怎么一回事
把函数指针当成参数传给另一个函数,就是所谓的回调机制。很多框架代码里,函数指针参数无处不在。比如你想实现一个"遍历数组并对每个元素做某种操作"的工具函数,操作本身可以是调用方传进来的函数,这就是标准库里很多"对容器元素施加操作"类函数的雏形。
void for_each(int *arr, int n, int (*action)(int)) { for (int i = 0; i < n; i++) { arr[i] = action(arr[i]); } } int double_it(int x) { return x * 2; } int square_it(int x) { return x * x; } int main() { int arr[] = {1, 2, 3}; for_each(arr, 3, double_it); // arr 变为 2, 4, 6 for_each(arr, 3, square_it); // arr 变为 4, 16, 36 return 0; }这种风格的代码把"行为"作为一种参数传递,灵活性一下子拉开了一个档次。写完了double_it、square_it,后面再有新操作,只写新函数就好,for_each一行不用动。我在给嵌入式设备写菜单模块时,也经常把菜单项对应的处理函数存成函数指针数组,按索引调用,整个分发逻辑变得异常简洁。
5.3 函数指针作为返回值:按需选择行为
函数指针还可以作为函数的返回类型。最常见的场景就是"根据运行时的不同情况,返回不同的处理函数"。假设你要做一个计算器,根据运算符选择对应的运算函数:
int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } int (*get_op(char c))(int, int) { if (c == '+') { return add; } else { return sub; } } int main() { int (*op)(int, int) = get_op('+'); printf("%d\n", op(3, 5)); // 8 return 0; }这段代码的返回类型声明int (*get_op(char c))(int, int)读起来很绕,但拆开看还是那套"由内向外读"法则:先看get_op(char c),说明它是一个接收 char 的函数;再看前面的*,说明它返回一个指针;最后看(int, int)和int,说明这个指针指向的是一个接收两个 int 并返回 int 的函数。不喜欢这种绕的声明,可以用typedef把它简化:
typedef int (*BinaryOp)(int, int); BinaryOp get_op(char c) { if (c == '+') { return add; } return sub; }一旦用了 typedef,代码的可读性直线上升,二进制的运算函数、UI 事件映射、状态机状态转换表,全都好写了。
6. 实战选型心得:这些场景我建议你少用、别用或大胆用指针
6.1 用 const 给指针参数上保险
函数中指针的另一个实用话题是const修饰。它解决的是"只读输入"与"可写输出"之间的语义问题。具体有三种常见形态:
const int *p:指针指向的内容不可修改,但指针本身可改。适合表示"我只读取数据,不会修改"。int *const p:指针本身不可修改,但指向的内容可改。这多用于固定指向某缓冲区、不允许指针乱跑的场合。const int *const p:内容和指向都不许改,基本是个保险箱。
在写接口时,我很推荐用const来传达参数意图。如果一个函数只读输入,它就该声明成const int *,调用者一眼知道"这个函数不会改我的数据";一个函数如果要"生产"数据,它的缓冲区参数也建议用char *out这种带out命名的方式标注。代码是给人读的,语义清晰比炫技重要太多。
6.2 什么时候用指针、什么时候别硬撑着用指针
有不少初学者,在学完指针之后恨不得所有参数都写成指针,觉得这样"高级"。实际上这会造成反效果。我给自己总结了几条选型经验:
- 传单个内置类型(int、double、char 这类)且不需要修改实参时,直接值传递。拷贝成本极低,代码也更简单。
- 传大型结构体且只读时,用
const 结构体类型 *。避免整块结构体的拷贝开销。 - 传数组、字符串时,用指针传递(数组退化的天然机制),同时带上长度参数,或靠约定好的终止符号(如字符串结尾的
\0)判断边界。 - 需要通过函数修改调用方的基础类型变量时,用指针参数(也就是
int *)。 - 需要通过函数修改调用方的指针变量本身时,用二级指针(
int **)。 - 需要把函数作为参数抽象化时,用函数指针。
另外一个我踩过很多次的坑是:能通过返回值返回小结构体的情况下,不要强行用输出参数或者堆内存。很多初学者喜欢把一切 return 都替换成指针参数,理由是"返回多个值"。但现代 C 语言对返回小结构体其实非常友好,比如返回一个struct Point { int x, y; };完全合理,没必要让调用方先定义变量再传地址。过早地引入指针参数只会让代码可读性降级。
6.3 学习路径建议:到底怎么练才算真会了
学习函数中指针这件事,唯一有效的路径就是自己动手画内存图和走读汇编级别的行为。我可以给出一个可复现的练习序列:
- 基础练习:写一个
swap函数的指针版本和非指针版本,对比结果,并且打印两个版本里形参和实参的地址。 - 数组练习:写一个函数遍历 int 数组,内部打印
sizeof(arr),验证数组参数退化;再用指针算术方式重写遍历逻辑,体会p[i]等价于*(p + i)。 - 返回练习:分别实现"返回局部变量地址的坏版""返回静态变量地址的版本""返回堆地址的版本",对照着跑,观察不同版本下数据的存活时长。
- 二级指针练习:实现链表的头插函数,分别用一级指针失效的版本和二级指针成功的版本;即使不写完整链表,只写节点插入也能体会到差异。
- 函数指针练习:实现一个极简计算器,把加减乘除四个函数存到函数指针数组里,根据输入的运算符选择调用;再把这个计算器扩展成"函数指针作为参数"的模式,加深回调运用。
做完这五组,函数中指针的基本功就非常扎实了。剩下的就是在真实项目里反复遇到 bug、反复排查,慢慢形成肌肉记忆。
我个人的体会是,函数中指针这块内容,最难的其实不是语法本身,而是很多教程默认你会"在函数被调用瞬间脑中自动构建栈帧图和类型退化图"。如果你每次读一段带指针的代码时,都能下意识地问自己三个问题:这个指针指向的风格是什么类型?函数内有没有修改指针本身?返回的指针指向的内存还会不会存在?那你就已经超过九成初学者了。等你把这套思维方式固化成习惯,再回头看那些以前让你崩溃的声明和调用,真的会流畅很多。