C++类对象的复制与赋值

本文主要介绍C++中类对象的赋值操作、复制操作,以及两者之间的区别,另外还会讲到“深拷贝”与“浅拷贝”的相关内容。

本系列内容会分为三篇文章进行讲解。

1 对象的赋值

1.1 what

如同基本类型的赋值语句一样,同一个类的对象之间也是可以进行赋值操作的,即将一个对象的值赋给另一个对象。

对于类对象的赋值,只会对类中的数据成员进行赋值,而不对成员函数赋值。

例如:obj1 和 obj2 是同一类 ClassA 的两个对象,那么对象赋值语句“obj2 = obj1;” 就会把对象 obj1 的数据成员的值逐位赋给对象 obj2。

1.2 代码示例

下面展示一个对象赋值的代码示例(object_assign_and_copy_test1.cpp),如下:

  1. #include <iostream>

  2.  
  3. using namespace std;

  4.  
  5. class ClassA

  6. {

  7. public:

  8. // 设置成员变量的值

  9. void SetValue(int i, int j)

  10. {

  11. m_nValue1 = i;

  12. m_nValue2 = j;

  13. }

  14. // 打印成员变量的值

  15. void ShowValue()

  16. {

  17. cout << "m_nValue1 is: " << m_nValue1 << ", m_nValue2 is: " << m_nValue2 << endl;

  18. }

  19. private:

  20. int m_nValue1;

  21. int m_nValue2;

  22. };

  23.  
  24. int main()

  25. {

  26. // 声明对象obj1和obj2

  27. ClassA obj1;

  28. ClassA obj2;

  29.  
  30. obj1.SetValue(1, 2);

  31. // 对象赋值场景 —— 将obj1的值赋给obj2

  32. obj2 = obj1;

  33. cout << "obj1 info as followed: " << endl;

  34. obj1.ShowValue();

  35. cout << "obj2 info as followed: " << endl;

  36. obj2.ShowValue();

  37.  
  38. return 0;

  39. }

  40.  

编译并运行上述代码,结果如下:

上面的执行结果表明,通过对象赋值语句,我们将obj1的值成功地赋给了obj2。

1.3 几点说明

对于对象赋值,进行以下几点说明:

  • 进行对象赋值时,两个对象的必须属于同一个类,如对象所述的类不同,在编译时将会报错;
  • 两个对象之间的赋值,只会让这两个对象中数据成员相同,而两个对象仍然是独立的。例如在上面的示例代码中,进行对象赋值后,再调用 obj1.set() 设置 obj1 的值,并不会影响到 obj2 的值;
  • 对象赋值是通过赋值运算函数实现的。每一个类都有默认的赋值运算符,我们也可以根据需要,对赋值运算符进行重载。一般来说,需要手动编写析构函数的类,都需要重载赋值运算符(具体原因下文会介绍);
  • 在对象声明之后,进行的对象赋值运算,才属于“真正的”对象赋值运算,即使用了赋值运算符“=”;而在对象初始化时,针对对象进行的赋值操作,其实是属于对象的复制。示例如下:
    1. // 声明obj1和obj2

    2. ClassA obj1;

    3. ClassA obj2;

    4. obj2 = obj1; // 此语句为对象的赋值

    5.  
    6. // 声明obj1

    7. ClassA obj1;

    8. // 声明并初始化obj2

    9. ClassA obj2 = obj1; // 此语句属于对象的复制

1.4 进一步研究

下面从内存分配的角度分析一下对象的赋值操作。

1.4.1 C++中对象的内存分配方式

在C++中,只要声明了对象,对象实例在编译的时候,系统就需要为其分配内存了。一段代码示例如下:

  1. class ClassA

  2. {

  3. public:

  4. ClassA(int id, char* name)

  5. {

  6. m_nId = id;

  7. m_pszName = new char[strlen(name) + 1];

  8. strcpy(m_pszName, name);

  9. }

  10. private:

  11. char* m_pszName;

  12. int m_nId;

  13. };

  14.  
  15. int main()

  16. {

  17. ClassA obj1(1, "liitdar");

  18. ClassA obj2;

  19.  
  20. return 0;

  21. }

在上述代码编译之后,系统为 obj1 和 obj2 都分配相应大小的内存空间(只不过对象 obj1 的内存域被初始化了,而 obj2 的内存域的值为随机值)。两者的内存分配效果如下:

1.4.2 默认的赋值运算符

