目录
一.类的6个默认成员函数
二. 构造函数
2.1 概念
2.2.特性
三.析构函数
3.1.概念
3.2 特性
四.拷贝构造函数
4.1.概念
4.2.特性
如果一个类中什么成员都没有,简称为空类。空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成函数。默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。
class Date {};
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;
};
int main()
{
Date d1;
d1.init(2023, 11, 05);
d1.print();
return 0;
}
构造函数是一个特殊的成员函数,1.名字与类名相同,2.创建类类型对象时由编译器自动调用,以保证每个数据成 员都有 一个合适的初始值3.在对象整个生命周期内只调用一次。
2.2.特性
构造函数 是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造构造函数的主要任务 并不是开 空间创建对象,而是 初始化对象。特征如下:
- 1. 函数名与类名相同。
- 2. 无返回值。
- 3. 对象实例化时编译器自动调用对应的构造函数。
- 4. 构造函数可以重载。
5. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。构造函数可以分成2类——无参构造函数和有参构造函数
class Date { public: /*void init(int year, int month, int day) { _year = year; _month = month; _day = day; }*/ // 1.无参构造函数 Date() {} // 2.有参构造函数 Date(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; }; int main() { Date d1; // 调用无参构造函数 Date d2(2015, 1, 1); // 调用带参的构造函数 d1.print(); d2.print(); return 0; }
注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则
就成了函数声明 以下代码的函数:声明了d3函数,该函数无参,返回一个日期类型的对象
Date d3()不是很惯用的函数声明,我换种形式 int func(),这样看是不是更清楚了,这就是相当于无参函数的声明。int func(void).
5. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
- 类中显式定义构造函数,就是显示上面的代码段,编译器就不用给出帮助,因为自己传参了。
- 类中没有显示定义构造函数,C++编译器会自动生成一个无参的默认构造函数。然后给出的成员变量的值都是随机值。
我们看一下这下面的代码段,为什么显示“没有合适的默认构造函数可用”??
6. 关于编译器生成的默认成员函数,很多童鞋会有疑惑:不实现构造函数的情况下,编译器会生成默认的 构造函数。但是看起来默认构造函数又没什么用?d对象调用了编译器生成的默认构造函数,但是d对象 _year/_month/_day,依旧是随机值。也就说在这里编译器生成的默认构造函数并没有什么用??解答: C++把类型分成内置类型(基本类型)和自定义类型。
- 内置类型: 就是语言提供的数据类型,如: int/char...,
- 自定义类型: 就是我们使用class/struct/union等自己定义的类型,
看看下面的程序,就会发现编译器生成默认的构造函数会对自定类型成员_t调用的它的默认成员函数(构造函数)大家看这里的Date类与上面那个有什么区别,是不是它的成员变量里既有内置类型又有自定义类型啊。但是我们现在并没有给Date类写构造函数,那我们在main函数里直接拿Date去创建一个对象,它自然就会去调用编译器自动生成的构造函数,那内置类型不做处理,我们不是还有一个自定义类型Time _t;呢,我们说对于自定义类型,编译器会自动去调用它对应的默认构造函数。
那我们在Time 类的默认构造函数里面故意加了一个打印:
7. 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。我们上面的特性说了,只能有一个默认构造函数,这里出现了俩个默认构造函数,所以就导致了对重载函数的调用不明确,因为不知道到底调哪个默认构造函数。
这里我们要特别区分俩个名词:默认成员函数和默认构造函数
- 默认成员函数:任何类在什么都不写时,编译器会自动生成以下6个默认成函数。
- 默认构造函数:类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数。
我们在之前数据结构的学习中,在学到栈的时候,有一个与栈相关的非常经典的题目——括号匹配问题。
来看一下我们C语言写出来的代码,我们进行判断之后,需要return的地方可能有好几处,但是呢,每次return之前,其实最好都要去调用一下
StackDestroy
把我们动态开辟的空间给销毁一下,但是我们可能很容易会忘掉导致内存泄漏。那现在我们学了C++,有没有什么好的办法可以帮助我们解决这个问题呢?
可不可以像上面的构造函数自动初始化一样自动对对象中的资源进行清理呢?
那当然是有的,就是我们接下来要学习的析构函数。先了解概率吧。
3.1.概念
通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没呢的?—— 析构函数: 与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
3.2 特性
析构函数 是特殊的成员函数,其 特征如下:
- 1. 析构函数名是在类名前加上字符 ~。(~就相当于运算符里面的按位取反操作符)
- 2. 无参数无返回值类型。
- 3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
注意:析构函数不能重载
- 4. 对象生命周期结束时,C++编译系统系统自动调用析构函数。
typedef int DataType; class Stack { public: Stack(size_t capacity = 3) { _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() { if (_array) { free(_array); _array = nullptr; _capacity = 0; _size = 0; } } private: DataType* _array; int _capacity; int _size; }; int main() { Stack s; s.Push(1); s.Push(2); return 0; }
5. 关于编译器自动生成的析构函数,是否会完成一些事情呢?下面的程序我们会看到,编译器生成的默认析构函数,对自定类型成员调用它的析构函数。class Time { public: ~Time() { cout << "~Time()" << endl; } private: int _hour; int _minute; int _second; }; class Date { private: // 基本类型(内置类型) int _year = 1970; int _month = 1; int _day = 1; // 自定义类型 Time _t; }; int main() { Date d; return 0; }
这里我们没有给Date显式定义析构函数,那d声明周期结束时,就会调用编译器自己生成的默认析构函数,那里面的内置类型不做处理,当然也不用处理,关键在于自定义类Time _t
;
申请的资源需要清理,那我们看编译器自己生成的默认析构函数会不会调用Time _t类的析构函数:这是上面代码的运行结果。显然是调用了。
这时候我们就应该想一个问题了?——在main方法中根本没有直接创建Time类的对象,为什么最后会调用Time类的析构函数?因为:main方法中创建了Date对象d,而d中包含4个成员变量,其中_year, _month, _day三个是内置类型成员,销毁时不需要资源清理,最后系统直接将其内存回收即可;而_t是Time类对象,所以在 d销毁时,要将其内部包含的Time类的_t对象销毁,所以要调用Time类的析构函数。但是:main函数中不能直接调用Time类的析构函数,实际要释放的是Date类对象,所以编译器会调用Date类的析构函数,而Date没有显式提供,则编译器会给Date类生成一个默认的析构函数,目的是在其内部调用Time 类的析构函数,即当Date对象销毁时,要保证其内部每个自定义对象都可以正确销毁,main函数中并没有直接调用Time类析构函数,而是显式调用编译器为Date类生成的默认析构函数。注意:创建哪个类的对象则调用该类的析构函数,销毁那个类的对象则调用该类的析构函数。
6. 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类 。
4.1.概念
在现实生活中,可能存在一个与你一样的自己,我们称其为双胞胎。那在创建对象时,可否创建一个与已存在对象一某一样的新对象呢?拷贝构造函数: 只有单个形参 ,该形参是对本 类类型对象的引用 ( 一般常用 const 修饰 ) ,在用 已存在的类类型 对象创建新对象时由编译器自动调用 。
4.2.特性
拷贝构造函数也是特殊的成员函数, 其特征如下:
- 1. 拷贝构造函数是构造函数的一个重载形式。
因为我们刚才上面说了嘛,它的作用其实也是用来初始化对象的,只不过参数类型指定了是我们当前类的类型嘛。所以它算是构造函数的一种重载形式。
- 2. 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
我们按照上面形式传参,会导致显示一个错误编译,没有运行就错了。
那相信大家刚才也注意到上面的概念了,在拷贝构造函数的概念中其实就指明了说它的参数类型应该是类对象的引用。
确实,我们这样修改之后就可以了。
2. 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
结合下面这张图给大家解释一下:
ps:图中还在形参前加了const,大家可以先不管,后面会解释。大家想一下,首先我们这里是用已有的类对象去创建一个相同的新对象(类对象的拷贝),所以会调用拷贝构造函数,那要调用函数是不是要先传参啊,而传值调用传的是啥(形参是实参的一份临时拷贝),是不是传的实参的拷贝,那要拷贝实参,是不是又是一个类对象的拷贝啊,那既然是类对象的拷贝,就又要调用拷贝构造函数,那就又需要传参,一传参就会再次调用拷贝构造函数,那这样是不是就陷入一个死递归了。
直接用“
=
”也可以,这样也是拷贝构造。
上面一开始拷贝构造函数的概念中说它的形参一般用const修饰:为什么要加个const呢?
其实很容易理解,大家想形参d是用来干嘛的, 是用来初始化我们新创建的对象的,那我们肯定不希望形参d被修改,所以加个const修饰:这样我们如果不小心写反了啥的是不是就直接报错了。另外,加const还有什么好处呢?
大家想,如果我们不加const,但传过来的参数是const修饰的,这样的话是不是根本就接收不了啊,这个问题我们之前也讲了,是不是属于权限放大了,是不行的。但是如果我们加了const,传过来的不管是否加const,是不是都可以接收啊。
3. 若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成 拷贝,这种拷贝叫做浅拷贝,或者值拷贝。,那我们上面说了拷贝构造函数是构造函数的一种重载形式,那其实就也属于是构造函数了,那构造函数我们不写的话编译器不是会自动生成嘛,那拷贝构造函数是不是也具有这样的特性呢?
是的,对于拷贝构造函数来说,若未显式定义,编译器也会生成默认的拷贝构造函数。我们刚才不是对Date类实现了一个拷贝构造函数嘛,先我们现在把它屏蔽调:class Date { public: //构造函数 Date(int year = 1900, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } //拷贝构造函数 /*Date(const Date& d) { _year = d._year; _month = d._month; _day = d._day; }*/ void Print() { cout << _year << "-" << _month << "-" << _day << endl; } private: int _year; int _month; int _day; }; int main() { Date d1; d1.Print(); Date d2(d1); d2.Print(); Date d3 = d1; d3.Print(); return 0; }
注意:在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的。
那既然编译器自动生成的拷贝构造函数就可以帮助我们完成类对象的拷贝了,那我们还需要自己写吗?
那为了解决这个问题,我们再来看这样一个类:4. 编译器生成的默认拷贝构造函数已经可以完成字节序的值拷贝了,还需要自己显式实现吗?当然像日期 类这样的类是没必要的。那么下面的类呢?验证一下试试?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; };
还是我们之前用过的这个栈Stack类,大家看它的成员变量是不是也都是内置类型啊,前面提到过指针也属于内置类型嘛。
那对于Stack这个类,我们也是没写拷贝构造函数的,那编译器自动生成的能不能完成下面这样的拷贝呢?
int main() { Stack s1; s1.Push(1); s1.Push(2); s1.Push(3); Stack s2(s1); return 0; }
为什么会这样呢,刚才Date类不也都是内置类型,为啥就没事呢?
大家有没有注意到我们上面的特性3,后面的一句话是:
默认的拷贝构造函数 拷贝对象 按内存存储字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。在这里其实就是对逐个成员变量依次进行拷贝,里面存的是啥就把啥拷过去。
但是,对于Stack类来说呢?
我们有没有想过,_array指向的空间在堆区malloc(是在堆区),在销毁对象的时候要销毁对象内部的成员变量,在成员变量中有一个变量是指向该堆区内存的指针,因此每销毁一个对象就要销毁对应的内存地址,而浅拷贝会导致被销毁的两个对象中的那两个成员指针变量指向同一块内存,从而导致该内存被销毁两次。
比如一个文件f1里面包含了各种资料,我们需要再继续创建一个不同名文件f2但是文件里面的内容相同,然后删除这个原本文件f1之前,我们是不是需要拷贝这个文件,然后才能删除。我们想想,内容是在不同的文件里面的,虽然是相同的,我们销毁f1文件里面的内容,是不是销毁f1文件,销毁f2文件里面的内容,是不是销毁f1和f2文件内容是占据不同的文件。
这就是深拷贝,就是将内容构造拷贝时,_array需要开一快同样的大小空间。
这样对两个对象进行操作就不会互相影响了。
Stack(const Stack& st) { _array = (DataType*)malloc(sizeof(DataType) * st._capacity); if (NULL == _array) { perror("malloc申请空间失败!!!"); exit(-1); } memcpy(_array, st._array, sizeof(DataType) * st._size); _capacity = st._capacity; _size = st._size; }
没有问题了。
注意: 类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构 造函数是一定要写的,否则就是浅拷贝。总结:在编译器生成的默认拷贝构造函数中,内置类型是按照浅拷贝(值拷贝)进行拷贝的,而自定义类型是调用其对应的拷贝构造函数完成拷贝的。
5. 拷贝构造函数典型调用场景:
- 使用已存在对象创建新对象
- 函数参数类型为类类型对象
- 函数返回值类型为类类型对象
为了提高程序效率,一般对象传参时,尽量使用引用类型(减少拷贝),返回时根据实际场景,能用引用尽量使用引用。
考研,还是就业?