> 文档中心 > 机械转码日记【13】构造函数、析构函数、拷贝构造函数

机械转码日记【13】构造函数、析构函数、拷贝构造函数

目录

前言

1.类的6个默认成员函数

2.构造函数

2.1构造函数的特性

2.1.1显式定义构造函数

2.1.2构造函数可以重载

2.1.3编译器自动生成默认构造函数时,对于内置类型成员变量不做处理,对于自定义类型成员会自动调用这个自定义类型成员的构造函数

​编辑2.1.4默认构造函数的三种形式

2.1.5C++11的改进——在成员变量声明时给缺省值

2.2 构造函数总结

3.析构函数

3.1析构函数的调用顺序和构造函数相反 

3.2 编译器自动生成析构函数时,对于内置类型成员变量不做处理,对于自定义类型成员会自动调用这个自定义类型成员的析构函数

4.拷贝构造函数

4.1定义拷贝构造函数要注意的地方

4.1.1定义拷贝构造函数要用引用传参

4.1.2定义拷贝函数最好用const修饰引用

4.2传指针算拷贝构造吗?

4.3 我们不写拷贝构造,编译器会默认生成拷贝构造

4.3.1对于内置类型的成员,编译器生成的拷贝构造是值拷贝或者叫做浅拷贝


前言

本篇博客介绍了类和对象里构造函数、析构函数、拷贝构造函数等知识,我们学习这几个函数要从两大方面去理解:

  • 它们的基本语法特性,函数名,参数,返回值,以及什么时候调用
  • 我们如果不写这些函数,编译器默认生成的它们干了些什么

下面就让我们带着这两个问题去学习这些函数吧!

新人创作,如果有错误或建议请大佬们指出!

本篇博客的代码已经上传到我的gitee中了,有需要的老铁们可以自取本篇博客代码

1.类的6个默认成员函数

如果一个类中什么成员都没有,简称为空类。空类中什么都没有吗?并不是的,任何一个类在我们不写的情况下,都会自动生成下面6个默认成员函数:

  • 构造函数
  • 析构函数
  • 拷贝构造
  • 赋值重载
  • 普通对象取地址
  • const对象取地址

2.构造函数

我们先来观察一下下面这段代码以及它的运行结果: 

可以看到我们如果没有调用初始化函数,我们的打印为随机值,只有我们调用了初始化函数之后我们打印出的值才是我们想要打印出的日期,那么在实际中,我们可不可能会忘了调用初始化函数呢?我想是非常有可能的!所以在这种背景之下,类的构造函数被发明出来了: 

2.1构造函数的特性

构造函数是特殊的成员函数,需要注意的是,构造函数的虽然名称叫构造,但是需要注意的是构造函数的主要任务并不是开空间创建对象,而是初始化对象。其特性如下:

  1. 构造函数的函数名与类名相同
  2. 构造函数无返回值
  3. 对象实例化时编译器会自动调用对应的构造函数
  4. 构造函数可以重载
  5. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义,编译器将不再生成
  6. 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数
  7. 编译器自动生成默认构造函数时,对于内置类型成员变量不做处理,对于自定义类型成员会自动调用这个自定义类型成员的构造函数

下面我将对以上特性一一进行讲解:

2.1.1显式定义构造函数

这一个小节是对特性1,2,3统一做讲解,因为他们都涉及到了我们如何去显式定义一个构造函数;如果我们没有自己写构造函数,编译器会自动生成一个默认构造函数(这个叫隐式定义);而我们自己写了构造函数,这个就叫显式定义;以我们上篇博客提到的日期类为例,显式定义它的构造函数:

