对象移动,就是把一个不想用了的对象A中的一些有用的数据提取出来,在构建新对象B的时候就不需要重新构建对象中的所有数据——从不想用了的对象A中提取出来的有用数据在构建对象B时都可以拿来使用。?
我们知道,拷贝构造函数、拷贝赋值运算符等,对对象复制的成本是很高的,尤其是容器,里面如有几千个元素,那么如果对这个容器对象进行复制,里面的元素都要逐个复制,非常影响程序运行效率。
为此,提出了移动构造函数和移动赋值运算符的概念,显然,移动这件事效率会很高,比复制效率高得多,如果源对象A不再使用,那么,直接把源对象A中的某些new出来的数据移动给目标对象B,那就相当于数据还是这一堆数据,只是属主换了另外一个人,这种数据移动的效率,显然比数据复制就高,甚至某些情况下会高很多。
如果复制数据,如要把对象A复制给对象B,那对象A里面的数据还能使用,但如果把对象A(实际上是对象A中部分数据)移动给对象B(对象A的数据就会出现残缺),那显然对象A就不能再被使用,否则因为数据的残缺可能会导致出现问题。
我们知道,拷贝构造函数的语法格式如下,与普通构造函数的区别仅在于其参数的类型是const引用类型
tempVal(const tempVal& t) :v1(t.v1), v2(t.v2)
{
cout << "调用拷贝构造函数" << endl;
}
与之相似,移动构造函数的语法格式也仅仅是在参数类型上做了修改,将const引用更换为右值引用即可
也就是
tempVal(const tempVal&& t) :v1(t.v1), v2(t.v2) {};
有关右值引用及其与临时对象的关系可参考
接下来我们通过代码来清晰的看到移动构造函数是如何实现代码性能提升的
我们首先定义一个类B
class B
{
public:
int m_pm;
B(int m=0):m_pm(m)
{
cout<<"类B的构造函数调用了"<<endl;
}
B(const B& b):m_pm(b.m_pm)
{
cout<<"类B的拷贝构造函数调用了"<<endl;
}
virtual ~B()
{
cout<<"类B的析构函数调用了"<<endl;
}
};
接着我们定义一个类A
最后我们再定义一个简单的函数
static A getA()
{
A a;
return a;
}
在测试函数中调用该函数,并运行观察结果(注意使用-fno-elide-constructors关闭编译器的优化选项)
void test()
{
A a=getA();
}
观察运行结果可以看到,系统一共执行了一次普通构造函数和两次拷贝构造函数的调用,其中
- 普通构造函数:是为了创建getA函数中的局部对象A
- 第一个拷贝构造函数:是getA函数返回时生成的临时对象
(有关临时对象的生成参考c++临时对象的探讨及相关性能提升-CSDN博客)- 第二个拷贝构造函数:测试函数test中getA返回的临时对象拷贝给test中的a引起
接下来,我们定义一个移动构造函数,再次实验观察运行结果
A(A&& tmpa) noexcept:m_pb(tmpa.m_pb)
{
tmpa.m_pb=nullptr;
cout<<"类A的移动构造函数调用了"<<endl;
}
注意,该移动构造函数与普通构造函数的区别
- 参数类型
- 移动构造函数的参数类型为右值引用
- 拷贝构造函数的参数类型为常量引用(左值引用)
- 具体实现
- 移动构造函数的实现是直接将指针所指向的地址进行交接(浅拷贝),无需重新开辟一块新的内存
- 拷贝构造函数的实现则是深拷贝,需要首先申请一块新的内存,之后再把待拷贝对象的内容复制到新申请的内存中
因此,单从实现上看,移动构造函数就比拷贝构造函数节省了内存资源的使用,而之所以移动构造的参数为右值引用,也是为了实现移动构造函数这种特性而产生的
接下来我们再次关闭编译器的优化选项,运行查看结果
可以看到,与之前没有添加移动构造函数相比,这次实验编译器使用移动构造函数替换了拷贝构造函数,并且没有对类B进行拷贝构造,类A的析构函数也少了很多,具体而言
而如果我们再将测试函数中代码稍作改变,如下,将测试函数中的a类型修改为右值引用:
void test()
{
A&& a=getA();
}
?再次运行观察实验结果
会发现编译器只进行了一次移动构造函数的调用,除此以外再没有其他多余的调用!
这是因为getA返回的临时对象引起了移动构造函数的调用,而这个临时对象返回到测试函数后被测试函数中右值引用类型的a直接接管
并且从此刻开始,这个由getA返回临时对象的生命周期将同test函数中a的声明周期一样(可以认为此时的a就是这个返回的临时对象),因此当测试函数结束时只进行了一次析构函数的调用(倒数第二行和倒数第三行的析构是getA函数结束时引起的对局部对象a的销毁)
接下来我们探讨在右值引用文章中提到的std::move()函数的作用
在测试函数中追加一行代码
void test()
{
A&& a=getA();
A a1(a);
}
编译运行观察输出结果
可以看到,追加的代码引起了拷贝构造函数的调用
接下来,我们修改代码如下:
void test()
{
A&& a=getA();
A a1(std::move(a));
}
再次观察结果发现,原来的拷贝构造函数的现在被移动构造函数替换了,原因就是因为std::move()函数将对象a从左值类型强制转换成了右值类型,而右值类型的变量会被具有右值引用类型形参的一点构造函数所接收?
因此,我们再次看到,所谓std::move()函数只是一个类型转换函数,其作用就是将一个左值对象强制转换为右值
如果我们继续修改代码
void test()
{
A&& a=getA();
A &&a1(std::move(a));
}
运行观察结果
会发现编译器只进行了一次移动构造函数的调用,但此时需要注意,这行代码根本不产生新对象,当然也不会调用类A的移动构造函数,可以通过跟踪调试观察,这行代码的效果等同于把对象a的名修改为a1,或者说对象a和对象a1代表同一个对象
在原有的基础上对类A增加拷贝赋值运算符和移动赋值运算符
//拷贝赋值运算符
A operator=(const A& src)
{
if(this==&src)
return *this;
delete this->m_pb;
this->m_pb=new B(*src.m_pb);//值的赋值
cout<<"类A的拷贝赋值运算符调用了"<<endl;
return *this;
}
//移动赋值运算符
A operator=(A&& a1)
{
if(this==&a1)
return *this;
delete this->m_pb;
this->m_pb=a1.m_pb;//指针的接管
a1.m_pb=nullptr;
return *this;
}
测试函数如下:
void test()
{
A a=getA();
A a2;
a2=std::move(a);
}
如果不生成自己的拷贝构造函数和拷贝赋值运算符,那么,在某些情况下,编译器会合成拷贝构造函数和拷贝赋值运算符,同样道理,在某些情况下,编译器会合成移动构造函数和移动赋值运算符。针对合成问题有一些说法,总结如下:
- 如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数(这三者之一,表示程序员要自己处理对象的复制或者释放问题),编译器就不会为它合成移动构造函数和移动赋值运算符。这说明只要程序员有自己复制对象和释放对象的倾向,编译器就不会帮助程序员生成移动动作的相关函数(所以有一些类是没有移动构造函数和移动赋值运算符的),这样就可以防止编译器合成出一个完全不是程序员自己想要的移动构造函数或者移动赋值运算符。
- 只有一个类没定义任何自己版本的拷贝构造函数、拷贝赋值运算符、析构函数,且类的每个非静态成员都可以移动时,编译器才会为该类合成移动构造函数或者移动赋值运算符。
- 可以移动的成员有
- 内置类型(如整型、实型等)的成员变量可以移动
- 如果成员变量是一个类类型,如果这个类有对应的移动操作相关的函数,则该成员变量可以移动。
- 在有必要的情况下,应该考虑尽量给类添加移动构造函数和移动赋值运算符,达到减少拷贝构造函数和拷贝赋值运算符调用的目的,尤其是需要频繁调用拷贝构造函数和拷贝赋值运算符的场合。
- 不抛出异常的移动构造函数、移动赋值运算符都应该加上noexcept,用于通知编译器该函数本身不抛出异常。否则有可能因为系统内部的一些运作机制原本程序员认为可能会调用移动构造函数的地方却调用了拷贝构造函数。此外,此举还可以提高编译器的工作效率。
- 一个对象移动完数据后当然不会自主销毁,但是,程序员有责任使这种数据被移走的对象处于一种可以被释放(析构)的状态
- 一个本该由系统调用移动构造函数和移动赋值运算符的地方,如果类中没有提供移动构造函数和移动赋值运算符,则系统会调用拷贝构造函数和拷贝赋值运算符代替。
参考:
《c++新经典》