两年前带过一位实习生,他第一次写冒泡排序,把所有交换逻辑直接写在了主函数里,代码又臭又长,排序的逻辑和交换变量混在一起,改了好几版才理清楚。后来我问他,为什么不封装成一个交换函数,他愣了半天说:“函数传进去的不是两个变量吗,为什么执行完外面的还是没变?”——这就是很多初学者第一次接触指针交换变量时的真实困惑。
这其实不只是一个“函数怎么写”的问题。它牵扯到C语言最核心的两个概念:参数的传递方式、变量的内存本质。搞懂了“通过指针交换两个变量”,你就能理解为什么函数能改外面的数据、为什么链表节点能互相串起来、为什么排序算法里交换两个元素是那样写的。这篇文章我就从实际需求出发,把指针交换变量这个技巧拆透了讲。
1. 先搞明白:为什么交换变量要用指针
1.1 一个被反复提起的基础问题
“交换两个变量的值”是C语言教材里最常见的入门练习之一,也是各类笔试、面试里的送分题。但送分题翻车的概率出奇地高。我问过不少实际写代码的人,让他们现场写一个swap函数,相当一部分人会写成这样:
void swap(int a, int b) { int temp = a; a = b; b = temp; } int main() { int x = 3, y = 5; swap(x, y); printf("x = %d, y = %d\n", x, y); // 输出还是 3 5 return 0; }结果当然交换失败。这不是函数写错了,而是C语言里“传值”这个机制天然决定了:你把x和y的值复制一份递给函数,函数里面交换的是副本,原变量文丝不动。
如果你只是在主函数里写int temp = x; x = y; y = temp;那完全用不到指针,这谁都会。问题在于:真实项目里,交换逻辑往往发生在函数内部——排序要封装、队列要封装、状态要封装。这时候就必须让函数有能力修改外面的变量。C语言给的办法就是:把变量的地址传进去。
1.2 指针到底在指什么
可以把内存想象成一条长长的储物柜走廊,每个柜子都有编号(地址),里面放着值。int x = 3;的意思是:在某个编号的柜子里放入数字3。而int *p = &x;的意思是:再准备一个柜子,里面放的不是3,而是装3的那个柜子的编号。
关键就在这:如果你把3复印一份给别人,别人改复印件,你的原件不受影响;如果别人有了你柜子的编号,他就能找到你的柜子,直接往里面换东西。
所以指针交换变量的本质,就是“把操作原变量的权限交出去”。不是把值交给函数,而是把“变量住在哪里”告诉函数。函数拿到地址后,通过*a这个解引用操作,直接对原地址上的内容做读写。
理解了这一层,你后面看所有的交换代码都会特别通顺。而且你会逐渐意识到:指针的意义远不止交换变量,它是C语言里“让函数操作外部数据”的唯一标准通道,后面学链表、学树的遍历、学回调函数,全都在用同一个底层思路。
2. 三种主流交换方式的核心思路
2.1 临时变量法:最稳妥、最推荐
交换两个变量,最基本的做法是引一个“暂存位”:
int temp = a; a = b; b = temp;这里temp的作用是记住a原来的值,防止赋值链断裂。你想象一下:两个杯子,一杯可乐、一杯雪碧,要互换液体,必须先把其中一杯倒进第三个空杯,再倒另一边。temp就是这个空杯。
这个方案的优势是:简单、直白、稳定,任何类型都能用,不怕溢出。局限只有一个:需要额外申请一个变量(内存开销一般是可忽略的)。我不止一次在技术讨论区看到有人嫌临时变量“不够高级”,其实恰恰相反,能写出临时变量方案的人,说明理解了解构与重组的基本流程,后面所有复杂交换都在这个思路上扩展。
2.2 加减法和异或法:省变量的“野路子”
不引入第三个变量,能不能交换?算法界提供了两类经典方案:
加减法:
a = a + b; b = a - b; // b 此时是原来的 a a = a - b; // a 此时是原来的 b异或法:
a = a ^ b; b = a ^ b; // b = 原来的 a a = a ^ b; // a = 原来的 b这两种做法的共同点,都是用信息冗余的方式,把两个变量的值“揉在一起”,再分步拆开。听起来巧妙,但实际工程里我并不推荐。
理由不只是“可读性差”,更重要的是边界问题。加减法在整数溢出的场景下(比如两个很大的int相加)结果是未定义行为,尤其在特殊硬件上可能直接出错。异或法虽然不会有溢出问题,但它要求a和b必须是独立变量——如果a和b指向同一个内存位置(你用swap(&a, &a)试试),异或完就直接归零了,这是个极其隐蔽的陷阱。
所以我的经验是:临时变量法永远是首选。只有当内存极其受限(比如某些嵌入式场景)且你能保证类型安全时,才考虑异或法。代码不仅是给机器看的,更是给人看的,可维护性永远排在“看起来聪明”前面。
2.3 指针方式的本质:让交换发生在原变量上
前面两种方法解决的都是“同一作用域内交换值”的问题。但现实里的交换需求往往跨函数,这时核心矛盾不再是“怎么省变量”,而是“怎么让函数修改外部的值”。
指针方式的技术要点,就是函数签名和调用方式:
void swap(int *a, int *b) { int temp = *a; *a = *b; *b = temp; } int main() { int x = 3, y = 5; swap(&x, &y); // 此时 x = 5, y = 3 return 0; }你会看到:函数参数是int *,传进去的是&x、&y,函数体内通过解引用操作原变量。交换的核心过程还是临时变量法,只是门口换成了“地址快递”。
这种写法为什么能生效?因为在C语言里,地址本身也是一种值,它可以被复制、被传递。swap(&x, &y)把两个地址复制进函数,函数虽然拿到的还是“复印件”(地址的副本),但这副本指向的柜子是真的。于是*a = *b这行代码实际完成的是:把地址b所指柜子里的值,拷贝到地址a所指柜子里。
这个细节我要特意强调:C语言的一切参数传递本质都是值传递。指针传递传递的是“地址值”,它之所以能改原变量,是因为“地址值”就是原变量的寻址凭证,而不是因为指针打破了传达规则。想通这一点,你对C语言的理解就能上一个台阶。
3. 指针交换变量的完整实操:从错误示例到正确写法
3.1 最常见的错误:拿指针变量本身去交换
有一类写法看起来像是用了指针,实际完全跑偏:
void swap(int *a, int *b) { int *temp = a; a = b; b = temp; }调用swap(&x, &y)之后,外部毫无变化。原因很简单:这个函数交换的是两个指针变量本身存储的地址值,也就是把“两个柜子的编号”交换了。编号换了,柜子里的内容却原封不动。
这就相当于你把两张写着地址的纸条交换了一下,纸条指向的实物没有任何移动。如果目的是交换地址,这种写法没错;但我们的目的是交换变量内容,那就必须解引用。
这类错误特别容易在初学者改写自己的代码时出现,因为语法上好像都“没毛病”,编译也不报错,运行结果却告诉你交换无效。排查时会很折磨人——你需要检查每一处解引用符号是否真的写在了操作数上。
3.2 正解拆解:每一步在做什么
把正确版本拆开看,只有三行核心代码,但每行都承载着具体语义:
void swap(int *a, int *b) { int temp = *a; // 读出a所指柜子里的值,存在临时变量 *a = *b; // 把b所指柜子里的值,写入a所指柜子 *b = temp; // 把当初存好的值,写入b所指柜子 }第一行的*a是解引用,它不是在声明指针,而是在“取指针指向的内容”。这个temp变量必须声明为int,不能声明成int *——因为我们要暂存的是值本身,不是地址。
第二行是赋值,但要注意赋值的语义:*a = *b是把“值”搬过去,而不是把“地址”赋过去。如果你写a = b,那只是让局部变量a指向b给的那个地址,柜子内容完全没动。
第三行同理。整个函数执行完后,外部变量x和y的内存内容确实完成了互换。
关于temp变量:有人觉得函数内部多出来一个局部变量不好,实际上无伤大雅。栈上临时变量的空间在函数返回时会自动回收,性能影响可以忽略不计。真出现瓶颈,问题几乎不可能出在一个局部int上。
3.3 参数与实参的关系:为什么必须是取地址符
很多教程只告诉你“要加&”,但不说为什么。这里必须说明白:
- 形式参数
int *a这个声明,意思是“我要接收一个地址”。 - 实参
&x表示“取出变量x的地址”。 - 两者匹配后,
a就指向了x所在的内存区域。 - 函数内
*a就是直接操作x所在区域的值。
如果实参写成swap(x, y),编译器会直接报错,因为int不能自动转成int *。这是编译期的保护,反而帮助了我们,避免传值却不自知。
如果你用的是非常老的编译器或者比较随意的嵌入式IDE,给出的警告可能不够显眼。务必留意编译输出里有没有“incompatible pointer type”这类字样,这往往是传参错误的信号。
4. 函数内外的差异与注意事项
4.1 值传递的陷阱:副本交换为什么总被误解
前面说了,C语言函数参数都是值传递。但不少人在别的语言里待久了,会默认“函数里改了参数,外面也改”。C语言里没有这个特性(除非用引用,C++才有)。
所以当你在函数里写int temp = a; a = b; b = temp;时,改的只是形参的副本,这些副本在函数返回后就消失了。外面主调函数看到的x、y依旧如故。
这正是为什么“交换变量需要指针”这个场景永远值得单独拿出来讲。它不是炫技,而是C语言访问外部变量的基本模型:想在函数里影响外部,就必须拿外部变量的地址。
同理,如果你希望函数返回后某个标志位被修改(比如文件读取是否成功),你也必须传入int *flag而不是int flag。我用一句话总结过这个规律:“你想让函数改外部变量的值,就传它的地址;你只是想让它读外部的值,直接传值或者传地址都行,但要权衡拷贝成本。”
4.2 何时需要传指针、何时可以传值
一个实际参考标准:
- 只是读取一个大结构体里的字段,优先传指针(避免整个结构体被复制进栈,效率更高)。
- 只是读取一个
int或char,直接传值,简单直接。 - 要修改这个变量本身,传指针。
- 要修改指针本身(比如让外部指针指向新地址),传二级指针
int **。
交换变量属于“要修改本身”,所以必须传一级指针。如果目标就是交换指针变量的指向,那就要动用二级指针:
void swapPtr(int **a, int **b) { int *temp = *a; *a = *b; *b = temp; }这个进阶版本在链表排序、内存池管理中经常出现。比如你有一组结构体指针,想根据某个字段值把指针重新排序,本质上交换的是指针,不是节点本身。这时候如果只传一级指针,函数内改的是地址副本,外头的指针数组顺序根本不动,同样会掉进“改了没变化”的深坑。
4.3 交换指针本身(二级指针)的完整示例
假设有两个char *字符串指针,希望交换它们指向的字符串,实现上一个比较直接的版本:
void swapString(char **s1, char **s2) { char *temp = *s1; *s1 = *s2; *s2 = temp; } int main() { char *name1 = "Alice"; char *name2 = "Bob"; swapString(&name1, &name2); // 注意传的是指针的地址 printf("%s %s\n", name1, name2); // Bob Alice return 0; }这里最关键的是:&name1的类型是char **,函数内部*s1拿到的才是name1本身,*s1 = *s2才会修改到name1。如果你错把参数声明成char *,函数里交换的就是字符串地址的副本,外面自然不变。
二级指针看起来绕,但规则和一级指针完全一致:“想改谁,就传谁的地址”。改一个int,传int *;改一个int *,传int **。逻辑链条是统一的。
5. 常见问题与排查技巧实录
5.1 野指针与空指针:交换操作前的雷区
写指针交换时,最容易踩的坑不是交换逻辑本身,而是指针指向不合法。
比如:
int *a; // 未初始化,野指针 int *b; swap(a, b);这属于未定义行为,运气好不发生崩溃,运气不好直接段错误。因为a里存放是一个随机地址,解引用时可能访问到受保护的内存区域。
更隐蔽的场景是“指向同一地址”:
int x = 10; swap(&x, &x);很多人觉得“自己换自己”不会出错。临时变量法确实没问题:读出x的值存入temp,再把x写回x,最后再写回x,结果还是x。但如果用异或法,a和b指向同一内存时直接归零,这就是我前面强调的异或法的隐性风险。
保险的做法是在swap函数开头加个保护:
if (a == NULL || b == NULL) return;以及如果调用方真的可能传同一个地址,至少避免异或写法。临时变量法是全场景安全的。
5.2 编译报错与运行时表现:一份速查清单
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
编译器提示incompatible type | 传了值而不是地址 | 调用处加& |
编译器提示incompatible pointer type | 指针层级不匹配 | 检查函数声明和实参类型 |
| 编译通过但运行结果没变 | 函数内只改了地址副本 | 检查是否漏了*解引用 |
| 编译通过但程序崩溃 | 野指针或空指针 | 检查指针是否初始化 |
| 打印出现异常大数字 | 解引用垃圾地址 | 在调试器里查看指针值 |
char *交换后字符串反而不是原来顺序 | 误用字符串常量自身赋值 | 区分交换指针与交换内容 |
列这张表时我回想了一下自己排查过的各种案例,其中“编译通过但结果没变”占比最高,七成左右是新人在*a = *b和a = b之间选错了。这个错非常隐蔽,因为编译器不会报错,你只能靠读代码或者加调试打印确认。
调试时有个小技巧:在交换前后各打印一次*a、*b,再打印一次a、b本身,马上就能看出是在“换值”还是“换牌”。
5.3 面试和作业里常考的三种变形
变形一:要求写swap时把两个整数的值互换,并且“不需要第三个临时变量”。这可以练异或或加减法,但务必跟考官确认溢出风险和同址风险,能自圆其说就没问题。
变形二:要求交换数组中的两个元素。这其实不是用int *接收两个单变量,而是接收两个元素地址:
void swapArray(int *arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; }等价写法是swap(&arr[i], &arr[j])。这里arr作为数组名退化成指向首元素的指针,&arr[i]是元素地址,语义完全一致。
变形三:要求在函数中修改主调函数里的指针,让它指向新malloc出来的内存块。这就必须用二级指针:
void allocate(int **p, int size) { *p = (int *)malloc(size * sizeof(int)); }常见错误是写成void allocate(int *p, int size),然后在函数里p = malloc(...),因为p是实参指针的副本,赋值完后外部指针仍是NULL。这个经典错误困扰了无数C语言新手,我的建议是画一下指针的指针关系图——p存的是“指针变量”的地址,*p才是那个指针变量本身。
6. 实用延伸:数组、链表场景中的应用
6.1 交换数组元素:排序算法的最小齿轮
所有排序算法——冒泡、选择、快速排序——底层都在干一件事:按某个规则交换元素。而每个元素在数组里的身份,就是它的地址&arr[i]。
所以封装一个好的swap函数,直接受益的是排序代码。以选择排序为例:
for (int i = 0; i < n - 1; i++) { int minIdx = i; for (int j = i + 1; j < n; j++) { if (arr[j] < arr[minIdx]) { minIdx = j; } } if (minIdx != i) { swap(&arr[i], &arr[minIdx]); } }这个swap因为没有返回值,且通过指针直接操作原数组,整段排序逻辑会非常干净。你不用在排序函数里反复写三行交换代码,也不担心被调函数“白干”。
它的另一个作用是提高代码置信度:每次交换只做一件事,出问题时排查范围更小。我在做某个涉及大量数据重排的项目时,就靠把交换逻辑单独封装,把排序中的“换数”和“找数”彻底分离,调试起来清晰很多。
6.2 链表中交换相邻节点:指针交换的进阶应用
链表节点交换有两种思路:一种是换数据,一种是换指针。数据量小的时候换数据方便,数据量大或用指针标识身份的场合,换指针更合理。
交换相邻节点指针的伪代码是经典操作,但很多教程写得绕。我用swap思维来简化它:先把两个节点的值换掉?不行,因为节点可能带有多个字段。这时有个更聪明的做法:交换两个节点的next指针指向,本质上可以分解为交换“节点变量内嵌的指针”的值。
假设节点结构:
typedef struct Node { int val; struct Node *next; } Node;若有两个相邻节点a->next指向b,b->next指向c,想交换a和b在链表中的位置,核心操作是重连三条链。但有一种优雅的思路:不需要动数据,而是让a和b这两个节点在链表中的“前驱指针”分别指向对方。
不过这属于链表专题,远超本文范围。我提这个是想说明:指针交换变量这个基础能力,往深扩展就是指针操作的核心素养。理解了解引用、取地址、参数传递,链表的那些指针绘图题就都不难了。
6.3 一个可以把代码写得更优雅的小扩展
如果你经常要交换一个结构体的两个实例,可以直接用临时变量法拷贝整个结构:
struct Point { int x; int y; }; void swapPoint(struct Point *p1, struct Point *p2) { struct Point temp = *p1; *p1 = *p2; *p2 = temp; }结构体整体拷贝的开销在绝大多数场景下是可接受的,而代码简洁程度非常高。注意这里temp的类型是struct Point,不是指针,因为我们要暂存的是值。这个例子里能再次看到一个统一规律:暂存“值”就声明值类型,暂存“地址”才声明指针类型。
7. 最后分享几点我在实际项目里的体会
我在参与一个数据处理模块的调试时,曾经被一个“交换后数据偶发错乱”的bug折磨了两天。最后查出来,是有人在排序过程中拿两个void *直接做了交换,却忘记了它们实际指向的数据块大小不同。这给了一个很深刻的教训:指针交换一对一等值是安全的,但交换不等大小内存块的“内容”时,必须显式指定字节长度,比如用memcpy配合临时缓冲。很多人以为“指针交换”只能交换变量地址指向的单个值,其实它完全可以交换任意大小内存块,前提是你必须按实际规模来操作。
所以我在团队的代码规范里特别加了一条:单值交换用指针 + 临时变量;内存块交换用memcpy+ 临时缓冲区;指针指向交换用二级指针 + 临时指针。分类分明,出问题时看一眼调用点就知道区分是不是用错了工具。
如果你是刚接触C语言指针的读者,我建议不要只盯着代码背,而是拿笔在纸上画一画:画出&x、a的指向关系,画出*a = *b之后内存里发生了什么。画两遍,指针交换变量这个技巧就再也不会忘了。往后的排序、链表、树、函数回调里,你会发现这段基础能力的价值被无限放大。