发现问题
swap函数是C++标准库<algorithm>里的一个常见函数,用于交换两个变量的值。如果你写过代码,相信交换两个变量的值对于你来说应该是易如反掌,甚至还可以想到多种方法来实现它。在我之前的认知里,C++里的swap函数是一个没有什么技术含量的函数,不过是一个可以交换两个变量的值的模板函数,除了方便一点点,其他也没有什么了,不是么?
直到最近,我才发现,swap函数并不是我想象的那样简单。它的背后可以发掘到一些有意思的内容。于是就有了这篇文章。
swap函数除了可以对基本数据类型变量(如int,double,char等等)进行交换以外,还可以交换一些复杂的数据类型(如string类)的值。值得一提的是,这种交换借助C++11中的move移动语义,对复杂的数据无需进行大量的复制操作。比如交换两个string,只需交换两个string变量中的指针,即可完成它们的交换,无需多次进行串的拷贝。
基于move移动语义实现的swap源码如下:
template<typename T>
void swap(T &a,T &b) noexcept
{
T temp = std::move(a);
a = std::move(b);
b = std::move(temp);
}
move函数相关的机制已经超出了本文能够的讨论范围,这里暂时不予过多的深究。
我们需要知道的是,借助move语义,交换复杂数据结构(C++中的class、struct)的效率将得到有效的改善。
这个时候,你可能会发现我们好像把数组给漏了,两个数组是不是也可以呢?
我们不妨在main函数中写入如下代码:
//compile error
char ss1[] = "889", ss2[] = "888923932";
swap(ss1, ss2);
可惜,编译器立马爆出一堆错误,错误原因:“no matching function for call to 'swap(char[4], char[10])' ”。
让我们在回过头去看看,前面swap函数的定义。哦!原来得保证传入的两个参数类型相同!刚刚我们传入的两个参数,一个是长度为4的一维数组,一个是长度为10的一维数组,自然就报错了啦。我们让两个数组长度保持一致,这样就可以成功交换了。
然而数组名是一个指针常量,它是无法像普通的指针那样,被重新赋值的。难道swap函数可以交换两个指针常量的值?我们不妨测试一下:
char ss1[] = "889", ss2[] = "888";
//交换前
printf("交换前的地址: ss1=%p, ss2=%p\n", ss1, ss2);
printf("交换前的内容: ss1=%s, ss2=%s\n", ss1, ss2);
//交换
swap(ss1, ss2);
//交换后
printf("交换后的地址: ss1=%p, ss2=%p\n", ss1, ss2);
printf("交换后的内容: ss1=%s, ss2=%s\n", ss1, ss2);
交换前后的结果:
可以看到,两个数组的首地址并未发生变化。swap函数似乎是识别了数组类型,然后交换了两个数组的内容。我不禁陷入了沉思:怎么做到的?基于move移动语义吗?
令人惊讶的是,这里实现的一维数组交换并非直接通过前面已经实现的swap函数,为了验证这一点,我们可以把上面的swap代码Ctrl+V一下,换成其他名字,在程序中进行调用,就像下面这样:
//把函数名swap改为change
template<typename T>
void mySwap(T &a,T &b) noexcept
{
T temp = std::move(a);
a = std::move(b);
b = std::move(temp);
}
int main(){
char ss1[] = "889", ss2[] = "888";
printf("交换前的地址: ss1=%p, ss2=%p\n", ss1, ss2);
printf("交换前的内容: ss1=%s, ss2=%s\n", ss1, ss2);
mySwap(ss1, ss2);
printf("交换后的地址: ss1=%p, ss2=%p\n", ss1, ss2);
printf("交换后的内容: ss1=%s, ss2=%s\n", ss1, ss2);
return 0;
}
编译过后,编译器成功地报错了:
这就表明,前面给出的swap函数无法实现两个等长数组之间的元素交换。
可是既然都是同一个名字swap,想必得重载swap函数了吧。在网上我没有找到这种情形下的swap函数的实现,我们不妨自己来造一个轮子,探索一下它的实现机理。
为了和标准库中的swap函数区分开来,在后面的代码中,我们一律把自己实现的带swap功能的函数命名为mySwap。
方法论
我们的先确立一下实现思路:识别数组类型,获得数组长度,通过循环逐个交换数组元素,最终实现对两个数组内容的交换。
基于上面的思路,我们要实现的mySwap函数需要满足三个要求:
首先,它得和标准库里的swap函数一样是模板函数;
其次,它要能够自动识别出数组类型;
最后,它也要能在不增加函数参数的前提下,自动获取数组的长度。
两个容易出现的错误
在上面的思路和要求的指导下,我们可能会定义出下面这个样子的模板函数:
//错误示范1
template<typename T>
void mySwap(T a[], T b[]){
//获取数组长度
int SIZE = sizeof(a) / sizeof(a[0]);
//其余代码略
}
这种定义方法是有问题的。前面已经提过,T类型的数组名本质上是一种指针常量,当数组以这种形式的作为函数的参数时,它会退化为指针。也就是说,在上面的函数体中,a实际上是一个指针,sizeof(a)实际上表示的是一个指针的大小。因此,数组的长度信息由于参数传递已经丢失,函数体中的SIZE并非真正的数组长度。
既然要获取数组的长度信息,我们加入一个非类型的模板参数SIZE,代码如下,这样是不是可以在编译的时候获取到数组长度了呢?
//错误示范2
template<typename T, int SIZE>
void mySwap(T a[SIZE], T b[SIZE]){
//代码略
}
一定程度上是可以的,但是需要显式指定它的长度。
以交换长度为4的char数组ss1和ss2中元素为例。
mySwap(ss1, ss2); //会出现编译错误
mySwap<char, 4>(ss1, ss2); //编译通过
mySwap(ss1,ss2)编译错误的根源在于,这里的mySwap依然会把参数ss1,ss2解读为char指针,而不是长度为4的char数组,这样在无形中又丢弃了数组的长度信息,无法自动获知SIZE的大小,必须通过手动指定才能解决问题。
之前我们已经看到,标准库里的swap函数并不需要我们显示地给出数组长度,所以一定还有其他办法,可以让编译器自动获取到数组的长度。
前两个错误示例的关键问题在于,函数总是把传入的数组参数解读为指针,致使长度信息丢失。这就不得不让我们去思考:是否存在一种参数定义方式,可以保留数组的长度信息?
一种实现方案
还记得C语言里学过的数组指针的定义么?声明一个指向长度为4的char数组的数组指针p,写法如下:
char (*p)[4]; //指向长度为4的char数组
如果把这种指针作为函数的参数,由于它指向一个定长数组,数组的长度信息就能够保留下来。
然而,编译器在编译期进行类型推导时,不会把数组类型推导成对应数组指针,使用数组指针作为函数参数,将会编译报错。
幸运的是,我们已经接近答案了。
做一个小小的调整,把数组指针改为数组引用,作为函数参数,在原来已有的swap函数的基础上重载,这种实现下的mySwap函数,在交换两个数组元素时进行,函数调用上可以获得等同于标准库swap函数的体验。代码如下:
//正确实现
//基于move移动语义对一般数据进行交换的Swap
template<typename T>
void mySwap(T &a,T &b) noexcept
{
T temp = std::move(a);
a = std::move(b);
b = std::move(temp);
}
//对数组元素进行交换的Swap
template<typename T, int SIZE>
void mySwap(T (&a)[SIZE], T (&b)[SIZE]) noexcept
{
for(int i = 0; i < SIZE; ++i)
mySwap(a[i], b[i]);
}
如果希望重载后的mySwap函数拥有更高的运行效率,我们可以进行循环展开,使用某些奇技淫巧,如Duff's Device,进行优化。这方面暂时不讨论。
如果你仔细推敲一下,你会惊喜地发现,我们在这里实现的mySwap函数不但可以交换两个一维数组的数据,也可以交换二维数组、三维数组,甚至是 n 维数组的数据。实际上,一个 n 维数组可以看成是由若干个 n - 1 维数组构成的一维数组,交换两个 n 维数组,在编译时,编译器会自动为我们生成交换 n-1维、n-2维、······、2维、1维数组对应的mySwap函数。这种运作方式和函数递归是有些许神似之处的。