终于到了介绍面向对象和类的环节
说实话东西有点多,可以分两章来写,this指针之前的会在这篇文章涉及
Classes and Object-Oriented Programming
之前我们写的代码都属于过程式,将解决问题分解为一个个步骤然后分步完成;但是这种方法不能模拟现实世界,可能做做计算比较合适,在大型工程中需要模拟社会与人类认知事物的方式,此时人们发明了类(C中结构体进化而来),用来将基本类型与一些操作组合在一起构建新类型
然后我们以这些原则为指导写面向对象的程序,此时的基础不再是基础类型与运算,而是对象的方法与对象交互
Encapsulation 封装
基本上,我们定义事物的方式都是提取它的特性,其他事物没有的性质来对事物下定义,比如一个正方体知道边长是特征,长方体长宽高是特征,球体半径是特性,银行账户的存款、利率等是特性,银行的地点、名称、负责人等等是特征
当然这些事物或者对象不只有这些静态标签,还有自己的动作,我们叫方法;球体可以滚动,人可以吃饭睡觉等等,是一些基于自己的属性进行的行为
这样将数据和方法组合到一个实体上的行为就是封装,有点像饭点打包,将我们需要的东西都用袋子装起来;下面是一个封装示意图,以银行贷款账户为例
注意我们这里定义对象属性的原则是符合我们需要,就像之前的正方体,如果我们只需要体积表面积,那么我们只需要给它一个边长属性;如果我们需要颜色、重量、材质,那么有必要加上其他的属性
Data Hiding
还是说回之前的银行账户,我们不希望这种重要数据被随便更改,那么引入的保护数据与方法的技术叫做数据隐藏或者信息隐藏
这个技术在C++里通过访问权限来实现,后面会讲到
Inheritance 继承
继承又是一个符合人类认知事物的概念,我们对生物分门、科、属,对人类分性别、阶级、工种,对建筑分风格、年代,本质上都是继承于基础概念后的细分过程
代码也是一样,模拟现实世界的时候也可以有继承下的细分类,比如我们定义棱台为一个基础类,那么下面可以派生出长方体为一个特例,因为长方体有三对相同的面,长方体下又可以派生出正方体,它的六个面都相同,但所有派生类都基于一个棱台,拥有八条边六个面的表面平整的立方体
Polymorphism 多态
这里指的是动态多态,因为多态也分很多种,像函数模板、类模板也属于多态,函数重载、操作符重载也属于多态,不过后面的都是编译期的操作
动态多态就是程序运行时能够基于不同的类型给出不同的结果,这里的类型指的是对象类型,具体来说是继承链上的对象类型
C++中动态多态通过指针或引用调用成员函数来实现,背后的机制是虚函数
Defining a Class
类属于自定义类型,定义或声明的时候使用class关键字:
再加上封装时讲到的数据保护技术,举个例子:
我们把不想被外部访问的数据、方法写在private标签后,希望外部可以调用的写在public后
一般public写在前,因为我们通常关注它的功能而不是具体实现;数据一般写在写在方法后面;构造函数和析构函数一般写在最前面
当然我们也可以用结构体,C中组织数据的方式,结构体可能对于我们这种小程序更简单,因为struct成员默认访问权限是public
class 和 struct 的唯一区别,是访问权限(包括继承的访问权限)默认是 private 还是 public
其它方面 class 和 struct 完全一样
我们应该多使用结构体而不是tuple或者pair这种工具类型,因为这样语义更明确
Creating Objects of a Class
把类名字当int、double这种普通类型使用就可以了
因为有了访问权限的限制,数据成员不可以被外部访问,所以我们只能调用public的部分
Constructors 构造函数
构造函数有以下特点:
- 名字与类名相同
- 没有返回值类型,没有返回值
创建对象只能用构造函数,只要有对象被创建,对应的构造函数就被调用
Default Constructors 默认构造函数
注意,这里的默认构造函数是我们没有写构造函数时,编译器生成的一个默认版本,以保证类的正常构造,这个构造函数什么也不做
所以实际上,Box这个类有一个编译器生成的构造函数,大概是下面这个样子:
默认构造函数指的是构造对象时不需要实参的构造函数,通常也意味着对象会被默认初始化
书上管这个叫default default constructor,因为这是默认情况下生成的默认构造函数,默认情况下指我们不写构造函数,默认构造指进行默认初始化的构造函数,唯一的作用就是保证对象能够构造
什么也不做的默认构造函数将产生什么后果呢?对于是基础类型的数据成员,什么也不做意味着数据里都是垃圾值,包括算数类型和指针
不过我们的Box类给了初始值,所以构造后数据成员的值都是有意义的(至于这个初始化顺序后面会讲到)
Defining a Class Constructor 构造函数的定义
虽然一般工程函数不会输出这种提示信息,但是写小程序用来学习或者调试的时候,输出一个定位信息或者局部变量是很好的~
之前已经说过,默认构造函数只有在我们没写的时候编译器才会生成,此处写了那就用写的版本,编译器不会再生成;话说回来,这个在《Primer》里叫做合成构造函数,我觉得这个名字更合理;所以下面28行处不会通过编译,因为已经不支持默认初始化了
有的写法会使用this->length=length的方式来初始化,但是书里还是采用不同的名字来进行编写,避免名字冲突造成歧义
Using the default Keyword
如果我们还是想要默认构造这样一个功能怎么办呢?
一个方法是我们自己写,什么参数都没有,什么效果也不做(这一点有待商榷)
还有一个方法是使用default关键字
there are also a few subtle technical reasons that make the compiler-generated version the better choice.
这里书上提到了一点,编译器生成的版本和我们写的空构造函数并不完全相同,这一点需要深入学习,但是对于简单类型不需要考虑这个问题(暂时认为语法糖即可)
Defining Functions Outside the Class
这一点和之前模块那里说的没有区别,是把声明和实现分开的一种写法,我们可以把实现移到cpp里而不是放在头文件里
Default Arguments for Constructor Parameters
和普通函数类似,构造函数也可以有默认参数
更类似的是,全带默认参数的构造函数和无参构造函数其实没有区别
这样会编译失败的,我们无法确定没有参数的时候是调用哪个版本;这种情况通常是把第一个删去,因为没有默认值通常没有什么实际意义
Using a Member Initializer List
我们不在函数体里写赋值语句,而是在参数列表之后加冒号,然后用大括号给数据成员以初始值,这种写法叫成员初始值列表
这种写法的好处是(我们需要首先了解一下构造对象的数据成员的过程):函数体内写赋值是在初始化数据成员之后的重新赋值,而初始值列表采用初始值直接进行初始化,中间少了一步所以会快很多,尤其是成员是对象的时候
这种写法的坑在于:数据成员初始化的顺序和列表里的顺序无关,而是和他们在类声明中出现的顺序有关;如果有的成员依赖其他成员进行初始化,我们需要特别注意这个顺序
Using the explicit Keyword
A problem with class constructors with a single parameter is that the compiler can use such a constructor as an implicit conversion from the type of the parameter to the class type.
我们尤其要注意只有一个参数的构造函数,如果不加以限制,很多时候这个构造函数会进行没必要的隐式类型转换
举个例子:
这个构造函数只有一个double参数,我们看26行,这个方法的参数居然是一个double类型!
虽然很符合直觉,好像这个地方参数就应该提供数值和对象两种,但是我们应该比的是体积,可这个地方其实用50.0这个double构造了一个临时对象,然后拿box1的体积去与50^3比较了,所以其实这个隐式转换是不符合我们预期的,这样的构造函数ref叫做converting constructor
C++11之后除了单参数构造函数,不加explicit的构造函数都是转换构造函数
为了避免这个问题,我们需要在这种构造函数前面加上一个explicit关键字:
再举个多参构造的例子:
不加explicit都是可以转换的,我们可以看看上面转换构造的定义
加了explicit之后,这些都不能过编译了,必须显式调用构造函数
Delegating Constructors 委托构造函数
这是一种代码复用的实现,构造函数完全可以有多个接口,但是实现不需要那么多,可以嵌套构造来简化实现
我们可以实现一个通用的三参版本,然后第二个构造函数复用第一个实现来简化:
不过之前看到的都是小括号的版本,由于C++20之后更推荐大括号初始化,所以初始值列表这里要习惯一下
可以看到30行这个对象的构造使用了两个构造函数体
但是要注意的是,委托别人的构造函数本身也是构造函数,这种委托构造函数也有一定的限制
If the name of the class itself appears as class-or-identifier in the member initializer list, then the list must consist of that one member initializer only
首先,会进行函数的嵌套调用;其次,它的初始值列表里不可以包含其他的内容,要委托就只能委托,不能进行其他成员的初始化动作
The Copy Constructor 复制构造函数
我们用之前的box2对象对box3初始化,这种复制的行为也很自然,但是我们并没有写这种构造函数,box3是怎么进行构造的呢?
实际上复制构造函数不写的话编译器也会生成一个合成的默认版本,通过复制现有对象的数据进行复制初始化
Copy constructors - cppreference.com
Implementing the Copy Constructor
复制构造函数只能接受一个同类型的参数,但是这样会产生上面这样的问题,我们的本意是用一个已有对象来初始化一个新对象,如果采用值传递的参数,类又复制了一次,可是我们根本没有实现复制行为!这样导致了复制构造的递归!
所以复制构造函数的参数必须是引用,而且一般是const-ref,首先因为我们不修改原有版本,只是创造一个副本;其次const引用可以接收非const,如果我们写了非const参数,那么const对象不能接收也就无法复制了
当然我们也可以把这个构造函数委托给之前那个通用版本
~但是完全没有必要,因为不牵扯指针浅复制的时候,大部分时间我们都可以用默认版本而不需自己编写
Deleting the Copy Constructor
有的时候我们也不想对象被复制,此时可以给一个删除关键词
此时对象复制行为将被禁止,这样的代码无法通过编译
此外,成员里有对象的复制构造函数被删除或者私有时,这个类的复制构造函数都会被删除,即使我们显式要求编译器生成default版本
Whenever you delete the copy constructor, you probably also want to delete the so-called copy assignment operator. We will tell you more about this in the next chapter.
这里就是后话了,复制行为除了构造还有赋值,这两个一般要删一起删
Defining Classes in Modules
export关键字要放在类的声明前;并且export不影响类原有的访问控制权限
当然我们还是可以把实现挪到cpp里和声明分开
Accessing Private Class Members 访问私有成员
我们不想把数据完全封闭起来,应该授权给可以修改它的对象进行修改,但是不应该在外部直接读取,所以经常会见到这种形式,一般叫getter/setter,用来修改数据;如果是bool类型那一般起名都是is什么,比如isValid()这样可读性比较好