-
2021-02-22 23:53:13
赋值运算符函数作为类的一个成员函数,主要用于对象之间的赋值。类一般都有默认的赋值运算符函数,然而默认赋值运算符函数只会浅拷贝,无法满足需求,有时还会产生致命错误。
如下:
class CString { public: CString() : _buf(nullptr) { } CString(const CString& str) : _buf(nullptr) { const int size = strlen(str._buf) + 1; _buf = new char[size]; memset(_buf, 0, size); strcpy(_buf, str._buf); } CString(const char* str) : _buf(nullptr) { const int size = strlen(str) + 1; _buf = new char[size]; memset(_buf, 0, size); strcpy(_buf, str); } ~CString() { if (_buf != nullptr) { delete _buf; _buf = nullptr; } } private: char* _buf; }; int main() { { CString str1("hello world!"); CString str2; str2 = str1; } }
如上代码所示,如果使用默认赋值运算符函数,
str2 = str1;
str2和str1将会指向同一块内存地址,当离开代码块作用域之后,str1和str2都将析构,这里将产生double free 内存错误。对于这种需要深拷贝的赋值运算符函数需要自己定义。
下面是针对CString类的几种赋值运算符函数的实现:
CString& CString::operator=(const CString& str) { if(this == &str) { return *this; } delete [] _buf; _buf = nullptr; const int size = strlen(str._buf) + 1; _buf = new char[strlen(str._buf) + 1]; memset(_buf, 0, size); strcpy(_buf, str._buf); return *this; }
如上赋值运算符返回值定义成引用类型,方便连续赋值,如
str1 = str2 = str3;
赋值运算符参数定义成对象的常量引用,主要保证不会修改被赋值的对象;
实现中,先判断是否是对象自己,如果是则返回自己,
接着释放_buf内存,并置空
最后申请新的内存,将参数str中_buf数据拷贝到当前对象的buf中。
以上实现就是一个深拷贝。但是严格来讲也是存在问题的,当new char 抛出异常时,由于在这之前已经
delete [] _buf
,则当前对象就不能在保持有效状态,这就违背了异常安全性原则。好的做法应该是先申请内存,再释放内存,如下:CString& CString::operator=(const CString& str) { if(this == &str) { return *this; } const int size = strlen(str._buf) + 1; char* buf = new char[strlen(str._buf) + 1]; memset(_buf, 0, size); delete [] _buf; _buf = buf; strcpy(_buf, str._buf); return *this; }
还有另一种更优雅的实现:
CString& CString::operator=(const CString& str) { if(this == &str) { return *this; } CString strTemp(str); char* buf = strTemp._buf; strTemp._buf = _buf; _buf = buf; return *this; }
这种实现将new出现异常的情况转移到拷贝构造函数中,即便出现异常也不会影响当前对象的状态。
一般我们自己定义的类都需要实现赋值运算符函数,特别是含有动态内存的对象。赋值运算符函数要保证对象的赋值是深拷贝,而且在实现的过程中也要注意异常安全性,这样才能能写出健壮而优雅的代码。
更多相关内容 -
面试题1:赋值运算符函数
2018-06-25 14:48:18剑指OFFER 第一道题目的C++代码, 题目:如下为类型CMyString的声明,请为该类型添加赋值运算符函数。可以说是非常详细了。 -
【拷贝构造和赋值运算符】C++ 拷贝构造函数和赋值运算符函数
2019-11-16 14:54:03文章目录1、拷贝构造函数和赋值运算符2、两者分别在何时调用3、深拷贝、浅拷贝:拷贝构造函数和赋值运算符函数的必要性和意义 本文前面主要介绍了拷贝构造函数和赋值运算符函数的区别,以及在什么时候调用拷贝构造...本文前面主要介绍了拷贝构造函数和赋值运算符函数的区别,以及在什么时候调用拷贝构造函数、什么情况下调用赋值运算符函数。最后,分析了下深拷贝和浅拷贝的问题,即拷贝构造函数和赋值运算符函数的必要性和意义。
1、拷贝构造函数和赋值运算符
在默认情况下(用户没有定义,但是也没有显式的删除),编译器会自动的隐式生成一个拷贝构造函数和赋值运算符函数(缺省的)。
class Person { public: ... Person(const Person& p) = delete; Person& operator=(const Person& p) = delete; private: int age; string name; };
用户可以使用delete来指定不生成拷贝构造函数和赋值运算符,这样的对象就不能通过值传递,也不能进行赋值运算。上面的定义的类 Person 显式地删除了拷贝构造函数和赋值运算符,在需要调用拷贝构造函数或者赋值运算符的地方,会提示无法调用该函数,它是已删除的函数。
如果我们不想编写拷贝构造函数和赋值运算符函数,又不允许别人使用编译器隐式生成的缺省函数,同时也不想显式地删除拷贝构造函数和赋值运算符函数,我们还可以通过将拷贝构造函数和赋值运算符函数声明成类私有函数的方式来实现。如下所示:
class Person { public: ... private: Person(const Person& p); //以常量引用的方式传递参数 Person& operator=(const Person& p);//返回值类型为该类型的引用 int age; string name; };
还有两点需要注意的是:
-
拷贝构造函数必须以引用的方式传递参数,基本上都是传常量引用的方式传递函数参数。这是因为,在值传递的方式传递给一个函数的时候,会调用拷贝构造函数生成函数的实参。如果拷贝构造函数的参数仍然是以值的方式,就会无限循环的调用下去,直到函数的栈溢出。
-
**赋值运算符函数的返回值类型要声明为该类型的引用,并在函数结束前返回实例自身的的引用(*this)加粗样式,只有返回一个引用,才能进行连续赋值。否则,如果函数的返回值是void,则应用改赋值运算符将不能进行连续赋值。假设有3个Person对象:p1、p2、p3,在程序中语句p1=p2=p3将不能通过编译。
2、两者分别在何时调用
拷贝构造函数和赋值运算符函数的行为比较相似,都是将一个对象的值复制给另一个对象;但是其结果却有些不同,拷贝构造函数使用传入对象的值生成一个新的对象的实例,而赋值运算符函数是将对象的值复制给一个已经存在的实例。这种区别从两者的名字也可以很轻易的分辨出来,拷贝构造函数也是一种构造函数,那么它的功能就是创建一个新的对象实例;赋值运算符函数是执行某种运算,将一个对象的值复制给另一个对象(已经存在的)。调用的是拷贝构造函数还是赋值运算符函数,主要是看是否有新的对象实例产生。如果产生了新的对象实例,那调用的就是拷贝构造函数;如果没有,那就是对已有的对象赋值,调用的是赋值运算符函数。
调用拷贝构造函数主要有以下场景:
- 对象作为函数的参数,以值传递的方式传给函数。
- 对象作为函数的返回值,以值的方式从函数返回。
- 使用一个对象给另一个对象初始化。
class Person { public: Person(){} Person(const Person& p) { cout << "Copy Constructor" << endl; } Person& operator=(const Person& p) { cout << "Assign" << endl; return *this; } private: int age; string name; }; void f(Person p) { return; } Person f1() { Person p; return p; } int main() { Person p; Person p1 = p; // 1 Person p2; p2 = p; // 2 f(p2); // 3 p2 = f1(); // 4 Person p3 = f1(); // 5 getchar(); return 0; }
上面代码中定义了一个类Person,显式地定义了拷贝构造函数和赋值运算符函数。然后定义了一个f函数,以值的方式参传入Person对象;f1函数,以值的方式返回Person对象。在main中模拟了5个中场景,测试调用的是拷贝构造函数还是赋值运算符函数。执行结果如下:
分析如下:
- 这是虽然使用了”=”,但是实际上使用对象p来创建一个新的对象p1。也就是产生了新的对象,所以调用的是拷贝构造函数。
- 首先声明一个对象p2,然后使用赋值运算符”=”,将p的值复制给p2,显然是调用赋值运算符函数,为一个已经存在的对象赋值 。
- 以值传递的方式将对象p2传入函数f内,调用拷贝构造函数构建一个函数f可用的实参。
- 这条语句拷贝构造函数和赋值运算符函数都调用了。函数f1以值的方式返回一个Person对象,在返回时会调用拷贝构造函数创建一个临时对象tmp作为返回值;返回后调用赋值运算符函数将临时对象tmp赋值给p2.
- 按照4的解释,应该是首先调用拷贝构造函数创建临时对象;然后再调用拷贝构造函数使用刚才创建的临时对象创建新的对象p3,也就是会调用两次拷贝构造函数。不过,编译器也没有那么傻,应该是直接调用拷贝构造函数使用返回值创建了对象p3。
3、深拷贝、浅拷贝:拷贝构造函数和赋值运算符函数的必要性和意义
说到拷贝构造函数,就不得不提深拷贝和浅拷贝。通常,默认生成的拷贝构造函数和赋值运算符函数,只是简单的进行值的复制。例如:上面的Person类,字段只有int和string两种类型,这在拷贝或者赋值时进行值复制创建的出来的对象和源对象也是没有任何关联,对源对象的任何操作都不会影响到拷贝出来的对象。反之,有如下一个CExample类:
class CExample { public: CExample(){pBuffer=NULL; nSize=0;} ~CExample(){delete pBuffer;} void Init(int n){ pBuffer=new char[n]; nSize=n;} private: char *pBuffer; //类的对象中包含指针,指向动态分配的内存资源 int nSize; };
CExample类的特点是包含指向其他资源的指针,即有一个对象为char *,pBuffer指向堆中分配的一段内存空间。
例1:
int main(int argc, char* argv[]) { CExample theObjone; theObjone.Init40); //现在需要另一个对对象2:theObjtwo,需要将他初始化成对象1:theObjone的状态 CExample theObjtwo=theObjone; ... }
由上面的分析可知,例1是使用一个对象给另一个对象初始化,所以要调用拷贝构造函数,由于没有显式地定义拷贝构造函数,故调用编译器隐式生成的缺省的拷贝构造函数,对类对象进行简单的值复制。其完成方式是内存拷贝,复制所有成员的值(包括指针的值,即地址)。完成后,theObjtwo.pBuffer==theObjone.pBuffer(地址相同)。即它们将指向同样的地方,指针虽然复制了,但所指向的空间并没有复制,而是由两个对象共用了。这样不符合要求,对象之间不独立了,并为空间的删除带来隐患。任何一个对象对该值的修改都会影响到另一个对象,这种情况就是浅拷贝。
为了解决这类问题,我们可以显示地在拷贝构造函数中解决指针成员的问题。
增加了显式定义拷贝构造函数后的CExample类定义为:
class CExample { public: CExample(){pBuffer=NULL; nSize=0;} ~CExample(){delete pBuffer;} CExample(const CExample&); //拷贝构造函数 void Init(int n){ pBuffer=new char[n]; nSize=n;} private: char *pBuffer; //类的对象中包含指针,指向动态分配的内存资源 int nSize; }; CExample::CExample(const CExample& RightSides) //拷贝构造函数的定义 { nSize=RightSides.nSize; //复制常规成员 pBuffer=new char[nSize]; //复制指针指向的内容 memcpy(pBuffer,RightSides.pBuffer,nSize*sizeof(char)); }
在显式定义了CExample类的拷贝构造函数后,在例1中就不会调用缺省的拷贝构造函数了,而是调用显式的拷贝构造函数,就避免了上述浅拷贝的问题了,这就是所谓的深拷贝。
深拷贝和浅拷贝主要是针对类中的指针和动态分配的空间来说的,因为对于指针只是简单的值复制并不能分割开两个对象的关联(只是复制了指针的值,及地址,指针指向是同一个内存空间),任何一个对象对该指针的操作都会影响到另一个对象。这时候就需要提供自定义的深拷贝的拷贝构造函数,消除这种影响。通常的原则是:
- 含有指针类型的成员或者有动态分配内存的成员都应该提供自定义的拷贝构造函数
- 在提供拷贝构造函数的同时,还应该考虑实现自定义的赋值运算符函数
例2:
int main(int argc, char* argv[]) { CExample theObjone; theObjone.Init(40); CExample theObjthree; theObjthree.Init(60); theObjthree=theObjone; //对一个已存在的对象赋值 return 0; }
注:”=”号的两种不同使用,这里的”=”号是赋值符,使用默认赋值符”=”,是把被赋值对象的原内容被清除,并用右边对象的内容填充。而例1中的”=”号是在对象声明语句中,表示初始化;更多时候,这种初始化也可用括号()表示。
由上面的分析可知,这里是对一个已存在的对象实例(theObjthree)赋值,故要调用赋值运算符函数,由于没有显式地定义赋值运算符函数,故调用编译器隐式生成的缺省的赋值运算符函数。但”=”的缺省操作只是将成员变量的值相应复制。旧的值被自然丢弃。
由于对象内包含指针,将造成不良后果:指针的值被丢弃了,但指针指向的内容并未释放(delete)。指针的值被复制了,但指针所指内容并未复制。 即:这里的theObjthree.pBuffer 原有的内存没被释放,造成内存泄露;theObjthree.pBuffer 和theObjone.pBuffer 指向同一块内存,和theObjone 或theObjthree任何一方变动都会影响另一方;在对象被析构时,pBuffer 被释放了两次。
因此,包含动态分配成员的类除提供拷贝构造函数外,还应该考虑重载”=”赋值操作符号。增加了显式定义赋值运算符函数后的CExample类定义为:
class CExample { ... CExample(const CExample&); //拷贝构造函数 CExample& operator = (const CExample&); //赋值符重载 ... }; CExample & CExample::operator = (const CExample& RightSides) //赋值运算符函数定义 { nSize=RightSides.nSize; //复制常规成员 char *temp=new char[nSize]; //复制指针指向的内容 memcpy(temp,RightSides.pBuffer,nSize*sizeof(char)); delete []pBuffer; //删除原指针指向内容 (将删除操作放在后面,避免X=X特殊情况下,内容的丢失) pBuffer=temp; //建立新指向 return *this }
对于拷贝构造函数的实现要确保以下几点:
- 对于值类型的成员进行值复制
- 对于指针和动态分配的空间,在拷贝中应重新分配分配空间
- 对于基类,要调用基类合适的拷贝方法,完成基类的拷贝
-
-
Th3.14:对象的移动、移动构造函数、移动赋值运算符函数
2021-11-18 20:54:16今天我们将学习 对象的移动、移动构造函数、移动赋值运算符函数。 今天总结的知识分为以下6个点: 一、对象移动的概念 二、移动构造函数和移动赋值运算符的概念 三、移动构造函数的演示 四、移动赋值运算符的演示 ...本博客将记录:新经典课程知识点的第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的路上~
-
面试题1---赋值运算符函数详解
2020-04-05 21:56:37如下类型CMyString的声明,请为该类型添加赋值运算符函数。 class CMyString { public: CMyString(char* pData=nullptr); CMyString(const CMyString& str); ~CMyString(void); private: char* m_pData; }...1.题目
如下类型CMyString的声明,请为该类型添加赋值运算符函数。
class CMyString { public: CMyString(char* pData=nullptr); CMyString(const CMyString& str); ~CMyString(void); private: char* m_pData; }
2.疑问
- 1.赋值运算符函数是什么?
- 2.nullptr是什么?是null吗?
- 3.我写出来的会是什么东西?
3.涉及知识点
(一)类的构造函数
1.构造函数与类名相同,是特殊的公有成员函数
2.构造函数无函数返回类型说明,实际上构造函数是有返回值的,其返回值类型即为构造函数所构建到的对象。
3.当新对象被建立时,构造函数便被自动调用,实例化的每个对象仅调用一次构造函数。
4.构造函数可以被重载(即允许有多个构造函数),重载由不同参数进行区分,构造时系统按照函数重载规则选择一个进行执行。
5.如果类中没有构造函数,则系统自动会生成缺省的构造函数。
6.只要我们定义了构造函数,则系统便不会生成缺省的构造函数。
7.构造函数也可在类外进行定义。
8.若构造函数是无参的或者各个参数均有缺省值,C++编译器均认为是缺省的构造函数。但是注意,缺省的构造函数只允许有一个。(二)类的析构函数
1.析构函数无返回值无参数,其名字与类名相同,只在类名前加上~ 即:~类名(){…}
2.析构函数有且只有一个
3.对象注销时自动调用析构函数,先构造的对象后析构。#include<iostream> using namespace std; class Text { private: long b; char a; double c; public: Text(); //Text(char a=0);无参数的和各个参数均有缺省值的构造函数均被认为是缺省构造函数 Text(char a); Text(long b,double c);//参数列表不同的构造函数的重载 ~Text()//析构函数有且只能有一个 { cout<<"The Text was free."<<this<<endl; } void print(); }; Text::Text() { cout<<"The Text was built."<<this<<endl; this->a=0; this->b=0; this->C=0; } Text::Text(char a) { cout<<"The Text was built."<<this<<endl; this->a=a; } Text::Text(long b,double c) { cout<<"The Text was built."<<this<<endl; this->a='0'; this->b=b; this->c=c; } void Text:print() { cout<<"a= "<<this->a<<" b= "<<" c="<<this->c<<endl; }
(三)引用
1.引用简介
引用就是某个变量(目标)的一个别名,对引用的操作与对变量直接操作完全一样。
引用的声明方法:类型标识符 &引用名=目标变量名。
【例1】:int a;int &ra=a;//定义引用ra,它是变量a的引用,即别名。
说明:
(1)&在此不是求地址运算,而是起标识作用。
(2)类型标识符是指目标变量的类型。
(3)声明引用时,必须同时对其进行初始化。
(4)引用声明完毕后,相当于目标变量名有两个名称,即该目标原名称和引用名,且不能再把该引用名作为其他变量名的别名。
ra=1;等价于a=1;
(5)声明一个引用,不是新定义了一个变量,它只表示该引用名是目标变量名的一个别名,它本身不是一种数据类型,因此引用本身不占存储单元,系统也不给引用分配存储单元。故:对引用求地址,就是对目标变量求地址。&ra与&a相等。
(6)不能建立数组的引用。因为数组是一个由若干个元素所组成的集合,所以无法建立一个数组的别名。
2.引用应用
(1)引用作为参数:引用的一个重要作用就是作为函数的参数。以前的C语言中函数参数传递是值传递,如果有大块数据作为参数传递的时候,采用的方案往往是指针,因为这样可以避免将整块数据全部压栈,可以提高程序的效率。但是现在(C++中)又增加了一种同样有效率的选择(在某些特殊情况下又是必须的选择),就是引用。
【例2】:void swap(int &p1,int &p2);//此处函数的形参p1,p2都是引用
{int p;p=p1;p1=p2;p2=p;}
为在程序中调用该函数,则相应的主调函数的调用点处,直接以变量作为实参进行调用即可,而不需要实参变量有任何的特殊要求。如:对应上面定义的swap函数,相应的主调函数可写为:int main() { int a,b; cin>>a>>b;//输入a,b两变量的值 swap(a,b);//直接以变量a和b作为实参调用swap函数 cout<<a<<' '<<b;//输出结果 }
上述程序运行,如果输入数据10 20并回车后,则输出结果为20 10。
由【例2】可看出:
(1)传递引用给函数与传递指针的效果是一样的。这时,被调用函数的形参就称为原来主调函数中的实参变量或对象的一个别名来使用,所以在被调函数中对形参变量的操作就是对其相应的目标对象(在主调函数中)的操作。
(2)使用引用传递函数的参数,在内存中并没有产生实参的副本,它是直接对实参操作;而使用一般变量传递函数的参数,当发生函数调用是,需要给形参分配存储单元,形参变量是实参变量的副本;如果传递的是对象,还将调用拷贝构造函数。因此,当参数传递的数据较大时,用引用比用一般变量传递参数的效率和所占空间都好。
(3)使用指针作为函数的参数随二胺也能达到与使用引用的效果,但是,在被掉函数中同样要给形参分配存储单元,且需要重复使用“*指针变量名”的形式进行运算,这很容易产生错误且程序的阅读性较差;另一方面,在主调函数的调用点处,必须用变量的地址作为实参。而引用更容易使用,更清晰。
如果既要利用引用提高程序的效率,又要保护传递给函数的数据不在函数中被改变,就应使用常引用。
3.常引用
常引用声明方式:const类型标识符 &引用名=目标变量名;
用这种方式声明的引用,不能通过引用对目标变量的值进行修改,从而使引用的目标成为const,达到了引用的安全性。
【例3】:int a; const int &ra=a; ra=1;//错误 a=1;//正确
这不光是让代码更加健壮,也有些其它方面的需要。
【例4】:假设有如下函数声明:string foo(); void bar(string & s);
那么下面的表达式将是非法的:
bar(foo()); bar("hello world");
原因在于foo()和"hello world"串都会产生一个临时对象,而在C++中,这些临时对象都是const类型的。因此上面的表达式就是试图将一个const类型的对象转换为非const类型,这是非法的。引用型参数应该在能被定义为const的情况下,尽量定义为const。
4.引用作为返回值
要以引用返回函数值,则函数定义时要按以下格式:
类型标识符 &函数名(形参列表及类型说明){函数体}
说明:(1)以引用返回函数值,定义函数时需要在函数名前加&
(2)用引用返回一个函数值的最大好处是,在内存中不产生被返回值的副本。
【例5】以下程序中定义了一个普通的函数fn1(它用返回值的方法返回函数值),另外一个函数fn2,它以引用的方法返回函数值。#include<iostream.h> float temp;//定义全局变量temp float fn1(float r);//声明函数fn1 float &f2(float r);//声明函数fn2 float fn1(float r)//定义函数fn1,它以返回值的方法返回函数值 { temp=(float)(r*r*3.14); return temp; } float &fn2(float r)//定义函数fn2,它以引用方式返回函数值 { temp=(float)(r*r*3.14); return temp; } void main()//主函数 { float a=fn1(10.0);//第1种情况,系统生成要返回值的副本(即临时变量) float &b=fn1(10.0);//第2种情况,可能会出错(不同 C++系统有不同规定) //不能从被调函数中返回一个临时变量或局部变量的引用 float c=fn2(10.0);//第3种情况,系统不生成返回值的副本 //可以从背调函数中返回一个全局变量的引用 float &d=fn2(10.0);//第4种情况,系统不生成返回值的副本 //可以从被调函数中返回一个全局变量的引用 cout<<a<<c<<d; }
引用作为返回值,必须遵守以下规则:
(1)不能返回局部变量的引用。主要原因是局部变量会在函数返回后被销毁,因此被返回的引用就成为了“无所指”的引用,程序会进入未知状态。
(2)不能返回函数内部new分配的内存的引用。虽然不存在局部变量的被动销毁问题,可对于这种情况(返回函数内部new分配内存的引用),又面临其它尴尬局面。例如,被函数返回的引用只是作为一个临时变量出现,而没有被赋予一个实际的变量,那么这个引用所指向的空间就无法释放,造成memory leak。
(3)可以返回类成员,但最好是const。主要原因是当对象的属性与某种业务规则相关联的时候,其赋值常常与某些其它属性或者对象的状态有关,因此有必要将赋值操作封装在一个业务规则当中。如果其它对象可以获得该属性的属性的非常量引用(或指针),那么对该属性的单纯赋值就会破坏业务规则的完整性。(四)this指针
(1)我们知道类的成员函数可以访问类的数据(限定符只是限定于类外的一些操作,类内的一切对于成员函数来说都是透明的),那么成员函数如何知道哪个对象的数据成员要被操作呢,原因在于每个对象都拥有一个指针:this指针,通过this指针来访问自己的住址。注:this指针并不是对象的一部分,this指针所占的内存大小是不会反应在sizeof操作符上的。this指针的类型取决于使用this指针的成员函数类型以及对象类型,(1)假如this指针所在类的类型是Stu_Info_Manage类型,(下面的测试用例中的类的类型)并且如果成员函数是非常量的,则this的类型是:Stu_Info_Manager const类型,(2)即一个指向非const Stu_Info_Manager对象的常量(const)指针。
(2)this指针常用概念
this只能在成员函数中使用。全局函数,静态函数都不能使用this。实际上,成员函数默认第一个参数为T* const register this。
为什么this指针不能在静态函数中使用?
大家可以这样理解,静态函数如同静态变量一样,它不属于具体的哪一个对象,静态函数表示了整个范围意义上的信息,而this指针却实实在在的对应一个对象,所以this指针当然不能被静态函数使用了,同理,全局函数也一样。
this指针是什么时候创建的?
this在成员函数的开始执行前构造的,在成员的执行结束后清除。
this指针只有在成员函数中才有定义。因此,你获得一个对象后,也不能通过对象使用this指针。所以,我们也无法知道一个对象的this指针的位置(只有在成员函数里才有this指针的位置)。当然,在成员函数里,你是可以知道this指针的位置的(可以&this获得),也可以直接使用的。
(3)this指针的使用:
一种情况就是,在类的非静态成员函数中返回类对象本身的时候,我们可以使用源点运算符(this).,箭头运算符this->,另外,我们也可以返回关于this的引用。(五)深拷贝与浅拷贝(位拷贝)
前提:在对象拷贝过程中,如果没有自定义拷贝构造函数,系统会提供一个缺省的拷贝构造函数,缺省的拷贝构造函数对于基本类型的成员变量,按字节复制,对于类类型成员变量,调用其相应类型的拷贝构造函数。
阅读《C++ primer》有一段这样的话:
由于并非所有的对象都会使用拷贝构造函数和复制函数,程序员可能对这两个函数有些轻视。请先记住以下的警告,在阅读正文时就会多心:
本章开头讲过,如果不主动编写拷贝构造函数和赋值函数,编译器将以“位拷贝”的方式自动生成缺省的函数。倘若类中含有指针变量,那么这两个缺省的函数就隐含了错误。以类String的两个对象a,b为例,假设a.m_data的内容为“hello”,b.m_data的内容为“world”。
下年a赋值给b,缺省赋值函数的“位拷贝”意味着执行b.m_data=a.m_data。这将造成三个错误:一是b.m_data原有的内存没被释放,造成内存泄漏;二是b.m_data和a.m_data指向同一块内存,a或b任何变动都会影响另一方;三是在对象被析构是,m_data被释放了两次。
拷贝构造函数和赋值函数非常容易混淆,常导致错写误用。拷贝构造函数是在堆象被创建时调用的,而赋值函数只能被已经存在了对象调用。以下程序中,第三个语句和第四个语句很相似,你分得清楚哪个调用了拷贝构造函数,哪个调用了赋值函数吗?
String a(“hello”);
String b(“world”);
String c=a;//调用了拷贝构造函数,最好写成c(a);
c=b;//调用了赋值函数
本例中第三个语句的风格较差,宜改写成String c(a)以区别第四个语句。
位拷贝(浅拷贝)举例,a指向b,b的改变其实会影响a的改变,同时a原本指向的空间发生泄漏。
然后这种情况下有了深拷贝。
何时调用 :以下情况都会调用拷贝构造函数:- 一个对象以值传递的方式传入函数体
- 一个对象以值传递的方式从函数返回
- 一个对象需要通过另外一个对象进行初始化。
浅拷贝:位拷贝,拷贝构造函数,赋值重载,多个对象共用同一块资源,同一块资源释放多次,崩溃或者内存泄漏。
深拷贝:每个对象共同拥有自己的资源,必须显示提供拷贝构造函数和赋值运算符。
缺省拷贝构造函数在拷贝过程中是按字节复制的,对于指针型成员变量只复制指针本身,而不复制指针所指向的目标–浅拷贝。
我们用自己编写的string举例。
class String { private: char * _str; public: const char* c_str() { return _str; } String(const char* str=""):_str(new char[strlen(str)+1]) { strcpy(_str,str); } String(const String &s):_str(NULL) { String tmp(s._str); swap(_str,tmp._str); } ~String() { if(_str) { delete[] _str; } } }
通过开辟空间的方式,进行深拷贝,这种方式采取的拷贝构造,注意这个
String(const String &s):_str(NULL) { String tmp(s.str); swap(_str,tmp._str); }
代码解析:其中this指向拷贝的对象,s指向试图拷贝的原对象。其中利用构造函数开辟空间,建立临时的tmp,然后进行交换完成拷贝。当然,我们也可以使用赋值操作符重载完成这一功能
String& operator =(const String& s) { if(this!=&s) { String tmp(s._str); swap(tmp._str,_str); return *this; } }//调用构造析构
//本代码是tmp调用的构造函数 String (const char* str=""):_str(new char[strlen(str)+1]) { strcpy(_str,str); } //调用这个构造函数,开辟空间,建立一个和s1一样大小的空间,并拷贝值
s1(this),s2(s)
建立tmp,tmp和s2一样大的空间,一样的数值(调用构造函数),然后交换使s1(this)指向2号空间,获得拷贝,tmp指向3号空间,tmp声明周期结束调用析构函数释放,功能完成。
(六)const关键字
1.定义const对象:const修饰符可以把对象转变为常数对象,意思就是说利用const进行修饰的变量的值在程序的任意位置将不能再被修改,就如同常数一样使用~任何修改该变量的尝试都会导致编译错误,因为常量在定有就不能被修改,所以定义时必须初始化。对于类中的const成员变量必须通过初始化列表进行初始化,如下所示:
class A { public: A(int i); void print(); const int &r; private: const int a; static const int b; }; const int A::b=10; A::A(int i):a(i),r(a) { }
2.const对象默认为文件的局部变量:在全局作用域里定义非const变量时,它在整个程序中可以访问,我们可以把一个非const变量定义在一个文件中,假设已经做了合适的声明,就可以在另外的文件中使用这个变量。在全局作用域声明的const变量是定义该对象的文件的局部变量。此变量只存在于那个文件中,不能被其他文件访问。通过指定const变量为extern,就可以在整个程序中访问const对象。
extern const int bufSize;
非const变量默认为extern。要使const变量能够在其他文件中访问,必须在文件中显示地指定它为extern。
3.const对象的动态数组:如果我们在自由存储区中创建的数组存储了内置类型的const对象,则必须为这个数组提供初始化:因为数组元素都是const对象,无法赋值。实现这个要求的唯一方法是对数组做值初始化。//Error const int *pci_bad=new const int[100]; //ok const int *pci_ok=new const int[100]();
C++允许定义类类型的const数组,但该类类型必须提供默认构造函数
const string *pcs=new string[100];//这里便会调用string类的默认构造函数初始化数组元素。
4.指针和const限定符的关系:
const限定符和指针结合起来常见的情况有以下几种。- (1)指向常量的指针(指向const对象的指针):C++为了保证不允许使用指针改变所指的const值这个特性,强制要求这个指针也必须具备const特性。
const double *cptr;
这里cptr是一个指向double类型const对象的指针,const确定了cptr指向的对象的类型,而而并非cptr本身,所以cptr本身并不是const。所以定义的时候并不需要对它进行初始,如果需要的话,允许给cptr重新赋值,让其指向另一个const对象。但不能通过cptr修改其所指对象的值。
*cptr=42;//error
- (2)常指针(const指针):C++还提供了const指针—本身的值不能修改
int errNumb=0; int *const curErr=&errNumb;//curErr是一个const指针
我们可以从右往左把上述定义语句读作“指向int型对象的const指针”。与其他const量一样,const指针的值不能被修改,这意味着不能使curErr指向其他对象。const指针也必须在定义的时候初始化。
curErr=curErr;//错误!即使赋给其相同的值
5.函数和const限定符的关系:
- (1)类中的const成员函数(常量成员函数):在一个类中,任何不会修改数据成员的函数都应该声明为const类型。使用const关键字进行说明的成员函数,称为常成员函数。只有常成员函数才有资格操作常量或常对象,没有使用const关键字说明的成员函数不能用来操作常对象。const是加载函数说明后面的类型修饰符,它是函数类型的一个组成部分,因此,在函数实现部分也要带const关键字。下面举例子说明常成员函数的特征。
class Stack { private: int m_num; int m_data[100]; public: void Push(int elem); int Pop(void); int GetCount(void)const;//定义为const成员函数 }; int Stack::GetCount(void)const { ++m_num;//编译错误,企图修改数据成员,m_num Pop();//编译错误,企图非const成员函数 return m_num; }
既然const是定义为const函数的组成部分,那么就可以通过添加const实现函数重载。
class R { public: R(int r1,int r2) { R1=r1; R2=r2; } void print(); void print() const; private: int R1,R2; }; void R::print() { cout<<R1; } void R::print()const { cout<<R2; } void main() { R a(5,4); a.print(); const R b(20,52); b.print(); }
6.const的难点:
int b=100; const int *a=&b;//[1] int const *a=&b;//[2] int* const a=&b;//[3] const int* const a=&b;//[4]
如果const位于星号的左侧,则const就是用来修饰指针所指向的变量,即指针指向的对象为常量;如果const位于星号的右侧,const就是修饰指针本身,即指针本身是常量。
因此,[1]和[2]的情况相同,都是指针所指向的内容为常量(const 放在变量声明符的位置无关),这种情况下不允许对内容进行更改操作,如*a=3;[3]为指针本身是常量,而指针所指向的内容不是常量,这种情况下不能对指针本身进行更改操作,如a++是错误的;[4]为指针本身和指向的内容均为常量。(七)赋值运算符
1.概述:首先介绍为什么要对赋值运算符“=”进行重载。某些情况下,当我们编写一个类的时候,并不需要为该类重载“=”运算符,因为编译系统为每个类提供了默认的赋值运算符“=”,使用这个默认的赋值运算符操作类对象时,该运算符会把这个类的所有数据成员都进行一次赋值操作。例如有如下类:
class A { public: int a; int b; int c; };
那么对这个类的对象进行赋值时,使用默认的赋值运算符是没有问题的。但是,在下面的示例中,使用编译系统默认提供的赋值运算符,就会出现问题了。示例代码如下:
#include<iostream> #include<string.h> using namespace std; class ClassA { public: ClassA { } ClassA(const char* pszInputStr)//深拷贝 { pszTestStr=new char[strlen(pszInputStr)+1]; strncpy(pszTestStr,pszInputStr,strlen(PszInputStr)+1); } virtual ~ClassA() { delete pszTestStr; } public: char* pszTestStr; };
我们修改一下前面出错的代码示例,现增加赋值运算符重载函数的类,代码如下:
ClassA& operator=(const ClassA& cls) { //避免自赋值 if(this!=&cls) { //避免内存泄漏 if(pszTestStr!=NULL) { delete pszTestStr; pszTestStr=NULL; } pszTestStr=new char[strlen(cls.pszTestStr)+1]; strncpy(pszTestStr,cls.pszTestStr,strlen(cls.pszTestStr)+1); } return *this; }
2.总结:综合上述示例内容,我们可以知道针对一下情况,需要显示地提供赋值运算符重载函数(即自定义赋值运算符重载函数):
- 用非类A类型的值为类A的对象赋值时(当然,这种情况下我们可以不提供相应的赋值运算符重载函数,而值提供相应的构造函数)。
- 当用类A类型的值为类A的对象赋值,且类A的数据成员中含有指针的情况下,必须显示提供赋值运算符重载函数。
(八)nullptr关键字
为了避免“野指针”(即指针在首次使用之前没有进行初始化)的出现,我们声明一个指针后最好马上对其进行初始化操作。如果暂时不明确该指针指向哪个变量,则需要赋予NULL值。除了NULL指针之外,C++11新标准中又引入了nullptr来声明一个“空指针”,这样,我们就有下面三种方法来获取一个“空指针”:
如下:int *p1=NULL;//需要引入cstdlib头文件 int *p2=0; int *p3=nullptr;
新标准中建议使用nullptr代替NULL来声明空指针。到这里,可能有疑问为什么要引入nullptr?这里有几个原因。
- NULL在C++中的定义,NULL在C++中被明确定义为整数0;
- nullptr关键字用于标识空指针,它可以转换成任何指针类型和bool布尔类型(主要是为了兼容普通指针可以作为条件判断语句的写法),但是不能转换为整数。
char *p1=nullptr;//正确 int *p2=nullptr;//正确 bool b=nullptr;//正确 if(b)判断为false int a=nullptr;//error
4.就题论题
当面试官要求应聘者定义一个赋值运算符函数时,他会在检查应聘者写出的代码时关注如下几点:
- 是否把返回值的类型声明为该类型的引用,并在函数结束前返回实例自身的引用(*this)。只有返回一个引用,才可以允许连续赋值。否则,如果函数的返回值是void,则应用该赋值运算符将不能进行连续赋值。假设有3个CMyString的对象:str、str2和str3,在程序中语句str1=str2=str3将不能通过编译。
- 是否把传入的参数的类型声明为常量引用。如果传入的参数不是引用而是实例,那么从形参到实参会调用一次复制构造函数。把参数声明为引用可以避免这样的无谓消耗,能提高代码的效率。同时,我们在赋值运算函数内不会改变传入的实例的状态,因此应该为传入的引用参数加上const关键字。
- 是否释放实例自身已有的内存。如果我们忘记在分配新内存之前释放自己已有的空间,则程序将出现内存侧漏。
- 判断传入的参数和当前的实例(this)是不是同一个实例。如果是同一个,则不进行赋值操作,直接返回。如果事先不判断就进行赋值,那么释放实例自身内存的时候就会导致严重的问题:当this和传入的参数是同一个实例时,一旦释放了自身的内存,传入的参数的内存也同时释放了,因此再也找不到需要赋值的内容了。
经典解法,适用于初级程序员
CMyString& CMyString::operator =(const CMyString &str) { if(this==&str) return *this; delete []m_pData; m_pData=nullptr; m_pData=new char[strlen(str.m_pData)+1]; strcpy(m_pData,str.m_pData); return *this; }
考虑异常安全性的解法,高级程序员必备
在前面的函数中,我们在分配内存之前先用delete释放了实例m_pData的内存。如果此时内存不足导致new char抛出异常,则m_pData将是一个空指针,这样非常容易导致程序崩溃。也就是说,一旦在赋值运算符函数内部抛出一个异常,CMyString的实例不再保持有效状态,这就违背了异常安全性原则。
要想在赋值运算符函数中实现异常安全性,我们有两种方法。一种简单的办法是我们先用new分配新内容,在用delete释放已有的内容。这样只在分配内容成功之后在释放原来的内容,也就是当分配内存失败时我们能确保CMyString的实例不会被修改。我们还有一种更好的办法,即先创建一个临时实例,在交换临时实例和原来的实例。下面是这种思路的参考代码:CMyString& CMyString::operator =(const CMyString &str) { if(this!=&str) { CMyString strTemp(str); char* pTemp=strTemp.m_pData; strTemp.m_pData=m_pData; m_pData=pTemp; } return *this; }
在这个函数中,我们先创建一个临时实例strTemp,接着把strTemp.m_pData和实例自身的m_pData进行交换。由于strTemp是一个局部变量,但程序运行到if的外面时也就出了该变量的作用域,就会自动调用strTemp的析构函数,把strTemp.m_pData所指向的内存释放掉。由于strTemp.m_pData指向的内存就是实例之前m_pData的内存,这六相当于自动调用析构函数释放实例的内存。
在新的代码中,我们在CMyString的构造函数里用new分配内存。如果由于内存不足抛出注入bad_alloc异常,但我们还没有修改原来实例的状态,因此实例的状态还是有效的,这也就确保了异常安全性。
如果应聘者在面试的时候能够考虑到这个层面,面试官就会觉得他对代码的异常安全性有很深的理解,那么他自然也就能通过这轮面试了。 -
赋值运算符函数(C++)
2020-02-02 22:22:42题目:如下为类型CMyString的声明,要求为该类型添加赋值运算符函数。 class CMyString { public: CMyString(char* pData = nullptr); CMyString(const CMyString& str); ~CMyString(void); private... -
3、赋值运算符函数执行时机详解
2020-09-02 14:40:27#include<iostream> #include<string> using namespace std;... // 构造函数 Data() { }; // 构造函数 Data(int _data):data(_data) { cout << "This is constructor" <. -
浅谈拷贝构造/赋值运算符函数
2019-12-13 19:51:11拷贝构造函数和赋值运算符函数都是类中的特殊函数。什么时候执行拷贝构造函数/赋值运算符函数?拷贝初始化和直接初始化有什么区别? 拷贝构造函数 如果一个构造函数的第一个参数是自身类类型的引用,且... -
赋值运算符函数严谨性的几点思考
2018-05-04 12:53:531. 需求class CMyString { public: CMyString(char* pData = NULL); CMyString(const CMyString&...2.定义赋值运算符函数需要考虑的四个准则是否把返回值的类型声明为该类型的引用,并在... -
C++编程中赋值运算符函数operator=介绍
2021-11-11 16:26:11本文主要介绍为什么使用赋值运算符重载函数以及如何正确使用赋值运算符重载函数的方法。 1.原因 我们在使用类的时候,并不需要为该类重载 ‘=’ 运算符,因为编译器会为每个类提供了默认的赋值运算符‘=’,当我们... -
详解C++ 拷贝构造函数和赋值运算符
2020-09-01 00:34:48本文主要介绍了拷贝构造函数和赋值运算符的区别,以及在什么时候调用拷贝构造函数、什么情况下调用赋值运算符。最后,简单的分析了下深拷贝和浅拷贝的问题。有需要的朋友可以看下 -
[剑指offer]面试题1(赋值运算符函数)
2018-01-23 21:43:49题目:如下为类型CMyString的声明,请为该类型添加赋值运算符函数。 class CMyString { public: CMyString(char *pData = NULL); CMyString(const CMyString& str); ~CMyString(void); private: char *m_... -
C++ 拷贝构造函数和赋值运算符函数及其必要性和意义
2018-01-22 22:16:50本文前面主要介绍了拷贝构造函数和赋值运算符函数的区别,以及在什么时候调用拷贝构造函数、什么情况下调用赋值运算符函数。最后,分析了下深拷贝和浅拷贝的问题,即拷贝构造函数和赋值运算符函数的必要性和意义。... -
C++ ——赋值运算符重载函数
2021-03-17 10:41:49文章目录前言一、赋值运算符重载函数是什么?二、细谈赋值运算符重载函数2.1 参数列表2.2 返回值2.3调用时机二、赋值运算符重载函数练习 前言 在介绍赋值运算符重载之前,我们先看一段代码: class Complex //定义... -
【剑指offer】面试题1:赋值运算符函数
2016-08-24 19:58:19题目要求为下面一个类实现一个赋值运算符的函数:class CMyString { public: CMyString(char* pData = NULL); CMyString(const CMyString& str); ~CMyString(); private: char* m_pData; }; 我们要实现任意 -
C++ 语言拷贝构造函数、拷贝赋值运算符和析构函数
2021-08-24 23:32:30C++ 语言拷贝构造函数、拷贝赋值运算符和析构函数 每个类都定义了一个新类型和在此类型对象上可执行的操作。类可以定义构造函数,用来控制在创建此类型对象时做什么。一个类通过定义五种特殊的成员函数来控制这些... -
C++赋值运算符函数
2019-11-13 15:36:50题目:给出如下类型为CMyString的声明,请为该类型添加赋值运算符函数。 class CMyString { public: CMyString(char* pData = NULL); CMyString(const CMyString& str); ~CMyString(void); private: ... -
赋值运算符函数的返回值类型详解
2014-08-02 21:06:09在c++赋值运算符函数的学习中,对于返回值类型的问题,一直非常费解,今天彻底总结一些每种不同返回值类型的结果: 1、当返回值为空时: void hasptr::operator=(const hasptr& s) 这个时候如果只有一个‘=’... -
C++常见笔试面试题目:string类的拷贝赋值运算符函数
2016-11-11 17:08:57要求:写出一个String类的赋值运算符函数 注意事项: (1)返回值的类型需声明为该类型的引用,并在函数结束前返回实例自身的引用(即*this),因为只有返回一个引用,才可以允许连续赋值。 (2)传入参数必须为常量... -
C++派生类的复制构造函数和赋值运算符函数(个人理解)
2019-12-24 11:57:12下面我的程序其实本质上就是模拟了默认的派生类复制构造函数和默认的派生类赋值运算符函数的实现。 #include<iostream> using namespace std; class B { private: int b; public: B(int bb = 0) :b(bb) {} ... -
剑指offer——面试题1:赋值运算符函数
2016-06-10 16:04:20题目:如下为类型CMyString的声明,请为该类型添加赋值运算符函数。 class CMyString { public: CMyString(char* pData = NULL); CMyString(const CMyString& str); ~CMyString(void); private: char* m_pData; }; ... -
拷贝构造函数与赋值运算符
2020-05-29 08:47:20默认拷贝构造函数和赋值运算符函数 在默认情况下(用户没有定义,但是也没有显式的删除),编译器会自动的隐式生成一个拷贝构造函数和赋值运算符函数(缺省的)。 禁止拷贝和赋值 使用delete来指定不生成拷贝构造... -
C++类和对象之this指针和赋值运算符函数
2020-04-19 18:25:59主要介绍:this指针,赋值运算符函数,赋值运算符函数的使用 -
C++类的构造函数、析构函数、赋值运算符函数、拷贝构造函数、移动赋值运算符函数、移动拷贝构造函数
2020-04-30 23:17:11C++类的构造函数学习笔记 构造函数 拷贝赋值运算符 移动构造函数 移动赋值运算符 析构函数 -
C++赋值运算符重载函数和拷贝构造函数
2017-10-05 14:43:16一、提供默认赋值运算符重载函数的时机 当程序没有显示地提供一个以本类或者本类的引用为参数的赋值运算符重载函数时,编译器会自动生成这样一个赋值运算符重载函数。#include using namespace std;class Data { ...