本文主要介绍C++中类对象的赋值操作、复制操作,以及两者之间的区别,另外还会讲到“深拷贝”与“浅拷贝”的相关内容。
本系列内容会分为三篇文章进行讲解。
如同基本类型的赋值语句一样,同一个类的对象之间也是可以进行赋值操作的,即将一个对象的值赋给另一个对象。
对于类对象的赋值,只会对类中的数据成员进行赋值,而不对成员函数赋值。
例如:obj1 和 obj2 是同一类 ClassA 的两个对象,那么对象赋值语句“obj2 = obj1;” 就会把对象 obj1 的数据成员的值逐位赋给对象 obj2。
下面展示一个对象赋值的代码示例(object_assign_and_copy_test1.cpp),如下:
#include
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。
对于对象赋值,进行以下几点说明:
// 声明obj1和obj2
ClassA obj1;
ClassA obj2;
obj2 = obj1; // 此语句为对象的赋值
// 声明obj1
ClassA obj1;
// 声明并初始化obj2
ClassA obj2 = obj1; // 此语句属于对象的复制
下面从内存分配的角度分析一下对象的赋值操作。
在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 的内存域的值为随机值)。两者的内存分配效果如下:
延续上面的示例代码,我们执行“obj2 = obj1;”,即利用默认的赋值运算符将对象 obj1 的值赋给 obj2。使用类中默认的赋值运算符,会将对象中的所有位于 stack 中的域进行相应的复制操作;同时,如果对象有位于 heap 上的域,则不会为目标对象分配 heap 上的空间,而只是让目标对象指向源对象 heap 上的同一个地址。
执行了“obj2 = obj1;”默认的赋值运算后,两个对象的内存分配效果如下:
因此,对于类中默认的赋值运算,如果源对象域内没有 heap 上的空间,其不会产生任何问题。但是,如果源对象域内需要申请 heap 上的空间,那么由于源对象和目标对象都指向 heap 的同一段内容,所以在析构对象的时候,就会连续两次释放 heap 上的那一块内存区域,从而导致程序异常。
~ClassA()
{
delete m_pszName;
}
为了解决上面的问题,如果对象会在 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 空间的内存,也就不会出现程序异常了。
下文讲述“对象的复制”的相关内容。
相对于“对已声明的对象使用赋值运算符进行的对象赋值”操作,使用拷贝构造函数操作对象的方式,称为“对象的复制”。
类的拷贝构造函数是一种特殊的构造函数,其形参是本类对象的引用。拷贝构造函数的作用为:在创建一个新对象时,使用一个已经存在的对象去初始化这个新对象。例如语句“ClassA obj2(obj1);”就使用了拷贝构造函数,该语句在创建新对象 obj2 时,利用已经存在的对象 obj1 去初始化对象 obj2。
对象的赋值与对象的拷贝,貌似都是只对类的成员变量进行拷贝,而不会对类的成员函数进行操作。—— 待进一步确认。
拷贝构造函数有以下特点:
这里展示一个自定义拷贝构造函数的代码示例(object_assign_and_copy_test2.cpp),如下:
#include
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 的成员变量的值,完成了我们自定义的初始化过程。
我们可以从调用形式上,对“对象的赋值”和“对象的复制”进行区分。在此,我们列出一些对应关系:
上面的对应关系是不严谨的,因为有些情况下,即使使用了赋值运算符“=”,但其实最终使用的仍然是类的拷贝构造函数,这就引出了拷贝构造函数的两种调用形式。
拷贝构造函数的调用语法分为两种:
拷贝构造函数的“赋值法”就很容易与“对象的赋值”场景混淆,其二者之间的区别是:对象的赋值场景必须是建立在源对象与目标对象均已声明的基础上;而拷贝构造函数函数的赋值法,必须是针对新创建对象的场景。代码如下:
【对象的赋值】:
// 声明对象obj1和obj2
ClassA obj1;
ClassA obj2;
obj1.SetValue(1, 2);
// 对象赋值场景 —— 将obj1的值赋给obj2
obj2 = obj1;
【拷贝构造函数的“赋值法”】:
// 创建并初始化对象obj1,此处调用了普通构造函数
ClassA obj1(1, 2);
// 创建并初始化对象obj2,此处调用了自定义的拷贝构造函数
ClassA obj2 = obj1;
当然,为了代码的清晰化,建议使用拷贝构造函数的“代入法”,更可以让人一眼就看出调用的是拷贝构造函数。
当使用类的一个对象去初始化另一个对象时,会调用拷贝构造函数(包括“代入法”和“赋值法”)。示例代码如下:
// 创建并初始化对象obj1,此处调用了普通构造函数
ClassA obj1(1, 2);
// 创建并初始化对象obj2,此处调用了自定义的拷贝构造函数
ClassA obj2 = obj1; // 代入法
ClassA obj3 = obj1; // 赋值法
当类对象作为函数形参时,在调用函数进行形参和实参转换时,会调用拷贝构造函数。示例代码如下:
// 形参是类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);”就会调用拷贝构造函数。
当函数的返回值是类的对象、在函数调用完毕将返回值(对象)带回函数调用处,此时会调用拷贝构造函数,将函数返回的对象赋值给一个临时对象,并传到函数的调用处。示例代码如下:
// 函数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++中的“深拷贝”和“浅拷贝”,以及赋值运算符的重载、拷贝构造函数的重载的相关内容。
浅拷贝:就是只拷贝类中位于 stack 域中的内容,而不会拷贝 heap 域中的内容。
例如,使用类的默认的赋值运算符“=”,或默认的拷贝构造函数时,进行的对象拷贝都属于浅拷贝。这也说明,“浅拷贝”与使用哪种方式(赋值运算符或是拷贝构造函数)进行对象拷贝无关。
浅拷贝会有一个问题,当类中存在指针成员变量时,进行浅拷贝后,目标对象与源对象的该指针成员变量将会指向同一块 heap 内存(而非每个对象单独一块内存),这就会导致由于共用该段内存而产生的内存覆盖、重复释放内存等等问题。详情可参考本系列第一章内容。
所以,对于带有指针的类对象的拷贝操作,正确的做法应当使两个对象的指针指向各自不同的内存,即在拷贝时不是简单地拷贝指针,而是将指针指向的内存中的每一个元素都进行拷贝。由此也就引出了“深拷贝”的概念。
深拷贝:当进行对象拷贝时,将对象位于 stack 域和 heap 域中的数据都进行拷贝。
前面也提到了,类默认提供的赋值运算符或拷贝构造函数,进行的都是浅拷贝,所以,为了实现对象的深拷贝,我们需要对赋值运算符或拷贝构造函数进行重载,以达到深拷贝的目的。
这里展示一段重载赋值运算符的示例代码,如下:
// 重载赋值运算符
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;
这里展示一段重载拷贝构造函数的示例代码,如下:
// 重载拷贝构造函数,重载后的拷贝构造函数支持深拷贝
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;
从上述两个示例代码可以看出,支持深拷贝的重载赋值运算符和重载拷贝构造函数相似,但两者也存在以下区别: