【剑指offer-题1】
赋值 vs 拷贝
对象在申明之后,再进行的赋值操作,就称为赋值运算。比如:
class Person {
public:
Person() {
}
Person(int a){
age = a;
_name = new char[1]{ '\0' };
}
Person(int a, const char *name) :age(a) {
_name = new char[strlen(name) + 1];
strcpy(_name, name);
_name[strlen(name)] = '\0';
}
private:
int age;
char *_name;
};
Person A(10), B;
B = A;
其中 B = A 就是赋值运算。
对象在申明的同时进行初始化操作,就称为拷贝运算。比如:
Person A(10);
Person B(A);
Person C = A;
其中第二行、第三行都是是拷贝运算了。
拷贝和赋值操作都有缺省的定义,即我们并不需要重载这两个函数就能够正确运行代码,但是,请注意,这里的正确只停留在编译阶段,运行时由于默认拷贝和赋值函数的特点会引发意想不到的错误。这也是下面要讨论的问题。
为什么要重载赋值与拷贝函数
在解释这一问题之前,需要先了解C++中类实例在内存中的分配方式。
C++类实例的内存分配方式
C++中,对象通常放置在三个内存区域:栈(stack),堆(heap),全局/静态数据区。
栈:自生自灭形对象,程序员无需对其生命周期进行管理。一般,临时对象,函数内部的局部对象都是栈对象。
堆:通过new/malloc在堆中动态分配的内存,通过delete/free进行释放。程序员需要对这种对象的创建和销毁进行精准的控制。
全局/静态数据区:全局对象和静态对象存放在该区。存放在该区中的对象一旦创建会在进程结束才会释放。
C++的对象,在编译时就会为其分配内存大小,因此,都是在堆上为其分配内存。至于其实体内部各个域的值,由其构造函数决定。
int main()
{
Person p1(10, "zhang");
Person p2;
}
在程序编译之后,p1, p2对象都在栈上被分配内存相应大小。p1对象的成员变量都被初始化,p2对象成员变量都是随机值。内存分配如下图。
默认赋值和拷贝操作
当执行默认的赋值或拷贝操作时:
- 位于栈空间域值会自动进行复制。
- 位于堆空间域值不会进行相应复制,两者会引用同一堆空间中的内容。
即经过默认赋值或拷贝操作后,内存分配更新如下。
可以看出:
4. 如果类对象中不含有任何堆空间域值,那么调用默认赋值或拷贝操作不会带来任何负面影响。
5. 如果类对象中包含堆空间域值,由于不同对象引用同一堆内存区域,那么会给程序带来意想不到的错误结果。比如在析构对象的时候,会连续两次析构同一内存区域,引发异常。在这种情况下,我们就需要对赋值运算符和拷贝构造函数同时进行重载。
如何重载赋值运算符
初级代码:
Person& operator=(const Person& person)
{
if (this == &person)
return *this;
if (_name)
delete _name;
age = person.age;
_name = new char[strlen(person._name) + 1];
strcpy(_name, person._name);
return *this;
}
问题一:为什么要先清空自身已有内存?
如果在分配新内存之前没有释放已有内存,会造成内存泄漏。
问题二:为什么有开始的条件判断 if (this == &person) ?
解决自赋值情况,即 p1 = p1。如果不进行判断,当进行同一实例赋值时,由于会对自身已有内存进行释放,则无法获得传入参数即自身的参数内容。
问题三:为什么要将传入参数设置为常量引用?
如果传入参数仅为对象实例,那么在传入该类实例参数时,会调用一次该类的拷贝构造函数。将参数设置为引用可以避免此类无谓消耗。
所以,**不要按值像函数传递对象。**尤其当对象中含有指向堆空间的指针时,丝毫不要考虑值传递,要用引用传递。
同时,当函数内部并未改变传入参数内容,应该将传入参数设置为 const。
问题四:为何要将返回类型设置为void?
如果函数的返回类型设置为void,将不允许对该类型变量进行连续赋值,即 p1 = p2 = p3将不能通过编译。
问题五:为何返回引用而不直接返回该对象?
如果返回类型直接为类对象本身,在进行简单的赋值操作时,会经历如下过程:
- 释放对象原有的堆内存
- 重新分类新内存
- 将源对象中的内容拷贝进目标对象堆空间
- 创建一个临时对象,即调用临时对象的拷贝构造函数,然后将临时对象返回。
- 释放该临时对象,即调用该临时对象的析构函数,释放相应的内存空间。
如果我们没有重载该对象的拷贝构造函数,也就是说没有进行深拷贝,那么在释放临时对象的堆空间时,会同时释放掉目标对象的堆空间。那么在调用目标对象的析构函数时,会引发异常。
所以,如果要返回对象本身,则一定要同时重载该对象的拷贝构造函数,进行深拷贝。
如果返回类型为类对象引用,在进行赋值操作时,会经历如下过程:
- 释放对象原有的堆内存
- 重新分类新内存
- 将源对象中的内容拷贝进目标对象堆空间
- 返回目标对象引用
可以看见,并不会调用该类的临时构造函数。
高级代码
在初级代码中,我们在分配内存之前就将对象原有内存释放掉了。如果在分配内存时由于内存不足导致分配内存失败,我们将无法获得该对象原有的状态,这就违背了异常安全性原则。
为解决异常安全性问题,可以有两种方法。
第一种即先new一块新内存,如果分配内存成功,再释放掉原有内存。否则不释放,进行其他异常处理。
还有一种更好的办法。我们可以创建一个临时对象,复制传入对象的内容,**注意是深度复制,即此时要同时重载该类的拷贝构造函数。**然后交换该临时对象和目标对象,这样当函数调用结束,销毁该临时对象时,会自动释放目标对象原有的内存空间。
Person& operator=(const Person& person)
{
if (this != &person)
{
Person tempObj(person);
char* temp = tempObj._name;
tempObj._name = _name;
_name = temp;
}
return *this;
}
此时。我们在临时对象tempObj中new出一块新内存。此时如果由于内存不足导致分配失败时,目标对象上原有的内存内容并没有发生改变,符合异常安全性原则。
来源:CSDN
作者:小瓶子的笔记本
链接:https://blog.csdn.net/ZLP_CSDN/article/details/104343382