【C++要笑着学】类的默认成员函数详解 | 构造函数 | 析构函数 | 构造拷贝函数
写在前面
以 "初学者" 的角度去探索式地学习。会一步步地推进讲解,而不是直接把枯燥的知识点倒出来,应该会有不错的阅读体验。
本章将继续讲解C++中的面向对象的知识点,本篇主要讲解默认成员函数中的构造函数、析构函数和拷贝构造函数。还是和以前一样,我们将由浅入深地去讲解,Ⅰ. 默认成员函数
如果一个类中什么成员都没有,我们称之为 "空类" 。
❓ 但是空类中真的什么都没有吗?
并不是的……
类有六个默认成员函数,特殊的点非常多,后面我们会壹壹学习。
对于默认成员函数,如果我们不主动实现,编译器会自己生成一份。
❓ 他们有什么用呢?举个例子:
比如我们在上一章里举过的一个 Stack 的例子,
如果需要初始化和清理,"构造函数" 和 "析构函数" 就可以帮助我们完成。
构造函数就类似于 Init,而析构函数就类似于 Destroy。
还是和以前一样,我们将先由浅入深地进行学习,我们先从 "构造函数" 开始讲起。
Ⅱ. 构造函数
0x00 引入
打开宇宙第一编辑器,一起敲一敲看看 ~
📚 为了能够更好地讲解,我们来写一个简单的日期类,通过日期类来讲解。
💬 Date.cpp
💡 对于 Date 类,我们可以通过我们写的成员函数 SetDate 给对象设置内容。
但是每次创建对象都要调用这个 SetDate ,是不是太鸡儿烦了?
❓ 那有没有什么办法能在创建对象时,自动将我们要传递的内容放置进去呢?
有!下面我们来隆重介绍一下 构造函数!
0x01 构造函数的概念
📚 构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用。
能够保证每个数据成员都有一个合适的初始值,并且在对象的生命周期内只调用一次。
构造函数的意义:能够保证对象被初始化。
构造函数是特殊的成员函数,主要任务是初始化,而不是开空间。(虽然构造函数的名字叫构造)
0x02 构造函数的特性
📚 构造函数是特殊的成员函数,主要特征如下:
① 构造函数的函数名和类名是相同的
② 构造函数无返回值
③ 构造函数可以重载
③ 会在对象实例化时自动调用对象定义出来。
比如下面的代码只要
就会自动调用,保证了对象一定是被初始化过的。我们直接来看看它是怎么用的!
💬 构造函数的用法:
🚩 运行结果如下:
🔑 解读:不给参数时就会调用无参构造函数,给参数则会调用带参构造函数。
📌 注意事项:
① 构造函数是特殊的,不是常规的成员函数,不能直接调
。🚩 运行结果:(报错)
② 如果通过无参构造函数创建对象,对象后面不用跟括号,否则就成了函数声明。
③ 这里如果调用带参构造函数,我们需要传递三个参数(这里我们没设缺省) 。
④ 如果你没有自己定义构造函数(类中未显式定义),C++ 编译器会自动生成一个无参的默认构造函数。当然,如果你自己定义了,编译器就不会帮你生成了。
🚩 运行结果如下:
🔑 没有定义构造函数,对象也可以创建成功,因此此处调用的是编译器默认生成的构造函数。
0x03 默认构造函数
无参构造函数、全缺省构造函数都被称为默认构造函数。
并且默认构造函数只能有一个!
📌 注意事项:
① 无参构造函数、全缺省构造函数、我们没写编译器默认生成的无参构造函数,都可以认为是默认成员函数。
② 语法上无参和全缺省可以同时存在,但如果同时存在会引发二义性:
🚩 运行结果如下:(报错)
🔑 解读:无参的构造函数和全缺省的构造函数都成为默认构造函数,并且默认构造参数只能有一个,语法上他们两个可以同时存在,但是如果有对象定义去调用就会报错。
推荐实现全缺省或者半缺省,因为真的很好用:
🚩 运行结果如下:
0x04 构造函数的特性的测试
📚 任何一个类的默认构造函数,只有三种:
① 无参的构造函数
② 全缺省的构造函数
③ 我们不写,编译器自己生成的构造函数
💬 如果你没有自己定义构造函数(类中未显式定义),
C++ 编译器会自动生成一个无参的默认构造函数。
当然,如果你自己定义了,编译器就不会帮你生成了。
🚩 运行结果如下:
好了,我们来好好探讨探讨这个问题!
在我们不是先构造函数的情况下,编译器生成的默认构造函数。
似乎这看起来没有什么鸟用啊,这不就是一堆随机值嘛……
d1 对象调用了编译器生成的默认函数,但 d1 对象 year / month / day 依旧是随机值,
也就是说这里编译器生成的默认构造函数好像并没有什么卵用。
🔑 解答:C++ 把类型分成内置类型(基本类型)和自定义类型。
内置类型就是语法已经定义好的类型:如 int / char...,
自定义类型就是我们使用 class / struct / union / 自己定义的类型。
💬 看看下面的程序,就会发现。编译器生成默认的构造函数会对自定类型成员 aa 调用的它的默认成员函数:
🚩 运行结果如下:
💬 测试:对自定义类型处理,会调用默认构造函数(不用参数就可以调的函数)
🚩 运行结果如下:
C++ 里面把类型分为两类:内置类型(基本类型)和 自定义类型。
C++ 规定:我们不写编译器默认生成构造函数,对于内置类型的成员变量,不做初始化处理。
但是对于自定义类型的成员变量会去调用它的默认构造函数(不用参数就可以调的)初始化。
如果没有默认构造函数(不用参数就可以调用的构造函数)就会报错!
💬 为了验证,这里我们故意写个带参的默认构造函数,让编译器不默认生成:
🚩 运行结果如下(报错)
💬 我们不写,让编译器默认生成一个:
是随机值没错,但是这是一种对自定义类型的 "处理" 。
这里说个题外话,个人认为 C++里,我们不写构造函数编译器会默认生成的这个特性设计得不好(狗头保命)……因为没有对内置类型和自定义类型统一处理,不处理内置类型成员变量,只处理自定义类型成员变量。
Ⅲ. 析构函数
0x00 引入
通过前面构造函数的学习,我们知道了一个对象是怎么来的了,
❓ 那一个对象又是怎么没的呢?既然构造函数的本质是初始化,那清理的工作交给谁来干呢?
💡 交给专门擦屁股的 —— 析构函数!
以前我们玩数据结构的时候经常忘记调用 destroy 函数,但是现在我们有析构函数了!!!
多么振奋人心啊!话不多说让我们开始讲解!!!
0x01 析构函数的概念
析构函数与构造函数的功能相反。
构造函数是特殊的成员函数,主要任务是初始化,而不是开空间;
析构函数也一样,主要任务是清理,而不是做对象销毁的工作。
(局部对象销毁工作是由编译器完成的)
📚 概念:对象在销毁时会自动调用析构函数,完成对象的一些资源清理工作。
0x02 析构函数的特性
构造函数是特殊的成员函数,主要特征如下:
① 析构函数名是在类名前面加上字符
② 析构函数既没有参数也没有返回值(因为没有参数,所以也不会构成重载问题)
③ 一个类的析构函数有且仅有一个(如果不写系统会默认生成一个析构函数)
④ 析构函数在对象生命周期结束后,会自动调用。
(和构造函数是对应的构造函数是在对象实例化的时候自动调用)
💬 为了演示自动调用,我们来让析构函数被调用时 "吱" 一声:
🚩 运行结果:
额,之前举得日期类的例子没法很好地展示析构函数的 "魅力" ……
就像本段开头说情景,我们拿 Stack 来举个例子,这就很贴切了。
我们知道,栈是需要 destroy 清理开辟的内存空间的。
这里我们让析构函数来干这个活,简直美滋滋!
💬 析构函数的用法:
🔑 解读:我们在设置栈的构造函数时,定义容量 capacity 时利用缺省参数默认给个4的容量,这样用的时候默认就是4,如果不想要4可以自己传。
如此一来,就可以保证了栈被定义出来就一定被初始化,用完后会自动销毁。以后就不会有忘记调用 destroy 而导致内存泄露的惨案了,这里的析构函数就可以充当销毁的作用。
❓ 问一个比较有意思的问题,这里是先析构 s1 还是先析构 s2?
既然都这样问了,应该是先析构 s2 了 ~
析构的顺序在局部的栈中是相反的,栈帧销毁清理资源时 s2 先清理,然后再清理 s1 。
(不信的话可以去监视一下 this 观察下成员变量)
0x03 析构函数的特性的测试
又到了测试环节,上号!
我们知道,如果没写析构函数编译器会自动生成一个。
那生成的析构函数会做什么事情呢?它会帮我们 destroy 嘛?
想屁吃?哪有这种好事。
如果我们不写默认生成的析构函数,结果和构造函数类似,
对于自定义类型的成员变量不作处理,对于自定义类型的成员变量会去调用它的析构函数。
难道就不能帮我把这些事都干了吗?帮我都销毁掉不就好了?
不不不,举个最简单的例子,迭代器,析构的时候是不释放的,因为不需要他来管,
所以默认不对内置类型处理是正常的,万一误杀了怎么办,对吧。
有人可能又要说了,这么一来默认生成的析构函数不就没有用了吗?
有用!他对内置类型的成员类型不作处理,会在一些情况下非常的有用!
比如说: 两个栈实现一个队列(LeetCode232) ,用C++可以非常的爽。
💬 自定义类型的成员变量调用它的析构函数:
🚩 运行结果如下:
Ⅳ. 拷贝构造函数
0x00 引入
我们在创建对象的时候,能不能创建一个与某一个对象一模一样的新对象呢?
当然可以,这时我们就可以用拷贝构造函数。
0x01 拷贝构造函数的概念
📚 拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用 const 修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
0x02 拷贝构造函数的特性
它也是一个特殊的成员函数,所以他符合构造函数的一些特性:
① 拷贝构造函数是构造函数的一个重载形式。函数名和类名相同,没有返回值。
② 拷贝构造函数的参数只有一个,并且必须要使用引用传参!
使用传值方式会引发无穷递归调用!
💬 拷贝构造函数的用法:
🚩 运行结果如下:
❓ 为什么必须使用引用传参呢?
调用拷贝构造,需要先穿参数,传值传参又是一个拷贝构造。
调用拷贝构造,需要先穿参数,传值传参又是一个拷贝构造。
调用拷贝构造,需要先穿参数,传值传参又是一个拷贝构造。
……
一直在传参这里出不去了,所以这个递归是一个无穷无尽的。
💬 我们来验证一下:
error: invalid constructor; you probably meant 'Date (const Date&)'
这里不是加不加 const 的问题,而是没有用引用导致的问题。
不用引用,他就会在传参那无线套娃递归。至于为什么我们继续往下看。
💬 拷贝构造函数加 const :
如果函数内不需要改变,建议把 const 也给它加上
第一个原因:怕出错,万一你一不小心写反了怎么办?
这样会产生一个很诡异的问题,这一个可以被编译出来的 BUG ,结果会变为随机值。
所以,这里加一个 const 就安全多了,这些错误就会被检查出来了。
第二个原因:以后再讲,因为涉及一些临时对象的概念。
🔺 反正,不想深究的话就记住:如果函数体内不需要改变,建议把 const 加上就完事了。
0x03 关于默认生成的拷贝构造
这里比较特殊,我们单独领出来讲。
📚 默认生成拷贝构造:
① 内置类型的成员,会完成按字节序的拷贝(把每个字节依次拷贝过去)。
② 自定义类型成员,会再调用它的拷贝构造。
💬 拷贝构造我们不写生成的默认拷贝构造函数,对于内置类型和自定义类型都会拷贝处理。但是处理的细节是不一样的,这个跟构造和析构是不一样的!
🚩 运行结果如下:
🔑 他这和之前几个不同了,这个他还真给我解决了。
所以为什么要写拷贝构造?写他有什么意义?没有什么意义。
默认生成的一般就够用了!
当然,这并不意味着我们都不用写了,有些情况还是不可避免要写的
比如实现栈的时候,栈的结构问题,导致这里如果用默认的 拷贝构造,会翻车。
按字节把所有东西都拷过来会产生问题,如果 Stack st1 拷贝出另一个 Stack st2(st1)
会导致他们都指向那块开辟的内存空间,导致他们指向的空间被析构两次,导致程序崩溃
然而问题不止这些……
其实这里的字节序拷贝是浅拷贝,下面几章我会详细讲一下深浅拷贝,这里的深拷贝和浅拷贝先做一个大概的了解。
🔺 总结:对于常见的类,比如日期类,默认生成的拷贝构造能用。但是对于栈这样的类,默认生成的拷贝构造不能用。
Ⅴ. 总结
默认成员函数有六只,本篇只介绍了三只,剩下的我们后面讲。
类和对象部分知识很重要,所以我们来做一个简单的总结 ~
0x00 构造函数
初始化,在对象实例化时候自动调用,保证实例化对象一定被初始化。
构造函数是默认成员函数,我们不写编译器会自己生成一份,我们写了编译器就不会生成。
我们不写内置类型成员变量不处理。
对于内置类型成员变量不处理。
对于自定义类型的成员变量会调用它的默认构造函数。
0x01 析构函数
完成对象中自愿的清理。如果类对象需要资源清理,才需要自己实现析构函数。
析构函数在对象生命周期到了以后自动调用,如果你正确实现了析构函数,保证了类对象中的资源被清理。
什么时候生命周期到了?如果是局部变量,出了作用域。全局和静态变量,整个程序结束。
我们不写编译器会默认生成析构函数,我们实现了,编译器就不会实现了。
对于内置类型成员变量不处理。
对于自定义类型的成员变量会调用它的析构函数。
0x02 拷贝构造
使用同类型的对象去初始化实例对象。
参数必须是引用!不然会导致无穷递归。
如果我们不实现,编译器会默认生成一份默认的拷贝构造函数。
默认生成的拷贝构造:
① 内置类型完成按子继续的值拷贝。 —— 浅拷贝
② 自定义类型的成员变量,会去调用它的拷贝构造。
🚩 运行结果如下:
参考资料:
Microsoft. MSDN(Microsoft Developer Network)[EB/OL]. []. .
百度百科[EB/OL]. []. https://baike.baidu.com/.
比特科技. C++[EB/OL]. 2021[2021.8.31]. .
📌 笔者:王亦优
📃 更新: 2022.3.15
❌ 勘误:Star丶北辰:拿栈举例时malloc空间 sizeof 有误【已修正】
📜 声明: 由于作者水平有限,本文有错误和不准确之处在所难免,本人也很想知道这些错误,恳望读者批评指正!
本章完。
- 点赞
- 收藏
- 关注作者
评论(0)