C++11:右值引用和移动语义
文章目录
1. 左值和右值表达式
首先要明确,左值和右值都是表达式。值(value)是无法进一步求值的表达式:例如表达式“1+1”就不是一个值,因为它可以被简化为表达式“3”,不能继续被化简,因此“3”是一个值。
根据左值和右值的特性,可以简单地用是否能取到地址来判断类型:
- 左值(l-value):能被取地址;
- 右值(r-value):不能被取到地址。
1.1 概念
左值具有确定的、可以被获得的内存地址。这意味着左值可以是变量,也可以是对指向特定内存地址的指针解引用的结果。
在C语言中,“左值”最初表示可以被赋值的对象,也就是赋值运算符左侧的对象,但是随着const
关键字的引入,这类对象就被称作“可更改的左值”。在C++中,左值和右值是表达式的分类,也就是说,一个表达式非左值即右值。
C++11将左值性(lvalueness)扩充为更复杂的值类别(value category),包含左值(lvalue),纯右值(prvalue)和临终值(xvalue)三个基本分类(fundamental classification),一个表达式必然是三者之一。
C++11标准引入了右值引用数据类型与移动语义,因而左值与右值的定义发生了很大变化。右值引用变量绑定到右值上,延长了右值对应的临时对象的生存期。移动语义把临时对象的内容移动(move)到左值对象上。
C++语言引入了const限定的对象(C语言只有const限定的内置类型)。const对象可以取地址,但是不能被赋值;而一些右值对象也可以出现在赋值号的左边被赋值。
因此,截至C++03标准,把具有标识的表达式规定为左值,不具有标识的表达式规定为右值。因而,名字、指针、引用等是左值,是命名对象,具有确定的内存地址;字面量、临时对象等为右值,右值仅在创建它的表达式中可以被访问。函数名字是左值(在C语言中规定它既不是左值也不是右值),数组名是常量左值,但是在大多数表达式中函数名字与数组名字自动隐式转换为右值。右值的生存期短暂,所以需要用左值去捕捉右值。把右值复制到左值上是常见操作。例如:
-
表达式
x=2
,声明了一个变量x并将x赋值为2,那么表达式x
的值是2,并且是一个左值,2就是一个右值,实现了用左值去捕捉右值,即将右值复制到左值上。 -
表达式
1+1
,在执行时,计算机生成一个整数值2,它是一个临时值,由于程序没有明确指定这个2如何在计算机中存储,所以这个表达式产生一个右值。
总的来说:
- 左值:表示一个占据内存中某个可识别的位置(也就是一个地址)的对象。
- 右值:即非左值,也就一个不表示内存中某个可识别位置的对象的表达式。
再例如:
int a;
a = 1;
赋值操作需要左操作数是一个左值。a
是一个有内存位置的对象,因此它是左值。
1 = a;
(a + 1) = 2;
这样的写法是错误的,因为常量1
和表达式a+1
都不是左值,即它们是右值。因为它们都是表达式的临时值,临时值是没有可识别的内存位置,原因是临时值作为计算过程的中间值,是被保存在寄存器中的。那么上面这种写法就是将它们赋值到了一个不存在的位置,这是不被允许的。
1.2 左值和右值
左值表达式:
- 可以被取地址,可以被修改(除了const修饰的左值),例如变量名或解引用的指针。
- 可以在赋值运算符的两侧。
例如:
int* pa = new int(1); // 指针
int a = *pa; // 被解引用的指针
int b = 2; // 变量名
const int c = 3; // 不可被修改
右值表达式:非左即右
- 不能被取地址,也不能被修改,例如字面常量、表达式的返回值、函数的(非左值引用)返回值。
- 只能在赋值运算符的右侧。
例如:
int func(int a, int b)
{
return a - b;
}
int main()
{
1;
int a = 1, b = 2;
a + b;
func(a, b);
return 0;
}
右值表达式只能在赋值运算符的右侧,也就是说它不能被赋值,只能将它赋值给其他变量:
// 错误写法
//1 = 11;
//x + y = 2;
//func(a, b) = 3;
// 正确写法
int x = 1;
int c = a + b;
int d = func(a, b);
通过示例可以知道右值和左值的区别:
-
右值的本质就是常量值或临时值,如上例中的
1
就是字面常量,x+y
和函数的返回值就是一个临时值。 -
临时值一般作为计算过程的中间值,因此它被保存在寄存器中,没有地址。而常量值存储在数据段中。
-
上面的函数的返回值是一个临时变量,如果返回一个引用就是左值了。
关于左值和右值,最后再用一个例子理解:
左值是一张纸,它里面的值就是字;右值是单纯的字。
如上例中的a + b
,就是1+2
,那么c = a + b
就是c=3
。用c
这个空间存储3这个值,c
就是纸,是左值;3就是字,是右值。假如d=c
,就是把c
这张纸中的字抄到d
这张纸上,那么c
就是右值,而这与d
上原来的内容是无关的。
详见AlseinX的回答,链接:https://www.zhihu.com/question/380792984/answer/1099475972
小结:在C++中,左值和右值是两种表达式的分类。左值是指可以出现在赋值号 = 的左边或右边的表达式,它有一个明确的内存地址,可以被引用或修改。右值是指只能出现在赋值号 = 的右边的表达式,它没有一个明确的内存地址,不能被引用或修改。右值一般是常量、临时对象或函数返回值。
2. 左值引用和右值引用
C++中引用就是给变量起别名,C++11以后,将C++11之前的引用称为左值引用,进而引入C++11中最强有力的特性:右值引用。
左值引用:
语法:左值引用和之前的引用一样,给左值取别名:
int main()
{
int* pa = new int(1);
int a = *pa;
int b = 2;
const int c = 3;
// 左值引用:起别名
int*& Pa = pa;
int& A = a;
int& B = b;
const int& C = c;
return 0;
}
右值引用:
语法:右值引用就是给右值取别名,通过&&
声明:
int func(int a, int b)
{
return a - b;
}
int main()
{
1;
int a = 1, b = 2;
a + b;
func(a, b);
// 右值引用:起别名
int&& x = 1;
int&& c = a + b;
int&& d = func(a, b);
return 0;
}
这段代码的核心思想是使用右值引用来优化程序性能,通过创建右值引用变量x、c和d,可以避免在每次调用函数func时创建临时变量。
右值本身是不能被取地址的,但是右值被起了别名以后会被存储到特定位置,并且可以被修改,可以使用const
关键字限制。
x = 2; // 修改引用指向的内容
cout << x << endl;
cout << &x << endl; // 取出引用指向内容的地址
输出:
2
00FEF860
2.1 相互引用
左值引用可以引用右值吗?
左值引用不能直接引用右值,原因是左值可以被修改,而右值不行。这就类似非const
变量能否接收普通变量,涉及到权限问题。权限只能被缩小或平移,不能被放大。
int main()
{
int a = 1, b = 2;
// int& c = a + b; // 错误
const int& c = a + b; // 正确
// c是左值引用
return 0;
}
这里我们可以想到在这之前我们总是有这样的习惯:函数的参数类型不仅是引用,而且还是被const
修饰的,认为“为保证安全性,避免被修改”是没错的,但从更深层次看来,加上const也是为了参数能够兼容左值引用和右值引用。
右值引用可以引用左值吗?
- 右值引用只能引用右值。
- 右值引用可以引用move以后的左值。
move函数是C++11新增的函数,被move后的左值能够赋值给右值引用,例如:
int main()
{
int a = 1, b = 2;
int&& c = move(a + b);
return 0;
}
这段代码的核心思想是使用move
函数将a+b
的值转移到右值引用c
中,从而实现a+b
的值被转移而不是拷贝。原本c+b
是一个左值,结果move函数的处理后就可以被右值引用。
C++中的move函数是一种将对象从一个位置移动到另一个位置的操作,它可以用来移动拥有资源的对象,以避免复制和析构的开销,而不会拷贝对象的内容。move函数是一个类似于强制转换的函数,它可以将一个右值转换为一个左值,从而使得右值的内存资源可以被释放,从而节省内存空间。
补充,move 函数的定义如下:
template<class _Ty>
inline typename remove_reference<_Ty>::type&& move(_Ty&& _Arg) _NOEXCEPT
{
//forward _Arg as movable
return ((typename remove_reference<_Ty>::type&&)_Arg);
}
注意:
- _Arg 参数的类型不是右值引用,而是万能引用。万能引用跟右值引用的形式一样,但是右值引用需要是确定的类型。
- 被move的左值的状态是未知的,它有可能已经被破坏了。
2.2 示例代码
C++中右值引用的使用场景主要用于移动语义,或者说,移动语义利用右值引用实现资源转移,即从一个对象将其内容移动到另一个对象,而不需要执行复制操作。
右值引用的意义在于提供了一种更加灵活的方式来处理右值,在函数调用中,右值引用可以用来避免拷贝构造函数的调用,从而提高程序的效率;在容器中,右值引用可以用来实现移动语义,从而更加高效地移动容器中的数据。
例如,当将一个临时变量赋值给一个普通变量时,会发生复制操作,如果这个对象很大,带来的开销是不可忽略的,而使用右值引用可以避免该复制操作,从而提高程序性能。
用一个简单的string类示例,它只实现了拷贝构造函数和赋值运算符重载函数等基本成员函数,并分别在它们内部增加提示语句。
#include <iostream>
#include <cstring>
using namespace std;
namespace xy
{
class string
{
public:
// 构造函数
string(const char* str = "")
{
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
// *拷贝构造函数
string(const string& s)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
cout << "string(const string& s) -- 深拷贝" << endl;
string tmp(s._str);
swap(tmp);
}
// *赋值运算符重载
string& operator=(const string& s)
{
cout << "string& operator=(const string& s) -- 深拷贝" << endl;
string tmp(s);
swap(tmp);
return *this;
}
void push_back(char ch)
{
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
_str[_size] = ch;
_str[_size + 1] = '\0';
_size++;
}
// +=运算符重载
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
strncpy(tmp, _str, _size + 1);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
// 其他接口...
private:
char* _str;
size_t _size;
size_t _capacity;
};
}
注意,这个string类是深拷贝。且为了方便稍后和库中的string一起测试,用一个命名空间包了起来。
理解了拷贝的方式,就能理解左值引用和右值引用的作用:
区分左值和右值的目的是优化程序的性能和内存管理。左值一般是有名字的变量,它们占用一定的内存空间,可以被重复使用或修改。右值一般是临时的表达式结果,它们在使用后就会被销毁,不能被重复使用或修改。如果能够将右值转移给左值,就可以避免不必要的拷贝和内存分配,提高程序的运行效率。C++11 之后引入了右值引用(&&)和移动语义(move),使得这种转移成为可能。
2.3 左值引用
使用场景
既然引用本身的价值就是减少拷贝,那么左值引用的优点也是类似的:
- 做参数:减少拷贝,提高效率;做输出型参数。
- 做返回值:减少拷贝,提高效率;引用返回,可以修改返回的对象(如operator[])。
输出型参数就像鱼钩一样,它作为参数被传入函数,执行完毕后便能取出。OJ常常将它用于(尤其是C语言)返回多个返回值。
然而,在“减少拷贝”方面左值引用并未覆盖所有情况。
例如to_string这个接口就是传值返回,原因是它的返回值是一个类型,它有析构函数,当跳出函数的作用域时析构函数会被自动调用,返回值就是一个局部对象了。除此之外,还有使用容器实现的二维数组,从语言角度上string和vector都是一个类型(class),都有自己的构造和析构函数。
注意,C++11之前的所有引用从C++11的角度看都是左值引用,此处讨论的也是如此。
而且C++98不支持返回二维数组的引用,只能返回指针。同样地,只支持返回string的拷贝,而不能返回string的引用。
例如,C++98难以解决上面这两种情况:
string to_string(int val); // to_string原型
vector<vector<int>> func(int num) // 返回值是二维数组,例如杨辉三角
如何避免返回值拷贝,提高效率?
不难想到,可以用输出型参数解决这个问题:
void to_string(int val, string& str);
void func(int num, vector<vector<int>>& vv);
当然,这样能解决问题,但没人会这样做,因为它不符合习惯。使用输出型参数也不那么优雅。
在全局中新增一个to_string接口,但是不用实现内部功能,只需要保证to_string的返回值是一个临时对象即可:
xy::string to_string(int val)
{
xy::string str;
// ...
return str;
}
测试:
int main()
{
xy::string str = to_string(1);
return 0;
}
输出:
string(const string& s) -- 深拷贝
从写法上看,to_string
内部会调用一次xy::string
拷贝构造函数,main
中创建str
对象也会调用一次拷贝构造函数,结果只打印了一次拷贝构造函数。原因是编译器(vs2019)优化了一次拷贝。如果使用gcc编译器,可以使用指令关闭拷贝构造优化:
回到程序本身,str = to_string(1)
的本意就是让1这个值初始化str,从以往的经验来看,这个1就是一个临时值,经过编译器优化以后是不会调用构造函数的。
VS优化的只会打印一次,它忽略了to_string的临时对象。原因是不优化的时候它在创建main函数的栈帧的时候扩大一点,以供这个临时变量存放,然后从to_string拷贝到这个地方,再拷贝到main函数栈帧中的ret上,这样就拷贝了两次,被VS优化了中间的环节。
缺点
左值引用虽然能在很多情况下避免拷贝带来的开销,但是左值引用在函数的返回值中不能起到作用,原因是函数内部的变量都是局部变量,出了函数的作用域就会被销毁,不论是否是引用,都无法在函数外部取到。
这就是右值引用存在的意义。
2.4 右值引用和移动语义
移动语义是C++11提供的一种优化技术,它可以将一个对象的资源(如内存、文件句柄等)从一个对象转移到另一个对象,而不需要复制或者销毁资源。
移动语义可以利用右值引用来实现,通过重载移动构造函数和移动赋值运算符来定义对象如何被移动。移动语义可以提高程序的性能,避免不必要的拷贝和内存分配。
简单来说,移动语义通过移动构造函数实现。移动构造函数就是一个资本家,它的参数是右值引用类型,这个构造函数想:“右值既然是临时值,在它消亡之前不如利用一下”,在内部会将右值的资源直接转移到自己身上,用来构造自己。
因此就左值引用不能用于返回值这一问题,可以在string类中写一个移动构造函数,在函数内部将传入的右值的资源通过swap函数转移到自己身上,增加语句以提示:
namespace xy
{
class string
{
public:
//移动构造
string(string&& s)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
cout << "string(string&& s) -- 移动构造" << endl;
swap(s);
}
private:
char* _str;
size_t _size;
size_t _capacity;
};
}
这样,就能解决左值引用在函数返回值中的问题。
右值引用和移动语义之间的关系:
- 右值引用是一种特殊的引用类型,它可以绑定到一个临时对象或者一个即将被销毁的对象,从而表示这个对象可以被移动而不需要拷贝。
- 移动语义需要右值引用来实现,因为右值引用可以区分出哪些对象是可以被安全地移动的,而不会影响其他地方的使用。
- 通过重载移动构造函数和移动赋值运算符,我们可以定义当一个对象被右值引用绑定时,如何将它的资源转移到另一个对象中,从而避免不必要的拷贝和内存分配。
移动构造函数和拷贝构造函数都是用来利用一个已有对象构造出一个新的对象的,但是它们的区别如下:
- 移动构造函数的参数是一个右值引用,而拷贝构造函数的参数是一个左值引用。在没有增加移动构造之前,由于拷贝构造采用的是 const 左值引用接收参数,因此无论拷贝构造对象时传入的是左值还是右值,都会调用拷贝构造函数;增加移动构造之后,由于移动构造采用的是右值引用接收参数,因此如果拷贝构造对象时传入的是右值,那么就会调用移动构造函数(最匹配原则)。
- 移动构造函数可以直接将已有对象的资源转移到新对象中,而不需要分配新的空间或者复制数据。这样可以提高性能,降低成本。例如 string 的拷贝构造函数是深拷贝,而移动构造函数中只需要调用 swap 函数进行资源转移,因此移动构造的代价比拷贝构造的成本小。
- 拷贝构造函数会保留已有对象的资源和数据,而移动构造函数会使已有对象失去资源和数据。因此,在使用移动构造函数后,已有对象可能处于不可预期的状态。
注意:
- 虽然 to_string 中返回值是一个左值,但是 string 对象在当前函数调用结束后就会立即被销毁,这种即将被消耗的值叫做 “将亡值”,比如匿名对象也是 “将亡值”。
- 既然 “将亡值” 马上就要被销毁了,那还不如物尽其用,最后再利用一下。因此编译器在识别 “将亡值” 时会将它识别为右值,这样就可以匹配到参数类型为右值引用的移动构造函数。
编译器的优化:
当一个函数在返回局部对象时,会先用这个局部对象拷贝构造出一个临时对象,然后再用这个临时对象来拷贝构造接收返回值的对象:
如上所说,C++11之前,编译器会将这两次拷贝构造优化为1次拷贝构造:
以上仅为VS编译器做的优化,即使它原本是为了解决C++11之前的问题,但是对C++11后的语法仍然有作用:
- 如果没有编译器优化为1次拷贝,C++11仍然会调用两次移动构造函数。
不同接收返回值的方式对拷贝次数带来的影响:
如果是用一个已经定义的对象来接收返回值,就相当于拷贝了operator[]操作符拷贝,此时虽然是两次拷贝构造,编译器也无法再优化下去了:
会先用这个局部对象拷贝构造出一个临时对象,然后再调用赋值运算符重载函数将这个临时对象赋值给接收函数返回值的对象。
- 编译器没有对这种情况进行优化,因此在 C++11 之前,有深拷贝的类就会有两次深拷贝,因为深拷贝的类的赋值运算符重载函数都以深拷贝的方式实现。
- 但在深拷贝的类中引入 C++11 的移动构造后,这里仍然需要再调用一次赋值运算符重载函数进行深拷贝,因此深拷贝的类不仅需要实现移动构造,还需要实现移动赋值。
注意:
对于有返回局部对象的函数,即使只是调用函数不接收返回值,也会存在一次拷贝构造或移动构造,因为返回值不论如何都存在,当函数结束后函数内的局部对象都会被销毁,所以就算不接收函数的返回值也会调用一次拷贝构造或移动构造创建临时对象。
小结
右值引用和移动语义是C++11引入的新特性,它们可以提高程序的性能和效率,避免不必要的内存分配和拷贝。
右值引用是一种特殊的引用类型,它只能绑定到临时对象或没有名称的变量,也就是右值。右值引用使用&&符号来声明,例如int&& r = 10;。右值引用可以让编译器区分左值和右值,从而选择合适的构造函数或赋值运算符。
移动语义是一种利用右值引用来实现对象所有权转移的机制。当一个对象被移动后,它的资源(例如指针、内存等)被转移到另一个对象上,而自身变成一个空对象,只能被析构。这样可以避免资源的复制和释放,提高效率。移动语义需要定义移动构造函数和移动赋值运算符,并使用std::move()函数来将左值转换为右值引用。
何时使用右值引用和移动语义:
- 当你需要返回一个大型或占用资源的对象时,你可以使用右值引用作为返回类型,让编译器执行强制或可选的复制/移动省略(copy/move elision),从而避免创建临时对象。
- 当你需要传递一个大型或占用资源的对象作为参数时,你可以使用右值引用作为参数类型,并在调用时使用std::move()函数将左值转换为右值引用,从而让编译器调用移动构造函数或移动赋值运算符
- 当你需要定义一个自己管理资源(例如指针、内存等)的类时,你可以定义移动构造函数和移动赋值运算符,并在其中实现资源所有权的转移逻辑。
关于内存泄露:
移动语义本身不会导致内存泄露的问题,但是如果使用不当,可能会出现一些意想不到的后果。例如,如果你使用std::move函数来转移一个对象的数据,那么你必须保证被转移的对象之后不再被使用,否则可能会访问到空指针或者无效数据。另外,如果你定义了自己的移动构造函数或者移动赋值运算符,那么你必须确保在转移资源的同时,把被转移对象的成员指针置为空,并且正确处理异常情况。否则,可能会出现资源泄露或者重复释放的问题。
2.5 移动赋值
移动赋值运算符是一种重载的赋值运算符,它的参数是自身类的右值引用,返回值是自身类的左值引用。它可以实现对象之间的资源所有权转移,而不是复制。
例如,如果你有一个字符串类,它有一个指向动态分配内存的指针成员变量。当你用一个临时字符串对象或一个即将被销毁的字符串对象来给另一个字符串对象赋值时,你可以使用移动赋值运算符来将源对象的指针直接赋给目标对象,并将源对象置为空。这样就避免了内存分配和复制的开销,并保证了源对象在析构时不会释放已经转移的资源。
移动赋值运算符需要使用noexcept关键字(非必要)来指定不抛出任何异常,并使用std::move()函数来将左值转换为右值引用。
以下是一个简单的示例:
namespace xy
{
class string {
public:
// 移动赋值运算符
string& operator=(string&& s) noexcept
{
cout << "string& operator=(string&& s) -- 移动赋值" << endl;
swap(s);
return *this;
}
private:
char* _str;
size_t _size;
size_t _capacity;
};
}
移动赋值运算符和赋值运算符都是用于给类类型的对象赋值的特殊成员函数,但是它们有一些区别:
- 移动赋值运算符的参数是一个右值引用,它可以接受临时对象或者被std::move转换为右值的对象,它不会分配新的内存,而是直接移动数据成员。
- 赋值运算符的参数是一个左值引用或者一个非引用形参,它可以接受左值或者右值,它会执行按位复制或者调用复制构造函数或者移动构造函数来生成临时对象。
对此,STL也根据C++11新增了移动构造函数,例如string类。
移动构造:
string (string&& str) noexcept;
移动赋值:
string& operator= (string&& str) noexcept;
2.6 右值引用的其他使用场景
右值引用版本的插入函数
除了移动构造和移动赋值之外,STL在C++11之后还给插入接口增加了右值引用版本。
例如,vector的push_back:
如果vector容器中存储的是string对象,可以有以下几种插入方式:
int main()
{
vector<xy::string> v;
xy::string s("1");
v.push_back(s); // 调用string的拷贝构造
v.push_back("2"); // 调用string的移动构造
v.push_back(xy::string("3")); // 调用string的移动构造
v.push_back(std::move(s)); // 调用string的移动构造
return 0;
}
STL容器中右值引用的插入接口是C++11新增的一种方式,它可以提高性能和效率。
- 右值引用的插入接口可以接受临时对象或者被std::move转换为右值的对象,它不会创建新的对象,而是直接移动数据到容器中。
- 右值引用的插入接口可以避免不必要的拷贝和内存分配,可以实现数据控制权的转移。
3. 完美转发
C++11中的完美转发是一种技术,它可以保持参数的值属性不变,即左值还是左值,右值还是右值。
- 完美转发可以避免不必要的拷贝和内存分配,提高性能和效率。
- 完美转发需要使用右值引用和std::forward函数来实现。
- 完美转发在变长模板中非常有用,因为它可以处理不同类型和数量的参数。
C++11中的forward函数的原型是一个用于实现完美转发的模板函数。它有两种重载版本,一种接收左值引用,一种接收右值引用。它的作用是根据传入参数的左右值属性来返回相应的左值或右值,从而避免不必要的拷贝。
3.1 万能引用
万能引用是一种C++11中的特殊引用,它可以绑定到左值或右值,而不需要指定类型。它的作用是实现完美转发,即保持参数的原始值类别。
万能引用可以让一个引用类型的参数既能绑定到右值,也能绑定到左值。万能引用的形式是T&&
,其中T
是一个模板类型或者auto类型。==万能引用的本质是根据实参的类型来推导T的类型,从而实现右值引用或者左值引用。==例如:
template<typename T>
void f(T&& param); // param是万能引用
int x = 10;
f(x); // T被推导为int&,param是左值引用
f(20); // T被推导为int,param是右值引用
auto&& var = x; // var是万能引用
下面重载了4个func函数,根据参数是左值和右值,const与否,一共有4个func重载函数:
#include <iostream>
using namespace std;
void Func(int& x)
{
cout << "左值引用" << endl;
}
void Func(const int& x)
{
cout << "const 左值引用" << endl;
}
void Func(int&& x)
{
cout << "右值引用" << endl;
}
void Func(const int&& x)
{
cout << "const 右值引用" << endl;
}
template<class T>
void PerfectForward(T&& t)
{
Func(t);
}
int main()
{
int a = 1;
PerfectForward(a); // 左值
PerfectForward(move(a)); // 右值
const int b = 2;
PerfectForward(b); // const 左值
PerfectForward(move(b)); // const 右值
return 0;
}
输出:
左值引用
左值引用
const 左值引用
const 左值引用
结果却与设想的不同,传入不同类型的参数,就是想让编译器匹配不同参数类型的重载函数,但是最终都调用了(const)左值引用。原因是:右值被引用后会导致右值被存储到特定位置,此时这个右值可以被取到地址,且可以被修改,所以在PerfectForward函数中调用Func函数时会将t识别成左值。也就是说,右值在经过一次参数传递后其属性会退化成左值,如果想要在这个过程中保持右值的属性,就需要进行完美转发。
代码没有实现完美转发,而是将所有的参数都视为左值。当传递一个右值给PerfectForward时,它会被绑定到T&&类型的万能引用上,但是在PerfectForward函数内部,t仍然是一个左值,因为它有一个名字。所以将t传递给Func时,它会匹配到左值引用类型的重载版本。同理,当传递一个const右值给PerfectForward时,它也会被视为const左值。
3.2 如何实现完美转发
完美转发使得一个函数可以将参数原封不动地传递给另一个函数,保持其值类别。它的实现需要使用std::forward函数和万能引用。例如:
#include <iostream>
using namespace std;
// 一个普通的函数
void func(int& a)
{
cout << "left" << endl;
}
// 一个重载的函数
void func(int&& a)
{
cout << "right" << endl;
}
// 一个模板函数,使用万能引用
template<typename T>
void func1(T&& a)
{
// 使用std::forward进行完美转发
func(forward<T>(a));
}
int main()
{
int x = 10; // x是左值
func1(x); // 输出left
func1(20); // 输出right
}
完美转发的目的是保持参数的原始值类别,即左值还是右值,const还是非const。但是在3.1的这段代码中,PerfectForward函数只是简单地将t传递给Func函数,而不使用std::forward进行类型转换。这样会导致t在PerfectForward函数内部被视为左值 ,从而调用错误的重载版本。例如,当传递一个右值给PerfectForward时,它应该调用Func(int&& x)或Func(const int&& x),但实际上却调用了Func(int& x)或Func(const int& x)。
为了解决这个问题,需要在PerfectForward函数中使用std::forward<T>(t)
来将t转换为正确的类型 。这样就可以实现完美转发了。修改后的代码如下:
#include <iostream>
using namespace std;
void Func(int& x)
{
cout << "左值引用" << endl;
}
void Func(const int& x)
{
cout << "const 左值引用" << endl;
}
void Func(int&& x)
{
cout << "右值引用" << endl;
}
void Func(const int&& x)
{
cout << "const 右值引用" << endl;
}
template<class T>
void PerfectForward(T&& t)
{
// 使用std::forward进行类型转换
Func(std::forward<T>(t));
}
int main()
{
int a = 1;
PerfectForward(a); // 左值
PerfectForward(move(a)); // 右值
const int b = 2;
PerfectForward(b); // const 左值
PerfectForward(move(b)); // const 右值
return 0;
}
输出:
左值引用
右值引用
const 左值引用
const 右值引用
std::forward
可以根据t的原始类型来返回相应的左值或右值引用。这样就可以保持参数的原始值类别不变,并调用正确的重载版本了。
3.3 完美转发的应用
下面是实现的简易的vector类,其中只有push_back接口和insert接口,且有完美转发的功能。代码如下:
#include <iostream>
#include <stdlib.h>
using namespace std;
namespace xy
{
template<typename T>
class vector
{
public:
vector() // 默认构造函数
: data(nullptr), size(0), capacity()
{}
~vector() // 析构函数
{
delete[] data;
}
void init()
{
data = (T*) malloc(sizeof(T) * 100);
}
void push_back(const T &x) // 在末尾添加一个元素(拷贝)
{
insert(size, x);
}
void push_back(T &&x) // 在末尾添加一个元素(移动)
{
insert(size, std::forward<T>(x));
}
void insert(size_t pos, const T &x) // 在指定位置插入一个元素(拷贝)
{
for (size_t i = size; i > pos; i--)
{
data[i] = data[i - 1]; // 将pos之后的元素后移一位
}
data[pos] = x; // 拷贝元素到pos位置,并更新size
size++;
}
void insert(size_t pos, T &&x) // 在指定位置插入一个元素(移动)
{
for (size_t i = size; i > pos; i--)
{
data[i] = std::forward<T>(data[i - 1]);
}
data[pos] = std::forward<T>(x);
size++;
}
private:
T *data; // 指向动态数组的指针
size_t size; // 已使用的空间
size_t capacity; // 总容量
};
}
测试:vector的元素类型是之前自定义的string类型(注意,这个string类在2.5中已经增加了移动赋值重载函数),分别传入左值引用和右值引用的参数:
int main()
{
xy::vector<xy::string> v;
v.init();
v.push_back("1"); // 右值引用
cout << "右值引用↑----------------左值引用↓" << endl;
xy::string s1("3"); // 左值引用
v.push_back(s1);
return 0;
}
输出:
string& operator=(string&& s) -- 移动赋值
string(const string& s) -- 深拷贝
右值引用↑----------------左值引用↓
string& operator=(const string& s) -- 深拷贝
string(const string& s) -- 深拷贝
使用完美转发后,右值引用版本的push_back函数接收到右值后,其属性不会退化到左值,所以调用的insert函数还是右值引用版本,匹配到string类的移动赋值版本的operator=
重载函数。
同理,对于左值引用的参数最终只会匹配到左值引用版本的insert函数,对应string类原来的operator=
重载函数。
注意:
- 想要保证右值的属性一直不发生变化,需要在每次右值被传参时都进行完美转发(事实上STL也是这么做的)。例如vector每次扩容后的资源转移,其中的赋值操作就必须使用
std::forward()或std::move()
。
想保证右值的属性不发生变化,有以下几种方法:
- 使用 const 关键字来修饰右值引用,例如 const int&& r = 10;。这样就可以防止对 r 的修改。
- 使用 std::move() 函数来将一个左值转换为右值,然后再绑定到右值引用上,例如 int x = 10; int&& r = std::move(x);。这样就可以避免对 x 的修改影响到 r。
- 使用 std::forward() 函数来实现完美转发,即根据参数的类型自动选择左值引用或右值引用。这样就可以保持参数的原始属性不变。
- push_back和insert接口中的参数
T&&
已经被推断为右值引用了,因为实例化vector对象时的参数类型就已经确定了。
3.4 补充
测试所实现的简易vector与STL之间最大的区别就是开辟空间方式的不同,STL首先通过空间配置器获取内存,在申请到内存之后不会立刻调用构造函数初始化,而是用定位new用左值或右值对申请的内存空间初始化,此时调用的函数是拷贝构造或移动构造。
简易实现的vector使用的是malloc开辟空间,申请到空间以后,会立刻调用构造函数初始化对象,所以在上面的例子中会调用string原有的operator=
,而不是string的拷贝构造函数(深拷贝)或移动构造函数(资源转移)。
ck函数接收到右值后,其属性不会退化到左值,所以调用的insert函数还是右值引用版本,匹配到string类的移动赋值版本的operator=
重载函数。
同理,对于左值引用的参数最终只会匹配到左值引用版本的insert函数,对应string类原来的operator=
重载函数。
注意:
- 想要保证右值的属性一直不发生变化,需要在每次右值被传参时都进行完美转发(事实上STL也是这么做的)。例如vector每次扩容后的资源转移,其中的赋值操作就必须使用
std::forward()或std::move()
。
想保证右值的属性不发生变化,有以下几种方法:
- 使用 const 关键字来修饰右值引用,例如 const int&& r = 10;。这样就可以防止对 r 的修改。
- 使用 std::move() 函数来将一个左值转换为右值,然后再绑定到右值引用上,例如 int x = 10; int&& r = std::move(x);。这样就可以避免对 x 的修改影响到 r。
- 使用 std::forward() 函数来实现完美转发,即根据参数的类型自动选择左值引用或右值引用。这样就可以保持参数的原始属性不变。
- push_back和insert接口中的参数
T&&
已经被推断为右值引用了,因为实例化vector对象时的参数类型就已经确定了。
3.4 补充
测试所实现的简易vector与STL之间最大的区别就是开辟空间方式的不同,STL首先通过空间配置器获取内存,在申请到内存之后不会立刻调用构造函数初始化,而是用定位new用左值或右值对申请的内存空间初始化,此时调用的函数是拷贝构造或移动构造。
简易实现的vector使用的是malloc开辟空间,申请到空间以后,会立刻调用构造函数初始化对象,所以在上面的例子中会调用string原有的operator=
,而不是string的拷贝构造函数(深拷贝)或移动构造函数(资源转移)。