赋值运算符与拷贝函数重载

不想你离开。 提交于 2020-02-17 05:35:04

【剑指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对象成员变量都是随机值。内存分配如下图。
在这里插入图片描述

默认赋值和拷贝操作

当执行默认的赋值或拷贝操作时:

  1. 位于栈空间域值自动进行复制。
  2. 位于堆空间域值不会进行相应复制,两者会引用同一堆空间中的内容。

即经过默认赋值或拷贝操作后,内存分配更新如下。
在这里插入图片描述
可以看出:
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出一块新内存。此时如果由于内存不足导致分配失败时,目标对象上原有的内存内容并没有发生改变,符合异常安全性原则。

附:
参考文章:
C++本质:类的赋值运算符重载,深拷贝,浅拷贝
C++类与对象的内存

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!