构造函数、析构函数及拷贝构造函数/浅拷贝/深拷贝
构造函数、析构函数及拷贝构造函数/浅拷贝/深拷贝
一、构造函数
当创建一个类型对象时,类通过一个或者几个特殊的成员函数来控制对象的初始化,这种函数就是构造函数。它的任务就是用来初始化类对象的成员的,所以当创建类对象或者类对象被创建就会调用构造函数。
构造函数的几个特点:
1. 函数名和类名必须一样,没有返回值。
2.当没有显式的定义构造函数时,系统会自己生成默认的构造函数。
3.构造函数可以重载(可以带多个参数,析构函数不可以重载,因为析构函数无参),不可以为虚函数。
class Date
{
public:
Date()
{ }
Date(int day)
{
_year = 1949;
_month = 10;
_day = day;
}
void print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year=1990;
int _month;
int _day;
};
在上面的代码中,定义了一个简单的Date类类型,可以看到有显式的给出了构造函数,第一个是没有参数列表且函数不做任何事的,还有一个是有一个整型参数day的,就是当我传了一个day参数,则在函数内部把它的year和month初始化为1994和10。这样的两个构造函数就构成了重载,因为能够重载,所以在写构造函数的时候要保证只有一个缺省的构造函数。参数列表为空或者参数全缺省称为缺省构造函数。
当不传参的定义一个Date类型对象,会调用显式定义的缺省构造函数,在没有初始化列表的情况下采取类内初始化或默认初始化,上面的程序中,如果不传参,那么构建的对象的_year成员为1990,另外两个值为随机值。
牢记:
我们在没有显式的定义构造函数时,系统会自动生成一个默认构造函数。当我们定义了一些其他的构造函数时,这个类就将没有默认构造函数。所以当我们显式的定义了其他构造函数,最好把默认构造函数也显式的定义一遍。这样也有好处,就是系统生成的默认构造函数有可能执行错误的操作或者无法完成类成员的初始化(例如:有一个成员是类类型的对象且它没有缺省的构造函数)。
当我们定义的默认构造函数并不需要干什么事情,只是因为上面的情况才显式的定义它,那么此时的默认构造函数等同于系统生成的默认构造函数,那么我们可以这么定义:
Date() = default;
因为在新标准中,如果需要系统默认的行为,就可以通过在参数列表后加上=default来使编译器生成构造函数。
2、初始化列表
如下图所示,在冒号和花括号之间的代码部分称为构造函数的初始值列表,它的作用是给创建的对象的某些成员赋初值。这种是在构建对象的时候的初始化,是在对象创建成功之前完成的,和在函数体内赋值是不一样的,函数体内赋值是你的对象成员都已经创建好后对成员进行的赋值。

那么,可以看到,这种初始化并不是必须的。但是在以下几种情况时是必须进行初始化的:
- 成员是const类型。
- 成员是引用类型。
- 有一个成员是类类型的对象(且它没有缺省的构造函数)
class Time
{
public:
Time( )
{
}
private:
int _hour;
};
class Date
{
public:
Date(int year=1990,int month=1,int day=1)
:_year(year), _month(month), _day(day), t(10)
{ }
void print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year=1990;
int _month;
int _day;
Time t;
};
.
解释:
1. 对于const和引用类型,必须要进行初始化,所以他们必须在初始化列表中进行初始化。
2. 当类类型成员有缺省的构造函数时,在创建对象的时候体统会默认调用,因为不用传参。当你的构造函数不是缺省的,如果不在初始化列表中进行调用构造函数,系统就无法知道怎么调用t的构造函数,那么就无法创建t了。
如上代码中,需要在参数列表中调用t的构造函数才不会出错。
成员初始化顺序:
在上面的初始列表中,每个成员只能出现一次,因为一个变量多次初始化是无意义的。
还有重要的一点,初始化列表的顺序并不限定初始化的执行顺序。成员的初始化顺序是与类中定义的顺序保持一致。可以看看下面的初始化列表:

在这里的意思是想要用1来初始化_month,再用_month初始化_year。但其实是_year被先初始化,而此时你的_month并没有初始化,所以,最后的结果是_year是一个随机值。
所以,最好让构造函数初始值的顺序与成员声明的顺序保持一致。
二、析构函数
1、认识析构函数
类的析构函数,它是类的一个成员函数,名字由波浪号加类名构成,是执行与构造函数相反的操作:释放对象使用的资源,并销毁非static成员。
同样的,我们来看看析构函数的几个特点:
- 函数名是在类名前加上~,无参数且无返回值。
- 一个类只能有且有一个析构函数,如果没有显式的定义,系统会生成一个缺省的析构函数(合成析构函数)。
- 析构函数不能重载。每有一次构造函数的调用就会有一次析构函数的调用。
拿程序说话:
//by Mr_Listening,06 08 2016
class Date
{
public:
Date(int year=1990,int month=1,int day=1)
: _month(year), _year(month), _day(day)
{ }
~Date()
{
cout << "~Date()" << this << endl;
}
private:
int _year=1990;
int _month;
int _day;
};
void test()
{
Date d1;
}
int main()
{
test();
return 0;
}
在test()函数中构造了对象d1,那么在出test()作用域d1应该被销毁,此时将调用析构函数,下面是程序的输出。当然在构建对象时是先调用构造函数的,在这里就不加以说明了。

