移动语义
文章目录
拷贝与移动
一个形象的比喻
如何把一个冰箱里的大象放到另一个冰箱中?打开冰箱1的门,打开冰箱2的门,将冰箱1里的大象移动冰箱2中,关上冰箱门。
这是一个很自然的方法,那么还有一种方法,将冰箱2里的大象复制一头,将复制的大象放到冰箱1中,再让冰箱2里的大象消失掉。这种方法是不是感觉似曾相识,这个就是C ++中拷贝复制的概念。
一种很自然的方法结果在C ++中表现的如此纠结。
拷贝
C++11 之前只有拷贝(复制)语义,定义了构造函数,析构函数,复制构造函数,赋值函数就称为有了拷贝控制。对对象的非指针,非引用的行为都会使用复制语义
#include <iostream>
class CCopyTest
{
public:
CCopyTest()
{
std::cout<<"默认构造函数"<<std::endl;
}
~CCopyTest()
{
std::cout<<"析构函数 "<<m_strName<<std::endl;
}
CCopyTest(const CCopyTest& t)
{
std::cout<<"复制构造函数"<<t.m_strName<<std::endl;
}
private:
std::string m_strName;
};
CCopyTest f()
{
CCopyTest t1;
//产生临时变量通过拷贝构造生成
return t1;
}
int main()
{
//临时变量通过拷贝构造生成t2
CCopyTest t2 = f();
}
用g++编译时加入参数 -fno-elide-constructors关闭编译的复制优化,可以看到整个构造,复制,析构的过程
,当一个类的生成和销毁很重时,那么复制语义就显的很笨拙了,如下面的例子,自定义CPointTest
有个 int*
类型的成员变量,指向动态分配的内存。在构造函数中分配内存;在析构函数中释放内存;在复制构造函数,赋值函数中对内存进行拷贝。这就是实现深拷贝
class CPointTest
{
public:
CPointTest()
{
m_size = 1024;
m_pInts = new int[1024];
memset(m_pInts,0,1024);
}
CPoinTest(int* pData,int size):m_pInts(pData),m_size(size)
{
}
~CPointTest()
{
delete [] m_pInts;
m_pInts = NULL;
}
//CPointTest,其包含指针,要实现正确的复制语义,拷贝构造函数及赋值函数必须对指针进行深拷贝
CPointTest(const CPointTest& t)
{
m_size = t.m_size;
m_pInts = new int[m_size];
memcpy(m_pInts,t.m_pInts,m_size);
}
//深拷贝
CPointTest& operator=(const CPointTest& t)
{
if (this != &t)
{
if (NULL != m_pInts)
{
delete [] m_pInts;
m_pInts = NULL;
}
m_size = t.m_size;
m_pInts = new int[m_size];
memcpy(m_pInts,t.m_pInts,m_size);
}
return *this;
}
private:
int *m_pInts = nullptr;
int m_size = 0;
};
CPointTest GetPointTest()
{
CPointTest t;
//通过t的拷贝构造函数产生临时变量,会进行资源的拷贝
return t;
}
int main()
{
//通过临时变量的拷贝构造函数产生t,会进行资源的拷贝
CPointTest t = GetPointTest();
}
上面的例子中,GetPointTest
方法返回的是一个临时变量。还是经历了多次拷贝,其实完全可以将临时变量t
的资源转移走,而不是再去重新分配内存,再拷贝数据。在C++ 11之前为了避免这种资源的复制,我们通常把形参定义为对象的引用或指针。但是这种写法显的很不自然。
移动
左值与右值
在赋值操作符的左边即为左值,在赋值操作符的右边即为右值。也可以这样理解左值和右值:
- 左值:有名字,可以取地址的值
- 右值:字面量等取不了地址值,没有名字的值
我们看几个例子
int i = 18; //i即为左值,18 字面量即为右值,可以对i取地址,但是不能对18取地址
int &r = i; //&r为左值引用
int &r3 = 18*i; //&r3不能引用一个右值
在C++11中,右值是又包括两个概念:
- 纯右值,上面的例子中,
18
,18*i
就是纯右值 - 将亡值,比如函数返回时的临时对象;将要被移动的对象
左值引用和右值引用
在C++ 11之前我们说的引用指的是左值引用(对左值的引用),在C++ 11中引入了右值引用,指的就是对右值的引用。左值引用只能引用左值,右值引用只能引用右值
int i = 42 //i是左值
int &r = i //&r为左值引用
int &&r = i //&&r是右值引用,不能将左值给右值引用
int &r = 42; //42是字面量,是右值,右值不能给左值引用
可以这样归纳下:
- 返回左值引用(引用)的函数,连同赋值,下标,解引用和前置递增/递减运算符,都是返回左值
- 返回非引用类型的函数,连同算术,关系,位及后置递增/递减运算符,都是返回右值(很显然这些操作运算符都会产生临时变量),这样值的引用就是右值引用
如下代码:
int GetVar()
{
int tmp = 18;
return tmp;
}
int i = GetVar();
i
称为左值,GetVar()返回的临时变量则为右值,这里是将右值赋给左值,返回的临时变量将被销毁
int &&i = GetVar();
int &&i
就是右值引用,引用的就是GetVar()
返回的临时变量,其作用就是延长了临时变量的生命周期。这里的临时变量不会像上面的的临时变量一样,赋值后即被销毁。
const
左值引用是可以指向右值
引用是变量的别名,对于左值引用,由于右值没有地址,没法被修改,所以无法引用右值,但是const左值引用是可以指向右边值的
const int &ref = 5;
const左值引用不会修改指向值,因此可以指向右值,比如vector
的push_back
方法
void push_back(const value_type& val);
如果没有const
,vec.push_back(5)
这样的代码就无法编译通过了。
const &
也称为万能引用
移动语义
定义右值引用的目的是为了引入移动语义,在有拥有内存资源的对象中,通过复制语义(深拷贝)来转移内存,将源对象赋值给目标对象,源对象中资源很可能是不需要再保留的,这时直接将源对象中的资源转移给目标对象(浅拷贝,只移动指针),就更贴切。
试想,在上面的CPonitTest
中再定义一个函数,来表示对资源的转移。
//move为ture,只是为了跟其它的构造函数作区分。这个构造方法表示只是移动指针,不进行拷贝
CPointTest(const CPointTest& tempData ,bool move)
{
m_pInts = tempData.m_pInts;
m_size = tempData.m_size;
// 为了防止tempaData析构是delete m_pInts,需要将它值为null
tempData.m_pInts = nullptr;
}
那么可以这样调用
CPontTest p(CPointTest(),true);
形参是const &
引用,所以可以引用一个临时变量(右值),但是,函数中tempData.m_pInts = nullptr
语句编译是会编译出错的,这样源对象还是保有
只使用左值引用来表示移动语义时,有两个问题:
- 为了满足所有的表示移动的场景,它必须是一个构造函数,并且要求是
const &
,但是为了与其它构造函数作区分,形参个数需不同 - 对const引用的形参,在函数中并不能改变的它
所以引入右值引用来实现移动语义也是为了解决这两个弊端,如下定义的为CPointTest
的移动构造函数,它的形参就一个右值引用
CPointTest(CPointTest&& tempData)
{
m_pInts = tempData.m_pInts;
m_size = tempData.m_size;
tempData.m_pInts = nullptr;
}
移动构造函数和移动赋值函数
支持移动语义的对象都需要定义移动构造函数和移动赋值函数,它们的形参都是右值引用,如:
//移动构造函数
CMoveTest(CMoveTest &&t) noexcept
//移动赋值函数
CMoveTest& operator=(CMoveTest&& t) noexcept
它们有三点要求:
- 动语义可以将一个对象中的资源移走,而不是赋值,所以它们并不分配内存
- 移动后的源对象会被销毁(形参是右值引用),所以内部资源会被置为无效(比如指针会被置为nullptr)
- 它们都需要声明为noexcept(不能抛异常)
如下代码为CMoveTest
定义了移动构造函数和移动赋值操作符
#include <iostream>
#include <string.h>
class CMoveTest
{
public:
CMoveTest()
{
std::cout<<"默认构造函数"<<std::endl;
m_size = 1024;
m_pInts = new int[1024];
memset(m_pInts,0,1024);
}
~CMoveTest()
{
std::cout<<"析构函数"<<std::endl;
delete [] m_pInts;
m_pInts = NULL;
}
CMoveTest(const CMoveTest& t)
{
std::cout<<"复制构造函数"<<std::endl;
m_size = t.m_size;
m_pInts = new int[m_size];
memcpy(m_pInts,t.m_pInts,m_size);
}
CMoveTest& operator=(const CMoveTest& t)
{
std::cout<<"赋值函数"<<std::endl;
if (NULL != m_pInts)
{
delete [] m_pInts;
m_pInts = NULL;
}
m_size = t.m_size;
m_pInts = new int[m_size];
memcpy(m_pInts,t.m_pInts,m_size);
return *this;
}
//移动构造函数,形参为一个右值引用
CMoveTest(CMoveTest &&t) noexcept
{
std::cout<<"移动构造函数"<<std::endl;
m_pInts = t.m_pInts;
m_size = t.m_size;
t.m_pInts = NULL;
}
//移动赋值函数
CMoveTest& operator=(CMoveTest&& t) noexcept
{
std::cout<<"移动赋值函数"<<std::endl;
if (this != &t)
{
if(m_pInts)
{
delete []m_pInts;
m_pInts = nullptr;
}
m_pInts = t.m_pInts;
m_size = t.m_size;
t.m_pInts = NULL;
}
return *this;
}
private:
int *m_pInts = nullptr;
int m_size = 0;
};
CMoveTest GetMoveTest()
{
CMoveTest t;
//产生临时变量,这里会调用移动构造函数通过t构造临时变量
return t;
}
int main()
{
//通过移动构造函数构造t1
CMoveTest t1 = GetMoveTest();
}
与拷贝构造函数不同的是移动构造函数不分配任何资源,它接管给定对象t中的内存,在接管内存之后,它将给定对象t的指针置为null,这样就完成了从给定对象的移动操作。
那么对应前面定义的GetTest函数就显的很自然了,移动构造函数并不会避免临时对象的产生,而是避免数据的复制。
移动对象后为可析构状态
被移走资源的对象应该处于可析构状态,从一个对象移动数据并不会销毁源对象,但有时在移动操作完成后,源对象可能会被销毁(如临时对象会被销毁,如GetMoveTest()
函数中的临时变量)。因此,当我们编写一个移动操作时,必须确保移后源对象进入一个可析构的状态。CMoveTest
的移动操作时满足这一要求的,这是通过将移后源对象的指针成员置为null来实现的。并且我们的程序不应该依赖于移动后源对象中的数据。
编译器生成移动操作
我们知道,如果我们在类中没有定义拷贝操作,那么编码器会自动为我们生成默认的拷贝操作,但是对移动操作,编译器是有条件生成的,要满足以下两点:
- 类中没有自定义拷贝控制成员(拷贝构造函数,赋值操作,析构函数)
- 它的所有数据成员都能够移动构造或移动赋值
编译器生成移动语义的要求比较严格。所以如果需要移动语义,建议自己定义,不要编译器合成
移动操作的匹配
如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数,赋值也是如此
CMoveTest c1,c2;
c1 = c2;//调用的拷贝赋值运算符
c2 = GetMoveTest();//GetMoveTest()是一个右值,使用移动赋值
std::move函数
通过std::move
可以将左值转换成右值引用,move
函数并不会产生移动操作,只是产生一个右值引用,真正的移动操作是在移动构造函数和移动赋值函数中完成的,如下代码:
string s = "abc";
string &&sr = std::move(s);
std::cout<<s<<std::endl;
对变量s
,通过调用string &&sr = std::move(s)
产生了右值引用string &&sr
,但这并不是指s
的资源产生了转移,std::cout<<s<<std::endl
还可以正确输出变量s
的值。要转移它的资源需要如下方式:
//调用移动构造函数
std::string ss(std::move(s));
//移动赋值函数
std::string ss1 = std::move(s);
//ss的资源被转移了,这条语句将会输出为空
std::cout<<ss<<std:endl;
std::move(s)
产生右值引用,从而触发调用string
的移动构造函数或移动赋值函数,此时变量s
的资源就被转移了
STL中的移动语义
移动语义已被全面引入了STL,比如string
,容器类,智能指针
#include <vector>
int main()
{
std::string str1 = "abcefgh";
std::vector<std::string> vec;
//复制
vec.push_back(str1);
//std::move产生右值,使用移动语义
vec.push_back(std::move(str1));
//str1不能再被使用
}
旧的标准中我们返回一个容器往往是返回一个容器的引用,在C++ 11 的STL的容器都支持了移动语义,就可以直接返回容器对象了,
//以前为了避免产生临时对象,避免复制的写法
void GetDatas(std::vector<Data>& Datas)
{
...
}
//有了移动语义的写法
std::vector<Data> GetDatas()
{
...
return Datas;
}
移动语义的使用
移动语义是C++ 11 中非常重要的特性,整个C++ 98的标准库都已经为C++ 11彻底翻修过,目的是为那些类型移动的可以实现成比复制更快的类型增添移动操作,而且库组件的实现也已经完成修订以充分利用这些移动操作。似乎只需要将现有的代码通过支持C++11的编译器编译后,就享受到了移动语义带来的性能提升。但是实际情况并非如此。
- 编译为自定义类型自动生成移动语义的是有要求的,必须没有声明复制操作,移动操作以及析构函数
- 即使在标准库中都已经支持移动操作,但是也可能不会像希望的那样带来那么大利好。这样取决已具体的实现
-
比如list,它的实现通常是会在堆上分配内存,将容器元素放在这个堆内存上,内部只是会维护指向堆内存的指针。那么对list的移动,只算交换指针,那么效率自然会有提升。
-
比如array,它是C++ 11引入的新的容器类型,表示数组。它的内存就是对象内部的一个缓存区(比如是在栈上分配的一段顺序的空间),所以对它的移动操作,还是要将元素进行复制
可见移动操作并非想象的多么便利,但是它的出现还是给我们提供了一个提升性能的机制,要充分利用移动语义,需要做到以下两点:
- 在适合的场景,为自定义的类声明移动操作
- 选择合适的容器类型(至少要知道容器特点,它的基本实现原理)来利用它的移动语义带来的性能提升
所以是要"明明白白"的使用移动语义