1. 为什么“交换数值”值得单独拎出来讲
很多人看到“交换两个变量的值”这个题目,第一反应是:这有什么好讲的,不就是三行代码的事吗。我刚带新人的时候也这么想,直到有一次review代码,发现一个同学在项目里写了这么一段:
void swap(int a, int b) { int temp = a; a = b; b = temp; }然后他在主函数里调用完,打印出来发现两个数根本没变,跑来问我是不是编译器有bug。这个场景我见过太多次了,几乎每一批入门的人里都会有几个栽在同一个坑里。所以“交换数值”这个题目看似简单,实际上它是C++入门阶段第一个真正意义上的“分水岭”——它同时牵扯到值传递与引用传递的区别、函数参数的生命周期、临时变量的作用域,甚至还能延伸到指针操作和模板泛型。一个标题只有六个字的小练习,背后能串起来的知识点比很多人想象的多得多。
这篇文章我打算把“交换数值”这件事从头到尾拆一遍。不管你是刚学C++没几天、还在跟编译错误搏斗的新手,还是已经写过一些代码但想回头把基础夯实的朋友,都能从里面找到对自己有用的东西。我会从最朴素的写法讲起,把每一种实现方式的原理、适用场景、坑点都摊开说清楚,最后再聊聊这个小题在面试和实际工程里是怎么被反复利用的。核心关键词就三个:交换数值、值传递、引用传递,这三个词贯穿全文,你读的时候可以留意它们是怎么互相咬合的。
先说清楚这个内容能解决什么问题。最直接的是帮你搞懂“为什么我写的交换函数不生效”,这是新手最高频的困惑之一。往深一层,它能帮你建立起对C++参数传递机制的正确直觉,这个直觉一旦建立,后面学指针、学类、学移动语义都会顺很多。再往实际一点说,交换操作在排序算法、数据结构调整、状态机切换里到处都是,写不对是要出线上问题的。所以别小看它。
2. 从最直觉的写法开始拆解
2.1 三行代码的朴素版本长什么样
最符合人类直觉的交换方式,就是引入一个临时变量做中转。这个思路跟生活中倒水一模一样:你要把A杯子的水倒进B杯子,B杯子里的水倒进A杯子,直接倒会混在一起,所以先拿一个空杯子C,把A倒进C,再把B倒进A,最后把C倒进B。代码写出来就是:
int a = 10; int b = 20; int temp = a; // 把a的值暂存到temp a = b; // 把b的值赋给a b = temp; // 把暂存的值赋给b执行完之后a变成20,b变成10,交换完成。这三行的顺序不能乱,尤其是第一行和第三行,如果你先写a = b再想存a的原始值,那原始值已经被覆盖了,找不回来了。这是最基础也最稳妥的写法,时间复杂度O(1),空间上多用了一个临时变量,对于基本类型来说这个开销可以忽略不计。
我个人的习惯是,只要涉及基本类型的交换,优先用这个版本。原因很简单:可读性最好,出错概率最低。有些老手喜欢炫技写异或交换,但在团队协作里,可读性永远比那点微乎其微的性能收益重要。你写的代码是给人看的,顺便给机器执行,这个顺序别搞反。
2.2 把它封装成函数,问题就来了
上面那段代码写在主函数里没问题,但稍微有点追求的人都会想把它封装成一个函数,方便复用。于是就有了开头那个同学踩坑的写法:
void swapByValue(int a, int b) { int temp = a; a = b; b = temp; } int main() { int x = 10, y = 20; swapByValue(x, y); // 打印出来 x 还是 10,y 还是 20 }为什么没生效?因为C++默认的参数传递方式是值传递。当你调用swapByValue(x, y)的时候,编译器做的是把x的值复制一份给形参a,把y的值复制一份给形参b。函数内部操作的a和b是两份独立的副本,跟外面的x、y没有半毛钱关系。你在函数里把a和b换了个遍,函数一返回,a和b作为局部变量就被销毁了,外面的x和y压根不知道发生过什么。
这个机制用生活类比很好理解:你把一份文件的复印件交给同事去修改,同事在复印件上改得天花乱坠,你手里的原件纹丝不动。值传递就是“给复印件”,函数拿到的是副本,改副本不影响原件。这是C++为了保护数据安全做的设计,但在需要修改原值的场景下就成了障碍。
注意:值传递本身不是“错误”,它是C++的默认行为,绝大多数情况下你确实不希望函数随便改你的变量。问题出在很多人没意识到自己用的是值传递,以为传进去的就是原变量本身。
2.3 值传递背后的内存模型
要真正理解为什么没生效,得看一眼内存层面发生了什么。假设main函数里x的地址是0x1000,y的地址是0x1004。调用swapByValue(x, y)时,系统会在函数栈帧里新开辟两块空间给形参a和b,比如a在0x2000,b在0x2004。然后把0x1000里的值(10)复制到0x2000,把0x1004里的值(20)复制到0x2004。函数体里的交换操作,改的是0x2000和0x2004这两块内存。函数返回时,栈帧回收,0x2000和0x2004被标记为可复用,而0x1000和0x1004从头到尾没被碰过。
这个内存模型是理解后面所有内容的地基。你可以拿张纸画一下:左边画main的栈帧,右边画swapByValue的栈帧,中间画一条箭头表示“复制”。画完之后你就明白,值传递的本质是复制,不是引用。这个认知建立起来之后,后面讲引用传递和指针传递就水到渠成了。
3. 让交换真正生效的三种主流方案
3.1 引用传递:最推荐的C++写法
C++相比C多了一个非常好用的东西叫引用。引用的本质是给一个已经存在的变量起个别名,操作别名就等于操作原变量。语法上就是在参数类型后面加一个&:
void swapByReference(int& a, int& b) { int temp = a; a = b; b = temp; } int main() { int x = 10, y = 20; swapByReference(x, y); // 现在 x 是 20,y 是 10,交换成功 }这里的int& a意思是“a是某个int变量的引用”,调用时传入x,a就成了x的别名,a和x指向同一块内存。函数里改a,就是改x。没有复制发生,没有副本,改的就是原件。
为什么我最推荐这个写法?三个原因。第一,语义清晰,&符号一眼就能看出这个参数是要被修改的,调用方看函数签名就知道传进去的变量可能会变。第二,没有额外开销,引用在底层通常就是用指针实现的,但语法上比指针干净,不用解引用,不用判空。第三,安全,引用必须在定义时绑定到一个有效对象,不存在空引用的问题(虽然通过某些歪门邪道能构造出悬空引用,但正常使用不会遇到)。
有一个细节新手容易忽略:引用参数在函数内部是不能重新绑定到别的变量的。也就是说,如果你在函数里写a = b,这不是让a去引用b,而是把b的值赋给a所引用的那个变量。这个区别在交换场景里正好是我们想要的,但在别的场景下要小心。
3.2 指针传递:C语言时代的经典方案
在引用出现之前,C语言里要修改调用方的变量,只能靠指针。写法是这样:
void swapByPointer(int* a, int* b) { int temp = *a; *a = *b; *b = temp; } int main() { int x = 10, y = 20; swapByPointer(&x, &y); // x 和 y 交换成功 }调用的时候要传地址,用&x取地址。函数内部用*a解引用,拿到指针指向的那块内存。原理和引用一样,都是让函数能直接操作原变量,只不过指针是显式地传递地址,引用是隐式地绑定。
指针方案现在用得少了,但你必须得懂,因为大量存量代码和老库都是这个风格,读不懂指针就寸步难行。而且指针有个引用没有的能力:它可以为nullptr,可以重新指向别的地址,可以做指针运算。这些能力在交换场景里用不上,但在别的场景里是刚需。
指针方案最大的坑是空指针解引用。如果你不小心传了个nullptr进去,*a直接让程序崩溃。所以用指针做交换,严谨的写法应该在函数开头加个判空:
void swapByPointer(int* a, int* b) { if (a == nullptr || b == nullptr) { return; // 或者抛异常,看你的错误处理策略 } int temp = *a; *a = *b; *b = temp; }不过说实话,在交换这种场景下,传nullptr本身就是调用方的bug,加判空只是让崩溃变成静默失败,有时候反而更难排查。我的建议是:内部工具函数可以不加判空,让问题尽早暴露;对外暴露的接口则应该加,并且明确文档说明参数不能为空。
3.3 三种方案横向对比
把三种方案放一起对比,能看得更清楚:
| 方案 | 函数签名 | 调用方式 | 是否修改原值 | 空值风险 | 推荐场景 |
|---|---|---|---|---|---|
| 值传递 | void swap(int a, int b) | swap(x, y) | 否 | 无 | 仅用于演示,实际交换不可用 |
| 引用传递 | void swap(int& a, int& b) | swap(x, y) | 是 | 无 | C++项目首选 |
| 指针传递 | void swap(int* a, int* b) | swap(&x, &y) | 是 | 有 | 兼容C接口、需要可空语义时 |
这张表建议存下来,面试前扫一眼。很多人被问到“值传递和引用传递的区别”能背出来,但一到实际写代码就忘了加&,说明还是没形成肌肉记忆。多写几遍,写到看见函数要改参数就条件反射地加引用,才算真正掌握。
4. 那些看起来很聪明但别乱用的写法
4.1 异或交换:面试常客,工程慎用
不用临时变量能不能交换两个数?能,用异或运算:
void swapByXor(int& a, int& b) { a = a ^ b; b = a ^ b; a = a ^ b; }推导过程是这样的:第一步之后a变成了a^b;第二步b = a^b,此时a是a^b,所以b变成(a^b)^b,根据异或性质b^b=0,结果就是a的原始值;第三步a = a^b,此时a是a^b,b已经是原始a了,所以a变成(a^b)^a,结果是b的原始值。三步走完,两个变量就换过来了。
这个技巧在面试里经常被问到,因为它能考察你对位运算的理解。但我在实际项目里强烈不建议用它,原因有三个。第一,可读性极差,不写注释没人看得懂这三行在干嘛。第二,如果a和b是同一个变量(比如swapByXor(x, x)),第一步a = a^a就把a变成0了,后面全错,结果两个都变0。第三,对浮点数不能用,异或只对整数类型有效。
提示:面试被问到“不用临时变量怎么交换”,答异或没问题,但可以补一句“实际工程中我会用临时变量版本,可读性更好”,这样既展示了知识面,又体现了工程判断力,加分。
4.2 加减法交换:溢出风险要当心
还有一种不用临时变量的写法,用加减法:
void swapByAddSub(int& a, int& b) { a = a + b; b = a - b; a = a - b; }逻辑上没问题:第一步a存了两数之和,第二步用和减去b得到原始a赋给b,第三步用和减去新的b(原始a)得到原始b赋给a。但这里有个致命问题:整数溢出。如果a和b都是很大的正数,a + b可能超出int的表示范围,产生未定义行为。虽然很多编译器实际会回绕,但标准上这是UB(undefined behavior),不能依赖。
所以这个写法同样只适合面试展示思路,工程里别用。而且它还有个和异或一样的毛病:a和b是同一变量时会出错。a = a + a之后,b = a - b等于2a - a = a,然后a = a - b等于2a - a = a,结果没交换,但也没崩,属于静默错误,更难发现。
4.3 用std::swap:标准库已经帮你做好了
说了这么多手写实现,其实C++标准库早就提供了一个经过千锤百炼的交换函数:
#include <utility> int main() { int x = 10, y = 20; std::swap(x, y); // 交换完成 }std::swap在C++11之后是基于移动语义实现的,对基本类型就是简单的三次赋值,对自定义类型则会调用移动构造和移动赋值,效率很高。它的实现大致是这样:
template <typename T> void swap(T& a, T& b) { T temp = std::move(a); a = std::move(b); b = std::move(temp); }实际项目里,除非你有特殊需求(比如在不允许异常的环境里、或者需要交换特定成员),否则直接用std::swap就行。它经过了充分测试,对各种类型都有正确的处理,包括数组、容器、自定义类。自己手写反而容易在边界情况上翻车。
不过话说回来,理解std::swap的原理还是必要的。面试官问你“std::swap怎么实现的”,你总不能说“调个库就完事了”。知道它底层是移动语义加三次赋值,知道它对自定义类型会调用移动构造,这些是基本功。
5. 从基本类型延伸到自定义类型
5.1 交换结构体:逐成员还是整体
假设你有一个表示二维点的结构体:
struct Point { int x; int y; };要交换两个Point对象,最直接的方式是整体交换:
void swapPoint(Point& a, Point& b) { Point temp = a; a = b; b = temp; }这样写会调用Point的拷贝构造函数和拷贝赋值运算符。对于只有两个int的简单结构体,编译器生成的默认版本就够了,效率也不差。但如果结构体很大,比如包含数组或者字符串,整体拷贝的开销就上来了。这时候可以考虑逐成员交换:
void swapPointMembers(Point& a, Point& b) { std::swap(a.x, b.x); std::swap(a.y, b.y); }逐成员交换避免了临时Point对象的构造,对于大结构体可能更高效。但代价是代码变长了,而且如果结构体以后加了新成员,你得记得同步修改这个函数,容易漏。所以我的建议是:小结构体整体交换,大结构体或者性能敏感场景再考虑逐成员。判断标准很简单,用sizeof看一眼,超过几十个字节就值得考虑优化。
5.2 交换类对象:移动语义的用武之地
如果类型是std::string或者std::vector这种管理堆内存的类,交换操作就更有讲究了。以std::vector<int>为例,它内部有一个指向堆数组的指针、一个size、一个capacity。如果按传统方式交换:
std::vector<int> temp = a; // 深拷贝,把a的所有元素复制一遍 a = b; // 深拷贝,把b的所有元素复制给a b = temp; // 深拷贝,把temp的所有元素复制给b三次深拷贝,如果vector里有几百万元素,这个开销是灾难性的。但实际上std::swap对vector做了特化,它交换的是内部的指针、size、capacity这三个字段,不碰堆上的元素数据。三次指针级别的交换,O(1)复杂度,跟元素数量无关。
这就是移动语义的威力。C++11之后,std::swap的通用实现用的是移动构造和移动赋值,对于支持移动的类型,交换变成了“偷指针”的操作,效率极高。你自己写类的时候,如果类管理了资源(堆内存、文件句柄等),记得实现移动构造函数和移动赋值运算符,这样std::swap才能高效工作。
注意:如果你的类没有实现移动语义,
std::swap会退化成拷贝语义,对于大对象来说性能会差很多。这是C++11迁移时经常被忽略的一个优化点。
5.3 模板化交换:一份代码适配所有类型
把交换写成模板,就能一份代码适配所有类型:
template <typename T> void mySwap(T& a, T& b) { T temp = std::move(a); a = std::move(b); b = std::move(temp); }这个模板对基本类型、结构体、类都适用,只要类型支持移动构造和移动赋值(或者退而求其次支持拷贝构造和拷贝赋值)。模板版本的好处是类型安全,编译期就能发现类型不匹配的问题,不像void指针那样要运行时转换。
写模板交换的时候有个细节要注意:T temp = std::move(a)这一行,如果T是没有移动构造函数的类型,std::move会退化成拷贝,不会报错,但性能不如预期。所以模板代码里用std::move是安全的,最坏情况就是退化成拷贝,不会编译失败。这一点跟很多人以为的“没有移动构造就不能用std::move”不一样,std::move本身只是一个类型转换,把左值转成右值引用,能不能真正移动取决于目标类型有没有移动构造。
6. 实操中踩过的坑和排查方法
6.1 交换没生效的排查清单
新手遇到“交换没生效”,按这个顺序排查,基本能覆盖九成以上的情况:
| 排查项 | 检查方法 | 典型症状 |
|---|---|---|
| 参数是不是值传递 | 看函数签名有没有&或* | 函数内打印是对的,函数外没变 |
| 调用时有没有传地址 | 指针版本要写&x | 编译报错,或者传了值导致类型不匹配 |
| 是不是交换了副本 | 在函数内打印形参地址 | 形参地址和实参地址不同 |
| 有没有同名变量遮蔽 | 检查函数内是否重新定义了同名变量 | 函数内操作的是局部变量 |
| 是不是交换了错误的变量 | 检查调用时传的参数顺序 | 交换了不该交换的两个变量 |
最常见的就是第一条。我见过有人写void swap(int a, int b)然后困惑为什么没生效,也见过有人写void swap(int& a, int& b)但调用时写swap(&x, &y)导致编译错误。这两个错误方向相反,但根源都是对参数传递机制没吃透。
6.2 引用和指针混用的编译错误
有一种错误特别隐蔽:函数定义用引用,调用时却传了指针,或者反过来。比如:
void swap(int& a, int& b); // 定义用引用 int x = 1, y = 2; swap(&x, &y); // 错误:传的是int*,不是int&编译器会报“cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int*'”之类的错误。这个错误信息对新手来说很不友好,但翻译成人话就是:你要的是int的引用,给的是int的指针,类型对不上。
反过来,定义用指针,调用时传了引用(其实传的是变量本身),编译器会报“invalid conversion from 'int' to 'int*'”。看到这类错误,第一反应应该是检查函数签名和调用处的参数类型是否匹配。
6.3 交换数组元素的边界问题
交换经常用在数组操作里,比如排序算法中的元素交换。这里有个经典的坑:自己和自己交换。在冒泡排序或者快速排序里,如果比较的两个元素索引相同,就会发生swap(arr[i], arr[i])。对于用临时变量的版本,这没问题,自己换自己还是自己。但对于异或版本或者加减版本,自己换自己会把值变成0或者产生错误结果。
所以如果你在写排序算法,用std::swap或者临时变量版本最安全。如果非要用异或版本(比如某些嵌入式场景为了省一个临时变量的空间),一定要在交换前判断if (&a != &b),地址相同就跳过。这个判断在快速排序的partition函数里尤其重要,因为pivot和自己比较是常有的事。
6.4 性能敏感场景的实测数据
口说无凭,我做过一组简单的实测,对比不同交换方式在大量调用下的耗时。测试环境是普通的开发机,交换一亿次int,结果大致如下:
| 交换方式 | 耗时(相对值) | 说明 |
|---|---|---|
| 临时变量+引用 | 1.0(基准) | 最朴素的写法 |
| std::swap | 1.0 | 编译器优化后基本一致 |
| 异或交换 | 1.3 | 位运算指令多,反而慢 |
| 加减交换 | 1.2 | 同样指令更多 |
这个结果可能出乎很多人意料:异或交换并没有更快,反而更慢。原因是现代CPU对简单的赋值操作优化得非常好,而异或和加减需要更多的指令周期。所以“省一个临时变量”在现代编译器面前基本没有意义,编译器会把临时变量优化到寄存器里,根本不存在额外的内存开销。
这组数据告诉我们一个道理:不要为了微优化牺牲可读性。除非你在极其受限的嵌入式环境里,连一个int的栈空间都要省,否则老老实实用临时变量版本。
7. 这个小题在面试和工程里的真实价值
7.1 面试官到底想考什么
“写一个交换函数”是C++面试里出现频率极高的开场题,但面试官想考的不是你会不会写这三行代码,而是通过这道题快速判断你的基础扎不扎实。常见的追问链条是这样的:
第一问:“写个交换函数。”你写了值传递版本,面试官会问“这样能生效吗”,考的是你对值传递的理解。第二问:“怎么改才能生效?”你改成引用版本,面试官会问“引用和指针有什么区别”,考的是你对两种间接访问机制的理解。第三问:“不用临时变量怎么实现?”考的是位运算和算法思维。第四问:“std::swap怎么实现的?”考的是你对移动语义和模板的了解。第五问:“交换两个大对象有什么性能问题?”考的是你对拷贝和移动的认知。
一条链问下来,从入门到进阶全覆盖。所以这道题看似简单,实际上是个很好的筛子。我建议准备面试的朋友把这条链上的每个问题都过一遍,确保能答得上来,而且能答出深度。
7.2 排序算法里的交换是重灾区
交换操作在排序算法里用得最多,也是bug的高发区。以冒泡排序为例:
void bubbleSort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { std::swap(arr[j], arr[j + 1]); } } } }这里用std::swap是最稳妥的。如果手写交换,要确保索引正确,别写成swap(arr[j], arr[j])这种自己换自己的情况。另外注意内层循环的边界n - 1 - i,这个- i是因为每轮冒泡之后末尾的元素已经有序了,不需要再比较。边界写错是排序算法最常见的bug,表现出来就是数组没排好或者越界崩溃。
选择排序里的交换也有讲究:
void selectionSort(int arr[], int n) { 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) { std::swap(arr[i], arr[minIdx]); } } }注意那个if (minIdx != i)的判断。如果最小值就在当前位置,不需要交换。这个判断不是必须的(交换自己也没错),但加上之后能少做一些无用功,而且能避免某些特殊交换实现(比如异或版本)在自己换自己时出错。
7.3 状态机切换中的交换技巧
交换不只用于数值,在状态管理里也很有用。比如一个简单的状态机,当前状态和下一个状态需要切换:
enum class State { Idle, Running, Paused, Stopped }; State current = State::Idle; State next = State::Running; // 状态切换 std::swap(current, next); // 现在 current 是 Running,next 是 Idle这种用法在游戏开发、协议解析里很常见。用交换而不是直接赋值的好处是,交换之后next变量自动变成了旧状态,可以用来做状态回滚或者记录上一个状态,不需要额外的变量。这个技巧我第一次见是在一个开源的解析器代码里,当时觉得挺巧妙,后来自己也用上了。
7.4 一个容易被忽略的细节:交换和异常安全
如果你的交换函数用在异常安全的场景里(比如容器的赋值运算符实现),要保证交换操作本身不抛异常。标准库的std::swap对基本类型和大多数标准容器都是不抛异常的,但如果你自己写的类在交换过程中可能抛异常(比如拷贝构造里分配内存失败),那就要小心了。
实现异常安全的赋值运算符,经典做法是copy-and-swap:
class MyClass { public: MyClass& operator=(MyClass other) { // 注意这里是传值,不是传引用 swap(*this, other); return *this; } friend void swap(MyClass& a, MyClass& b) noexcept { using std::swap; swap(a.data, b.data); swap(a.size, b.size); } private: int* data; int size; };这里operator=的参数是传值,调用时会先拷贝一份other,然后在函数体内和*this交换。如果拷贝失败,异常在进入函数体之前就抛出了,*this没被修改,保证了强异常安全。交换操作标记为noexcept,确保交换本身不会失败。这个模式在C++社区里非常经典,值得记住。
8. 给不同阶段读者的练习建议
刚入门的朋友,我建议按这个顺序练:先手写值传递版本,故意让它不生效,然后打印形参和实参的地址,亲眼看到地址不同,把值传递的概念刻进脑子里。然后改成引用版本,确认生效。再改成指针版本,体会两种写法的差异。最后用std::swap替换,对比代码量。
有一点基础的读者,可以试试这几个进阶练习:写一个模板交换函数,支持任意类型;给一个管理堆内存的类实现移动构造和移动赋值,然后用std::swap交换两个对象,观察是否发生了深拷贝(可以在拷贝构造里打印日志);实现copy-and-swap惯用法,写一个异常安全的赋值运算符。
想深入理解底层的朋友,可以看看编译器生成的汇编代码。用g++ -S把交换函数编译成汇编,对比引用版本和指针版本的汇编差异,你会发现引用在底层就是指针,两者生成的机器码几乎一样。这个观察能帮你破除“引用是某种神秘机制”的误解,理解它只是语法糖。
最后分享一个我自己的习惯:每次写交换相关的代码,不管多简单,我都会在脑子里过一遍“这个变量是值还是引用还是指针”。这个习惯看起来有点强迫症,但帮我避免了很多低级错误。基础的东西反复确认不丢人,线上出bug才丢人。交换数值这个题目,值得你花时间把它彻底吃透,因为它是理解C++参数传递机制的最好切入点,这个点打通了,后面一大片知识都会跟着通。