3.1 拷贝构造函数概述
现在我们来学习一种特殊的构造函数——拷贝构造函数。
对于普通类型的对象来说,他们之间的复制是很简单的,例如:
int a = 10;
int b =a;
自己定义的类的对象同样是对象,谁也不能阻止我们用以下的方式进行复制,例如:
#include <iostream>
using namespace std;
class Test
{
int p;
public:
Test(int temp)
{
p = temp;
}
void Show()
{
cout << "p = " << p << endl;
}
};
int main()
{
Test a(99);
Test b = a;
a.Show();
b.Show();
getchar();
return 0;
}
程序将正确运行并输出:
p = 99
p = 99
普通对象和类对象同为对象,他们之间的特性有相似之处也有不同之处,类对象内部存在成员变量,而普通对象是没有的,当同样的复制方法发生在不同的对象上的时候,那么系统对他们进行的操作也是不一样的,就类对象而言,相同类型的类对象是通过拷贝构造函数来完成整个复制过程的,在上面的代码中,我们并没有看到拷贝构造函数,同样完成了复制工作,这又是为什么呢?因为当一个类没有自定义的拷贝构造函数的时候系统会自动提供一个默认的拷贝构造函数,来完成复制工作。
例如:
#include <iostream>
using namespace std;
class Test
{
int p1;
int p2;
public:
Test(int i, int j)
{
cout << "class Test:\n";
p1 = i;
p2 = j;
}
void Show()
{
cout << "p1 = " << p1 << endl;
cout << "p2 = " << p2 << endl;
}
};
int main()
{
Test a(99, 100);
Test b = a;
a.Show();
b.Show();
getchar();
return 0;
}
程序将正确运行并输出:
class Test:
p1 = 99
p2= 100
p1 = 99
p2= 100
(请注意构造函数只调用了1次)
下面,我们为了说明情况,就普通情况而言(以上面的代码为例),我们来自己定义一个与系统默认拷贝构造函数一样的拷贝构造函数,看看它的内部是如何工作的!
#include <iostream>
using namespace std;
class Test
{
int p1;
int p2;
public:
Test(int i, int j)
{
cout << "class Test:\n";
p1 = i;
p2 = j;
}
Test(const Test &cT) //这里就是自定义的拷贝构造函数
{
cout << "进入拷贝构造函数" << endl;
p1 = cT.p1; //这句如果去掉就不能完成复制工作了,它是复制过程的核心语句
p2 = cT.p2; //这句如果去掉就不能完成复制工作了,它是复制过程的核心语句
}
void Show()
{
cout << "p1 = " << p1 << endl;
cout << "p2 = " << p2 << endl;
}
};
int main()
{
Test a(99, 100);
a.Show();
Test b = a;
b.Show();
getchar();
return 0;
}
程序将正确运行并输出:
class Test:
p1 = 99
p2= 100
进入拷贝构造函数
p1 = 99
p2= 100
上面代码中的Test(const Test &cT) 就是我们自定义的拷贝构造函数,拷贝构造函数的名称必须与类名称一致,函数的形式参数是本类型的一个引用变量,且必须是引用。
当用一个已经初始化过了的自定义类类型对象去初始化另一个新构造的对象的时候,拷贝构造函数就会被自动调用。如果你没有自定义拷贝构造函数的时候系统将会提供给一个默认的拷贝构造函数来完成这个过程,上面代码的复制核心语句就是通过Test(const Test &cT) 拷贝构造函数内的 p1 = cT.p1; 和 p2 = cT.p2; 语句完成的。如果取掉这两句代码,那么b对象的p1,p2属性将得到一个未知的随机值。
3.2 浅拷贝和深拷贝
下面我们来讨论一下关于浅拷贝和深拷贝的问题。
就上面的代码情况而言,很多人会问到,既然系统会自动提供一个默认的拷贝构造函数来处理复制,那么我们没有必要去自定义拷贝构造函数呀,对,就普通情况而言这的确是没有必要的,但在某些状况下,类体内的成员是需要开辟动态堆内存的,如果我们不自定义拷贝构造函数而让系统自己处理,那么就会导致堆内存的所属权产生混乱。试想一下,已经开辟的一端堆地址原来是属于对象a的,由于复制过程发生,b对象取得是a已经开辟的堆地址,一旦程序产生析构,释放堆的时候,计算机不清楚这段地址是真正属于谁的,当连续发生两次析构的时候就出现了运行错误。
为了更详细的说明问题,请看如下的代码。
#include <iostream>
using namespace std;
class Internet
{
char name[20];
char *cname;
public:
Internet(char *name)
{
cout << "载入构造函数:" << endl;
strcpy(Internet::name, name);
cname = new char[strlen(name)+1];
if (cname != NULL)
{
strcpy(Internet::cname, name);
}
}
~Internet()
{
cout << "载入析构函数:";
delete[] cname;
cin.get();
}
void Show()
{
printf("name的地址: %x ;name的字符串: %s\n", name, name);//显示name的地址和字符串
printf("cname的地址: %x ;cname的字符串: %s\n", cname, cname);//显示cname的地址和字符串
}
void SetMember(char *name)
{
cout << "修改成员:" << endl;
strcpy(Internet::cname, name);
}
};
int main()
{
Internet a("中国");
Internet b = a;
a.Show();
b.Show();
a.SetMember("地址");
a.Show();
b.Show();
getchar();
return 0;
}
程序输出:
载入构造函数:
name的地址: 23ff40;name的字符串: 中国
cname的地址: 33778;cname的字符串: 中国
name的地址: 23ff40;name的字符串: 中国
cname的地址: 33778;cname的字符串: 中国
修改成员:
name的地址: 23ff40;name的字符串: 中国
cname的地址: 33778;cname的字符串: 地址
name的地址: 23ff40;name的字符串: 中国
cname的地址: 33778;cname的字符串: 地址
载入析构函数:
载入析构函数:
程序员希望用对象a复制出另一个具有相同结构和内容的对象b,特别希望所有b中的成员都要独占内存空间,以便修改b内的成员时不要影响到a的成员。
但是在执行Internet b = a;的结果证明,系统在执行默认的拷贝构造函数后,获得了对象a所需的静态内存资源,并将全部处在对象a内的成员数据按位拷贝到对象b中。因此对象a和b的指针成员cname的值都是相同的(即都指向了相同的地址),这也就必然导致在执行程序中a.Show(); 和b.Show(); 语句时所显示的值是相同的;执行语句a.SetMember("地址");后,不但修改了a成员的值,也修改了b成员的值。
这不仅没有达到设计要求,而且当程序结束分别自动执行a和b的析构函数时,将以 delete[] cname; 语句对同一地址的对象释放两次。而很有可能在释放首次后,该地址又为另外的进程所占用,所以第二次释放该地址时会造成不可预料的结果,属于不安全的安排,甚至可能危及整个系统的安全。(引自〈〈c++STL程序开发指南〉〉)
那么将如何解决这个问题呢?我们可以创建一个拷贝构造函数来解决。
代码如下:
#include <iostream>
using namespace std;
class Internet
{
char name[20];
char *cname;
public:
Internet(char *name)
{
cout << "载入构造函数:" << endl;
strcpy(Internet::name, name);
cname = new char[strlen(name)+1];
if (cname != NULL)
{
strcpy(Internet::cname, name);
}
}
Internet(const Internet &temp)
{
cout << "载入拷贝构造函数" << endl;
strcpy(Internet::name, temp.name);
cname = new char[strlen(name)+1];//这里注意,深拷贝的体现!
if (cname != NULL)
{
strcpy(cname, name);
}
}
~Internet()
{
cout << "载入析构函数:";
delete[] cname;
cin.get();
}
void Show()
{
printf("name的地址: %x ;name的字符串: %s\n", name, name);//显示name的地址和字符串
printf("cname的地址: %x ;cname的字符串: %s\n", cname, cname);//显示cname的地址和字符串
}
void SetMember(char *name)
{
cout << "修改成员:" << endl;
strcpy(Internet::cname, name);
}
};
int main()
{
Internet a("中国");
Internet b = a;
a.Show();
b.Show();
a.SetMember("地址");
a.Show();
b.Show();
getchar();
return 0;
}
程序输出:
载入构造函数:
载入拷贝构造函数
name的地址: 23ff40;name的字符串: 中国
cname的地址: 33778;cname的字符串: 中国
name的地址: 23ff20;name的字符串: 中国
cname的地址: 3377e0;cname的字符串: 中国
修改成员:
name的地址: 23ff40;name的字符串: 中国
cname的地址: 33778;cname的字符串: 地址
name的地址: 23ff20;name的字符串: 中国
cname的地址: 3377e0;cname的字符串: 中国
载入析构函数:
载入析构函数:
上面代码就演示了深拷贝的问题,对对象b的cname属性采取了新开辟内存的方式避免了内存归属不清所导致析构释放空间时候的错误。最后我必须提一下,对于上面的程序我的解释并不多,就是希望读者本身运行程序观察变化,进而深刻理解。
深拷贝和浅拷贝的定义可以简单理解成:如果一个类拥有资源(堆,或者是其它系统资源),当这个类的对象发生复制过程的时候(复制指针所指向的值),这个过程就可以叫做深拷贝,反之对象存在资源但复制过程并未复制资源(只复制了指针所指的地址)的情况视为浅拷贝。
浅拷贝资源后在释放资源的时候会产生资源归属不清的情况导致程序运行出错,这点尤其需要注意!
原则上,应该为所有包含动态分配成员的类都提供拷贝构造函数。
3.2 拷贝构造函数的另一种调用
当对象直接作为参数传给函数时,函数将建立对象的临时拷贝,这个拷贝过程也将调用拷贝构造函数。
例如:
#include <iostream>
using namespace std;
class Date{
int n;
public:
Date(int i = 0)
{
cout << "载入构造函数" << endl;
n = i;
}
Date(const Date &d)
{
cout << "载入拷贝构造函数" << endl;
n = d.n;
}
int GetMember()
{
return n;
}
};
void Display(Date obj) //针对obj的操作实际上是针对复制后的临时拷贝进行的
{
cout << obj.GetMember() << endl;
}
int main()
{
Date a;
Date b(99);
Display(a); //对象直接作为参数
Display(b); //对象直接作为参数
getchar();
return 0;
}
程序输出:
载入构造函数:
载入构造函数:
载入拷贝构造函数
0载入拷贝构造函数
99
还有一种情况,也是与临时对象有关的。
当函数中的局部对象被用作返回值,返回给函数调用时,也将建立此局部对象的一个临时拷贝,此时拷贝构造函数也将被调用。——可是经测试发现情况有异。
代码如下:
#include <iostream>
using namespace std;
class Date{
int n;
public:
Date(int i = 0)
{
cout << "载入构造函数" << endl;
n = i;
}
Date(const Date &d)
{
cout << "载入拷贝构造函数" << endl;
n = d.n;
}
void Show()
{
cout << "n = " << n << endl;
}
};
Date GetClass(void) //函数中的局部对象被用作返回值,按理说应该引用拷贝构造函数
{
Date temp(100);
return temp;
}
int main()
{
Date a;
a.Show();
a = GetClass();//这里GetClass()函数中的局部对象被用作返回值
a.Show();
Date b = GetClass();//这里GetClass()函数中的局部对象被用作返回值
b.Show();
getchar();
return 0;
}
程序输出:
载入构造函数:
n = 0
载入构造函数:
n = 100
载入构造函数:
n = 100
按理第2个和第3个应该输出'载入拷贝构造函数"才对,这个结果与预想的不一样,到底是哪里出问题了呢?
注:后来有论坛上的朋友告诉我说这是因为编译器的不同而导致不同的输出。
有人得到这样的输出结果:
载入构造函数
n = 0
载入构造函数
载入拷贝构造函数
n = 100
载入构造函数
载入拷贝构造函数
n = 100
还有人得到这样的输出结果:
载入构造函数
n = 0
载入构造函数
载入拷贝构造函数
n = 100
载入构造函数
载入拷贝构造函数
载入拷贝构造函数
n = 100
(用的是vc++)
3.3 无名对象
现在我们来说一下无名对象。什么是无名对象?利用无名对象初始化对象系统不会调用拷贝构造函数?这是我们需要回答的两个问题。
首先我们来回答第一个问题。很简单,如果在程序的main函数中有:
Internet ("中国"); //Internet表示一个类
这样的一句语句就会产生一个无名对象。
无名对象会调用构造函数,但利用无名对象初始化对象时系统不会调用拷贝构造函数!
下面的代码是常见的利用无名对象初始化对象的例子。
#include <iostream>
using namespace std;
class Internet
{
char name[20];
char *cname;
public:
Internet(char *name)
{
cout << "载入构造函数:" << endl;
strcpy(Internet::name, name);
cname = new char[strlen(name)+1];
if (cname != NULL)
{
strcpy(Internet::cname, name);
}
}
Internet(const Internet &temp)
{
cout << "载入拷贝构造函数" << endl;
strcpy(Internet::name, temp.name);
cname = new char[strlen(name)+1];//这里注意,深拷贝的体现!
if (cname != NULL)
{
strcpy(cname, name);
}
}
~Internet()
{
cout << "载入析构函数:";
delete[] cname;
cin.get();
}
void Show()
{
printf("name的地址: %x ;name的字符串: %s\n", name, name);//显示name的地址和字符串
printf("cname的地址: %x ;cname的字符串: %s\n", cname, cname);//显示cname的地址和字符串
}
};
int main()
{
Internet a = Internet("中国");
a.Show();
getchar();
return 0;
}
程序输出:
载入构造函数:
name的地址: 23ff40;name的字符串: 中国
cname的地址: 33778;cname的字符串: 中国
载入析构函数:
上面代码的运行结果有点“出人意料”,从思维逻辑上说,当无名对象创建了后,是应该调用自定义拷贝构造函数,或者是默认拷贝构造函数来完成复制过程的,但事实上系统并没有这么做,因为无名对象使用过后在整个程序中就失去了作用。对于这种情况c++会把代码看成是: Internet a ("中国"); 省略了创建无名对象这一过程,所以说不会调用拷贝构造函数。
4.1赋值符的重载
由于并非所有的对象都会使用拷贝构造函数和赋值函数,程序员可能对这两个函数有些轻视。请先记住以下的警告,在阅读正文时就会多心:
本章开头讲过,如果不主动编写拷贝构造函数和赋值函数,编译器将以“位拷贝”的方式自动生成缺省的函数。倘若类中含有指针变量,那么这两个缺省的函数就隐含了错误。以类String的两个对象a,b为例,假设a.m_data的内容为“hello”,b.m_data的内容为“world”。
现将a赋给b,缺省赋值函数的“位拷贝”意味着执行b.m_data = a.m_data。这将造成三个错误:一是b.m_data原有的内存没被释放,造成内存泄露;二是b.m_data和a.m_data指向同一块内存,a或b任何一方变动都会影响另一方;三是在对象被析构时,m_data被释放了两次。
拷贝构造函数和赋值函数非常容易混淆,常导致错写、错用。拷贝构造函数是在对象被创建时调用的,而赋值函数只能被已经存在了的对象调用。以下程序中,第三个语句和第四个语句很相似,你分得清楚哪个调用了拷贝构造函数,哪个调用了赋值函数吗?
String a(“hello”);
String b(“world”);
String c = a; // 调用了拷贝构造函数,最好写成 c(a);
c = b; // 调用了赋值函数
本例中第三个语句的风格较差,宜改写成String c(a) 以区别于第四个语句。
请看下面的代码:
#include <iostream>
using namespace std;
class Date{
int n;
public:
Date(int i = 0)
{
cout << "载入构造函数" << endl;
n = i;
}
Date(const Date &d)
{
cout << "载入拷贝构造函数" << endl;
n = d.n;
}
void Show()
{
cout << "n = " << n << endl;
}
};
int main()
{
Date a(100);
a.Show();
Date b = a; //"="在对象声明语句中,表示初始化,调用拷贝构造函数
b.Show();
Date c;
c.Show();
c = a; //"="在赋值语句中,表示赋值操作,调用赋值函数
c.Show();
getchar();
return 0;
}
程序输出:
载入构造函数:
n = 100
载入拷贝构造函数
n = 100
载入构造函数:
n = 0
n = 100
在程序中语句Date b = a; 和c = a; 都用到了"="号,但两者的意义不同。在对象声明语句Date b = a;中,"="表示初始化,调用拷贝构造函数。而在赋值语句c = a;中,表示赋值操作,调用赋值函数。将对象a的内容复制到对象b,这其中涉及到对象b原有内容的丢弃,新内容的复制。
C++编译器将自动产生默认的赋值函数A & operate =(const A &a);但"="的默认操作只是将成员变量的值相应复制。旧的值被自然丢弃。
由于对象内包含指针,将造成不良后果:指针的值被丢弃了,但指针指向的内容并未释放;指针的值被复制了,但指针所指内容并未复制——这与系统默认拷贝构造函数相似。
因此,包含动态分配成员的类除提供拷贝构造函数外,还应该考虑重载"="赋值操作符号。
示例:类String的拷贝构造函数和赋值函数:
// 拷贝构造函数
String::String(const String &other)
{
// 允许操作other的私有成员m_data
int length = strlen(other.m_data);
m_data = new char[length+1];
strcpy(m_data, other.m_data);
}
// 赋值函数
String & String::operator =(const String &other)
{
// (1) 检查自赋值
if(this == &other)
return *this;
// (2) 释放原有的内存资源
delete [] m_data;
// (3)分配新的内存资源,并复制内容
int length = strlen(other.m_data);
m_data = new char[length+1];
strcpy(m_data, other.m_data);
// (4)返回本对象的引用
return *this;
}
类String拷贝构造函数与普通构造函数的区别是:在函数入口处无需与NULL进行比较,这是因为“引用”不可能是NULL,而“指针”可以为NULL。
类String的赋值函数比构造函数复杂得多,分四步实现:
(1)第一步,检查自赋值。你可能会认为多此一举,难道有人会愚蠢到写出 a = a 这样的自赋值语句!的确不会。但是间接的自赋值仍有可能出现,例如
// 内容自赋值
b = a;
…
c = b;
…
a = c; // 地址自赋值
b = &a;
…
a = *b;
也许有人会说:“即使出现自赋值,我也可以不理睬,大不了花点时间让对象复制自己而已,反正不会出错!”
他真的说错了。看看第二步的delete,自杀后还能复制自己吗?所以,如果发现自赋值,应该马上终止函数。注意不要将检查自赋值的if语句
if(this == &other)
错写成为
if( *this == other)
(2)第二步,用delete释放原有的内存资源。如果现在不释放,以后就没机会了,将造成内存泄露。
(3)第三步,分配新的内存资源,并复制字符串。注意函数strlen返回的是有效字符串长度,不包含结束符‘\0’。函数strcpy则连‘\0’一起复制。
(4)第四步,返回本对象的引用,目的是为了实现象 a = b = c 这样的链式表达。注意不要将 return *this 错写成 return this 。那么能否写成return other 呢?效果不是一样吗?
不可以!因为我们不知道参数other的生命期。有可能other是个临时对象,在赋值结束后它马上消失,那么return other返回的将是垃圾。(引自〈〈高质量c++编程指南〉〉)
4.2 在拷贝构造函数中使用赋值函数
为了简化程序,我们通常在拷贝构造函数中使用赋值函数。
例如:#include <iostream>
using namespace std;
class Date{
int da, mo, yr;
public:
Date(int d = 0, int m = 0, int y = 0)
{
cout << "载入构造函数" << endl;
da = d;
mo = m;
yr = y;
}
Date(const Date &other);
Date & operator =(const Date &other);
void Show()
{
cout << mo << "-" << da << "-" << yr << endl;
}
};
Date::Date(const Date &other) //拷贝构造函数中使用赋值函数
{
cout << "载入拷贝构造函数" << endl;
*this = other;
}
Date & Date::operator =(const Date &other)
{
cout << "载入赋值函数" << endl;
if(this == &other)
return *this;
da = other.da;
mo = other.mo;
yr = other.yr;
return *this;
}
int main()
{
Date a(1, 3, 6);
a.Show();
Date b = a;
b.Show();
Date c;
c.Show();
c = a;
c.Show();
getchar();
return 0;
}
程序输出:
载入构造函数:
3-1-6
载入拷贝构造函数
载入赋值函数
3-1-6
载入构造函数:
0-0-0
载入赋值函数
3-1-6
请注意:程序输出了两次“载入赋值函数”,这是因为我们在拷贝构造函数中使用了赋值函数,这样使程序变得简洁。如果把拷贝构造函数改写为:
Date::Date(const Date &other)
{
cout << "载入拷贝构造函数" << endl;
da = other.da;
mo = other.mo;
yr = other.yr;
}
则程序将输出:
载入构造函数:
3-1-6
载入拷贝构造函数
3-1-6
载入构造函数:
0-0-0
载入赋值函数
3-1-6
4.3 偷懒的办法处理拷贝构造函数和赋值函数
如果我们实在不想编写拷贝构造函数和赋值函数,又不允许别人使用编译器生成的缺省函数,怎么办?
偷懒的办法是:只需将拷贝构造函数和赋值函数声明为私有函数,不用编写代码。
例如:
class A
{ …
private:
A(const A &a); // 私有的拷贝构造函数
A & operator =(const A &a); // 私有的赋值函数
};
如果有人试图编写如下程序:
A b(a); // 调用了私有的拷贝构造函数
b = a; // 调用了私有的赋值函数
编译器将指出错误,因为外界不可以操作A的私有函数。(引自〈〈高质量c++编程指南〉〉)
评论