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

本文详细探讨了C++中对象的赋值和复制操作,包括对象赋值的基本原理、代码示例及注意事项,以及对象复制的拷贝构造函数。重点阐述了浅拷贝可能导致的问题,并通过示例展示了如何通过重载赋值运算符和拷贝构造函数实现深拷贝,以避免内存问题。此外,文章还区分了对象赋值与对象复制的不同调用场景。
摘要由CSDN通过智能技术生成

本文主要介绍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 空间呢。
  • 如果在重载赋值运算符和重载拷贝构造函数都可以解决问题时,建议选择重载拷贝构造函数,因为貌似坑少一些^-^。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值