延续上面的示例代码,我们执行“obj2 = obj1;”,即利用默认的赋值运算符将对象 obj1 的值赋给 obj2。使用类中默认的赋值运算符,会将对象中的所有位于 stack 中的域进行相应的复制操作;同时,如果对象有位于 heap 上的域,则不会为目标对象分配 heap 上的空间,而只是让目标对象指向源对象 heap 上的同一个地址。

执行了“obj2 = obj1;”默认的赋值运算后,两个对象的内存分配效果如下:

因此,对于类中默认的赋值运算,如果源对象域内没有 heap 上的空间,其不会产生任何问题。但是,如果源对象域内需要申请 heap 上的空间,那么由于源对象和目标对象都指向 heap 的同一段内容,所以在析构对象的时候,就会连续两次释放 heap 上的那一块内存区域,从而导致程序异常。

  1. ~ClassA()

  2. {

  3. delete m_pszName;

  4. }

1.4.3 解决方案

为了解决上面的问题,如果对象会在 heap 上存在内存域,则我们必须重载赋值运算符,从而在进行对象的赋值操作时,使不同对象的成员域指向不同的 heap 地址。

重载赋值运算符的代码如下:

  1. // 赋值运算符重载需要返回对象的引用,否则返回后其值立即消失

  2. ClassA& operator=(ClassA& obj)

  3. {

  4. // 释放heap内存

  5. if (m_pszName != NULL)

  6. {

  7. delete m_pszName;

  8. }

  9. // 赋值stack内存的值

  10. this->m_nId = obj.m_nId;

  11. // 赋值heap内存的值

  12. int nLength = strlen(obj.m_pszName);

  13. m_pszName = new char[nLength + 1];

  14. strcpy(m_pszName, obj.m_pszName);

  15.  
  16. return *this;

  17. }

使用上面重载后的赋值运算符对对象进行赋值时,两个对象的内存分配效果如下:

这样,在对象 obj1、obj2 退出其的作用域,调用相应的析构函数时,就会释放不同 heap 空间的内存,也就不会出现程序异常了。

下文讲述“对象的复制”的相关内容。

2 对象的复制

2.1 what

相对于“对已声明的对象使用赋值运算符进行的对象赋值”操作,使用拷贝构造函数操作对象的方式,称为“对象的复制”。

类的拷贝构造函数是一种特殊的构造函数,其形参是本类对象的引用。拷贝构造函数的作用为:在创建一个新对象时,使用一个已经存在的对象去初始化这个新对象。例如语句“ClassA obj2(obj1);”就使用了拷贝构造函数,该语句在创建新对象 obj2 时,利用已经存在的对象 obj1 去初始化对象 obj2。

对象的赋值与对象的拷贝,貌似都是只对类的成员变量进行拷贝,而不会对类的成员函数进行操作。—— 待进一步确认。

2.2 拷贝构造函数的特点

拷贝构造函数有以下特点:

  • 拷贝构造函数也是一种构造函数,所以其函数名与类名相同,并且该函数也没有返回值类型;
  • 拷贝构造函数只有一个参数,并且该参数是其所属类对象的引用;
  • 每一个类都必须有一个拷贝构造函数,我们可以根据需要重载默认的拷贝构造函数(自定义拷贝构造函数),如果没有重载默认的拷贝构造函数,系统就会生成产生一个默认的拷贝构造函数,默认的拷贝构造函数将会复制出一个数据成员完全相同的新对象;

2.3 自定义拷贝构造函数

这里展示一个自定义拷贝构造函数的代码示例(object_assign_and_copy_test2.cpp),如下:

  1. #include <iostream>

  2.  
  3. using namespace std;

  4.  
  5. class ClassA

  6. {

  7. public:

  8. // 普通构造函数

  9. ClassA(int i, int j)

  10. {

  11. m_nValue1 = i;

  12. m_nValue2 = j;

  13. }

  14. // 自定义的拷贝构造函数

  15. ClassA(const ClassA& obj)

  16. {

  17. m_nValue1 = obj.m_nValue1 * 2;

  18. m_nValue2 = obj.m_nValue2 * 2;

  19. }

  20. // 打印成员变量的值

  21. void ShowValue()

  22. {

  23. cout << "m_nValue1 is: " << m_nValue1 << ", m_nValue2 is: " << m_nValue2 << endl;

  24. }

  25. private:

  26. int m_nValue1;

  27. int m_nValue2;

  28. };

  29.  
  30.  
  31. int main()

  32. {

  33. // 创建并初始化对象obj1,此处调用了普通构造函数

  34. ClassA obj1(1, 2);

  35. // 创建并初始化对象obj2,此处调用了自定义的拷贝构造函数

  36. ClassA obj2(obj1);

  37.  
  38. obj1.ShowValue();

  39. obj2.ShowValue();

  40.  
  41. return 0;

  42. }

  43.  

