课程内容
类型和变量
可理解为类和对象的别名
- C++中只规定了类型的最小位数,所以有的编译器可以实现更多位数的
- 参考cppreference——基础类型
类型
= 类型数据 + 类型操作
- 例如:int=4字节大小的数据 + 基本操作(
+-*/%
),可联想数据结构,int、double类型本质都是数据结构 - 类型数据+类型操作 ➡️成员属性+成员方法
- 可理解为加强版的C语言struct(可以放属性,但不能放方法)
访问权限
先区分类内和类外的概念
访问权限在类内设置,它控制的是类外能不能访问类内
- public:公共
- 类内和类外都可以访问它
- private:私有
- 只有类内的方法可以访问它
- protected:受保护
- 除了类内,继承的类内也可以访问它
- friendly:友元
- 它修饰的函数可以访问类内的私有成员和受保护成员
构造函数和析构函数
对象的生命周期:构造→使用→析构
三种基本的构造函数
联想局部变量的初始化,对象也需要初始化
构造函数类型 | 使用方式 | ⚠️注意 |
---|---|---|
默认构造函数 | People bob; 原型:People(); |
1、零参构造函数 2、编译器自动生成的 |
转换构造函数 | People bob("DoubleLLL"); 原型:People(string name); |
1、一个参数的有参构造函数 2、该参数传递给类的成员变量,且不是本类的const引用 [PS] 有点像隐式的类型转换 |
拷贝构造函数 | People bob(hug); 原型:People(const People &a); |
1、特殊的有参构造函数,传入的是本类的对象 2、与赋值运算符"="不等价 [PS] 处理为const &更方便 |
析构函数
销毁对象
原型:~People();
⚠️注意:
1、没有参数,也没有返回值
2、资源回收时使用
小结
都没有返回值,函数名与类名一致
- 在工程开发中,构造函数和析构函数的功能会设计得非常简单
- ❓为什么不在构造函数中进行大量的资源申请?
- 原因:构造函数的Bug,编译器较难察觉
- 解决方式:伪构造函数、伪析构函数;工厂设计模式
- [+] 移动构造函数(另一个关键构造函数,后续学习)
- 来自C++ 11标准——C++重新归回神坛的标准
- 在此之前,STL的性能低下,因为C++的语言特性不好,它没有区分左值、右值概念,使得STL使用过程中发生的大量拷贝操作,尤其是深拷贝操作,会大大影响性能
- 在此之后,有了右值的概念,引入了移动构造函数
返回值优化(RVO)
编译器默认开启了RVO
引入
对象a通过fun()返回值进行构造
输出结果:
- 理论上:应该输出1次transform和2次copy
- 详见具体分析👇
- 实际上:没有输出copy,且a.x的值为对象temp中的x值,即局部变量temp的地址直接使用了对象a的地址(先开辟的对象a的数据区,见下)
- temp更像是一个引用
- 存在编译器优化,即返回值优化RVO🌟
具体分析
对象初始化过程:1、开辟对象数据区➡️2、匹配构造函数➡️3、完成构造
- 了解对象初始化过程后,再分析上面代码中的构造过程:
- 首先,开辟对象a的数据区
- 然后,进入func函数,开辟对象temp的数据区,通过「转换构造」初始化局部变量temp——A temp(69);
- 再者,通过「拷贝构造」将temp拷贝给临时匿名对象,并销毁对象temp——return temp;
- 最后,通过「拷贝构造」将临时匿名对象拷贝给a,销毁临时匿名对象——Aa= func();
- 👉由此可见,过程包含1次转换构造、2次拷贝构造
- 编译器优化
- 第1次优化,取消第1次「拷贝构造」,直接将temp拷贝给a(Windows下的优化)
- 第2次优化,取消第2次「拷贝构造」,直接是temp指向a(Mac、Linux下的优化)
关闭RVO后
通过g++ -fno-elide-constructors编译源文件,即可关闭RVO
- 出现了两次额外的「拷贝构造」,具体分析见上
注意点
- 编译器本质上是通过替换this指针实现RVO的
- 因为编译器一般会对拷贝构造进行优化,所以在工程开发中,不要改变拷贝构造的语义
- 即:拷贝构造中只做拷贝操作,而不要做其它操作
- 如:拷贝构造中,对拷贝的属性加1,就不符合拷贝构造的语义,编译器把拷贝构造优化掉后,结果与优化前不一致
+ 赋值运算的优化
也存在RVO——优化了1次拷贝构造,即将局部变量拷贝给临时匿名对象
- 添加了红框部分代码
- 优化前结果:
- 1次「转换构造」 + 1次「拷贝构造」
- 优化后结果:
- 1次「拷贝构造」
+ 拷贝构造函数的调用分析
多种写法下,拷贝构造函数是如何调用的?
场景:类A中包含一个自定义类Data的对象d,红框为添加的代码
「主要关注第29行;关闭RVO编译,否则会跳过拷贝构造函数」
- 自定义拷贝构造函数,并且对每个成员属性都进行了显式拷贝,即第29行不变
- 在构造对象d时,会调用Data类的拷贝构造函数
- 自定义拷贝构造函数,不对每个成员属性进行显式拷贝,即删去",d(a.d)"
- 在构造对象d时,会调用Data类的默认构造函数(自定义时没显式拷贝,则匹配默认构造)
- 不自定义拷贝构造函数,编译器自动为其添加默认的拷贝构造函数,即删去第29~31行
- 在构造对象d时,会调用Data类的拷贝构造函数(编译器默认的)
结论:
❗️要想达到预期的结果,在自定义拷贝构造函数时,应该对每个成员属性进行显式拷贝
- 如果自定义的拷贝构造函数什么都不写,那该函数就什么都不会做
其它知识点
引用
引用就是其绑定对象的别名
- ❗️引用在定义的时候就需要初始化,即绑定对象,如:
- People a;
- 定义时就初始化:People &b = a;
- 否则:People &b; b = c; 会发生歧义——绑定对象还是赋值?
类属性与方法
加有static关键字
区别于成员属性(每一个对象特有的)、成员方法(this指针指向当前对象)
- 类属性:该类所有对象统一的属性
- 全局唯一、共享
- 例如:全人类的数量——人类中所有对象的数量
- 类方法:不单独属于某个对象的方法
- 不和对象绑定,无法访问this指针
- 例如:测试某个高度是否为合法身高
const方法
不改变对象的成员属性,不可调用非const成员函数
- 提供给const对象使用(其任何属性都不能被改变)
⚠️:
mutable:可变的,其修饰的变量在const方法中可变
default和delete
默认函数的控制,C++ 11
- default:显式地使用编译器默认提供的规则
- 仅适用于类的特殊成员函数(默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符),且该特殊成员函数没有默认参数
- ❗️该特性没有功能上的意义,这是C++的设计哲学:关注可读性、可维护性等
- 理解这种理念,可以提高自己在C++上的审美标准
- delete:显式地禁用某个函数
struct和class
- struct
- 默认访问权限:public(黑名单机制,需要显式定义private成员)
- 也是用来定义类的,有成员属性和成员方法,要与C中区分开来
- class
- 默认访问权限:private(白名单机制:需要显式定义public成员)
❓:C++为什么要保留struct关键字?默认权限为什么是public?
- 都是为了兼容C语言,可以减小推广难度
PS:前端语言JavaScript为了减小推广难度,从名称蹭Java的热度,本质与Java无关
代码演示
类的示例
- 类中属性和方法的声明和定义,建议分开
- this指针只在成员方法中使用,它指向当前对象的地址
简单实现cout
- cout是一个对象,是一个高级变量
- 返回其自身引用可以实现连续cout,使用引用的原因后续理解
- 字符串需要使用const类型变量接收,否则会警告,因为是字符串是字面量
- 命名空间的精髓:相同的对象名可以存在不同的命名空间中
构造函数和析构函数
运行结果:
1)析构顺序的探讨
- 构造顺序:对象a,对象b
- 析构顺序:对象b,对象a
- ❓为什么析构函数的调用顺序是反过来的?是因为编译器产生的特例,还是一个正常的语言特性?👉语言特性
- 对象b的构造可能依赖对象a的信息➡️在析构的时候对象b也可能会用到对象a的信息➡️对象b要先于对象a析构
- ❗️谁先构造,它就后析构
- PS
- 这与对象放在堆空间还是栈空间无关,实验表明,析构顺序都是反过来的
- 可以认为是一种拓扑序
2)转换构造函数
- ❓为什么叫做转换构造函数(单一参数的构造函数)
- 将一个变量转化成了该类型的对象
- 🌟a=123 涉及运算符重载:隐式类型转换➡️赋值➡️析构,详解见代码
3)拷贝构造函数
1、加引用:防止无限调用拷贝构造
- ❗️如果拷贝构造函数为:A(A a) {},则A b = a;时,
- 因为[形参a]不是引用(值传递),所以需要先将[对象a]拷贝到[形参a]中生成临时对象
- 此时,又会发生拷贝构造,而这个拷贝构造同样会经历上述过程
- 从而无限递归
- PS:引用不产生任何拷贝行为,比指针更方便
2、加const:防止const类型对象使用非const类型的拷贝构造,报错
- 也防止被拷贝的对象被修改
注:
- 在定义对象时,"="调用的是拷贝构造函数,而不是赋值运算,例如,A b = a
4)思考
- 对象是什么时候完成了构造?
- 「参考代码,以默认构造函数为例」
- 功能上的构造 [逻辑上]:到第46行,构造函数表面上执行完成了
- 编译器的构造[实际上]:到第39行,就已经可以调用对象成员了❗️🌟
- 通过思考部分的代码可以理解:
- 场景:在类Data中添加有参构造函数,使编译器帮其添加的默认构造函数被删除
- 过程:在生成类A对象时,成员属性Data类对象p、q需要已经完成构造,而此时Data类的默认构造已经被删除了
- 结果
- 如果不使用初始化列表初始化p、q,则报错
- 如果使用初始化列表,则可行,初始化列表属于编译器所谓的构造
- ❗️这说明编译器的构造是在函数声明后(第39行)就完成了
- ⚠️:
- 编译器会默认添加默认构造函数和拷贝构造函数
- 构造行为都应该放在编译器所谓的构造里,如使用初始化列表
+)左值引用
- 后续学习
+)友元函数
- 在类内声明(同时保证是类的管理者批准的)
- 在类外实现,本质是类外的函数,但可以访问类内的私有成员属性
深拷贝、浅拷贝
拷贝对象:数组
- 编译器默认添加的拷贝构造为浅拷贝
- 对于指针,只拷贝地址,所以对拷贝后的对象的修改,会修改原对象
- 自定义深拷贝版的拷贝构造函数
- 对于指针,拷贝其指向地址的值
- PS:构造函数只有在初始化时才被调用,不存在自己拷贝自己的行为
⚠️:
- 为了适配重载"<<"时的const参数,需要实现const版的"[]"重载
- Array类里的end和数组结尾数据没关系,他只是用来监控数组越界的情况
new、malloc差别分析
运行结果:
- malloc和new都可以申请空间,分别对应free和delete(如果是数组,则为delete[])销毁空间
- new会自动调用构造函数,对应的delete会自动调用析构函数;而malloc和free都不会
- malloc +new可以实现原地构造,常用于深拷贝,其中,new还可以对应不同类的构造函数
类属性与方法、const、mutable
- 类属性:带static在类内声明,不带static在类外初始化
- 类方法:可以通过对象或类名两种方式调用它
- 因此,对象调用的方法不一定是成员方法,还可能是类方法
- const:const对象只能调用const方法,const方法内只能调用const方法
- mutable:其修饰的变量可在const方法中改变
default、delete
功能需求:使某个类下的对象,不能被拷贝
- 禁用拷贝构造函数和赋值运算符
- 拷贝构造函数:设置为=delete,或者,将其放在private权限中
- 赋值运算符重载函数:同样设置为=delete(永远不能使用赋值运算),或者,将其放在private权限中(只有类内方法可以使用赋值运算)
- PS:赋值运算符要同时考虑const版和非const版
- ❓要真正实现对象不能被拷贝,应该将拷贝构造函数和赋值运算符重载都设置为=delete,否则类内方法还是可以拷贝该对象
附加知识点
- 构造函数后的花括号
- 加花括号,表示是一个函数实现
- 不加花括号,则只是一个函数声明,还需要写一个专门的实现
思考点
- 要关注编译器默认做的很多事情,这是C++复杂的地方
Tips
- C++
- 学习方法:按照编程范式分门别类地学习
- 学习重点:程序的处理流程 [远比C复杂]