左值引用、右值引用及移动语义
0.什么是左值和右值?
左值是变量的地址,如变量名或指针。右值则是变量存储的内容。
int a=3; // a变量名,3是变量a存储的内容
当一个对象被用作左值的时候,用的是对象的身份,也就是在内存中的位置。而当对象被用作右值的时候,用的则是对象的值。左值是持久的,它存在于作用域期间。而右值是短暂的,它可以是字面常量或Lambda表达式或者是表达式产生的临时对象。
1.左值引用『&』
左值引用本质上是一个隐式指针,为对象的一个别名。通常说的『引用』指的就是『左值引用』。
int &ref_a = a;
声明引用时需注意:
- 引用在声明时必须初始化。
- 引用作为目标的别名使用,对引用的改动实际就是对目标的改动。
- 引用和变量指向同一地址单元。
引用与指针的差别
- 指针是个变量,可以把它再赋值指向别去的地址。
- 建立引用时必须进行初始化,且不会再关联其他不同变量。
- 因为指针是变量,所以可以有指针的引用。
2.右值引用『&&』
右值引用(Rvalue Referene) 是C++ 新标准(C++11, 11 代表2011 年) 中引入的新特性。左值引用使标识符可以绑定左值,而右值引用可以绑定右值。
针对临时对象的右值引用有什么存在的意义?
这其实就像计算机中拷贝文件,对文件的拷贝总是费时的且占用空间的,但移动文件却是便捷高效的。如果我们想把某个文件从同一个磁碟的A文件夹移动到B文件夹,如果我们复制文件、粘贴文件、删除A文件夹下原文件,这个操作是费时且低效的,移动他是最有效的办法–只是改变文件目录记录文件依然还在磁碟原来的位置。
3.移动语义-移动构造函数和移动赋值运算符
移动语义避免了移动原始数据,而只是修改了记录。这相当于计算机中移动文件,实际文件还留在原来的位置,而只是修改了记录。
实现移动语义:定义两个构造函数。一个是使用const左值引用作为参数的拷贝构造函数—可以实现深拷贝。一个是使用右值引用作为参数,它只在函数内实现所有权的转移—类似浅拷贝,但需要将指针成员指向NULL,而这是修改,因此不能为const。
// 涉及到内存管理,new的对象在堆上
class obj{
public:
obj() : _val(0), _name(nullptr){}
obj(int v, char* n):_val(v){
int len = strlen(n) + 1;
_name = new char[len];
strcpy(_name, n);
std::cout<<"constructor!\n";
}
// obj(const obj&) = delete;//防止不期望的拷贝
obj(const obj& o1){
_val = o1._val;
int len = strlen(o1._name) + 1;
_name = new char[len];
strcpy(_name, o1._name);
std::cout<<"copy constructor!\n";
}
obj(obj&& oo){
_val = oo._val;
_name = oo._name;
oo._name = nullptr;
std::cout<<"move constructor!\n";
}
// 重载双目+运算符
obj operator + (const obj& o1) const{//成员函数重载双目运算符参数只有一个,另一个是this指针
int val = this->_val + o1._val;
int len = strlen(o1._name) + strlen(this->_name) + 1;
char *name = new char[len];
strcpy(name, this->_name);
strcpy(name + strlen(this->_name), o1._name);
obj tmp(val, name);
}
obj& operator = (obj&& o){
if(this == &o)//防止自赋值
return *this;
delete [] _name;
int len = strlen(o._name);
_val = o._val;
_name = o._name;
o._name = nullptr;
return *this;
}
private:
int _val;
char* _name;
};
int main(){
obj o1(1,"abcd"); // 调用constructor
obj o2(o1); // 调用copy constructor
obj o3(o1 + o2);
// std::move()接受一个参数,返回该参数对应的右值引用
obj o4(std::move(o1 + o2));
return 0;
}
从示例代码中可以看到,移动构造函数和移动赋值函数实现的功能都是将传入的右值临时对象申请的内存的所有权转让给创建的新对象或者是被赋值对象,而原临时对象指向nullpter。
移动构造在GNU和VS下的区别
obj o3(o1+o2); // GNU下constructor、operator+
obj o3(o1+o2); // VS 下constructor创建临时对象,再move constructor 创建对象o3
o1+o2的结果是一个临时对象,是右值。通常认为上面的语句是必然会调用移动构造函数的,但编译器的解释有所不同.
GNU编译器优化不会调用移动构造函数
这对如上语句,在GNU G++编译器下会进行优化,认为o3是+的受益人,它会将双目运算+返回的临时变量tmp直接转移给o3,因此这个过程在GNU G++中是不会调用移动构造函数。
VS编译器则会调用移动构造函数
步骤是先执行operator+,在执行过程中调用constructor构建临时对象tmp。然后tmp被当做右值通过移动构造函数将tmp申请的内存对象转移给了o3,同时析构临时对象tmp(内存已被转移给了o3,tmp._name=nullptr)。
4.std::move() / forward()
- std::move()接受一个参数,返回该参数对应的右值引用
- forward()接收一个参数,返回该参数本来所对应的类型的引用。(即完美转发)
#include <iostream>
//#include <utility> //for std::forward
using namespace std;
void print(const int& t)
{
cout <<"lvalue" << endl;
}
void print(int&& t)
{
cout <<"rvalue" << endl;
}
template<typename T>
void Test(T&& v) //v是Universal引用
{
//不完美转发
print(v); //v具有变量,本身是左值,调用print(int& t)
//完美转发
print(std::forward<T>(v)); //按v被初始化时的类型转发(左值或右值)
//强制将v转为右值
print(std::move(v)); //将v强制转为右值,调用print(int&& t)
}
int main()
{
cout <<"========Test(1)========" << endl;
Test(1); //传入右值
int x = 1;
cout <<"========Test(x)========" << endl;
Test(x); //传入左值
cout <<"=====Test(std::forward<int>(1)===" << endl;
Test(std::forward<int>(1)); //T为int,以右值方式转发1
//Test(std::forward<int&>(1)); //T为int&,需转入左值
cout <<"=====Test(std::forward<int>(x))===" << endl;
Test(std::forward<int>(x)); //T为int,以右值方式转发x
cout <<"=====Test(std::forward<int&>(x))===" << endl;
Test(std::forward<int&>(x)); //T为int,以左值方式转发x
return 0;
}
/*输出结果
e:\Study\C++11\16>g++ -std=c++11 test2.cpp
e:\Study\C++11\16>a.exe
========Test(1)========
lvalue
rvalue
rvalue
========Test(x)========
lvalue
lvalue
rvalue
=====Test(std::forward<int>(1)===
lvalue
rvalue
rvalue
=====Test(std::forward<int>(x))===
lvalue
rvalue
rvalue
=====Test(std::forward<int&>(x))===
lvalue
lvalue
rvalue
*/
5.右值引用与模板函数
在使用右值引用作为函数模板的参数时,与之前的用法有些不同:如果函数模板参数以右值引用作为一个模板参数,当对应位置提供左值的时候,模板会自动将其类型认定为左值引用;当提供右值的时候,会当做普通数据使用。可能有些口语化,来看几个例子吧。
template<typename T>
void foo(T&& t)
{}
//随后传入一个右值,T的类型将被推导为:
foo(42); // foo<int>(42)
foo(3.14159); // foo<double><3.14159>
foo(std::string()); // foo<std::string>(std::string())
//不过,向foo传入左值的时候,T会被推导为一个左值引用:
int i = 42;
foo(i); // foo<int&>(i)
因为函数参数声明为T&&,所以就是引用的引用,可以视为是原始的引用类型。那么foo()就相当于:foo<int&>(); // void foo<int&>(int& t);
这就允许一个函数模板可以即接受左值,又可以接受右值参数;这种方式已经被std::thread的构造函数所使用,所以能够将可调用对象移动到内部存储,而非当参数是右值的时候进行拷贝。
6.移动构造和移动赋值
回顾一下如何用c++实现一个字符串类MyString,MyString内部管理一个C语言的char *数组,这个时候一般都需要实现拷贝构造函数和拷贝赋值函数,因为默认的拷贝是浅拷贝,而指针这种资源不能共享,不然一个析构了,另一个也就完蛋了。
#include <iostream>
#include <cstring>
#include <vector>
using namespace std;
class MyString{
public:
static size_t CCtor; //统计调用拷贝构造函数的次数
static size_t MCtor; //统计调用移动构造函数的次数
static size_t CAsgn; //统计调用拷贝赋值函数的次数
static size_t MAsgn; //统计调用移动赋值函数的次数
public:
// 构造函数
MyString(const char* cstr=0){
if (cstr) {
m_data = new char[strlen(cstr)+1];
strcpy(m_data, cstr);
}else{
m_data = new char[1];
*m_data = '\0';
}
}
// 拷贝构造函数
MyString(const MyString& str) {
CCtor ++;
m_data = new char[ strlen(str.m_data) + 1 ];
strcpy(m_data, str.m_data);
}
// 移动构造函数
MyString(MyString&& str) noexcept:m_data(str.m_data){
MCtor ++;
str.m_data = nullptr; //不再指向之前的资源了
}
// 拷贝赋值函数=号重载
MyString& operator=(const MyString& str){
CAsgn ++;
if (this == &str) // 避免自我赋值!!
return *this;
delete[] m_data;
m_data = new char[ strlen(str.m_data) + 1 ];
strcpy(m_data, str.m_data);
return *this;
}
// 移动赋值函数=号重载
MyString& operator=(MyString&& str) noexcept{
MAsgn ++;
if (this == &str) // 避免自我赋值!!
return *this;
delete[] m_data;
m_data = str.m_data;
str.m_data = nullptr; //不再指向之前的资源了
return *this;
}
~MyString() {
delete[] m_data;
}
char* get_c_str() const { return m_data; }
private:
char* m_data;
};
size_t MyString::CCtor = 0;
size_t MyString::MCtor = 0;
size_t MyString::CAsgn = 0;
size_t MyString::MAsgn = 0;
int main(){
vector<MyString> vecStr;
vecStr.reserve(1000); //先分配好1000个空间
for(int i=0;i<1000;i++){
vecStr.push_back(MyString("hello"));
}
cout << "CCtor = " << MyString::CCtor << endl;
cout << "MCtor = " << MyString::MCtor << endl;
cout << "CAsgn = " << MyString::CAsgn << endl;
cout << "MAsgn = " << MyString::MAsgn << endl;
}
/* 结果
CCtor = 0
MCtor = 1000
CAsgn = 0
MAsgn = 0
*/
可以看到,移动构造函数与拷贝构造函数的区别是,拷贝构造的参数是const MyString& str,是常量左值引用,而移动构造的参数是MyString&& str,是右值引用,而MyString(“hello” )是个临时对象,是个右值,优先进入移动构造函数而不是拷贝构造函数。而移动构造函数与拷贝构造不同,它并不是重新分配一块新的空间,将要拷贝的对象复制过来,而是"偷"了过来,将自己的指针指向别人的资源,然后将别人的指针修改为nullptr,这一步很重要,如果不将别人的指针修改为空,那么临时对象析构的时候就会释放掉这个资源,"偷"也白偷了。下面这张图可以解释copy和move的区别。
不用奇怪为什么可以抢别人的资源,临时对象的资源不好好利用也是浪费,因为生命周期本来就是很短,在你执行完这个表达式之后,它就毁灭了,充分利用资源,才能很高效。
对于一个左值,肯定是调用拷贝构造函数了,但是有些左值是局部变量,生命周期也很短,能不能也移动而不是拷贝呢?C++11为了解决这个问题,提供了std::move()方法来将左值转换为右值,从而方便应用移动语义。我觉得它其实就是告诉编译器,虽然我是一个左值,但是不要对我用拷贝构造函数,而是用移动构造函数吧。。。
int main(){
vector<MyString> vecStr;
vecStr.reserve(1000); //先分配好1000个空间
for(int i=0;i<1000;i++){
MyString tmp("hello");
vecStr.push_back(tmp); //调用的是拷贝构造函数
}
cout << "CCtor = " << MyString::CCtor << endl;
cout << "MCtor = " << MyString::MCtor << endl;
cout << "CAsgn = " << MyString::CAsgn << endl;
cout << "MAsgn = " << MyString::MAsgn << endl;
cout << endl;
MyString::CCtor = 0;
MyString::MCtor = 0;
MyString::CAsgn = 0;
MyString::MAsgn = 0;
vector<MyString> vecStr2;
vecStr2.reserve(1000); //先分配好1000个空间
for(int i=0;i<1000;i++){
MyString tmp("hello");
vecStr2.push_back(std::move(tmp)); //调用的是移动构造函数
}
cout << "CCtor = " << MyString::CCtor << endl;
cout << "MCtor = " << MyString::MCtor << endl;
cout << "CAsgn = " << MyString::CAsgn << endl;
cout << "MAsgn = " << MyString::MAsgn << endl;
}
/* 运行结果
CCtor = 1000
MCtor = 0
CAsgn = 0
MAsgn = 0
CCtor = 0
MCtor = 1000
CAsgn = 0
MAsgn = 0
*/