本文主要介绍C++中类对象的赋值操作、复制操作,以及两者之间的区别,另外还会讲到“深拷贝”与“浅拷贝”的相关内容。
本系列内容会分为三篇文章进行讲解。
1 对象的赋值
1.1 what
如同基本类型的赋值语句一样,同一个类的对象之间也是可以进行赋值操作的,即将一个对象的值赋给另一个对象。
对于类对象的赋值,只会对类中的数据成员进行赋值,而不对成员函数赋值。
例如:obj1 和 obj2 是同一类 ClassA 的两个对象,那么对象赋值语句“obj2 = obj1;” 就会把对象 obj1 的数据成员的值逐位赋给对象 obj2。
1.2 代码示例
下面展示一个对象赋值的代码示例(object_assign_and_copy_test1.cpp),如下:
-
#include <iostream>
-
using namespace std;
-
class ClassA
-
{
-
public:
-
// 设置成员变量的值
-
void SetValue(int i, int j)
-
{
-
m_nValue1 = i;
-
m_nValue2 = j;
-
}
-
// 打印成员变量的值
-
void ShowValue()
-
{
-
cout << "m_nValue1 is: " << m_nValue1 << ", m_nValue2 is: " << m_nValue2 << endl;
-
}
-
private:
-
int m_nValue1;
-
int m_nValue2;
-
};
-
int main()
-
{
-
// 声明对象obj1和obj2
-
ClassA obj1;
-
ClassA obj2;
-
obj1.SetValue(1, 2);
-
// 对象赋值场景 —— 将obj1的值赋给obj2
-
obj2 = obj1;
-
cout << "obj1 info as followed: " << endl;
-
obj1.ShowValue();
-
cout << "obj2 info as followed: " << endl;
-
obj2.ShowValue();
-
return 0;
-
}
编译并运行上述代码,结果如下:
上面的执行结果表明,通过对象赋值语句,我们将obj1的值成功地赋给了obj2。
1.3 几点说明
对于对象赋值,进行以下几点说明:
- 进行对象赋值时,两个对象的必须属于同一个类,如对象所述的类不同,在编译时将会报错;
- 两个对象之间的赋值,只会让这两个对象中数据成员相同,而两个对象仍然是独立的。例如在上面的示例代码中,进行对象赋值后,再调用 obj1.set() 设置 obj1 的值,并不会影响到 obj2 的值;
- 对象赋值是通过赋值运算函数实现的。每一个类都有默认的赋值运算符,我们也可以根据需要,对赋值运算符进行重载。一般来说,需要手动编写析构函数的类,都需要重载赋值运算符(具体原因下文会介绍);
- 在对象声明之后,进行的对象赋值运算,才属于“真正的”对象赋值运算,即使用了赋值运算符“=”;而在对象初始化时,针对对象进行的赋值操作,其实是属于对象的复制。示例如下:
-
// 声明obj1和obj2
-
ClassA obj1;
-
ClassA obj2;
-
obj2 = obj1; // 此语句为对象的赋值
-
// 声明obj1
-
ClassA obj1;
-
// 声明并初始化obj2
-
ClassA obj2 = obj1; // 此语句属于对象的复制
-
1.4 进一步研究
下面从内存分配的角度分析一下对象的赋值操作。
1.4.1 C++中对象的内存分配方式
在C++中,只要声明了对象,对象实例在编译的时候,系统就需要为其分配内存了。一段代码示例如下:
-
class ClassA
-
{
-
public:
-
ClassA(int id, char* name)
-
{
-
m_nId = id;
-
m_pszName = new char[strlen(name) + 1];
-
strcpy(m_pszName, name);
-
}
-
private:
-
char* m_pszName;
-
int m_nId;
-
};
-
int main()
-
{
-
ClassA obj1(1, "liitdar");
-
ClassA obj2;
-
return 0;
-
}
在上述代码编译之后,系统为 obj1 和 obj2 都分配相应大小的内存空间(只不过对象 obj1 的内存域被初始化了,而 obj2 的内存域的值为随机值)。两者的内存分配效果如下:
1.4.2 默认的赋值运算符
延续上面的示例代码,我们执行“obj2 = obj1;”,即利用默认的赋值运算符将对象 obj1 的值赋给 obj2。使用类中默认的赋值运算符,会将对象中的所有位于 stack 中的域进行相应的复制操作;同时,如果对象有位于 heap 上的域,则不会为目标对象分配 heap 上的空间,而只是让目标对象指向源对象 heap 上的同一个地址。
执行了“obj2 = obj1;”默认的赋值运算后,两个对象的内存分配效果如下:
因此,对于类中默认的赋值运算,如果源对象域内没有 heap 上的空间,其不会产生任何问题。但是,如果源对象域内需要申请 heap 上的空间,那么由于源对象和目标对象都指向 heap 的同一段内容,所以在析构对象的时候,就会连续两次释放 heap 上的那一块内存区域,从而导致程序异常。
-
~ClassA()
-
{
-
delete m_pszName;
-
}
1.4.3 解决方案
为了解决上面的问题,如果对象会在 heap 上存在内存域,则我们必须重载赋值运算符,从而在进行对象的赋值操作时,使不同对象的成员域指向不同的 heap 地址。
重载赋值运算符的代码如下:
-
// 赋值运算符重载需要返回对象的引用,否则返回后其值立即消失
-
ClassA& operator=(ClassA& obj)
-
{
-
// 释放heap内存
-
if (m_pszName != NULL)
-
{
-
delete m_pszName;
-
}
-
// 赋值stack内存的值
-
this->m_nId = obj.m_nId;
-
// 赋值heap内存的值
-
int nLength = strlen(obj.m_pszName);
-
m_pszName = new char[nLength + 1];
-
strcpy(m_pszName, obj.m_pszName);
-
return *this;
-
}
使用上面重载后的赋值运算符对对象进行赋值时,两个对象的内存分配效果如下:
这样,在对象 obj1、obj2 退出其的作用域,调用相应的析构函数时,就会释放不同 heap 空间的内存,也就不会出现程序异常了。
下文讲述“对象的复制”的相关内容。
2 对象的复制
2.1 what
相对于“对已声明的对象使用赋值运算符进行的对象赋值”操作,使用拷贝构造函数操作对象的方式,称为“对象的复制”。
类的拷贝构造函数是一种特殊的构造函数,其形参是本类对象的引用。拷贝构造函数的作用为:在创建一个新对象时,使用一个已经存在的对象去初始化这个新对象。例如语句“ClassA obj2(obj1);”就使用了拷贝构造函数,该语句在创建新对象 obj2 时,利用已经存在的对象 obj1 去初始化对象 obj2。
对象的赋值与对象的拷贝,貌似都是只对类的成员变量进行拷贝,而不会对类的成员函数进行操作。—— 待进一步确认。
2.2 拷贝构造函数的特点
拷贝构造函数有以下特点:
- 拷贝构造函数也是一种构造函数,所以其函数名与类名相同,并且该函数也没有返回值类型;
- 拷贝构造函数只有一个参数,并且该参数是其所属类对象的引用;
- 每一个类都必须有一个拷贝构造函数,我们可以根据需要重载默认的拷贝构造函数(自定义拷贝构造函数),如果没有重载默认的拷贝构造函数,系统就会生成产生一个默认的拷贝构造函数,默认的拷贝构造函数将会复制出一个数据成员完全相同的新对象;
2.3 自定义拷贝构造函数
这里展示一个自定义拷贝构造函数的代码示例(object_assign_and_copy_test2.cpp),如下:
-
#include <iostream>
-
using namespace std;
-
class ClassA
-
{
-
public:
-
// 普通构造函数
-
ClassA(int i, int j)
-
{
-
m_nValue1 = i;
-
m_nValue2 = j;
-
}
-
// 自定义的拷贝构造函数
-
ClassA(const ClassA& obj)
-
{
-
m_nValue1 = obj.m_nValue1 * 2;
-
m_nValue2 = obj.m_nValue2 * 2;
-
}
-
// 打印成员变量的值
-
void ShowValue()
-
{
-
cout << "m_nValue1 is: " << m_nValue1 << ", m_nValue2 is: " << m_nValue2 << endl;
-
}
-
private:
-
int m_nValue1;
-
int m_nValue2;
-
};
-
int main()
-
{
-
// 创建并初始化对象obj1,此处调用了普通构造函数
-
ClassA obj1(1, 2);
-
// 创建并初始化对象obj2,此处调用了自定义的拷贝构造函数
-
ClassA obj2(obj1);
-
obj1.ShowValue();
-
obj2.ShowValue();
-
return 0;
-
}
编译并执行上述代码,结果如下:
上述执行结果表明,通过调用自定义的拷贝构造函数,我们在创建对象 obj2 时,结合对象 obj1 的成员变量的值,完成了我们自定义的初始化过程。
2.4 调用形式上的区别
我们可以从调用形式上,对“对象的赋值”和“对象的复制”进行区分。在此,我们列出一些对应关系:
- 对象的赋值:指的是调用了类的赋值运算符,进行的对象的拷贝操作;
- 对象的复制:指的是调用了类的拷贝构造函数,进行的对象的拷贝操作。
上面的对应关系是不严谨的,因为有些情况下,即使使用了赋值运算符“=”,但其实最终使用的仍然是类的拷贝构造函数,这就引出了拷贝构造函数的两种调用形式。
拷贝构造函数的调用语法分为两种:
- 类名 对象2(对象1)。例如:“ClassA obj2(obj1);”,这种调用拷贝构造函数的方法称为“代入法”;
- 类名 对象2 = 对象1。例如:“ClassA obj2 = obj1;”,这种调用拷贝构造函数的方法称为“赋值法”。
拷贝构造函数的“赋值法”就很容易与“对象的赋值”场景混淆,其二者之间的区别是:对象的赋值场景必须是建立在源对象与目标对象均已声明的基础上;而拷贝构造函数函数的赋值法,必须是针对新创建对象的场景。代码如下:
【对象的赋值】:
-
// 声明对象obj1和obj2
-
ClassA obj1;
-
ClassA obj2;
-
obj1.SetValue(1, 2);
-
// 对象赋值场景 —— 将obj1的值赋给obj2
-
obj2 = obj1;
【拷贝构造函数的“赋值法”】:
-
// 创建并初始化对象obj1,此处调用了普通构造函数
-
ClassA obj1(1, 2);
-
// 创建并初始化对象obj2,此处调用了自定义的拷贝构造函数
-
ClassA obj2 = obj1;
当然,为了代码的清晰化,建议使用拷贝构造函数的“代入法”,更可以让人一眼就看出调用的是拷贝构造函数。
2.5 调用拷贝构造函数的三个场景
2.5.1 类对象初始化
当使用类的一个对象去初始化另一个对象时,会调用拷贝构造函数(包括“代入法”和“赋值法”)。示例代码如下:
-
// 创建并初始化对象obj1,此处调用了普通构造函数
-
ClassA obj1(1, 2);
-
// 创建并初始化对象obj2,此处调用了自定义的拷贝构造函数
-
ClassA obj2 = obj1; // 代入法
-
ClassA obj3 = obj1; // 赋值法
2.5.2 类对象作为函数参数
当类对象作为函数形参时,在调用函数进行形参和实参转换时,会调用拷贝构造函数。示例代码如下:
-
// 形参是类ClassA的对象obj
-
void funA(ClassA obj)
-
{
-
obj.ShowValue();
-
}
-
int main()
-
{
-
ClassA obj1(1, 2);
-
// 调用函数funA时,实参obj1是类ClassA的对象
-
// 这里会调用拷贝构造函数,使用实参obj1初始化形参对象obj
-
funA(obj1);
-
return 0;
-
}
说明:在上面的main函数内,语句“funA(obj1);”就会调用拷贝构造函数。
2.5.3 类对象作为函数返回值
当函数的返回值是类的对象、在函数调用完毕将返回值(对象)带回函数调用处,此时会调用拷贝构造函数,将函数返回的对象赋值给一个临时对象,并传到函数的调用处。示例代码如下:
-
// 函数funB()的返回值类型是ClassA类类型
-
ClassA funB()
-
{
-
ClassA obj1(1, 2);
-
// 函数的返回值是ClassA类的对象
-
return obj1;
-
}
-
int main()
-
{
-
// 定义类ClassA的对象obj2
-
ClassA obj2;
-
// funB()函数执行完成、返回调用处时,会调用拷贝构造函数
-
// 使用obj1初始化obj2
-
obj2 = funB();
-
return 0;
-
}
说明:在上面的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 空间呢。
- 如果在重载赋值运算符和重载拷贝构造函数都可以解决问题时,建议选择重载拷贝构造函数,因为貌似坑少一些^-^。