class Date{public:void Print(){cout << _year << "-" << _month << "-" << _day << endl;}    //这就是显式定义构造函数的写法:Date(){_year = 1; // 年_month = 1; // 月_day = 1; // 日}private:int _year; // 年int _month; // 月int _day; // 日};int main(){Date d1;d1.Print();//如果不初始化,就会打印出随机值return 0;}

可以看到我们所显式定义的构造函数,他是一个没有返回值的,然后它的函数名为Date,函数体里面我们写上我们想要给成员变量初始化的值 ,然后我们运行一下:

可以看到,我们并没有自己调用构造函数,而是编译器自动的帮我们调用了构造函数,直接帮我们初始化了。

2.1.2构造函数可以重载

前面我们学了函数重载,那么构造函数可以重载吗?当然可以!但是重载了之后,我们调用这个函数的方式和普通的函数不太一样,直接先来个例子吧,刚刚我们的构造函数是无参的,现在我们把他改成有参的吧:

//构造函数可以重载class Date{public:void Print(){cout << _year << "-" << _month << "-" << _day << endl;}Date(){_year = 1; // 年_month = 1; // 月_day = 1; // 日}    //构造函数有参Date(int year,int month,int day){_year = year; // 年_month = month; // 月_day = day; // 日}private:int _year; // 年int _month; // 月int _day; // 日};int main(){Date d1;//无参    //在这里无法区分是一个函数的申明还是一个对象的定义//Date d1();//没有参数也不能这么写,会errd1.Print();//带参Date d2(2022,5,12);//传参的方式和别的函数不同,比较特殊,要把它当成一个特殊的函数,不能用常规的方式理解他d2.Print();return 0;}

可以看到我们已经定义了一个有参构造函数,但是如何用一个有参构造函数去初始化我们的对象呢?我们是使用Date d2(2022,5,12)来进行有参构造的,而不是我们常规的函数调用形式d2 = Date(2022,5,12);那么我们的无参构造可以用Date d1()的形式去初始化d1对象吗?直接运行下看看:

可以看到我们的编译器报错了,编译器说我们没有成功创建一个类对象,在这里不能这样写的原因是因为有歧义,Date d1()也可能是一个函数声明,这个函数的返回类型是Date类,函数名为d1,很明显我们的编译器将这一句语句当成了函数声明(因为编译器也提示了d1的类型为Date(*)();这是一个函数指针,因为d1是函数名,函数名就是函数指针)。

我们再写一个全缺省的构造函数:

class Date{public:void Print(){cout << _year << "-" << _month << "-" << _day << endl;}    //无参构造    Date(){_year = 1; // 年_month = 1; // 月_day = 1; // 日}    //全缺省构造,函数重载Date(int year = 1, int month = 2, int day = 3){_year = year; // 年_month = month; // 月_day = day; // 日}private:int _year; // 年int _month; // 月int _day; // 日};int main(){//无参Date d1;//语法上可以通过,但是编译不能通过,因为无参构造和缺省构造会有歧义,编译器不知道调用谁,所以一般只写一个全缺省构造,这样写更好一点d1.Print();}

 运行一下上述代码,出错:

请看上图分析,我们的Date d1是有歧义的,它可能调用的是无参构造,也可能调用的是没有传参数的全缺省构造,所以在日常写构造函数时,都写全缺省构造,因为它很方便,可以不传参数,可以传部分参数,也可以全传了:

2.1.3编译器自动生成默认构造函数时,对于内置类型成员变量不做处理,对于自定义类型成员会自动调用这个自定义类型成员的构造函数

如果我们不显式定义一个类的构造函数,编译器会自动生成一个无参的默认构造函数,这个构造函数对于内置类型的成员变量是不做处理的,比如我们写这么一段代码然后运行一下:

但如果是自定义类型的成员变量,比如是class或者struct,就会调用这个成员变量自己的构造函数,比如下面这段代码:

class A{public:A(){cout <<"A()d的构造函数" << " A()" << endl;_a = 0;}private:int _a;};class Date{public:// 我们不写,编译器会生成一个默认无参构造函数// 自定义类型:class/struct去定义类型对象// 默认生成构造函数对于内置类型成员变量不做处理,对于自定义类型成员变量才会处理void Print(){cout << _year << "-" << _month << "-" << _day << endl;}private:int _year; // 年int _month; // 月int _day; // 日A _aa;};int main(){Date day;return 0;}

运行一下: 

我们打印出了这段话,而且当我们调试时,在运行到Date day这句语句时,按F11逐语句运行也会进入到A的构造函数里面

2.1.4默认构造函数的三种形式

上面说到如果成员变量是自定义类型,那么编译器自动生成构造函数的时候,会自动调用这个自定义成员的构造函数,但是这个自定义成员的构造函数要符合要求,一共有三种形式:

