【C++】—— 类与对象(二)
1、类的默认成员函数
类的默认成员函数就是用户没有显式实现,编译器会自动生成
的成员函数被称为默认成员函数。一个类,我们不写的情况下编译器会默认生成 6 个默认成员函数。
需要注意的是这 6 个最重要的默认成员函数是前 4 个,最后两个取地址重载不重要,我们稍微了解一下即可。
其次就是 C++11 以后还会增加两个默认成员函数,移动构造和移动赋值,这个我们以后再讲解。
默认成员函数是学习 C++ 的基础,很重要,但也比较复杂,我们学习默认成员函数时要从以下两个方面取学习:
- 我们不写时,编译器默认生成的函数行为是什么,是否满足我们的需求
- 编译器默认生成的 函数不满足我们的需求,我们需要自己实现,那么如何自己实现。
2、构造函数
2.1、初见构造
构造函数虽然名字叫构造,但它并不是用来开空间创建对象的。对象的空间在函数创建栈帧时就一次性开辟好了
(不仅是对象,所有变量都是如此)。
构造函数的功能是对象实例化时初始化对象。构造函数的本质是要替代我们以前 S t a c k Stack Stack 和 D a t e Date Date 类中写的 I n i t Init Init 函数的功能,构造函数一系列的特点完美的替代了 Init
构造函数的特点:
- 函数名与类名相同
- 无返回值(返回值啥都不需要给,也不需要写void)
- 对象实例化时系统会自动调用对应的构造函数
- 构造函数可以重载
我们来看日期类的构造函数
注:实际运行代码时全缺省的构造函数不能与无参的或带参的构造函数同时存在,并且全缺省的构造函数的功能已经包括了无参构造和带参构造,我们只需保留全缺省构造函数即可,这里只是为了演示。
可以看到,上述构造函数可以重载
;函数名与类名相同
;无返回值
构造函数的调用也与一般的函数调用不同,我们一起来看看:
int main() { Date d1; Date d2(2025, 1, 1); d1.Print(); d2.Print(); return 0; }
运行结果:
可以看到,我们并没有显式去调用构造函数,而两个对象 d 1 d1 d1 和 d 2 d2 d2 的初始化已经完成了,可见:对象定义(实例化)时系统会 自动调用 对应的构造函数
在对象实例化时:
- 若调用的构造函数 需要传参,则在对象名后面传递参数,如:
Date d2(2025, 1, 1);
- 若需要调用的构造函数 无需传参,应写成
Date d1;
的形式(以 D a t e Date Date 类为例),不能写成Date d1();
,因为这样与函数声明无法区分。
2.2、深入构造
构造函数的进阶特点:
- 如果类中没有显式定义构造函数,则 C++ 编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成
- 无参构造函数、 全缺省构造函数、 我们不写构造时编译器默认生成的构造函数,都叫做 默认构造函数
- 这三个函数
有且只有一个存在
,不能同时存在 。因为他们都可以不传参数,会造成调用歧义- 有很多小伙伴以为默认构造函数是编译器默认生成的那个构造函数,但实际上无参构造函数、全缺省构造函数也是默认构造函数
- 总结下来就是==不传实参 就可以调用的构造就叫 默认构造==
- 我们不写,编译器默认生成的构造,对
内置类型
成员变量的初始化没有要求
,也就是说是否初始化内置类型成员时不确定的,看编译器。
- 对于
自定义类型
成员变量,要求调用这个成员变量的 默认构造函数 来初始化,如果这个成员变量没有默认构造函数
,程序会报错。这时,我们要初始化这个变量,需要用初始化列表才能解决,这点我们等下再学习
我们来看看我们不写构造函数时,编译器自动生成的默认构造
对内置类型和自定义类型的成员变量的处理
我们来看下面一段代码:
class A { public: A(int a = 100, int b = 100, int c = 100) { _a = a; _b = b; _c = c; //打印,看该默认构造是否被调用 cout << _a << " " << _b << " " << _c << endl; } private: int _a; int _b; int _c; }; class Date { public: void Print() { cout << _year << "/" << _month << "/" << _day << endl; } private: int _year; int _month; int _day; A _a; }; int main() { Date d1; d1.Print(); return 0; }
运行结果:
在 D a t e Date Date 类中,有 4 个成员变量:_ y e a r year year;_ m o n t h month month;_ d a y day day;_ a a a,前三个是内置类型,最后一个自定义类型。 D a t e Date Date 类中并没有写构造函数。
- 系统自动生成默认构造函数并没有对内置类型成员变量初始化(有些编译器会初始化成 0,看具体编译器)
- 但是对于自定义类型 A 类,系统
调用了A类中的默认构造函数
,完成了对 _a 自定义类型成员变量的初始化
而如果 自定义类型没有默认构造函数,系统就会报错
如:
class A { public: A(int a) { _a = a; } private: int _a; }; class Date { public: void Print() { cout << _year << "/" << _month << "/" << _day << endl; } private: int _year; int _month; int _day; A _a; }; int main() { Date d1; d1.Print(); return 0; }
上述代码,A类中写了需要传递实参的构造函数
,它不属于无参和全实参构造;同时,因为写了构造函数,编译器也不会自动生成,因此A类中是没有默认构造函数的。
回到文章开头的那两个问题:
- 我们不写时,编译器默认生成的函数行为是什么,是否满足我们的需求
- 编译器默认生成的 函数不满足我们的需求,我们需要自己实现,那么如何自己实现。
总结:
- 对内置类型,编译器自动生成的默认构造大多数情况时
不满足
我们的使用需求的。- 对自定义类型,编译器自动生成的默认构造会
自动调用该自定义类型的默认构造
,从而完成对自定义类型的初始化。当然如果该自定义类型没有默认构造
,编译器会报错
构造函数应写尽写!
2.3、初始化列表
2.3.1、什么是初始化列表
前面我们写的构造函数,虽然在调用之后,对象中有了初始值,但是它并不能称为是对对象中成员变量的初始化;构造函数体中的语句我们只能将其称为赋初值,而不能称作是初始化
。因为初始化只能初始化一次,而构造函数体内可以多次赋值。
构造函数初始化的方式是 初始化列表,初始化列表的使用方式是:以一个冒号开始
,接着是一个以逗号分隔
的数据或成员列表,每个成员变量后面跟一个放在括号中
的初始值或表达式
。
既然是定义初始化,那就只能初始化一次,因此每个成员变量在初始化列表中只能出现一次
尽量使用初始化列表初始化,因为初始化列表是定义的地方,所以即使是不在初始化列表初始化的成员也会走一遍初始化列表
。对于没有显示在初始化列表初始化的内置类型
成员是否初始化取决于编译器
;对于没有显示在初始化列表初始化的自定义类型
成员编译器会调用这个成员类型的默认构造函数
,如果没有默认构造会编译错误。
单看文字,相信大家对什么定义初始化;什么赋值;初始化列表的使用方式一脸懵逼。没关系,我们直接上代码
2.3.2、初始化列表和函数体关系
既然初始化列表是 变量的初始化,那之前学习的写在函数体中的代码是什么呢?是 赋值,相当于给成员变量的赋值。
初始化列表和函数体可以混合使用
如:
Date(int year = 2000, int month = 1, int day = 1) :_year(year + 10) ,_month(3) { _day = day; }
运行结果:
注:成员变量后面的括号可以是初始值
或表达式
,不一定要形参
编译器会先
执行初始化列表
,再
执行函数体
中代码(先初始化才能赋值对吧)
虽然动图中,在初始化列表代码并没有执行 _ d a y day day,但实际上 _ d a y day day 是被隐式执行了,只是并没有被初始化,为随机值。初始化列表是定义的地方,_ d a y day day 不再初始化列表定义又在哪定义呢。
函数体可以做到初始化列表做不到的功能,有时需要他们配合使用
如:
Date(int year = 2000, int month = 1, int day = 1) :_year(year) ,_month(month) ,_day(day) ,_ptr((int*)malloc(sizeof(int))) { if (nullptr == _ptr) { perror("malloc fail"); exit(1); } else { memset(_ptr, 0, 1); } }
2.3.3、必须使用初始化列表的情况
c o n s t const const 成员变量;引用成员变量;没有默认构造的类类型变量,必须放在初始化列表位置进行初始化
,否则会编译报错
为什么呢?我们一起来看看
2.3.3.1、 c o n s t const const 成员变量
我们先来看下面这代码是否可行
int main() { const int a; a = 1; return 0; }
是不行的,因为 c o n s t const const 修饰的变量必须进行初始化
,且初始化后值不能再改变
因此 c o n s t const const 修饰的变量需在初始化列表中定义初始化
class A { public: A() :_a(10) {} private: const int _a; };
2.3.3.2、引用成员变量
引用成员变量的原因与 c o n s t const const 的类似
引用在定义时必须初始化
class A { public: A(int& xx) :_x(xx) {} private: int& _x; };
我们可以看到一个有意思的情况:
class A { public: A(int& rx) :_rx(rx) {} void func() { ++_rx; ++_rx; } private: int& _rx; }; int main() { int n = 0; A a(n); a.func(); cout << n << endl; return 0; }
运行结果:
可以看到, n n n 是变了
为什么呢?
- 把 n n n 传给rx, r x rx rx 是 n n n 的别名, r x rx rx 就是 n n n,而 _ r x rx rx 又是 r x rx rx 的别 名,这样 _ r x rx rx 就是 n n n 的别名,_ r x rx rx 就是 n n n。在类中对_ r x rx rx 的改变,类外的 n n n 自然也就跟着改变啦
当你想在对象中发生了某个行为,外面的变量 n n n 就跟着变,就能这样使用
2.3.3.3、没有默认构造的类
我们知道,对于自定义类型,我们不写,编译器会自动调其默认构造函数
,如果其没有默认构造函数,则报错
。
那对于没有默认构造函数的类型又该怎么办呢?
class Time { public : Time(int hour) : _hour(hour) { cout << "Time()" << endl; } private: int _hour; }; class Date { public: private: int _year; int _month; int _day; Time _t; };
这时候就需要在初始化列表中自己传参数了
Date(int year = 2000, int month = 1, int day = 1) :_year(year) ,_month(month) ,_day(day) ,_t(1) {}
当然,如果是全缺省默认构造也可以这么给值
。
2.3.4、变量声明时给缺省值(默认值)
C++11 支持在 声明的位置 给 缺省值(默认值),这个缺省值主要是给 没有显式在初始化列表初始化的成员用的
如:
class Date { public: Date(int year = 2000, int month = 1, int day = 1) :_year(year) ,_month(month) {} private: //C++11 //声明, 给缺省值 int _year = 10; int _month = 10; int _day = 10; }; int main() { Date d(2024); return 0; }
运行结果:
为什么是这个结果呢?先别急
我们 先来思考这样写 p r i v a t e private private 的内容是定义吗?
它还是声明,不是定义。
定义的特点是木已成舟,已经把空间开出来
了;而声明是告知
你有这个东西存在
- 如果成员在初始化列表中
没有显式示初始化
,则用声明时给的缺省值对其进行初始化;如果在初始化列表中显式初始化
,这用成员变量后边()
中的表达式
对其进行初始化
现在,我们再看为什么是这个结果:
- 首先是 _ y e a r year year:这里直接传实参 2024,这个没有问题
- 后是 _ m o n t h month month:_ m o n t h month month 在初始化列表中
显示初始化
,所以没有声明时给的缺省值什么事
。因为没传参数,这里 _ m o n t h month month 的值是形参中的缺省值1
- 最后来看 _ d a y day day:
- _ d a y day day 并
没有在初始化列表中显示初始化
,因此用声明时给的缺省值初始化
。- 这里别看函数形参 d a y day day 有个缺省值 1 就以为初始化为 1,函数中并没有使用 d a y day day 这个形参。我多设置几个形参怎么了嘛,而且 d a y day day 只是形参名,与成员变量 _ d a y day day 并没有关系, d a y day day 也可以叫其他名。
- 即使形参 d a y day day 给上实参,初始化的值依然是声明时的缺省值,因为初始化列表中没有显式示初始化
缺省值不仅仅可以是一个数值,它还可以是表达式
class Date { public: Date(int year = 2000, int month = 1, int day = 1) :_year(year) ,_month(month) ,_day(day) {} private: int _year = 1; int _month = 1; int _day = 1; int* _ptr = (int*)malloc(12); Time _t = 1; //Time是一个类,前文有提到,这里不再赘述 };
相当于只要初始化列表成员变量后面()
中允许的,声明时给的缺省值也允许、
2.3.5、初始化列表初始化顺序
我们先看下面代码的运行结果是什么
A.输出 1 1 B.输出 2 2 C.编译报错
D.输出 1 随机值 E.输出 1 2 F.输出 2 1
class A { public : A(int a) : _a1(a) , _a2(_a1) {} void Print() { cout << _a1 << " " << _a2 << endl; } private: int _a2 = 2; int _a1 = 2; }; int main() { A aa(1); aa.Print(); }
答案:D
为什么呢?
我们先来看个小知识点
- 初始化列表中按照成员变量在类中声明顺序进行初始化,跟成员在初始化列表出现的先后顺序无关。因此建议声明顺序和初始化顺序保持一致
上述代码中:先初始化的是 _ a 2 a2 a2,_ a 2 a2 a2 用 _ a 1 a1 a1 来定义初始化,那此时的 _ a 1 a1 a1 是什么情况呢?
这里,在建立函数栈帧时,整个对象的空间就已经开好了
,用 _ a 1 a1 a1 去初始化并不会出现什么语法错误
因为此时的 _ a 1 a1 a1 还是随机值,所以 _ a 2 a2 a2初始化后还是随机值
再之后是 _ a 1 a1 a1 的初始化,初始化为 1 ,这点相信大家没什么问题。
为什么初始化列表的初始化顺序是按声明的顺序走呢?
因为声明中的顺序其实是内存中成员变量存放的顺序,编译器这么做也是合理的
2.3.6、总结
2.3.6.1、构造函数特点总结
- 函数名与类名相同
- 无返回值(返回值啥都不需要给,也不需要写void)
- 对象实例化时系统会自动调用对应的构造函数
- 构造函数可以重载
- 如果类中没有显式定义构造函数,则 C++ 编译器会自动生成一个
无参的默认构造函数
,一旦用户显式定义编译器将不再生成
无参构造函数
、全缺省构造函数
、我们不写编译器默认生成的构造函数
,都叫做默认构造函数。但是这三个函数有且只有一个存在,不能同时存在。无参构造函数和全缺省构造函数虽然构成函数重载,但是调用时会存在歧义。要注意很多小伙伴会认为默认构造函数是编译器默认生成的那个叫默认构造,实际上无参构造函数、全缺省构造函数也是默认构造,总结一下就是不传实参就可以调用的构造就叫默认构造
- 我们不写,编译器默认生成的构造,对
内置类型
成员变量的初始化没有要求,也就是说是否初始化是不确定的,看编译器。对于自定义类型
成员变量,要求调用这个成员变量的默认构造函数初始化。如果这个成员变量没有默认构造函数,那么就会报错
,我们要初始化这个成员变量,需要用初始化列表才能解决
说明:C++ 把类型分成内置类型
(基本类型)和⾃定义类型
。内置类型就是语⾔提供的原⽣数据型,如: i n t int int / c h a r char char / d o u b l e double double /指针等,⾃定义类型就是我们使⽤ c l a s s class class / s t r u c t struct struct 等关键字⾃⼰定义的类型。
2.3.6.1、初始化列表知识点总结
初始化列表的使用方式是以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个“成员变量”后面跟着一个放在括号中的初始值或表达式
- 每个成员变量在初始化列表中只能出现一次,语法理解上初始化列表可以认为是每个成员变量 定义初始化 的地方
- 引
用成员变量
、const成员变量
,没有默认构造的类类型变量
,必须放在初始化列表位置进行初始化,否则会编译报错- C++11 支持在成员变量声明的位置给缺省值,这个缺省值主要是给没有显式在初始化列表初始化的成员使用的
- 尽量使用初始化列表初始化,因为那些你不在初始化列表初始化的成员也会走初始化列表,如果这个成员在声明位置
给了缺省值
(默认值),初始化列表会用这个缺省值初始化
。如果你没有给缺省值
,对于没有显式在初始化列表初始化列表初始化的内置类型成员是否初始化取决于编译器,C++没有规定。对于没有显式在初始化列表初始化的自定义类型成员会调用这个成员类型的默认构造函数,如果没有默认构造会编译错误- 初始化列表中按照成员变量在类中声明顺序进行初始化,跟成员在初始化列表出现的先后顺序无关。建议声明顺序和初始化列表顺序保存一致
2.3.6.2、初始化列表行为脉络
- 每个构造函数
都有
初始化列表- 每一个成员
都要走
初始化列表
在
初始化列表初始化的成员(显式写)没有在
初始化化列表初始化的成员(不显式写)
- 声明的地方
有缺省值
没有缺省值
内置类型
不确定是否初始化,看编译器,大概率是随机值自定义类型
,调用默认构造,没有默认构造就编译报错- 引用、const、没有默认构造的自定义类型,
必须
在初始化列表初始化
3、析构函数
3.1、初见析构
析构函数与构造函数功能相反
。就像构造函数不是完成对象的创建;析构函数也不是完成对对象本身的销毁。局部对象的空间是建立函数栈帧时开好的
;销毁是在函数结束时,栈帧销毁,它就释放了
,不需要我们管。
C++ 规定对象在销毁时会自动调用析构函数,完成对象中资源的清理释放工作。析构函数的功能类比我们之前实现栈 S t a c k Stack Stack、或实现队列 Q u e u e Queue Queue 中的 D e s t r o y Destroy Destroy 功能。
D a t e Date Date 类没有 D e s t r o y Destroy Destroy,其实就是没有资源需要释放
,所以严格来说 D a t e Date Date 是不需要析构函数的。
析构函数的特点:
- 析构函数名是在类名前加上字符 ~。
- 无参数无返回值。(这里构造类似,也不需要加void)
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
- 对象生命周期结束时,系统会自动调用析构函数
- 一个局部域的多个对象,C++ 规定后定义的先析构
析构函数的命名也很好理解:析构函数和构造函数是相反的功能。而 “~” 操作符表示取反
因为析构函数无参数无返回值,所以析构函数不构成函数重载,只能有一个析构函数
析构函数有点类似于栈:后定义的先析构
因为 D a t e Date Date 类中并没有资源需要清理,我们用栈类进行举例:
我们调试看一下;
注:该栈对象的销毁是发生在 m a i n main main函数 结束时,销毁对象之前调用了析构函数
3.2、深入析构
与构造函数类似,析构函数也有进阶特点:
- 跟构造函数类似,我们不写编译器自动生成的析构函数对
内置类型
成员不做任何在处理,自定义类型
成员调用它的析构函数
- 我们显示写析构函数,对于自定义类型成员也会调用它的析构,也就是说
自定义类型
成员无论什么情况都会自动调用其自身析构函数
- 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,如 D a t e Date Date 类;如果默认生成的析构就可以用,也就不需要显示写析构,如 M y Q u e u e MyQueue MyQueue(用两个栈模拟实现队列)、但是有资源申请时,一定要自己写析构,否则会造成资源泄露,如 S t a c k Stack Stack 类。
下面实现 M y Q u e u e MyQueue MyQueue 类(用两个栈模拟实现队列)就不用我们写析构,因为编译器自动会调用两个 S t a c k Stack Stack 类的析构函数
// 两个Stack实现队列 class MyQueue { public : //编译器默认⽣成MyQueue的析构函数调⽤了Stack的析构,释放的Stack内部的资源 private: Stack pushst; Stack popst; }; int main() { Stack st; MyQueue mq; return 0; }
可以看到, M y Q u e u e MyQueue MyQueue 类自动调用了两次 S t a c k Stack Stack 类的析构
并且,因为 s t st st 对象是先定义; m q mq mq 对象是后定义,析构时,先析构 m q mq mq,再析构 s t st st
现在,我比较作,自己在 M y Q u e u e MyQueue MyQueue 上写了一个析构函数,但啥都不干,会发生什么呢?
// 两个Stack实现队列 class MyQueue { public : ~MyQueue() { cout << "~MyQueue()" << endl; } private: Stack pushst; Stack popst; }; int main() { Stack st; MyQueue mq; return 0; }
可以看到, M y Q u e u e MyQueue MyQueue 中的资源还是正常释放的
这是因为即使我们显示写析构函数,对自定义类型
,编译器执行完当前自己的析构函数后还会调用自定义类型的析构函数。
那要是连 S t a c k Stack Stack 类中的析构函数也不写呢?那就内存泄漏了。自己作,真救不了。
以前写C语言时,可能申请了空间总是忘记释放,现在有了析构函数就不再有这样的烦恼啦。