我们知道,在构造函数中,成员的在初始化是在函数体执行前完成的,并按照成员在类中出现的顺序进行初始化,而在析构函数中,首先执行函数体,然后再销毁成员,并且成员按照初始化的逆序进行销毁。
2、销毁,清理?
我们一直在说析构函数的作用是在你的类对象离开作用域后释放对象使用的资源,并销毁成员。那么到底这里所说的销毁到底是什么?那么继续往下看:
void test ()
{
int a=10;
int b=20;
}
回想我们在一个函数体内定义一个变量的情况,在test函数中定义了a和b两个变量,那么在出这个函数之后,a和b就会被销毁(栈上的操作)。那么如果是是一个指向动态开辟的一块空间的指针,我们都知道需要自己进行free,否则会造成内存泄漏。
说到这里,其实在类里面的情况和这是一样的,这就是合成析构函数体为空的原因,函数并不需要做什么,当类对象出作用域时系统会释放你的内置类型的那些成员。但是像上面说的一样,如果,我的成员里有一个指针变量并且指向了一块你动态开辟的内存,那么像以前那样也需要自己来释放,此时就需要在析构函数内部写你的释放代码,这样在调用析构函数的时候就可以把你所有的资源进行释放。(其实这才是析构函数有用的地方,对吗)
那么还有一点,当类类型对象的成员还有一个类类型对象,那么在析构函数里也会调用这个对象的析构函数。
3、析构函数来阻止该类型对象被销毁?
我们如果不想要析构函数来对对象进行释放该怎么做呢,不显式的定义显然是不行的,因为编译器会生成默认的合成析构函数。之前我们知道了如果想让系统默认生成自己的构造函数可以利用default,那么其实还有一个东西叫做delete。
class Date
{
public:
Date(int year=1990,int month=1,int day=1)
: _year(year),_month(month), _day(day)
{ }
~Date() = delete;
private:
int _year=1990;
int _month;
int _day;
};
如果我这么写了,又在底下创建Date类型的对象,那么这个对象将是无法被销毁的,其实编译器并不允许这么做,直接会给我们报错。
但其实是允许我们动态创建这个类类型对象的,像这样:Date* p = new Date;虽然这样是可行的,但当你delete p的时候依然会出错,原因就不用说了吧。
所以既然这样做的话既不能定义一个对象也不能释放动态分配的对象,所以还是不要这么用为好喽。
4、注意喽
一般在你显式的定义了析构函数的情况下,应该也把拷贝构造函数和赋值操作显式的定义。为什么呢??
看下面的改动:
class Date
{
public:
Date(int year=1990,int month=1,int day=1)
: _year(year),_month(month), _day(day)
{
p = new int;
}
~Date()
{
delete p;
}
private:
int _year=1990;
int _month;
int _day;
int *p;
};
成员中有动态开辟的指针成员,在析构函数中对它进行了delete,如果不显式的定义拷贝构造函数,当你这样:Date d2(d1)来创建d2,我们都知道默认的拷贝构造函数是浅拷贝,那么这么做的结果就会是d2的成员p和d1的p是指向同一块空间的,呢么调用析构函数的时候回导致用一块空间被释放两次,程序会崩溃的哦!
三、类对象的拷贝
对于普通类型的对象来说,它们之间的复制是很简单的,例如:
int a=88;
int b=a;
而类对象与普通对象不同,类对象内部结构一般较为复杂,存在各种成员变量。下面看一个类对象拷贝的简单例子。
#include <iostream>
using namespace std;
class CExample {
private:
int a;
public:
CExample(int b)
{ a=b ;}
void Show ()
{
cout<<a<<endl ;
}
};
int main()
{
CExample A(100);
CExample B=A;
B.Show ();
return 0;
}
运行程序,屏幕输出100。从以上代码的运行结果可以看出,系统为对象B分配了内存并完成了与对象A的复制过程。就类对象而言,相同类型的类对象是通过拷贝构造函数来完成整个复制过程的。下面举例说明拷贝构造函数的工作过程。
#include <iostream>
using namespace std;
class CExample {
private:
int a;
public:
CExample(int b)
{ a=b;}
CExample(const CExample& C)
{
a=C.a;
}
void Show ()
{
cout<<a<<endl;
}
};
int main()
{
CExample A(100);
CExample B=A;
B.Show ();
return 0;
}
CExample(const CExample& C)就是我们自定义的拷贝构造函数。可见,拷贝构造函数是一种特殊的构造函数,函数的名称必须和类名称一致,它的唯一的一个参数是本类型的一个引用变量,该参数是const类型,不可变的。例如:类X的拷贝构造函数的形式为X(X& x)。
当用一个已初始化过了的自定义类类型对象去初始化另一个新构造的对象的时候,拷贝构造函数就会被自动调用。也就是说,当类的对象需要拷贝时,拷贝构造函数将会被调用。以下情况都会调用拷贝构造函数:
- 一个对象以值传递的方式传入函数体,
- 一个对象以值传递的方式从函数返回,
- 一个对象需要通过另外一个对象进行初始化。
如果在类中没有显式地声明一个拷贝构造函数,那么,编译器将会自动生成一个默认的拷贝构造函数,该构造函数完成对象之间的位拷贝。位拷贝又称浅拷贝,后面将进行说明。自定义拷贝构造函数是一种良好的编程风格,它可以阻止编译器形成默认的拷贝构造函数,提高源码效率。
四、浅拷贝和深拷贝
在某些状况下,类内成员变量需要动态开辟堆内存,如果实行位拷贝,也就是把对象里的值完全复制给另一个对象,如A=B。这时,如果B中有一个成员变量指针已经申请了内存,那A中的那个成员变量也指向同一块内存。这就出现了问题:当B把内存释放了(如:析构),这时A内的指针就是野指针了,出现运行错误。
深拷贝和浅拷贝可以简单理解为:如果一个类拥有资源,当这个类的对象发生复制过程的时候,资源重新分配,这个过程就是深拷贝,反之,没有重新分配资源,就是浅拷贝。下面举个深拷贝的例子。
#include <iostream>
using namespace std;
class CA
{
public:
CA(int b,char* cstr)
{
a=b;
str=new char[b];
strcpy(str,cstr);
}
CA(const CA& C)
{
a=C.a;
str=new char[a]; //深拷贝
if(str!=0)
strcpy(str,C.str);
}
void Show()
{
cout<<str<<endl;
}
~CA()
{
delete str;
}
private:
int a;
char *str;
};
int main()
{
CA A(10,"Hello!");
CA B=A;
B.Show();
return 0;
}
深拷贝和浅拷贝的定义可以简单理解成:如果一个类拥有资源(堆,或者是其它系统资源),当这个类的对象发生复制过程的时候,这个过程就可以叫做深拷贝,反之对象存在资源,但复制过程并未复制资源的情况视为浅拷贝。
浅拷贝资源后在释放资源的时候会产生资源归属不清的情况导致程序运行出错。
CA(const CA& C)是自定义的拷贝构造函数,拷贝构造函数的名称必须与类名称一致,函数的形式参数是本类型的一个引用变量,且必须是引用。
当用一个已经初始化过了的自定义类类型对象去初始化另一个新构造的对象的时候,拷贝构造函数就会被自动调用,如果你没有自定义拷贝构造函数的时候,系统将会提供给一个默认的拷贝构造函数来完成这个过程,上面代码的复制核心语句就是通过CA(const CA& C)拷贝构造函数内的语句完成的。
浅拷贝:又称值拷贝,将源对象的值拷贝到目标对象中去,本质上来说源对象和目标对象共用一份实体,只是所引用的变量名不同,地址其实还是相同的。举个简单的例子,你的小名叫西西,大名叫冬冬,当别人叫你西西或者冬冬的时候你都会答应,这两个名字虽然不相同,但是都指的是你。
假设有一个String类,String s1;String s2(s1);在进行拷贝构造的时候将对象s1里的值全部拷贝到对象s2里。
我们现在来简单的实现一下这个类
#include <iostream>
#include<cstring>
using namespace std;
class STRING
{
public:
STRING( const char* s = "" ) :_str( new char[strlen(s)+1] )
{
strcpy_s( _str, strlen(s)+1, s );
}
STRING( const STRING& s )
{
_str = s._str;
}
STRING& operator=(const STRING& s)
{
if (this != &s)
{
this->_str = s._str;
}
return *this;
}
~STRING()
{
cout << "~STRING" << endl;
if (_str)
{
delete[] _str;
_str = NULL;
}
}
void show()
{
cout << _str << endl;
}
private:
char* _str;
};
int main()
{
STRING s1("hello linux");
STRING s2(s1);
s2.show();
return 0;
}
其实这个程序是存在问题的,什么问题呢?我们想一下,创建s2的时候程序必然会去调用拷贝构造函数,这时候拷贝构造仅仅只是完成了值拷贝,导致两个指针指向了同一块内存区域。随着程序的运行结束,又去调用析构函数,先是s2去调用析构函数,释放了它指向的内存区域,接着s1又去调用析构函数,这时候析构函数企图释放一块已经被释放的内存区域,程序将会崩溃。s1和s2的关系就是这样的:

