0%

C++拷贝构造函数与赋值函数

这里我们用类String 来介绍这两个函数:

拷贝构造函数是一种特殊构造函数,具有单个形参,该形参(常用const修饰)是对该类类型的引用。当定义一个新对象并用一个同类型的对象对它进行初始化时,将显式使用拷贝构造函数。为啥形参必须是对该类型的引用呢?试想一下,假如形参是该类的一个实例,由于是传值参数,我们把形参复制到实参会调用拷贝构造函数,如果允许拷贝构造函数传值,就会在拷贝构造函数内调用拷贝构造函数,从而形成无休止的递归调用导致栈溢出。

1
2
string(const string &s);
//类成员,无返回值

赋值函数,也是赋值操作符重载,因为赋值必须作为类成员,那么它的第一个操作数隐式绑定到 this 指针,也就是 this 绑定到指向左操作数的指针。因此,赋值操作符接受单个形参,且该形参是同一类类型的对象。右操作数一般作为const 引用传递。

1
2
string&	operator=(const string &s);
//类成员,返回对同一类类型(左操作数)的引用

拷贝构造函数和赋值函数并非每个对象都会使用,另外如果不主动编写的话,编译器将以“位拷贝”的方式自动生成缺省的函数。在类的设计当中,“位拷贝”是应当防止的。倘若类中含有指针变量,那么这两个缺省的函数就会发生错误。这就涉及到深复制和浅复制的问题了。

拷贝有两种:深拷贝,浅拷贝
当出现类的等号赋值时,会调用拷贝函数,在未定义显示拷贝构造函数的情况下,系统会调用默认的拷贝函数——即浅拷贝,它能够完成成员的一一复制。当数据成员中没有指针时,浅拷贝是可行的。
但当数据成员中有指针时,如果采用简单的浅拷贝,则两类中的两个指针将指向同一个地址,当对象快结束时,会调用两次析构函数,而导致指针悬挂现象。所以,这时,必须采用深拷贝。
深拷贝与浅拷贝的区别就在于\深拷贝会在堆内存中另外申请空间来储存数据**,从而也就解决了指针悬挂的问题。指向不同的内存空间,但内容是一样的
简而言之,\当数据成员中有指针时,必须要用深拷贝**

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class A{
char * c;
}a, b;
//浅复制不会重新分配内存
//将a 赋给 b,缺省赋值函数的“位拷贝”意味着执行
a.c = b.c;
//从这行代码可以看出
//b.c 原有的内存没有释放
//a.c 和 b.c 指向同一块内存,任何一方的变动都会影响到另一方
//对象析构的时候,c 被释放了两次(a.c == b.c 指针一样)
//深复制需要自己处理里面的指针
class A{
char *c;
A& operator =(const A &b)
{
//隐含 this 指针
if (this == &b)
return *this;
delete c;//释放原有内存资源
//分配新的内存资源
int length = strlen(b.c);
c = new char[length + 1];
strcpy(c, b.c);
return *this;

}
}a, b;
//这个是深复制,它有自定义的复制函数,赋值时,对指针动态分配了内存

这里再总结一下深复制和浅复制的具体区别:

  1. 当拷贝对象状态中包含其他对象的引用时,如果需要复制的是引用对象指向的内容,而不是引用内存地址,则是深复制,否则是浅复制。
  2. 浅复制就是成员数据之间的赋值,当值拷贝时,两个对象就有共同的资源。而深拷贝是先将资源复制一份,是对象拥有不同的资源(内存区域),但资源内容(内存里面的数据)是相同的。
  3. 与浅复制不同,深复制在处理引用时,如果改变新对象内容将不会影响到原对象内容
  4. 与深复制不同,浅复制资源后释放资源时可能会产生资源归属不清楚的情况(含指针时,释放一方的资源,其实另一方的资源也随之释放了),从而导致程序运行出错

深复制和浅复制还有个区别就是执行的时候,浅复制是直接复制内存地址的,而深复制需要重新开辟同样大小的内存区域,然后复制整个资源。

好,有了前面的铺垫,下面开始讲讲拷贝构造函数和赋值函数,其实前面第一部分也已经介绍了许多

这里以string 类为例来进行说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class String
{
public:
String(const char *str = NULL);
String(const String &rhs);
String& operator=(const String &rhs);
~String(void){
delete[] m_data;
}
private:
char *m_data;
};
//构造函数
String::String(const char* str)
{
if (NULL == str)
{
m_data = new char[1];
*m_data = '\0';
}
else
{
m_data = new char[strlen(str) + 1];
strcpy(m_data, str);
}
}
//拷贝构造函数,无需检验参数的有效性
String::String(const String &rhs)
{
m_data = new char[strlen(rhs.m_data) + 1];
strcpy(m_data, rhs.m_data);
}
//赋值函数
String& String::operator=(const String &rhs)
{
if (this == &rhs)
return *this;
delete[] m_data; m_data = NULL;
m_data = new char[strlen(rhs.m_data) + 1];
strcpy(m_data, rhs.m_data);
return *this;
}

类String 拷贝构造函数与普通构造函数的区别是:在函数入口处无需与 NULL 进行比较,这是因为“引用”不可能是NULL,而“指针”可以为NULL。(这是引用与指针的一个重要区别)。然后需要注意的就是深复制了。

相比而言,对于类String 的赋值函数则要复杂的多:

1、首先需要执行检查自赋值

这是防止自复制以及间接复制,如 b = a; c = b; a = c;之类,如果不进行自检的话,那么后面的 delete 将会进行自杀操作,后面随之的拷贝操作也会出错,所以这是关键的一步。还需要注意的是,自检是检查地址,而不是内容,内存地址是唯一的。必须是 if(this == &rhs)

2、释放原有的内存资源

必须要用 delete 释放掉原有的内存资源,如果此时不释放,该变量指向的内存地址将不再是原有内存地址,也就无法进行内存释放,造成内存泄露。

3、分配新的内存资源,并复制资源

这样变量指向的内存地址变了,但是里面的资源是一样的

4、返回本对象的引用

这样的目的是为了实现像 a = b = c; 这样的链式表达,注意返回的是 *this 。

但仔细一想,上面的程序没有考虑到异常安全性,我们在分配内存之前用delete 释放了原有实例的内存,如果后面new 出现内存不足抛出异常,那么之前delete 的 m_data 将是一个空指针,这样很容易引起程序崩溃,所以我们可以调换下顺序,即先 new 一个实例内存,成功后再用 delete 释放原有内存空间,最后用 m_data 赋值为new后的指针。

接下来说说拷贝构造函数和赋值函数之间的区别。

拷贝构造函数和赋值函数非常容易混淆,常导致错写、错用。拷贝构造函数是在对象被创建是调用的,而赋值函数只能在已经存在了的对象调用。看下面代码:

1
2
3
4
5
String a("hello");
String b("world");
String c = a;//这里c对象被创建调用的是拷贝构造函数
//一般是写成 c(a);这里是与后面比较
c = b;//前面c对象已经创建,所以这里是赋值函数

上面说明出现“=”的地方未必调用的都是赋值函数(算术符重载函数),也有可能拷贝构造函数,那么什么时候是调用拷贝构造函数,什么时候是调用赋值函数你? 判断的标准其实很简单:如果临时变量是第一次出现,那么调用的只能是拷贝构造函数,反之如果变量已经存在,那么调用的就是赋值函数。