1 类和对象

课程内容

类型和变量

可理解为类和对象的别名

  • image-20210723211429904
  • C++中只规定了类型的最小位数,所以有的编译器可以实现更多位数的

类型

= 类型数据 + 类型操作

  • 例如: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()返回值进行构造

image-20210723211452133

输出结果:

image-20210723211500226
  • 理论上:应该输出1次transform和2次copy
    • 详见具体分析👇
  • 实际上:没有输出copy,且a.x的值为对象temp中的x值,即局部变量temp的地址直接使用了对象a的地址(先开辟的对象a的数据区,见下)
    • temp更像是一个引用
    • 存在编译器优化,即返回值优化RVO🌟

具体分析

对象初始化过程:1、开辟对象数据区➡️2、匹配构造函数➡️3、完成构造

  • 了解对象初始化过程后,再分析上面代码中的构造过程:
    • image-20210723211510540
    • 首先,开辟对象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

image-20210723211519691
  • 出现了两次额外的「拷贝构造」,具体分析见上

注意点

  • 编译器本质上是通过替换this指针实现RVO的
  • 因为编译器一般会对拷贝构造进行优化,所以在工程开发中,不要改变拷贝构造的语义
    • 即:拷贝构造中只做拷贝操作,而不要做其它操作
    • 如:拷贝构造中,对拷贝的属性加1,就不符合拷贝构造的语义,编译器把拷贝构造优化掉后,结果与优化前不一致

+ 赋值运算的优化

也存在RVO——优化了1次拷贝构造,即将局部变量拷贝给临时匿名对象

image-20210723211527733
  • 添加了红框部分代码
  • 优化前结果:
    • image-20210723211535203
    • 1次「转换构造」 + 1次「拷贝构造」
  • 优化后结果:
    • image-20210723211540883
    • 1次「拷贝构造」

+ 拷贝构造函数的调用分析

多种写法下,拷贝构造函数是如何调用的?

场景:类A中包含一个自定义类Data的对象d,红框为添加的代码

image-20210723211549347

「主要关注第29行;关闭RVO编译,否则会跳过拷贝构造函数」

  1. 自定义拷贝构造函数,并且对每个成员属性都进行了显式拷贝,即第29行不变
  • image-20210723211557942
  • 在构造对象d时,会调用Data类的拷贝构造函数
  1. 自定义拷贝构造函数,不对每个成员属性进行显式拷贝,即删去",d(a.d)"
  • image-20210723211603337
  • 在构造对象d时,会调用Data类的默认构造函数(自定义时没显式拷贝,则匹配默认构造)
  1. 不自定义拷贝构造函数,编译器自动为其添加默认的拷贝构造函数,即删去第29~31行
  • image-20210723211610555
  • 在构造对象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无关

代码演示

类的示例

image-20210723211620159
  • 类中属性和方法的声明和定义,建议分开
  • this指针只在成员方法中使用,它指向当前对象的地址

简单实现cout

image-20210723211627367
  • cout是一个对象,是一个高级变量
    • 返回其自身引用可以实现连续cout,使用引用的原因后续理解
    • 字符串需要使用const类型变量接收,否则会警告,因为是字符串是字面量
  • 命名空间的精髓:相同的对象名可以存在不同的命名空间中

构造函数和析构函数

image-20210723211634762 image-20210723211642463

运行结果:

image-20210723211648649

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行)就完成了
  • ⚠️:
    • 编译器会默认添加默认构造函数和拷贝构造函数
    • 构造行为都应该放在编译器所谓的构造里,如使用初始化列表

+)左值引用

  • 后续学习

+)友元函数

  • 在类内声明(同时保证是类的管理者批准的)
  • 在类外实现,本质是类外的函数,但可以访问类内的私有成员属性

深拷贝、浅拷贝

拷贝对象:数组

image-20210723211710169
  • 编译器默认添加的拷贝构造为浅拷贝
    • 对于指针,只拷贝地址,所以对拷贝后的对象的修改,会修改原对象
  • 自定义深拷贝版的拷贝构造函数
    • 对于指针,拷贝其指向地址的值
    • PS:构造函数只有在初始化时才被调用,不存在自己拷贝自己的行为

⚠️:

  • 为了适配重载"<<"时的const参数,需要实现const版的"[]"重载
  • Array类里的end和数组结尾数据没关系,他只是用来监控数组越界的情况

new、malloc差别分析

image-20210723211717190

运行结果:

image-20210723211724784
  • malloc和new都可以申请空间,分别对应free和delete(如果是数组,则为delete[])销毁空间
  • new会自动调用构造函数,对应的delete会自动调用析构函数;而malloc和free都不会
  • malloc +new可以实现原地构造,常用于深拷贝,其中,new还可以对应不同类的构造函数

类属性与方法、const、mutable

image-20210723211734657
  • 类属性:带static在类内声明,不带static在类外初始化
  • 类方法:可以通过对象或类名两种方式调用它
    • 因此,对象调用的方法不一定是成员方法,还可能是类方法
  • const:const对象只能调用const方法,const方法内只能调用const方法
  • mutable:其修饰的变量可在const方法中改变

default、delete

功能需求:使某个类下的对象,不能被拷贝

image-20210723211742795
  • 禁用拷贝构造函数和赋值运算符
    • 拷贝构造函数:设置为=delete,或者,将其放在private权限中
    • 赋值运算符重载函数:同样设置为=delete(永远不能使用赋值运算),或者,将其放在private权限中(只有类内方法可以使用赋值运算)
    • PS:赋值运算符要同时考虑const版和非const版
  • ❓要真正实现对象不能被拷贝,应该将拷贝构造函数和赋值运算符重载都设置为=delete,否则类内方法还是可以拷贝该对象

附加知识点

  • 构造函数后的花括号
    • 加花括号,表示是一个函数实现
    • 不加花括号,则只是一个函数声明,还需要写一个专门的实现

思考点

  • 要关注编译器默认做的很多事情,这是C++复杂的地方

Tips

  • C++
    • 学习方法:按照编程范式分门别类地学习
    • 学习重点:程序的处理流程 [远比C复杂]