所以程序会崩溃是应该的,那么这个问题应该怎么去解决呢?这就引出了深拷贝。
深拷贝,拷贝的时候先开辟出和源对象大小一样的空间,然后将源对象里的内容拷贝到目标对象中去,这样两个指针就指向了不同的内存位置。并且里面的内容是一样的,这样不但达到了我们想要的目的,还不会出现问题,两个指针先后去调用析构函数,分别释放自己所指向的位置。即为每次增加一个指针,便申请一块新的内存,并让这个指针指向新的内存,深拷贝情况下,不会出现重复释放同一块内存的错误。
深拷贝实际上是这样的:

深拷贝的拷贝构造函数和赋值运算符的重载传统实现:
STRING( const STRING& s )
{
//_str = s._str;
_str = new char[strlen(s._str) + 1];
strcpy_s( _str, strlen(s._str) + 1, s._str );
}
STRING& operator=(const STRING& s)
{
if (this != &s)
{
//this->_str = s._str;
delete[] _str;
this->_str = new char[strlen(s._str) + 1];
strcpy_s(this->_str, strlen(s._str) + 1, s._str);
}
return *this;
}
这里的拷贝构造函数我们很容易理解,先开辟出和源对象一样大的内存区域,然后将需要拷贝的数据复制到目标拷贝对象,
那么这里的赋值运算符的重载是怎么样做的呢?

