本文将介绍带你一步步的了解 c++11 中:
- 右值、右值引用
- 移动构造函数
- && 解密
- move 移动语义
- forward 完美转发
产生原由
class Object
{
public:
//无参构造函数
Object() : m_num(new int(10))
{
std::cout << "contr function..." << std::endl;
printf("m_num 地址:%p\n", m_num);
}
//拷贝构造函数
Object(const Object& o) : m_num(new int(*o.m_num))
{
std::cout << "copy contr function..." << std::endl;
}
private:
int* m_num;
};
我们知道,某个类中如果含有指向堆内存的(一般含有指针)成员变量,如果不编写拷贝构造函数,那么编译器将调用默认的拷贝构造函数,即只进行浅拷贝。即下图中只拷贝了 a 指针,将会出现 a、b指向同一块内存,为了防止堆区地址双重释放,那么应该编写 拷贝构造函数 防止浅拷贝发生问题。即在拷贝构造函数中重新分配一块内存进行初始化。
问题:在 c++11 中引入了右值引用和移动构造函数又是为了什么呢?
下面的代码中调用 getObj
函数初始化一个对象 oo1
, 分析其执行过程:
1、getObj
函数中初始化一个临时对象 temp
, 调用构造函数;
2、将临时对象赋值给 oo1
, 调用拷贝构造函数;
class Object
{
...
}
Object getObj() {
//1、初始化一个临时对象 temp, 调用构造函数;
Object temp;
return temp;
}
int main() {
//2、将临时对象赋值给 `oo1`, 调用拷贝构造函数;
Object oo1 = getObj();
return 0;
};
执行结果:
contr function...
m_num 地址:00E7F6D8
copy contr function...
根据执行结果,与预期一致。
问题: 如果在第一步中,在调用 getObj
时,创建的临时对象 temp
在构造过程中如果要进行大量的初始化工作(特别耗时),并且其用完后将被释放; 第二步中,将临时对象拷贝给 oo1
时,也需要进行大量的拷贝工作。oo1
在生命周期结束后也将释放。
思考: 中间产生了临时对象 temp
只起到赋值作用,极大的耗费性能。有什么方式可以避免产生这个中间的临时变量呢?怎么去优化它呢?
答案: c++ 11
的右值引用。
右 值
c++11 中引入了右值的概念,使用 && 标记。
不必要去记其概念,只需要知道怎么去判别即可,可以被取地址的即为左值,反之为右值。
int x = 1000;
int y = 2000;
x = y;
其中, 等号左边的 x、y 可以取地址,即为左值; 等号右边 1000、 2000 为右值; 处于等号左右的 x = y 中,因为其都可以进行取地址,所以都为左值。
右值引用
右值引用也即是一个引用,和左值引用一样,只不过左值引用是左值的别名。右值不具备名字,所以只能使用右值引用标记它。因为左值引用和右值引用都是别名,不拥有所绑定对象的内存,所以必须进行初始化操作。右值被右值引用接收后重新有了名字,只要该引用变量存活,,右值也将存活。即右值引用可以延长某块内存的存活时间。
int&& data = 1000; //必须进行初始化
class Object
{
public:
Object()
{
std::cout << "contr function..." << std::endl;
}
Object(const Test& a)
{
std::cout << "copy contr function..." << std::endl;
}
};
Object getObj()
{
return Object();
}
int main()
{
int a1;
int &&a2 = a1; // error
Object& t = getObj(); // error
Object && t = getObj();
const Object& t = getObj();
return 0;
}
-
int &&a2 = a1;
a1 具有名字,其为左值,左值赋值给右值引用 错误 -
Object& t = getObj();
getObj 函数返回一个没有名字的右值,将一个右值赋值给左值引用 错误 -
Object && t = getObj();
右值赋值给右值引用 正确 -
const Object& t = getObj();
常量左值引用被成为万能引用,既可以引用左值也可以引用右值 正确
性能优化
介绍完右值和右值引用, 在回到上面的问题:中间产生了临时对象
temp只起到赋值作用,极大的耗费性能。有什么方式可以避免产生这个中间的临时变量呢?怎么去优化它呢?
class Object
{
...
}
Object getObj() {
//1、初始化一个临时对象 temp, 调用构造函数;
Object temp;
return temp;
}
int main() {
//2、将临时对象赋值给 `oo1`, 调用拷贝构造函数;
Object oo1 = getObj();
return 0;
};
getObj
函数中创建的临时对象(堆上)构建完成后,还没有使用,就释放掉了,那么如果可以复用这个临时对象,将会对性能有很大帮助。
那么如何复用呢? 给该类编写移动构造函数即可。
class Object
{
...
//移动构造函数
Object(Object&& o) {
m_num = o.m_num;
o.m_num = nullptr;
std::cout << "move contr function..." << std::endl;
}
private:
int* m_num;
}
Object getObj() {
//1、初始化一个临时对象 temp, 调用构造函数;
Object temp;
return temp;
}
int main() {
//2、 调用移动构造函数
Object oo1 = getObj();
return 0;
};
执行结果:
contr function...
m_num 地址:00CCF5B0
move contr function...
执行结果调用了移动构造函数。那么我们分析移动构造函数中发生了什么?
//移动构造函数
Object(Object&& o) {
m_num = o.m_num;
o.m_num = nullptr;
std::cout << "move contr function..." << std::endl;
}
...
Object oo1 = getObj();
因为临时对象用完就释放,白白构造那么长时间。在执行 Object oo1 = getObj();
这条语句是,调用移动构造函数将临时对象 temp
所指的内存直接赋值给 oo1
对象的指针(m_num = o.m_num;
); 然后避免 temp 出了作用域销毁内存,则将 temp 指向的内存置空(o.m_num = nullptr;
)。 oo1
对象直接拥有了 构造 temp
时分配的内存。
上图充分的展示了拷贝构造函数和移动构造函数的关系,可以看出,移动构造函数整体上少分配了一块内存,相当于浅拷贝,只不过最后将原始指针置空,因此极大的节省了空间和时间。
问题1: 什么时候会调用移动构造函数?
答案: 要求右侧的对象是一个临时对象,才会调用移动构造函数,如果没有移动构造函数,则将调用拷贝构造函数。因此可以看出,移动构造函数不是必须存在的,只是为了性能优化而存在的。
可以将上述代码中移动构造函数注释掉,编译器将会调用拷贝构造函数。
问题2: 怎么编写移动构造函数呢?
从上面可以看出,移动构造函数实质是为了复用其他对象的资源而产生的,这种资源往往是堆内存的资源。那么在编写移动构造函数过程中,只需要转移该类中关于堆上的资源即可。
右值符号 && 解密
在很多代码模板函数中经常会出现诸如以下的代码:
1、 static_cast<typename remove_reference<_Ty>::type&&>(_Arg)
2、 typename<class T> void function(T&& t)
代码中的 && 会不会让你晕头转向? 如果是,那么和我一起解密吧!
c++ 中有一种叫做未定义的引用类型,通常有以下两种方式:
自动类型推导的 auto&&
模板类型推导的 T&&
有一种特列 const T&&
, 表示右值引用,不属于未定义类型引用。
那么接下来记住两个规则即可,不对,是一个规则(引用折叠):
使用右值推导 T&& 和 auto&& 得到的是一个右值引用类型;其他的都是左值引用类型。
int a = 10;
int b = 250;
auto&& x = a; //a 是一个左值, auto&& 表示左值引用
auto&& y = 100; //100 是右值, auto&& 表示右值引用
int&& a1 = 5;
auto&& b1 = a1; //a1是右值引用,不是右值,所以b1 是左值引用
int a2 = 10;
int& a3 = a2; //a2是左值,a3为左值引用
auto&& c1 = a3; // a3是左值引用, c1 即是左值引用类型
auto&& c2 = a2; // a2是左值, c2 即是左值引用类型
const int& d1 =3;
const int&& d2 = 4;
auto&& e1 = d1; //d1是常量左值引用, e1 即为常量左值引用
auto&& e2 = d2; //d2是常量右值引用, e2 即为常量左值引用
通过以上既可以理解,使用右值推导 T&& 和 auto&& 得到的都是右值引用类型,其他的都是左值引用类型。
void printX(int &x)
{
std::cout << "l-value: " << x << std::endl;
}
void printX(int &&x)
{
cout << "r-value: " << x << endl;
}
void forward(int &&x)
{
printX(x);
}
int main()
{
int a = 100;
printX(a);
printX(20);
forward(500);
return 0;
system("pause");
};
上面定义了两个重载函数 printX
, 先对上面的输出结果进行推导:
1、 printX(a)
; 其中 a
是一个左值,那么调用第一个 printX
函数,输出应该是左值;
2、 printX(20)
; 其中 20 是右值, 那么调用第二个printX
函数, 输出应该是右值;
3、 forward(500)
, 其中 500 是右值,forward
形参x
是右值引用类型,继续调用printX
,由于此时的右值具备的名字,所以将退化成一个左值,所以将调用第一个 printX
函数, 输出应该是左值;
对于最后以重情况可能稍微难以理解,只需要记住,右值引用在传递的过程中将会退化成左值引用。这也是 std::forward
为了防止退化成左值引用,所以才出现的,被誉为完美转发 ,即不做任何变动的转发。将上述 forward
函数中的 printX(x)
改成 printX(std::forward<int>(x))
将会调用第二个函数,输出应该是右值。
输出结果:
l-value: 100
r-value: 20
l-value: 500
结果完全正确。
std::move
在上述的例子中,有一种情况是不能进行赋值的,即用一个左值初始化一个右值引用;
int a = 10;
int&& b = a; //error 无法将右值引用绑定到左值
所以 std::move
函数应运而生, move
通常被理解为“移动”, 但在本文中被译为“转移”更加合适,即转移所有权,将你的房产名字转给你的老婆,房子本身不变,只是所有者发生了变化。
再来看 std::move 的源代码:
template<class _Ty> inline
_CONST_FUN typename remove_reference<_Ty>::type&&
move(_Ty&& _Arg) _NOEXCEPT
{ // forward _Arg as movable
return (static_cast<typename remove_reference<_Ty>::type&&>(_Arg));
}
使用 std::move 后,上述代码可以更改为:
int a = 10;
int&& b = std::move(a); //ok
对于这种将左值转化为右值的方法有什么用处呢?
vector<string> vec;
vec.push_back("wang");
vec.push_back("zhuo");
.....
//插入一百万条数据
.....
vector<string> vec1 = vec;
vector<string> vec2 = std::move(vec);
如果用 vec
这个左值直接初始化 vec1
,将会发生大量的内存拷贝。y
如果用 vec2 = std::move(vec),
直接将 vec
的所有权转移给 vec2
即可
用处? 在对于拥有大量的堆内存或者动态数组时候,使用 std::move 可以有效的节省时间效率。
如果将 std::move 和 移动构造函数结合起来,尽可能重复利用资源, 移动构造函数接收的是一个右值引用类型。
std::forward
上文也提及到了完美转发 forward,即在右值引用传递的过程中,为了防止被编译器当作左值处理,使其以原由类型进行转发,引入了 forward。
原型如下:
std::forward<T>(x);
当forward 的模板类型参数 T 为左值引用类型时,x将被转换成 T类型的左值,否则将被转换成右值。
template<typename T>
void printX(T& t)
{
std::cout << "left value"<< std::endl;
}
template<typename T>
void printX(T&& t)
{
std::cout << "rifht value " <<std::endl;
}
template<typename T>
void test(T && v)
{
printX(v);
printX(move(v));
printX(forward<T>(v));
}
int main()
{
test(100);
int num = 10;
test(num);
test(forward<int>(num));
test(forward<int&>(num));
test(forward<int&&>(num));
return 0;
}
1、test(100)
, 100 是右值,test
形参是未定义引用类型,即根据上文提到的 ”使用右值推导 T&&
和 auto&&
得到的是一个右值引用类型“ 形参 v 是一个具有名字的右值引用,但编译器将其视为左值。
* 传递给第一个函数 `printX `变为左值,调用第一个,输出左值。
* 使用`move(v)` 之后,左值 v 被 move 成右值,输出右值。
* `printX(forward<T>(v))`, T为右值引用,因此最终将会成为右值, 输出右值
2、test(forward<int&>(num))
; 模板参数为int&
, 根据 ”当forward
的模板类型参数 T 为左值引用类型时,x将被转换成 T类型的左值“ , 将会得到一个左值,test
形参为未定义类型 T&&
, 根据 ”使用右值推导 T&&
和 auto&&
得到的是一个右值引用类型,反之为左值引用“,即test 形参为左值引用类型。
* `printX(v);` v 是左值, 输出左值
* `printX(move(v));` 左值经过 `move` 后成为右值,输出右值
* `printX(forward<T>(v));` T类型为 `int&` ,v将被转换成左值, 输出左值。
你学会了吗? 其他的几种留给你自己分析。