一、构造与析构函数
1.1、为什么需要构造与析构函数
为什么需要构造与析构函数?
我们先来用OPP思想实现一个顺序栈:
#include <iostream>
using namespace std;
class SeqStack//顺序栈
{
public:
void init(int size = 10)
{
_pstack = new int[size];
_top = -1;
_size = size;
}
void release()
{
delete []_pstack;
_pstack = nullptr;
}
void push(int val)
{
if (full())
{
resize();
}
_pstack[++_top] = val;
}
void pop()
{
if (empty()) return;
--_top;
}
int top()//获取栈顶元素
{
return _pstack[_top];
}
bool empty()
{
return _top == -1;
}
bool full()
{
return _top == _size-1;
}
private:
int *_pstack;//动态开辟数组,存储顺序栈的元素
int _top;//指向栈顶位置
int _size;//数组扩容的总大小
void resize()
{
int *ptmp = new int[_size*2];
for (int i=0; i<_size; i++)
{
ptmp[i] = _pstack[i];
}
delete []_pstack;
_pstack = ptmp;
_size *= 2;
}
};
int main()
{
SeqStack s;
s.init(5);
for (int i=0; i<15; ++i)
{
s.push(rand()%100);
}
while (!s.empty())
{
cout << s.top() << " ";
s.pop();
}
return 0;
}
测试一下:测试成功。
等等,我们好像忘了什么。没错,聪明的你一定发现了,我们堆上开辟的资源忘记释放了。当我们代码过多的时候,初始化或者释放的时候必须手动调用,手动释放,稍不注意就忘记释放了,程序容易崩溃。
有没有什么好的方法能够让我们自动初始化,自动释放资源呢?
此时我们C++中的构造函数与析构函数就该登场了!
1.2、构造函数与析构函数
构造函数:主要用来在创建对象时初始化对象, 即为对象成员变量赋初始值,总与new运算符一起使用在创建对象的语句中。 特别的一个类可以有多个构造函数 ,可根据其参数个数的不同或参数类型的不同来区分它们,即构造函数的重载。
析构函数:析构函数(destructor) 与构造函数相反,当对象结束其生命周期,如对象所在的函数已调用完毕时,系统自动执行析构函数。析构函数不能重载。 析构函数往往用来做“清理善后” 的工作(例如在建立对象时用new开辟了一片内存空间,delete会自动调用析构函数后释放内存)。
注意:
1.构造函数或析构函数与函数的名字和类名一样
2.构造函数或析构函数没有返回值
3.先构造的后析构,后构造的先析构。
4.析构函数不带参数,因此每一个类只能有一个析构函数;构造函数可以带参数,因此可以提供多个构造函数,即构造函数的重载。
5.构造函数无法自己调用,但是析构可以自己调用,析构函数调用后,对象不存在了,不能再调用对象的方法了,但是它的内存还在,等函数消亡后内存才不存在了,强行调用会造成内存的非法访问。即如下图:
我们定义一个对象,它会有如下操作:
1.开辟内存
2.调用构造函数
3.调用析构函数
4.释放内存
对象的产生与消亡:
构造函数调用完,对象就产生了。即先开辟内存,成员变量就有了,但是它们的值还不合法,合法的初始化后,对象才真正产生了。当调用析构后,对象就消失了。
不同对象的生命周期:
1.栈上局部对象:定义的时候调用构造,出函数作用域调用析构。
2.栈上全局对象(.data段):定义时候调用构造,程序结束时候才析构。
3.堆上对象:定义时:①.先malloc开辟内存②.调用构造;释放时:①.调用析构 ②.再释放内存。
例如:ps->~SeqStack() + free(ps);
我们举一个析构与构造例子:
SeqStack(int size = 10)//构造函数
{
_pstack = new int[size];
_top = -1;
_size = size;
}
~SeqStack()//析构函数
{
delete []_pstack;
_pstack = nullptr;
}
我们将刚才的代码修改一下:用构造与析构调用并打印它们。
#include <iostream>
using namespace std;
class SeqStack//顺序栈
{
public:
SeqStack(int size = 10)//构造函数
{
cout << this << " SeqStack() " <<endl;
_pstack = new int[size];
_top = -1;
_size = size;
}
~SeqStack()//析构函数
{
cout << this << " ~SeqStack() " <<endl;
delete []_pstack;
_pstack = nullptr;
}
void push(int val)
{
if (full())
{
resize();
}
_pstack[++_top] = val;
}
void pop()
{
if (empty()) return;
--_top;
}
int top()//获取栈顶元素
{
return _pstack[_top];
}
bool empty()
{
return _top == -1;
}
bool full()
{
return _top == _size-1;
}
private:
int *_pstack;//动态开辟数组,存储顺序栈的元素
int _top;//指向栈顶位置
int _size;//数组扩容的总大小
void resize()
{
int *ptmp = new int[_size*2];
for (int i=0; i<_size; i++)
{
ptmp[i] = _pstack[i];
}
delete []_pstack;
_pstack = ptmp;
_size *= 2;
}
};
int main()
{
SeqStack s;
for (int i=0; i<15; ++i)
{
s.push(rand()%100);
}
while (!s.empty())
{
cout << s.top() << " ";
s.pop();
}
cout << endl;
return 0;
}
来瞧一瞧:构造函数与析构函数代替了对象成员变量的初始化与资源释放操作。这就是使用构造与析构的好处。
二、深拷贝与浅拷贝分析
2.1、浅拷贝
拷贝构造函数:我们没有提供拷贝构造时系统会自动提供,用同类型的已经存在的对象来产生一个同类型的新对象,做的是内存的数据拷贝,即浅拷贝。拷贝构造函数,又称复制构造函数,是一种特殊的构造函数,它由编译器调用来完成一些基于同一类的其他对象的构建及初始化。其形参必须是引用,但并不限制为const,一般普遍的会加上const限制。 此函数经常用在函数调用时用户定义类型的值传递及返回。拷贝构造函数要调用基类的拷贝构造函数和成员函数。如果可以的话,它将用常量方式调用,另外,也可以用非常量方式调用。
浅拷贝:对象之间直接进行内存拷贝。
我么来看看几种构造情况:
情况1. 当没有提供构造函数的时候,编译器会为你生成默认构造和默认析构,是空函数,什么也不做。
SeqStack s;//没有提供构造函数的时候,编译器会为你生成默认构造和默认析构,是空函数,什么也不做。
情况2.调用带整型参数的构造函数
SeqStack s1(10);//s1的生成调用了带整型参数的构造函数
定义了一个s1对象,开辟内存在栈上12个字节,调用构造函数时在堆上new int[10],并且把堆内存的地址赋给栈。
情况3.拷贝构造:我们没有提供拷贝构造系统给自动提供,用同类型的已经存在的对象来产生一个同类型的新对象,做的是内存的拷贝。
//s2由s1初始化,有一个已经存在的栈对象来构造一个新的栈对象
//对象的生成一定会调用构造,拷贝构造函数
SeqStack s2 = s1;//等价于SeqStack s2(s1);
系统默认的拷贝构造:
//默认的拷贝构造
SeqStack(const SeqStack &src)
{
_pstack = src._pstack;
_top = src._top;
_size = src._size;
}
但此时程序崩溃
我们来研究一下,为什么会崩溃,如图所示:
同样,我们定义了一个s1对象,开辟内存在栈上12个字节,调用构造函数时在堆上new int[10],并且把堆内存的地址赋给栈。定义了一个s2,用s1进行了拷贝构造,做的是内存的拷贝,将s1内存中成员变量的值直接赋给s2,造成s1中指针的值与s2指针的值一样,s2中指针也指向了那块堆内存。s1先生成,s2后生成,s2先析构,将堆内存释放并将自己的指针指向NULL,造成s1中的指针为野指针,s1析构时变为释放野指针操作,因此程序崩溃。
浅拷贝出错问题: 对象使用浅拷贝不一定有错,但是对象有成员变量中有指针指向了对象内存之外的外部资源,那么当发生浅拷贝时,两个对象不同的指针指向同一个资源。第一次析构将堆内存释放,第二次析构时候就会出错。
情况4.都是已经存在的对象互相赋值。
s2 = s1;//赋值操作
s2.=(s1)//s1赋给s2是s1调用了自己的等号函数把s1当作实参传入。
此时程序崩溃
s1,s2都是已经存在的对象,我们此时没有给类提供赋值操作,那么他会用系统默认的赋值函数,做直接的内存拷贝。将s1赋值给s2,直接做的是内存的赋值,把s1的指针赋值给s2,s1与s2的指针指向同一块内存,会发生浅拷贝。而且还把s1指向的资源丢失了,s1指向的内存丢失了,释放的机会都没有。
赋值运算符的重载函数: 我们不提供,系统自动使用默认的内存拷贝,与默认拷贝构造一样。我们需要将其重载:将s2指向的原来的内存先释放掉,根据s1大小重新开辟一块大小相同的内存指向,自己的指针指向自己的内存,析构的时候各自释放各自的。s1赋给s2是s2调用了自己的等号函数把s1当作实参传入。因此我们需要重载等号。
赋值运算符符的重载函数三步:
①防止自赋值
②释放当前对象占用的外部资源
③拷贝构造一样的操作
//赋值运算符重载函数
void operator= (const SeqStack &src)
{
cout << "operator=" << endl;
if (this == &src)//防止自赋值
{
return;
}
//需要先释放当前对象占用的外部资源
delete[]_pstack;
_pstack = new int[src._size];//根据原始大小给当前开辟空间,与拷贝构造一样
for (int i=0; i<=src._top; ++i)
{
_pstack[i] = src._pstack[i];
}
_top = src._top;
_size = src._size;
}
此时,程序执行成功。
2.2、深拷贝
深拷贝: 对象的成员变量有指针,构造的时候指针指向对象外部的堆内存,这个对象发生默认的拷贝浅拷贝一定会发生问题,应该用深拷贝。 构造对象的成员变量不仅仅要把值拷贝,如果对象的指针指向了外部资源,应该给该对象再单独开辟一块外部资源,让新对象的指针去指向。此时,每个对象的指针就指向了自己独有的外部资源,析构的时候各自析构自己的内存。
因此,我们需要自定义拷贝构造函数。
//自定义拷贝构造函数
SeqStack(const SeqStack &src)
{
cout << "SeqStack(const SeqStack &src" << endl;
_pstack = new int[src._size];//根据原始大小给当前开辟空间
for (int i=0; i<=src._top; ++i)
{
_pstack[i] = src._pstack[i];
}
_top = src._top;
_size = src._size;
}
此时就是深拷贝了,为其单独开辟了一块同样大小的内存,再释放时没有问题。
程序执行成功:
这里还产生了一个问题:为什么拷贝时候要使用for循环,使用memcpy函数补上更简单吗?
因为在进行数据拷贝时候,如果是整型,不会占用整型之外的资源,进行memcpy拷贝没有问题。但如果拷贝时候拷贝的对象,每一个对象中都含有指针,指针指向外部资源,会发生浅拷贝,析构指向时,释放同一块内存,程序崩溃。