这种方法解决了我们的指针悬挂问题,通过不断的开空间让不同的指针指向不同的内存,以防止同一块内存被释放两次的问题,还有一种深拷贝的现代写法:
STRING( const STRING& s ):_str(NULL)
{
STRING tmp(s._str);// 调用了构造函数,完成了空间的开辟以及值的拷贝
swap(this->_str, tmp._str); //交换tmp和目标拷贝对象所指向的内容
}
STRING& operator=(const STRING& s)
{
if ( this != &s )//不让自己给自己赋值
{
STRING tmp(s._str);//调用构造函数完成空间的开辟以及赋值工作
swap(this->_str, tmp._str);//交换tmp和目标拷贝对象所指向的内容
}
return *this;
}
先来分析一下拷贝构造是怎么实现的:

拷贝构造调用完成之后,会接着去调用析构函数来销毁局部对象tmp,按照这种思路,不难可以想到s2的值一定和拷贝构造里的tmp的值一样,指向同一块内存区域,通过调试可以看出来:
在拷贝构造函数里的tmp:

调用完拷贝构造后的s2:(此时tmp被析构)

可以看到s2的地址值和拷贝构造里的tmp的地址值是一样
关于赋值运算符的重载还可以这样来写:
STRING& operator=(STRING s)
{
swap(_str, s._str);
return *this;
}
#include <iostream>
#include<cstring>
using namespace std;
class STRING
{
public:
STRING( const char* s = "" ) :_str( new char[strlen(s)+1] )
{
strcpy_s( _str, strlen(s)+1, s );
}
//STRING( const STRING& s )
//{
// //_str = s._str; //浅拷贝的写法
// cout << "拷贝构造函数" << endl;
// _str = new char[strlen(s._str) + 1];
// strcpy_s( _str, strlen(s._str) + 1, s._str );
//}
//STRING& operator=(const STRING& s)
//{
// cout << "运算符重载" << endl;
// if (this != &s)
// {
// //this->_str = s._str; //浅拷贝的写法
// delete[] _str;
// this->_str = new char[strlen(s._str) + 1];
// strcpy_s(this->_str, strlen(s._str) + 1, s._str);
// }
// return *this;
//}
STRING( const STRING& s ):_str(NULL)
{
STRING tmp(s._str);// 调用了构造函数,完成了空间的开辟以及值的拷贝
swap(this->_str, tmp._str); //交换tmp和目标拷贝对象所指向的内容
}
STRING& operator=(const STRING& s)
{
if ( this != &s )//不让自己给自己赋值
{
STRING tmp(s._str);//调用构造函数完成空间的开辟以及赋值工作
swap(this->_str, tmp._str);//交换tmp和目标拷贝对象所指向的内容
}
return *this;
}
~STRING()
{
cout << "~STRING" << endl;
if (_str)
{
delete[] _str;
_str = NULL;
}
}
void show()
{
cout << _str << endl;
}
private:
char* _str;
};
int main()
{
//STRING s1("hello linux");
//STRING s2(s1);
//STRING s2 = s1;
//s2.show();
const char* str = "hello linux!";
STRING s1(str);
STRING s2;
s2 = s1;
s1.show();
s2.show();
return 0;
}
转自:https://www.cnblogs.com/MrListening/p/5557114.html

浙公网安备 33010602011771号