  1. 可以是我们自己写的无参构造函数
  2. 也可以是全缺省构造函数
  3. 也可以不写,系统自动生成的构造函数

这种函数叫做默认构造函数,可以不传参就调用,像以下这种构造函数就不行:

#includeclass Stack{public:    //自己写的有参构造,errStack(int capacity){_a = (int*)malloc(sizeof(int) * capacity);assert(_a);_top = 0;_capacity = capacity;}private:int* _a;int _top;int _capacity;};class MyQueue{public:// 默认生成构造函数就可以用了void push(int x){}int pop(){}private:Stack _st1;Stack _st2;};int main(){Stack st1;MyQueue q;return 0;}

运行会报错,原因分析请看下图,当我们要初试化q对象的时候,先调用MyQueue的构造函数,而我们没有显式定义MyQueue的构造函数,因此编译器自动生成一个;因为MyQueue成员变量中有自定义类型Stack,所以编译器自动生成MyQueue的构造函数的时候,也会调用Stack的构造函数,但Stack中的构造函数不是默认构造函数,所以会报错“无法引用MyQueue的默认构造函数”。

2.1.5C++11的改进——在成员变量声明时给缺省值

可能有的同学会问,编译器默认生成的构造函数不处理内置类型不是很鸡肋吗?和没有构造函数有什么区别,确实,C++在创立之初可能确实有很多不足之处,所以在C++11中,我们可以在类内部直接初始化内置类型的成员变量:

2.2 构造函数总结

一般情况一个C++类,都要自己写构造函数。一般只有少数情况可以让编译器默认生成:

  1. 类里面成员都是自定义类型成员,并且这些成员都提供了默认构造函数
  2. 如果还有内置类型成员,声明时给了缺省值

3.析构函数

前面通过构造函数的学习,我们知道一个对象时怎么来的,那一个对象又是怎么没呢的?
析构函数:与构造函数功能相反,析构函数不是完成对象的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成类的一些资源清理工作。

其特征如下:

  1. 析构函数名是在类名前加上字符 ~
  2. 无参数无返回值
  3. 一个类有且只有一个析构函数。若未显式定义,系统会自动生成默认的析构函数
  4. 对象生命周期结束时,C++编译系统系统自动调用析构函数

显式定义一个析构函数来看看吧:

class Date{public:Date(int year = 1, int month = 1, int day = 1){_year = year;_month = month;_day = day;}void Print(){cout << _year << "-" << _month << "-" << _day << endl;}~Date(){cout <<"析构函数"<< "~Date()" << endl;}private:int _year; // 年int _month; // 月int _day; // 日};int main(){Date day;return 0;}

运行一下:

析构函数是在对象的生命周期结束时,自动被编译器调用的,上面的程序中,day的生命周期是main函数,main函数销毁了,对象day就被销毁了,我们可以打个断点,调试一下:

3.1析构函数的调用顺序和构造函数相反 

如题,析构函数的调用顺序和构造函数是相反的,我们的变量都存放在栈区,先定义的函数自然就是在后面销毁,我们运行一下下面的代码:

class Date{public:Date(int year = 1, int month = 1, int day = 1){cout <<"构造"<< this << endl;_year = year;_month = month;_day = day;}void Print(){cout << _year << "-" << _month << "-" << _day << endl;}~Date(){cout << "析构" << this << endl;}private:int _year; // 年int _month; // 月int _day; // 日};int main(){//栈里面定义对象,析构顺序和构造顺序是反的Date day1;Date day2;return 0;}

 运行结果如下:

能够看到先被构造的对象,是在后面被析构的。 

3.2 编译器自动生成析构函数时,对于内置类型成员变量不做处理,对于自定义类型成员会自动调用这个自定义类型成员的析构函数

这一点和我们前面的构造函数的特性是一模一样的,对于内置类型的成员变量,析构函数是不做处理的,但是对于自定义的成员变量,编译器会自动调用它的析构函数,我们看下面这段代码:

class Stack{public:Stack(int capacity = 10){_a = (int*)malloc(sizeof(int) * capacity);assert(_a);_top = 0;_capacity = capacity;}~Stack(){cout << "~Stack():" << this << endl;//资源清理free(_a);_a = nullptr;_top = _capacity = 0;}private:int* _a;int _top;int _capacity;};class MyQueue {public:void push(int x) {}int pop() {}private:Stack _st1;Stack _st2;};int main(){//内置类型不做处理,自定义类型自动调用它的析构函数MyQueue q;return 0;}

运行一下: 

以上是运行结果以及内部的运行逻辑,q出生命周期后,自动调用析构函数销毁对象q,此时我们没有显式定义析构函数,于是系统会自动生成,但此时我们的成员变量为自定义类型,于是我们会调用自定义类型的析构函数,因此打印出了两个stack对象的this指针。

4.拷贝构造函数

在创建对象时,可否创建一个与一个对象一某一样的新对象呢?这个时候我们的拷贝构造函数就诞生了。拷贝构造函数是构造函数的一种重载形式,那么它怎么用呢,请看下面的代码:

//拷贝构造class Date{public:Date(int year = 1, int month = 1, int day = 1){_year = year;_month = month;_day = day;}//拷贝构造Date(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(2022, 5, 15);Date d2(d1);d1.Print();d2.Print();}

运行一下:

4.1定义拷贝构造函数要注意的地方

其实定义拷贝构造函数有两个需要注意的地方:

