本博客将记录:新经典课程知识点的第14节的笔记!
今天我们将学习 对象的移动、移动构造函数、移动赋值运算符函数。
今天总结的知识分为以下6个点:
一、对象移动的概念
二、移动构造函数和移动赋值运算符的概念
三、移动构造函数的演示
四、移动赋值运算符的演示
五、合成的移动操作
六、总结
一、对象移动的概念:
在以往的coding过程中,有时对于一个类的对象do大量的对象拷贝和赋值操作都是非常消耗性能的!因此C++11中提出了“对象移动”的操作。那么什么叫做“对象移动”呢?
所谓的对象移动:其实就是把该对象所占据的内存空间的访问权限转移(移动)给另一个对象
比如:原来这块内存空间是属于张三的,你现在do了对象转移,则该内存空间就属于李四了!
二、移动构造函数和移动赋值运算符的概念:
我们在前面的章节中提及过,C++11引入右值引用、std::move()函数以及对象移动的概念就是为了提高程序运行的效率!为什么这么说呢?因为我们平时在类中定义的拷贝构造函数以及拷贝赋值运算符重载函数会do大量的拷贝和赋值的操作。这些操作都是非常地耗时的。因此这样你写的代码的效率就会非常低下了!
可能光说文字大家还不是很有体会,那么我举个例子吧:比如vector这个容器,如果你在这个容器中push_back了成千上万甚至更多个对象的话,当你要对vector容器的对象do拷贝操作or赋值操作时,是不是就要挨个地进行拷贝、挨个地进行赋值了呢?是的!!!这样do的代码无疑是非常低效率的!
综上所述:C++11引入了移动构造函数 和 移动赋值运算符重载函数。这两个函数可以帮助我们避免do大量的拷贝和赋值操作,从而大大地提高我们写的代码的执行效率!(也即提高程序的效率了!)
由于移动构造函数以及移动赋值运算符重载函数 与 拷贝构造函数以及拷贝赋值运算符重载函数 非常地类似因此,下面给出5点说明:
(1)如果把 对象A 移动给 对象B后,那么 对象A 就 不能 再使用了
(不论是把对象A整体还是一部分移动给B,A都不能再用了,因为你A已经没有完整的权限用/操作这块内存空间中的东西了!)
(2)这里所谓的“移动”,并不是说把内存中的数据所占据的内存 从一个地址 倒腾 到另一个地址,而只是变更一下所属权而已!
(只是把地址变更一下所有权而已,原本这个房子是属于你的,现在你把房产证的名字换成我,这个房子的地址还是不变的,只是所有者从你变成了我而已!)
(那房产证上写的是我的名字你当然没有权限来住我的房子了对吧?)
(如果说要将地址倒腾掉的话,就是把这个房子的地址都给搬移了,那这样就和拷贝构造函数以及赋值运算符函数没区别了,何来的提升程序的效率呢?对吧?)
(3)这种直接变更内存空间的所有权的构造函数就比单纯的拷贝和赋值的函数的效率要大得多!
(4)那么移动构造函数怎么写呢?(其实和拷贝构造函数写法很类似的!)
(以Time类为例子)
拷贝构造函数:Time::Time(const Time& t){/.../} //const的左值引用&
移动构造函数:Time::Time( Time&& t)noexcept{/.../}// 右值引用&&
(注意这里的右值引用不能是const的,因为你用右值引用do函数参数就算为了让其绑定到一个右值上去的!就是说这个右值引用是一定要变的,但是你一旦加了const就没法改变该右值引用了)
拷贝赋值运算符重载函数:Time& operator=(const Time& t){/.../} //const的左值引用&
移动赋值运算符重载函数:Time& operator=( Time&& t)noexcept{/.../} // 右值引用&&
(介绍到这里相信大家已经明白了我们之前为啥要引入右值引用这个概念了)
(C++11引入右值引用&&类型就是为了写移动构造函数和移动赋值运算符重载函数的!)
(5)移动构造函数 和 移动赋值运算符函数 应该完成什么工作呢?
a)完成必要的内存移动,斩断原对象和其所占据的内存空间的关系。
b)然后,确保 移动后 原对象处于一种“即便被销毁也没有什么问题”的这样一种状态。就比如让对象A移动给对象B,移动后,A对象与它原来所代表的这块内存空间应该没有任何关系了。并且当我销毁该对象A时,不会有任何异常错误出现,且对象B过继自对象A的这块内存空间的数据也不会受到任何影响。此时,我们不应该再使用对象A去对这块内存空间do事情了,而是用对象B来对这块内存do事情!
三、移动构造函数的演示:
移动构造函数格式:
className(className&& tobj) noexcept :initialList {/.../}
注意喔,这里一定要传入一个是右值的对象!不然的话你没法触发(编译器帮你调用)移动构造函数!并且要声明一下noexcept关键字告诉编译器为的移动构造函数是不会触发异常的!为什么要这样干呢?不就是为了防止重复释放同一内存空间的问题嘛。因为你要是在移动构造函数的函数体内实现时没有将原对象所拥有的这块内存空间赋值为nullptr的话,当你释放原对象时就已经把该空间释放了,那你再释放得到这块内存空间的新对象时,编译器就会报错说你重复释放同一内存空间了。
废话不多说,直接上代码:
补充:noexcept关键字:C++11引入的关键字,作用:通知标准库我们所定义的这个移动构造函数or移动赋值运算符函数是不抛出任何异常的!(提高编译器的工作效率!)后续学习到该关键字时我会详细总结下来,这里就不做赘述了。
#include<iostream>
using namespace std;
namespace Test1 {
class B {
public:
int m_bm;
public:
B() :m_bm(0) {}
B(int b):m_bm(b){}
B(const B& b):m_bm(b.m_bm) {}
B& operator=(const B& b) {
m_bm = b.m_bm;
return *this;
}
virtual ~B() {}
};
class A {
private:
B* m_pb;
public:
A() :m_pb(new B){
cout << "A类的默认的无参构造函数执行了!" << endl;
}
A(const A& b) :m_pb(new B(*b.m_pb)) {
cout << "A类的拷贝构造函数执行了!" << endl;
}
A& operator=(const A& b) {
if (this == &b) return *this;
delete this->m_pb;//把自己原来开辟的内存空间先释放掉!
this->m_pb = new B(*b.m_pb);
cout << "A类的拷贝赋值运算符重载函数执行了!" << endl;
return *this;
}
//移动构造函数不用new对象了!且移动构造函数需要传入一个右值!
A(A&& b)noexcept :m_pb(b.m_pb) {//让新对象拥有原对象所代表的内存空间的访问权限
b.m_pb = nullptr;//并删去原对象所拥有的访问权限!就完成了该移动函数的使命了!
cout << "A类的移动构造函数执行了!" << endl;
}
//移动赋值运算符重载函数也不用new对象了!且移动赋值运算符重载函数需要传入一个右值!
A& operator=(A&& b)noexcept {
if (this == &b)return *this;
delete this->m_pb;//还是先把自己原来的那块内存先干掉!!!
m_pb = b.m_pb;//让新对象拥有原对象所代表的内存空间的访问权限
b.m_pb = nullptr;//并删去原对象所拥有的访问权限!
cout << "A类的移动赋值运算符重载函数执行了!" << endl;
return *this;
}
virtual ~A() {
if (this->m_pb) {
delete m_pb;
m_pb = nullptr;
}
cout << "A类的析构函数执行了!" << endl;
}
};
//这里来一个static静态函数(别的.cpp源文件不可访问!)
static A getA() {
A a;
return a;
//返回一个临时对象,又因为临时对象属于右值。因此必须是调用移动构造函数让右值引用绑定到右值上!
}
}
void test() {
Test1::A a = Test1::getA();//创建新对象a,且调用移动构造函数将getA函数返回值临时对象移动给a
}
int main(void) {
test();
return 0;
}
运行结果:
可以看出,编译器为getA()函数所返回的临时对象调用了A类的移动构造函数。 有的同学可能会有所疑惑:为什么这里会优先调用类的移动构造函数而不是拷贝构造函数呢?
答:因为这里return回去的是临时对象,而临时对象是右值,我们在3.12节就讲过,左值引用只可以绑定到左值对象上;右值引用只可以绑定到右值对象上,除非你用std::move()函数将左值强行转换为一个右值,这样也可以被右值引用绑定上!
这种将局部的A类对象a的数据直接移动给要do返回值的临时对象节省了拷贝的操作!very good!
我这里再画个图帮助你理解一下getA()这个函数都调用过程:
注意:移动构造函数以及移动赋值操作符重载函数把原对象中的该指针过继给我的现对象。然后将原对象的指针的访问其原来所指向空间的权限删去,也即指向-->nullptr即可了!我们一定要很小心的写移动构造函数的代码!如果忘了将原对象的指针的访问权限删去,则会造成重复delete的严重错误!这会导致你的程序崩溃!
如果说你向用std::move()将一个左值对象当作右值给移动构造函数or移动赋值运算符函数都右值引用参数绑定的话,也ok:
//只改这个test()函数都代码,其余的都保持不变!
void test() {
Test1::A a = Test1::getA();//创建新对象a,且调用移动构造函数将getA函数返回值临时对象移动给a
Test1::A a1(std::move(a));//创建新对象a1,并调用类的移动构造函数
}
此时:
①调用getA()函数并取得其返回值时会因为返回值是临时对象(而临时对象又是一个右值)而调用移动构造函数之外
②当使用std::move()函数将左值对象a强制类型转换为右值对象b并用以创建新的A类对象a1时,也同样会调用移动构造函数。
下面展示几张我用VS2022调试的代码图片:
一开始未创建新对象a1时:
创建新对象a1时:
创建a1时,因为a对象我们用了std::move()函数将其强制转换为右值了,因此a对象此时也就被释放掉了(这是std::move()函数都特点!)
且我们从调试的结果也可以看出来,此时原对象a中的内存空间中的数据都移动给了新对象a1了!
请继续看一下代码:
//只改这个test()函数都代码,其余的都保持不变!
void test() {
Test1::A a = Test1::getA();//创建新对象a,且调用移动构造函数将getA函数返回值临时对象移动给a
Test1::A&& a2(std::move(a));//不是创建新对象a2,根本不会调用移动构造函数
}
运行结果:
注意:
Test1::A&& a2(std::move(a));这行代码其实就是给对象a取一个别名a2而已。当然,std::move(对象)这个函数建议我们使用完该对象作右值后,就不要再用该对象去do别的任何事情了!这是该函数给我们开发者的建议~
复习,右值引用的功能:
①给对象取一个别名(首先)
②将引用绑定到一个右值对象上(其次但又重要)
提醒:什么是右值?只读的对象不就是右值了嘛~
(千万不要以为右值引用只有将引用绑定到一个右值对象上这一个功能,其本身还是一个引用类型,而引用类型本质作用还是给对象取别名)这个点你千万不能忘记!!!
请看我在VS2022上执行完 Test1::A&& a2(std::move(a));这行代码之后的调试结果:
可见,调试结果已经印证了我上述说的内容。
你甚至可以这么干,将getA()函数的返回值用作一个右值
void test() {
Test1::A&& a3 = Test1::getA();
}
运行结果:
四、移动赋值运算符的演示:
移动构造函数格式:
className& operator=(className&& tobj) noexcept {/.../}
注意喔,这里一定要传入一个是右值的对象!不然的话你没法触发(编译器帮你调用)移动构造函数!
修改test()函数:
void test() {
Test1::A a;//调用无参构造函数
Test1::A newObj1;//调用无参构造函数
newObj1 = a;//调用拷贝赋值运算符重载函数
Test1::A newObj2;//调用无参构造函数
newObj2 = std::move(a);//调用移动赋值运算符重载函数
}
运行结果:
可见,运行结果已经印证了我上述test函数中所注释的内容。
注意:delete this->m_pb;//把自己原来的那块内存先干掉!!!这行代码是非常重要的!因为这里对于每一个A对象,创建时都会给其分配一个堆区的内存空间,用来存储其成员变量B* m_pb;所以一旦你要将新对象的该成员变量把原对象的成员变量m_pb所指向的内存空间无误的过继过来,就必须先释放自己先前的内存空间,再过继!!!(这里的解释应该很好理解,大家拿我给出的代码跑一跑就会发现这一行非常重要的哈!)
五、合成的移动操作:
所谓合成构造函数,其实就是由编译器给出的默认的XX构造函数。在某些条件下(或者说在必要的时候):编译器能合成移动构造函数,移动赋值运算符重载函数。
a)在你自定义一个类时,若你写了拷贝构造函数or拷贝赋值运算符函数,编译器就不会为你生成默认的移动构造函数和移动赋值运算符函数。并且,如果你尝试写std::move()函数去调用移动构造函数来创建对象or调用移动赋值运算符函数来给对象赋值时,因为没这2种函数,编译器只能退而求其次取我们自己写的拷贝构造函数和拷贝赋值运算符函数来执行创建对象和给对象赋值的操作。
情况以下代码:
例子1:
把A类的移动赋值函数注释掉,只留下A的拷贝赋值函数
//A& operator=(A&& b)noexcept {
//if (this == &b)return *this;
//delete this->m_pb;//还是先把自己原来的那块内存先干掉!!!
//m_pb = b.m_pb;//让新对象拥有原对象所代表的内存空间的访问权限
//b.m_pb = nullptr;//并删去原对象所拥有的访问权限!
//cout << "A类的移动赋值运算符重载函数执行了!" << endl;
//return *this;
//}
void test() {
Test1::A a;//调用无参构造函数
Test1::A newObj1;//调用无参构造函数
newObj1 = a;//调用拷贝赋值运算符重载函数
Test1::A newObj2;//调用无参构造函数
newObj2 = std::move(a);//调用拷贝赋值运算符重载函数
}
运行结果:
例子2:
把A类的移动构造函数注释掉,只留下A的拷贝构造函数
//A(A&& b)noexcept :m_pb(b.m_pb) {//让新对象拥有原对象所代表的内存空间的访问权限
// b.m_pb = nullptr;//并删去原对象所拥有的访问权限!
// cout << "A类的移动构造函数执行了!" << endl;
//}
Test1::A a;//调用无参构造函数
Test1::A newObj1(a);//调用拷贝构造函数
Test1::A newObj2(std::move(a));//调用拷贝构造函数
运行结果:
b)只有当一个类中没有定义任何自己版本的拷贝函数(没有拷贝构造函数也没有拷贝赋值运算符),且类的每个非静态成员变量都可以移动时,编译器才会为该类合成移动函数(移动构造函数和移动赋值运算符函数)
补充:什么叫成员变量都可以移动呢?也即当成员变量是以下2种类型并且满足其对应条件时,就叫做成员变量可移动。我们知道,一个变量只分为这2种类型:
成员变量是①内置类型 时,该变量都是可移动的,
成员变量是②类类型(struct/class类型都可)时,则该成员变量所属的类必须要有相应的移动函数才会使得外面整体的类的成员变量可移动。
请看以下代码:(添加一个测试用的TC类,并修改上述例子中的test函数的代码)
class TC {
public:
int i;//成员变量是内置类型 可移动
std::string s;//成员变量是类类型 该标准库的类中是含有自己的移动函数的!
//此时该TC类的all非静态成员变量都是可移动的,那么编译器就会为我们生成相应的该类的移动函数
};
void test() {
TC a;
a.i = 100;
a.s = "I Love China!";
const char* p = a.s.c_str();
TC b = std::move(a);
//这一行代码证明了:TC类中具有移动函数!
const char* q = b.s.c_str();
}
运行结果:
注意:这里的移动函数的操作是:先将a对象的成员变量string类的s所代表的那块内存空间的使用权过继给b.s ,再把a.s所代表的内存空间指向NULL了(也即删除a对象访问其成员变量s原来所代表的内存空间的权限的意思),因此我们可以看到a.s == ""空字符串!
实际上,这个过继操作就是由string类内的移动构造函数来do的!(std::move(a)将对象a变成一个右值,以此触发string类的移动构造函数创建了新对象b)
六、总结:
(1)在写自定义的类时,尽量给你的类写上对应的移动构造函数以及移动赋值运算符重载函数-以减少大量的关于该类的拷贝和赋值的操作。
(当然,这只是针对复杂的类,或说一些会大量调用其拷贝构造函数or拷贝赋值运算符函数的类;若是比较简单的类or不会大量调用上述两种函数的类就可以不写上移动函数)
(2)写移动函数时,一定要在对应的位置上加上noexcept关键字,来通知编译器你写的这个函数并不会抛出异常!
(3)当把原对象所代表的内存空间的使用权限过继给新对象后,一定要记得把原对象所占据的内存空间指向空(值 = NULL | 指针 = nullptr)!
(4)若没有移动函数,编译器会为你自动调用对应的拷贝函数完成相应的创建对象和给对象赋值的操作(相比用移动函数,这样do你的代码效率就是低的!)。
相信通过以上我的总结,你对于移动函数有了初步的认识和理解。在日后的coding中若碰到这方面的问题,我们都可以再深入到书本中去学习哈。
好,那么以上就是这一3.14小节我所回顾的内容的学习笔记,希望你能读懂并且消化完,也希望自己能牢记这些小小的细节知识点,加油吧,我们都在coding的路上~