指针这个知识点,很多学C语言的人都是绕过的,不是不想学,是真的被“指针就是地址”这句话给带偏了。尤其教材到了第八章,开始讲“利用指针返回多个结果”的时候,很多人会突然懵掉:函数不是只能return一个值吗?怎么就能返回多个结果了?指针到底在这个过程中做了什么?如果你正在被这个问题卡住,这篇内容可以帮你把整条线理清楚。我会从底层原理讲到实际的代码写法,再讲几个我能想到的、最常见的翻车场景,尽量让你看完能直接上手写。
我写这篇文章的背景是你手里大概率有《C语言程序设计(第四版)》(何钦铭、颜晖主编)这本书,第八章讲指针。我尽量贴合书里的思路讲,但我会用更生活化的方式把里面的逻辑拆开,不会上来就甩术语。
1. 为什么要舍近求远:函数返回值到底卡在哪里
先看最基本的问题:C语言的函数为什么默认只能返回一个值?有人说这是“语言规定”,但这背后其实有一个很现实的硬件原因。C语言是贴近底层的语言,函数的返回值通常是通过固定的寄存器(比如EAX寄存器)传给调用者的,一个函数返回完,这个寄存器里就只能存一份数据。也就是说,你想一次性从这个“口子”里掏出两份以上的数据,硬件层面就不支持。
当然,有人会说:那我可以定义一个结构体,把多个结果打包,然后return这个结构体。确实可以,但这是“把多个值塞进一个盒子里”的思路。对于结果数量固定、类型固定的场景,这招很好用。问题在于很多场景的结果是动态的,或者你压根就不想为了返回两三个值单独定义一个结构体,或者你想返回结果的同时还要把某个已有的外部变量也改掉——这时候结构体就解决不了了。
于是指针登场。指针的思路不是“改变返回通道”,而是“改变数据流向”:你把自己的变量的地址交给函数,让函数顺着这个地址直接找到你变量的内存位置,往里写数据。函数本身该返回什么还返回什么,被“顺便”修改的数据就通过地址传出去了。这就是“利用指针返回多个结果”的本质:不依赖return,而是直接操作调用者变量的内存。
书上第八章反复强调“实参和形参之间的单向值传递”,核心就是想说明这件事——普通变量作为参数传进去,函数里改的只是形参副本,根本碰不到实参;但如果你传的是实参的地址(指针),函数就能通过这个地址“隔空”修改实参。这才是指针参数存在的意义。
2. 从0到1:利用指针返回多个结果的最小案例
先不看复杂例子,看一个所有教材都会讲的小函数:计算两个整数的商和余数。
你想写一个函数,一个数除以另一个数,想同时知道商和余数,用return顶多只能返回一个,另一个怎么办?用指针参数接。
#include <stdio.h> void div_mod(int a, int b, int *quotient, int *remainder) { if (b == 0) { *quotient = 0; *remainder = 0; return; } *quotient = a / b; *remainder = a % b; } int main() { int q, r; div_mod(17, 5, &q, &r); printf("商=%d, 余数=%d\n", q, r); return 0; }这代码很短,但里面藏了几个初学者最容易忽略的细节。
第一,调用的时候必须用&取地址,把q和r的地址传给函数。如果你直接写div_mod(17, 5, q, r),编译器会报警告,运行时大概率直接崩。因为你传给函数的是q和r里保存的值(此时还没初始化,可能是任意值),函数拿这个值当内存地址去写,写到哪里都不知道。
第二,函数内部要用*quotient = a / b,不是quotient = a / b。quotient是地址(指向main里q的地址),*quotient才是这个地址里存的值。你赋值给quotient,只是改变了指针的指向,对q没有任何影响。
第三,函数里对*quotient的赋值,等价于对main函数里q变量的赋值。这就是“通过地址间接修改外部变量”的原理,你可以把它理解成:你把自己的保险柜地址告诉别人,别人不需要你把保险柜搬过去,他直接去那个地址开柜取东西。
再补一个知识点:函数的形参int *quotient确实是个局部变量,它接收的是地址值,但这个局部变量存在的意义就是帮你“导航”。你在这个导航上输入目的地,它带你去的是调用者的地盘,而不是它自己的小房间。
这个小例子的核心逻辑,就是第八章里所有“利用指针返回多个结果”题目的最小原型。后面所有的变体,不管是数组、字符串、还是链表操作,本质都是这一个模式:调用者准备变量,取地址,传进去,函数用指针接收,再通过星号运算改值。
3. 实战拆解:三个高频场景的指针返回方案
3.1 一次算出一组统计结果
很多练习题会让你写一个函数,同时求数组的最大值、最小值和平均值。如果用return,一次只能返回一个,你可能会被迫把逻辑变成三个函数,可那样数组要遍历三遍。用指针参数,一次遍历全搞定。
#include <stdio.h> void analyze(int arr[], int n, int *max, int *min, double *avg) { if (n <= 0) return; *max = arr[0]; *min = arr[0]; double sum = 0; for (int i = 0; i < n; i++) { if (arr[i] > *max) *max = arr[i]; if (arr[i] < *min) *min = arr[i]; sum += arr[i]; } *avg = sum / n; } int main() { int data[] = {3, 7, 2, 9, 5}; int max_val, min_val; double avg_val; analyze(data, 5, &max_val, &min_val, &avg_val); printf("max=%d, min=%d, avg=%.2f\n", max_val, min_val, avg_val); return 0; }这个例子里有几个值得琢磨的地方。
数组名传参时其实也是地址传递,但这是另一套机制,数组名本身就会退化成指向首元素的指针,所以arr[]和*arr在形参列表中写法等价。但要注意,数组传进来后,你可以在函数里通过下标修改数组元素的值,这是合法的,因为你能通过地址找到原始内存。如果你想防止函数意外修改数组内容,可以在形参上加const,比如const int arr[],这是第八章习题里很常见的进阶要求。
另外注意我用了double *avg而不是float *avg,因为平均值会出现小数,float精度不够容易出怪问题,用double更稳妥。
3.2 字符串处理中的指针返回
字符串本质上是一种特殊的数组,所以在“返回多个结果”的场景里,它也经常被用来做试验田。
比如写一个函数,把字符串里的空格都去掉,同时统计去掉的空格个数。去掉空格后的字符串需要写回原字符串,空格计数需要传出去,一个函数要同时完成两个动作。
#include <stdio.h> void remove_spaces_and_count(char *str, int *count) { char *src = str; char *dst = str; *count = 0; while (*src) { if (*src == ' ') { (*count)++; } else { *dst = *src; dst++; } src++; } *dst = '\0'; } int main() { char s[] = "hello world, c lang"; int space_count = 0; remove_spaces_and_count(s, &space_count); printf("处理结果: %s\n", s); printf("去除空格数: %d\n", space_count); return 0; }这里有个细节值得注意:为什么dst和src都指向同一个str?这是原地处理字符串的经典写法。一个指针负责读,一个指针负责写,两者速度不同,所以读指针永远走在写指针前面。每次遇到非空格字符,就把它搬到写指针的位置,最后在写指针位置补上字符串结束符'\0'。
处理完以后,s的内容已经被改掉了,空格数量通过count指针传回调用者。你可能会问:这算“返回多个结果”吗?严格来说,只有一个结果是通过return返回的(这里没有return),另外两个结果都是通过参数内的指针返回的。实际工程里这种做法非常普遍,因为很多场景不是“要返回值”,而是“要在原地修改一个对象,并告诉外部这个对象发生的增量变化”。
3.3 链表遍历时的多结果返回
书第八章往后会接触链表。链表的操作里特别容易出现“想同时拿到多个信息”的需求,比如统计链表的节点数和总数值之和。
#include <stdio.h> #include <stdlib.h> struct Node { int val; struct Node *next; }; void analyze_linked_list(struct Node *head, int *count, long *sum) { *count = 0; *sum = 0; struct Node *p = head; while (p != NULL) { (*count)++; *sum += p->val; p = p->next; } } int main() { struct Node n1 = {10, NULL}; struct Node n2 = {20, NULL}; struct Node n3 = {30, NULL}; n1.next = &n2; n2.next = &n3; int node_cnt = 0; long total = 0; analyze_linked_list(&n1, &node_cnt, &total); printf("节点数=%d, 总和=%ld\n", node_cnt, total); return 0; }链表场景里,指针参数的效果比数组场景更明显。因为你遍历链表时,没办法用数组下标的方式去定位某个节点,只能靠一个指针一个指针地往下跳。如果你在函数里临时统计,统计完又没带出去,那这个统计就白做了。用指针参数把count和sum传出去,配合return返回其他内容,是链表函数设计的惯用招式。
4. 新手必踩的坑:指针返回结果时的常见问题速查
我见过不少学第八章学到崩溃的,不是理解不了原理,而是写出代码之后各种报错、各种乱码、各种程序崩溃。这里整理几个出现频率极高的问题,你可以对照排查。
空指针和野指针。空指针是值为NULL的指针,对它解引用会直接导致段错误。野指针是地址指向了一块你根本不该碰的内存,这个更危险,因为它往往不会立刻报错,而是让你在某次看起来很正常的运行中突然翻车。有个很常见的新手操作:定义一个指针变量就急着用它接收结果,但根本没指向任何有效变量。
int *ptr; *ptr = 100; // 大错特错,ptr不知指向哪里这题的解法是先把指针和某个实实在在的变量绑定在一起:
int value; int *ptr = &value;或者用malloc动态分配内存,但malloc用完后必须记得free,不然就内存泄漏。
函数内部修改指针指向和修改指针指向的值,是完全不同的两件事。这个坑几乎所有初学者都会踩。看这两段代码的区别:
void func1(int *p) { p = p + 1; // 只改变了形参的指向,外面的指针不受影响 } void func2(int *p) { (*p) = (*p) + 1; // 修改了p指向的那个变量的值 }func1里改的是形参指针自己的值,函数结束后形参销毁,外面的变量毫发无损;func2里改的是指针指向的内存空间值,这个效果会保留。如果你想通过指针“返回多个结果”,必须使用*p的形式去写值,而不是让指针自己移动。
传进去的是指针,就一定是“返回多个结果”的指针参数吗?不是。比如你要写的函数只是使用指针指向的内容,并不需要修改外部变量,那这个参数只是“传递数据”而不是“返回结果”。区分方法很简单:函数里是否对*参数进行了赋值。赋值了,才叫返回结果;只读,那只是个输入参数。这个区分在第八章的很多概念题里会被反复考到。
别用未初始化指针做接收参数。这是我在习题里最常看到的问题。有人写:
int *p; func(&p); // 咦,为什么崩溃?这里传的是p这个指针变量的地址,也就是int **类型,而func如果期望的是int *,类型就对不上。当然,如果你明确写了void func(int **pp),那传&p是合法的,但是你要清楚这个p自己还没绑定任何有效变量或内存,你必须在func内部先让*pp指向一个有效区域才能用。这种场景不是用来处理简单结果的,而是用来处理“指针本身需要被修改”的进阶场景,我在下一节单独讲。
我整理了一个问题速查表,方便你对照着找自己的问题。
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 程序编译通过,运行崩溃 | 对空指针或野指针解引用 | 检查指针是否绑定有效变量,用malloc后判空 |
| 函数运行完,外部变量没变化 | 函数内只改形参指针自身,没改*p | 用*p = ...写值 |
调用时不知道要不要加& | 混淆了“传值”和“传地址” | 形参是int *,实参就要&变量 |
| 编译警告“类型不兼容” | 传了int**给int*参数 | 检查级数,指针嵌套别弄错 |
| 返回的指针指向的内容乱码 | 返回了函数内局部变量的地址 | 不要返回局部变量地址,改用static或malloc |
| 打印指针指向的内容不对 | 忘记解引用,打印的是地址 | 用printf("%d", *ptr)而不是printf("%d", ptr) |
5. 再进一步:什么时候需要用到指针的指针
5.1 为什么要引入二级指针
上面讨论的所有例子,都是调用者在自己的代码里预先准备好了变量,然后取地址传给函数。函数拿到一级指针,直接改指向的值。
但有些场景,你想让函数去修改调用者手里的那个指针本身。比如你在函数内部动态分配了一段内存,希望调用者获得这段内存的起始地址,同时返回分配是否成功。如果函数参数只用int *,你传ptr和ptr的地址是两码事:
func(ptr):只是把ptr的值拷贝给形参,你在函数里改形参指向哪,外面的ptr根本不知道。func(&ptr):形参是int **,也就是“指向指针的指针”,函数里写*pp = malloc(...),才能把新分配的内存地址写进外面的ptr变量。
这就是“指针的指针”最经典的使用场景。在第八章里,一般不会讲太深,但我发现很多练习题答案是直接用到这个技术的,所以你要提前知道它为什么存在。
举个例子,写一个函数,根据输入构造一个字符串,并让调用者通过参数拿到这个字符串的地址。
#include <stdio.h> #include <stdlib.h> #include <string.h> int create_message(char **msg_ptr) { char *tmp = (char *)malloc(64 * sizeof(char)); if (tmp == NULL) { return -1; } strcpy(tmp, "hello from function"); *msg_ptr = tmp; return 0; } int main() { char *message = NULL; int ret = create_message(&message); if (ret == 0) { printf("%s\n", message); free(message); } return 0; }这里create_message接收的参数是char **,你传进去的是&message,也就是char **。函数内部分配好内存后,*msg_ptr = tmp这行代码做了一件关键的事:把message这个变量本身的值改成tmp指向的地址。message初始是NULL,一次调用后它变成了一个指向合法字符串的指针,后续你可以直接使用它。
我之前见过很多人的写法是:
void create_message(char *msg) { msg = (char*)malloc(64); }然后在main里:
char *msg = NULL; create_message(msg); printf("%s", msg);结果程序直接崩溃,或者打印出乱码。原因就是上面说的:函数只修改了形参副本,外面的msg依然是NULL。这种错误几乎每一个学二级指针的人都会踩到,所以我在这里详细说一下,你可能现在用不到,但等学到后面动态内存管理,你一定会感激这个知识点的。
5.2 智能指针的概念,C语言里应该怎样理解
热搜词里出现了“智能指针实现”,很多人会疑惑:C语言并没有内置的智能指针,那它到底是什么?C++里的std::unique_ptr和std::shared_ptr才是智能指针,它们能在对象生命周期结束时自动释放内存。但C语言里没有析构函数,所以你想实现类似效果,只能通过结构体包装指针,再在特定时机调用清理函数。
struct SmartPtr { int *data; size_t refs; }; void smart_ptr_free(struct SmartPtr *sp) { if (sp && sp->data) { free(sp->data); sp->data = NULL; } }这个用法本身和“第八章指针”不是一个层级的,但如果你在这门课之外对内存安全有点追求,可以先了解一下概念:C语言的所有“智能”都是靠约定和封装实现的,裸指针没有任何自动保护机制,越界写、重复释放、泄漏全都有可能发生。利用指针返回结果时,最稳妥的习惯就是“谁分配谁释放,谁接收谁负责”。
5.3 和数组、文件指针的联动
你留意到热词里有“指针数组”和“文件指针”,这两点也顺带提一下,因为它们跟“返回多个结果”经常混在一起考。
指针数组是指一个数组,里面的每个元素都是一个指针。它经常用来管理多个字符串,比如:
char *names[3] = {"Alice", "Bob", "Cindy"};这里names[0]是一个char*,指向字符串常量。如果你要把这些字符串传给某个函数做处理,比如替换某个字符并返回发生了多少次替换,那你要传的实际上是char **类型,因为数组名names会退化成char **。如果你传递数组首地址并接收“替换次数”,这个函数签名可以是:
void replace_char(char **strings, int n, char old_char, char new_char, int *total) { *total = 0; for (int i = 0; i < n; i++) { char *p = strings[i]; while (*p) { if (*p == old_char) { *p = new_char; (*total)++; } p++; } } }调用时:replace_char(names, 3, 'A', 'a', &count);
一层层指针嵌套会让人头晕,但只要你记住一个原则:每多一层指针,就多一层“这个变量本身也是个需要被修改的地址”的含义。char **说的是“一个指向char指针的指针”,它既可能是“指向数组首元素的指针”,也可能是“指向某个指针变量的指针”。明确这一点,面对任何复杂的指针类型都不会慌。
文件指针的情况稍微特殊一点,FILE *本身就是一个指针,你可以在函数里打开文件、写入数据,然后通过指针参数返回打开是否成功或文件内偏移量。它的思路和普通指针没有本质区别,只是类型变成了FILE *。不少习题会让你写一个函数,传入文件名,把文件的字节数和行数都返回出来,这时候函数签名往往是void count_file(const char *filename, long *size, int *lines),内部用fopen打开文件,用fseek和ftell得到大小,再逐行统计行号。做法还是那一套:调用者准备两个变量,取地址,接收结果。
6. 我自己的使用心得
写完这么多,最后说点题外的经验之谈。
我刚开始教别人“利用指针返回多个结果”的时候,喜欢画很多箭头图、内存示意图,后来发现效果一般。真正有效的方法是让他们先写一个错得离谱的版本,比如前面说的void func(int *p) { p = ... },运行后看到外部变量没变,再对比正确的*p = ...版本,这才真能理解“指针是值传递”这句话到底在说什么。所以你要是学这一章觉得绕,不妨故意写错几次,看看错误运行时到底发生了什么。
还有个更实用的小技巧:每次拿到一个带指针参数的函数,你先别急着看参数名,先用笔写出来“这个参数是输入还是输出”。输入型指针经常加const修饰,输出型指针一定会在函数内部被*p = ...赋值。养成这个习惯,第八章做练习时你一眼就能看穿出题人的意图。后面真做了工程、接了大项目,你会发现在C语言里“通过指针返回多个结果”几乎是无处不在的基础功,学会它,等于给你的C语言水平扩展了一个维度。