  • 定义拷贝构造函数不能使用值传参,要用引用传参,不然会出现死递归
  • 定义拷贝函数最好用const修饰引用,防止被拷贝对象的内容被修改

4.1.1定义拷贝构造函数要用引用传参

如果我们采用值传参会怎么样呢,来试验一下吧:

 编译器直接报错,是为什么呢?来分析一下:

其实我们之前学的,形参是实参的一份拷贝,对于自定义类型来说内部并没有那么简单,我们如果采用值传递来定义拷贝构造函数,在传参的时候,由于形参是实参的一份拷贝,自定义类型拷贝的话会自动调用它的拷贝构造函数相当于自己在调用自己,是在递归,而这个递归是没有结束条件的,一直会递归下去。因此要用引用传参,引用传参不同,他只是给传过来的变量取了个别名,并没有拷贝。

4.1.2定义拷贝函数最好用const修饰引用

为什么要用常引用呢?请看下面这种情况:

//把拷贝构造函数写反,让类的成员变量赋给传进来的变量Date(Date& d){d._year =_year;d._month =_month;d._day =_day;}

 运行一下:

夭寿啦!我已经初始化了,又变成随机值了,所以我们要加上const修饰引用,让传进来的变量不能被修改:

4.2传指针算拷贝构造吗?

如果我们在定义拷贝构造的时候将引用传参变为指针传参,算拷贝构造吗?

Date(const Date* d){_year = d->_year;_month = d->_month;_day = d->_day;}

虽然我们达到了同样的目的,但是他不是拷贝构造,编译器规定的拷贝构造是使用对象的引用在传参,这只能算是普通的构造。

4.3 我们不写拷贝构造,编译器会默认生成拷贝构造

class Date{public:Date(int year = 1, int month = 1, int day = 1){_year = year;_month = month;_day = day;}void Print(){cout << _year << "-" << _month << "-" << _day << endl;}private:int _year;int _month;int _day;};int main(){Date d1(2022,5,22);Date d2(d1);d1.Print();d2.Print();}

可以看到上面这段代码,我们没有自己写拷贝构造函数,我们将这上面的代码运行一下:

 而自动生成的拷贝构造函数,它有什么说法呢:

  • 对于内置类型的成员,编译器生成的拷贝构造是值拷贝或者叫做浅拷贝
  • 对于自定义类型的成员,编译器会去调用这个成员的拷贝构造函数

4.3.1对于内置类型的成员,编译器生成的拷贝构造是值拷贝或者叫做浅拷贝

对于内置类型的成员,我们编译器生成的拷贝构造是浅拷贝(也叫做值拷贝),浅拷贝是什么意思呢?浅拷贝其实类似于C语言中的memcpy这个库函数,他是将原对象的成员所占空间的起始地址到结束地址这块空间的内容都拷贝给另一个对象。

下面再来看看这段代码,我们一样没有写它的构造函数,编译器自动生成Stack的默认构造函数:

class Stack{public:Stack(int capacity = 4){cout << "Stack(int capacity = 4)" << endl;_a = (int*)malloc(sizeof(int)*capacity);assert(_a);_top = 0;_capacity = capacity;}~Stack(){cout << "~Stack()" << endl;free(_a);_a = nullptr;_capacity = _top = 0;}private:int* _a;int _top;int _capacity;};int main(){Stack st1(10);Stack st2(st1);return 0;}

运行一下,发现程序崩溃了:

为什么呢?因为是浅拷贝的问题,我们拷贝的st2的_a成员和st1的_a成员指向了同一片空间,在析构时对同一片空间进行了free操作,会造成内存的非法访问,导致程序崩溃。在这里我们必须使用深拷贝才不会报错,这个我们以后再细讲。