【C++】类的默认成员函数之——构造函数+析构函数
1. 类的6个默认成员函数
通过上一篇文章的学习,我们知道如果一个类中没有成员变量,也没有成员函数,啥也没有,那我们把它叫做空类。
即如果一个类中什么成员都没有,简称为空类。
比如:
class Date
{
};
那现在问大家一个问题:空类中真的什么都没有吗?
🆗,其实并不是的。
对于任何一个类来说,它们都有6个默认成员函数,即使是空类。
默认成员函数:即用户没有显式实现,编译器自动生成的成员函数称。
那这6个默认成员函数都是什么呢?
大家先简单了解一下,接下来我们会一一学习。
2. 构造函数
2.1 构造函数的引出
通过上一篇文章的学习,相信大家已经有能力能够写一个简单的类了。
那现在有这样一个类:
class Date
{
public:
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
那对于一个类来说,我们实例化出来对象之后一般会对其进行一个初始化:
如果有时候不初始化直接用可能就会出现问题,但是有时候我们可能会忘记初始化,直接就对对象进行一些操作了。
再举个大家可能经历过的例子:
比如我们写了一个栈的类,然后用该类创建一个对象,对象创建好之后我们就迫不及待地往栈里放数据了,上去直接调用压栈的成员函数,哐哐哐数据就搞进去了。
但是一运行发现程序崩溃了,最后吭哧吭哧去调试发现没有对创建出来的栈进行初始化,空间都没开呢,就放数据了。
有可能忘了不说,每次创建一个对象都要初始一次,好像也有点麻烦。
那针对上面提到的这种情况呢,C++呢就提供了一种方法帮助我们解决这个问题:
那就是我们接下来要学的——构造函数。
有了构造函数,我们每创建完一个对象,就不用手动去调用Init函数进行初始化了,因为在创建对象时编译器会自动去调用构造函数对对象进行初始化。
那构造函数到底是个啥呢?
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
那接下来我们就来详细地认识一下构造函数。
2.2 构造函数的特性
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
其特征如下:
- 构造函数的函数名与类名相同
也就是说定义好一个类,它的构造函数的函数名就确定好了,跟当前类的类名是相同的。
- 构造函数无返回值
要注意这里说的无返回值不是说返回类型是
void
,而是根本就不写返回类型。比如上面我们定义的那个Date类,如果要写它的构造函数就应该是这样的:
- 对象实例化时编译器自动调用对应的构造函数
有了构造函数我们初始化对象就不用再手动初始化了,实例化一个对象时编译器会自动调用其对应的构造函数。
- 构造函数可以重载
🆗,构造函数可以重载,那是不是就意味着一个类可以有多个构造函数,那也就是说,该类创建的对象可以有多种初始化方式。
那不能光说不练啊,现在已经认识了构造函数了,那我们练习一下呗,就给上面的Date类写一下构造函数:
Date()
{
}
首先看这是不是就是一个构造函数啊,当然是,没有返回值,并且函数名和类名相同嘛。
但是我们说构造函数是用来初始化对象的,那啥也不写是不是没意思啊,写点东西吧:
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
这样,我们把年月日都初始化成1。
那我们来试一下,刚才不初始化都打印出来随机值了,那现在有构造函数不是说会自动初始化吗,行不行啊,验证一下:
哦豁,可以啊,这次我们并没有调用初始化函数,但是打印出来不是随机值,而是我们在构造函数中给定的初值,说明我们实例化对象的时候确实自动调用构造函数进行初始化了。
那这样的话我们每次创建Date类的对象初值都是1 1 1,如果我们想每次都按照自己的想法给对象进行初始化呢?能做到吗?
是不是可以啊。
上面提到的构造函数的第4条特性是啥?
是不是构造函数可以重载啊,那我们重载一下给参数不就行了。
这样的话我们不知道初始化给什么初值的时候就可以调用无参的构造函数,自己想指定初值的话调用有参数的传参不就行了
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
这样是不是就搞定了。
这是不是就达到我们想要的效果了。
但是要注意,调用无参构造函数的时候我们不要写成这样Date d1();
即后面不要加括号。
这样的话编译器会报一个警告,大家看这样写的话是不是可能会被认为是一个函数声明啊,是吧。
一个返回类型为Date,函数名为d1,无参的函数声明是不是也长这样啊。
那大家再来思考一下:
这两个构造函数有没有必要分开写,或者说,能不能一个函数就搞定了。
🆗,当然是可以的,怎么做呢?
上一篇文章刚学的——缺省参数
是不是可以这样写:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
用这一个是不是就行了啊:
我们不传参,就用默认的,传了,就用我们传的。
另外,还有一个需要注意的点:
我们现在呢,实现了一个带缺省值的构造函数,那大家思考一下,这两个构造函数可以同时存在吗?
那要告诉大家的是,首先在语法上,它们两个是可以存在的,因为它们构成重载嘛,但是,我们现在再去运行程序:
报错了,为什么?
原因在于,我们这里是不是调用了无参的构造函数啊,d1我们创建时没传参嘛,但是上面这两个构造函数是不是都适用于无参的情况啊,所以编译器就不知道该调那个了,就报错了。
那我们把d1的创建注释掉呢?
就不报错了,好吧,这是需要大家注意的一个地方。
那除了上面这些,其实构造函数还有一些其它的特性:
如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
哦豁,那也就是说,构造函数不一定非要我自己写,如果我们自己没有定义构造函数,编译器会自动生成一个。只不过是无参的嘛。
那现在把我们自己定义的构造函数全部注释掉:
我们发现确实没问题,编译通过了。
将Date类中构造函数屏蔽后,代码可以通过编译,因为编译器生成了一个无参的默认构造函数
那特性还说了,如果我们自己定义的有,编译器就不再生成了:
这个大家好理解,我们上面自己写的无参构造函数把_year、 _month、 _day全部初始化为1,打印出来确实是全1。
那编译器会自动生成的话,我们以后是不是就不用自己写构造函数了?
我们把自己写的构造函数屏蔽掉,然后直接运行:
欸!这~怎么回事嘛?
不是说有自动生成的构造函数嘛,怎么还是随机值啊。
这编译器自动生成的默认构造函数怎么没用啊?
什么原因呢?
🆗,这个地方呢,大家可以认为是我们的祖师爷设计的不好的一个地方,或者说是一个失误。
具体是这样的:
C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int/char...(包括各种指针类型),自定义类型就是我们使用class/struct/union等自己定义的类型
而编译器自动生成的构造函数不会对内置类型进行处理,对于自定义类型会处理,怎么处理?会去调用该自定义类型对应的默认构造函数
所以,刚才为什么打印出来是随机值?
因为我们Date类中的成员变量都是int,是内置类型,但是编译器自动生成的构造函数不会处理内置类型,所以还是随机值。
那我们来看这样的场景:
class Time
{
public:
Time()
{
cout << "Time()" << endl;
_hour = 0;
_minute = 0;
_second = 0;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 基本类型(内置类型)
int _year;
int _month;
int _day;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
大家看这里的Date类与上面那个有什么区别,是不是它的成员变量里既有内置类型又有自定义类型啊。
但是我们现在并没有给Date类写构造函数,那我们在main函数里直接拿Date去创建一个对象,它自然就会去调用编译器自动生成的构造函数,那内置类型不做处理,我们不是还有一个自定义类型Time _t;呢,我们说对于自定义类型,编译器会自动去调用它对应的默认构造函数。
那我们在Time 类的默认构造函数里面故意加了一个打印:
如果运行会打印,就说名编译器自动调用了:
🆗,是不是调了啊。
那说到底内置类型呢?这样的话内置类型不写构造函数就没法初始化了吗?
🆗,我们的祖师爷呢在后来也发现了这个问题,并在C++11中针对内置类型不初始化的缺陷打了一个补丁。
即非静态成员变量在类中声明的时候可以给缺省值。
这样如果我们不写构造函数,内置类型的初始化就会按给定的缺省值进行初始化
无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数,默认构造函数只能有一个
所以这里想告诉大家的是,不要认为默认构造函数就是我们不写编译器自动生成的那个,除了这个之外,我们自己定义的无参的构造函数,或者全缺省的构造函数,都可以认为是默认构造函数。
为什么说只能有一个呢,因为我们调用这些构造函数是不是都不用传参啊,那这样如果同时存在多个的话,编译器就不知道到底该调哪个了。
这个问题我们上面也有提到过的。
3. 析构函数
3.1 析构函数的引出
首先我们来回顾一个问题:
我们在之前数据结构的学习中,在学到栈的时候,有一个与栈相关的非常经典的题目——括号匹配问题。
链接: link
不知道大家做过这个题没有,只不过当时我们用的栈是用C语言写的,那现在我们也可以用C++的类实现了。
但是这道题里有一个比较恶心的点,是什么呢?
来看一下我们C语言写出来的代码,我们进行判断之后,需要return的地方可能有好几处,但是呢,每次return之前,其实最好都要去调用一下StackDestroy把我们动态开辟的空间给销毁一下,但是我们可能很容易会忘掉导致内存泄漏。
那现在我们学了C++,有没有什么好的办法可以帮助我们解决这个问题呢?
可不可以像上面的构造函数自动初始化一样自动对对象中的资源进行清理呢?
那当然是有的,就是我们接下来要学习的析构函数
析构函数:
其与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
解释一下,我们用这样一个类来举例:
typedef int DataType;
class Stack
{
public:
//构造函数
Stack(size_t capacity = 4)
{
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = capacity;
_size = 0;
}
void Push(DataType data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
// 其他方法...
private:
DataType* _array;
int _capacity;
int _size;
};
是一个栈类,并且我们已经写好了构造函数。
那我们上面说 析构函数不是完成对对象本身的销毁,而是完成对象中资源的清理工作 是什么意思呢?
🆗,我们那这个类去实例化栈对象:
int main()
{
Stack s;
s.Push(1);
s.Push(2);
return 0;
}
大家思考一下,这里的对象s需要我们自己去销毁吗?
是不是不需要啊,因为s是定义在栈区上的局部变量,程序结束,它是不是就随着main函数的栈帧自动销毁了啊。
那析构函数的作用是啥呢?
完成对象中资源的清理工作,什么意思?
🆗,像栈这样的对象,它里面是不是有在堆上动态开辟的空间啊,那经过C语言的学习我们都知道,这些空间是不是需要我们手动去释放的啊,否则可能会导致内存泄漏。
所以说,析构函数就是来帮我们干这件事情的。
那析构函数到底是个啥,又怎么用呢?
3.2 析构函数的特性
和构造函数一样,析构函数也是一个特殊的成员函数,其特征如下:
析构函数名是在类名前加上字符 ~
也就是说,一个类定义好之后,它的析构函数的函数名也是确定的,即在类名前面加上“~”。
~是啥,在C语言中是不是按位取反啊,表示它的功能和构造函数是相反的。
无返回值且无参数
和构造函数一样,析构函数也是没有返回值的,并且析构函数还没有参数。
对象生命周期结束时,C++编译系统系统自动调用析构函数
析构函数起作用的关键就在这里,对象声明周期结束时编译器会自动调用析构函数对对象的资源进行清理。
析构函数不能重载
注意析构函数不能重载,因为它连参数都没有,何谈重载。
那了解到这里,我们就可以尝试写一个析构函数来练练手了:
就给我们刚才那个栈类写一个析构函数吧。
~Stack()
{
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
那是不是很简单啊,就是释放我们在堆上开辟的空间嘛。
然后呢,它没有返回值,没有参数
那就写好了,那测试一下呗:
为了方便看出来是否自动调用了析构函数,我们可以在加一个打印:
此时我们的main函数里并没有显式的调用
~Stack
函数:
然后我们运行:
是不是自动调用了啊。
一个类只能有一个析构函数。若未显式定义,编译器会自动生成默认的析构函数
这一点呢和构造函数一样,如果我们自己不写析构函数,则编译器会自动生成默认的析构函数。
然后说一个类只能有一个析构函数,我们上面说了析构函数不能重载,所以肯定只能有一个了。
那编译器默认生成的析构函数有什么特点呢?
和编译器默认生成的构造函数一样,内置类型成员不处理,当然如果全是内置类型的成员变量也不需要处理,比如上面写的Date类。
那同样,对于自定义类型,会自动调用其对应的析构函数。
举个栗子:
typedef int DataType;
class Stack
{
public:
Stack(int capacity = 4)
{
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = capacity;
_size = 0;
}
void Push(DataType data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
// 其他方法...
//析构函数
~Stack()
{
cout << "~Stack" << endl;
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
private:
DataType* _array;
int _capacity;
int _size;
};
class Date
{
private:
// 基本类型(内置类型)
int _year;
int _month;
int _day;
// 自定义类型
Stack _s;
};
int main()
{
Date d;
return 0;
}
这里我们没有给Date显式定义析构函数,那d声明周期结束时,就会调用编译器自己生成的默认析构函数,那里面的内置类型不做处理,当然也不用处理,关键在于自定义类型Stack _s;申请的资源需要清理,那我们看编译器自己生成的默认析构函数会不会调用Stack 类的析构函数:
🆗,是不是调了啊。
- 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类
- 点赞
- 收藏
- 关注作者
评论(0)