编译并执行上述代码,结果如下:

上述执行结果表明,通过调用自定义的拷贝构造函数,我们在创建对象 obj2 时,结合对象 obj1 的成员变量的值,完成了我们自定义的初始化过程。

2.4 调用形式上的区别

我们可以从调用形式上,对“对象的赋值”和“对象的复制”进行区分。在此,我们列出一些对应关系:

  • 对象的赋值:指的是调用了类的赋值运算符,进行的对象的拷贝操作;
  • 对象的复制:指的是调用了类的拷贝构造函数,进行的对象的拷贝操作。

上面的对应关系是不严谨的,因为有些情况下,即使使用了赋值运算符“=”,但其实最终使用的仍然是类的拷贝构造函数,这就引出了拷贝构造函数的两种调用形式。

拷贝构造函数的调用语法分为两种:

  • 类名 对象2(对象1)。例如:“ClassA obj2(obj1);”,这种调用拷贝构造函数的方法称为“代入法”;
  • 类名 对象2 = 对象1。例如:“ClassA obj2 = obj1;”,这种调用拷贝构造函数的方法称为“赋值法”。

拷贝构造函数的“赋值法”就很容易与“对象的赋值”场景混淆,其二者之间的区别是:对象的赋值场景必须是建立在源对象与目标对象均已声明的基础上;而拷贝构造函数函数的赋值法,必须是针对新创建对象的场景。代码如下:

【对象的赋值】:

  1. // 声明对象obj1和obj2

  2. ClassA obj1;

  3. ClassA obj2;

  4.  
  5. obj1.SetValue(1, 2);

  6. // 对象赋值场景 —— 将obj1的值赋给obj2

  7. obj2 = obj1;

【拷贝构造函数的“赋值法”】:

  1. // 创建并初始化对象obj1,此处调用了普通构造函数

  2. ClassA obj1(1, 2);

  3. // 创建并初始化对象obj2,此处调用了自定义的拷贝构造函数

  4. ClassA obj2 = obj1;

当然,为了代码的清晰化,建议使用拷贝构造函数的“代入法”,更可以让人一眼就看出调用的是拷贝构造函数。

2.5 调用拷贝构造函数的三个场景

2.5.1 类对象初始化

当使用类的一个对象去初始化另一个对象时,会调用拷贝构造函数(包括“代入法”和“赋值法”)。示例代码如下:

  1. // 创建并初始化对象obj1,此处调用了普通构造函数

  2. ClassA obj1(1, 2);

  3. // 创建并初始化对象obj2,此处调用了自定义的拷贝构造函数

  4. ClassA obj2 = obj1; // 代入法

  5. ClassA obj3 = obj1; // 赋值法

2.5.2 类对象作为函数参数

当类对象作为函数形参时,在调用函数进行形参和实参转换时,会调用拷贝构造函数。示例代码如下:

  1. // 形参是类ClassA的对象obj

  2. void funA(ClassA obj)

  3. {

  4. obj.ShowValue();

  5. }

  6.  
  7. int main()

  8. {

  9. ClassA obj1(1, 2);

  10.  
  11. // 调用函数funA时,实参obj1是类ClassA的对象

  12. // 这里会调用拷贝构造函数,使用实参obj1初始化形参对象obj

  13. funA(obj1);

  14.  
  15. return 0;

  16. }

说明:在上面的main函数内,语句“funA(obj1);”就会调用拷贝构造函数。

2.5.3 类对象作为函数返回值

当函数的返回值是类的对象、在函数调用完毕将返回值(对象)带回函数调用处,此时会调用拷贝构造函数,将函数返回的对象赋值给一个临时对象,并传到函数的调用处。示例代码如下:

  1. // 函数funB()的返回值类型是ClassA类类型

  2. ClassA funB()

  3. {

  4. ClassA obj1(1, 2);

  5. // 函数的返回值是ClassA类的对象

  6. return obj1;

  7. }

  8.  
  9. int main()

  10. {

  11. // 定义类ClassA的对象obj2

  12. ClassA obj2;

  13. // funB()函数执行完成、返回调用处时,会调用拷贝构造函数

  14. // 使用obj1初始化obj2

  15. obj2 = funB();

  16.  
  17. return 0;

  18. }

说明:在上面的main函数内,语句“obj2 = funB();”就会调用拷贝构造函数。由于对象obj1是函数funB中定义的,在函数funB结束时,obj1的生命周期就结束了,因此在函数funB结束之前,执行语句"return obj1"时,会调用拷贝构造函数将obj1的值拷贝到一个
临时对象中,这个临时对象是系统在主程序中临时创建的。funB函数结束时,对象obj1消失,但是临时对象将会通过语句“obj2 = funB()”赋值给对象obj2,执行完这条语句后,临时对象也自动消失了。

下文主要介绍C++中的“深拷贝”和“浅拷贝”,以及赋值运算符的重载、拷贝构造函数的重载的相关内容。

3 浅拷贝

3.1 what

浅拷贝:就是只拷贝类中位于 stack 域中的内容,而不会拷贝 heap 域中的内容。

例如,使用类的默认的赋值运算符“=”,或默认的拷贝构造函数时,进行的对象拷贝都属于浅拷贝。这也说明,“浅拷贝”与使用哪种方式(赋值运算符或是拷贝构造函数)进行对象拷贝无关。

3.2 问题

浅拷贝会有一个问题,当类中存在指针成员变量时,进行浅拷贝后,目标对象与源对象的该指针成员变量将会指向同一块 heap 内存(而非每个对象单独一块内存),这就会导致由于共用该段内存而产生的内存覆盖、重复释放内存等等问题。详情可参考本系列第一章内容

所以,对于带有指针的类对象的拷贝操作,正确的做法应当使两个对象的指针指向各自不同的内存,即在拷贝时不是简单地拷贝指针,而是将指针指向的内存中的每一个元素都进行拷贝。由此也就引出了“深拷贝”的概念。

4 深拷贝

深拷贝:当进行对象拷贝时,将对象位于 stack 域和 heap 域中的数据都进行拷贝。

前面也提到了,类默认提供的赋值运算符或拷贝构造函数,进行的都是浅拷贝,所以,为了实现对象的深拷贝,我们需要对赋值运算符或拷贝构造函数进行重载,以达到深拷贝的目的。

4.1 赋值运算符的重载

这里展示一段重载赋值运算符的示例代码,如下:

// 重载赋值运算符
ClassA& operaton= (ClassA& obj)
{
	// 拷贝 stack 域的值
	m_nId = obj.m_nId;
	// 适应自赋值(obj = obj)操作
	if (this == &a)
	{
		return *this;
	}

	// 释放掉已有的 heap 空间
	if (m_pszName != NULL)
	{
		delete m_pszName;
	}

	// 新建 heap 空间
	m_pszName = new char[strlen(obj.m_pszName) + 1];
	// 拷贝 heap 空间的内容
	if (m_pszName != NULL)
	{
		strcpy(m_pszName, obj.m_pszName);
	}
	return *this;
}

private:
	int m_nId;
	char* m_pszName;

4.2 拷贝构造函数的重载

这里展示一段重载拷贝构造函数的示例代码,如下:

// 重载拷贝构造函数,重载后的拷贝构造函数支持深拷贝
ClassA(ClassA &obj)
{
	// 拷贝 stack 域的值
	m_nId = obj.m_nId;
	// 新建 heap 空间
	m_pszName = new char[strlen(obj.m_pszName) + 1];
	// 拷贝 heap 空间的内容
	if (m_pszName != NULL)
	{
		strcpy(m_pszName, obj.m_pszName);
	}
}

private:
	int m_nId;
	char* m_pszName;

4.3 总结

从上述两个示例代码可以看出,支持深拷贝的重载赋值运算符和重载拷贝构造函数相似,但两者也存在以下区别:

  • 重载赋值运算符最好有返回值,以方便进行链式赋值(obj3=obj2=obj1),返回值类型也最好是对象的引用;而重载拷贝构造函数因为属于构造函数的一种,所以不需要返回值;
  • 重载赋值运算符首先要释放掉对象自身的 heap 空间(如果存在的话),然后再进行 heap 内容的拷贝操作;而重载拷贝构造函数无需如此,因为拷贝构造函数函数是在创建(并初始化)对象时调用的,对象此时还没有分配 heap 空间呢。
  • 如果在重载赋值运算符和重载拷贝构造函数都可以解决问题时,建议选择重载拷贝构造函数,因为貌似坑少一些^-^。

猜你喜欢

转载自blog.csdn.net/qq_20853741/article/details/113899880
今日推荐