五、类
面向对象编程是一个巨大的编程范式。C++中的类class就是基于对象的程序设计。
我们可以用类来定义一个新的类型,这些新类型就可以像内置类型一样使用。
内置类型颗粒度太太小,现实需求又非常复杂,这就需要我们把内置类型适度的进行拼搭,拼搭成一个能描述现实问题的大粒度颗粒,来解决现实问题。
C++的数据类型有:常量、变量、布尔类型、指针类型、字符串类型、引用类型、枚举类型、数组类型、vector容器类型、复数类型、pair类型、类类型。所以类也是一种数据类型。
你可以把类看成一个新的数据类型,或者说是应用程序中的一种设施,这种设施是把数据和函数封装在一起的设施。
类的重点内容有:
a、怎样定义一个类?通过共有类接口和私有类接口实现。也就是信息隐藏(information hiding)的概念。
b、怎样定义和操纵类的对象实例?类域、嵌套类、做为名字空间成员的类、局部类...
c、类对象的初始化、析构、赋值如何实现?特殊成员函数:构造函数constructor、析构函数destructor、拷贝赋值操作符copy assignment operator...
这里重点强调一组特殊成员函数:转换函数conversion functions,就是将class类型定义一组标准转换。当类对象被用作函数实参、或作为内置或重载操作符的操作数时,这些转换函数就由编译器隐式的调用。
e、操作符重载的概念和设计,使我们能够使用内置操作符来操作class类型的操作数,使class类型对象的用法像内置类型对象的用法一样直观。 所以,我们把像赋值、下标、调用、new和delete等重载操作符声明为一个类的友元friend,使其拥有特殊的访问权限。
1、类和结构体的联系和区别
关于结构体的内容可以参考我的另外一篇博文:【C语言学习笔记】八、结构体-CSDN博客
C++之所以兼容结构体,是因为希望和C保持兼容性。结构体是C的语法,但C中没有类。
类和结构体可以说没什么区别。唯一的区别就是结构体中的变量默认都是共有的,类中的变量没有public声明就都默认是私有的。
public表示可以在类以外的任何地方访问这些变量
2、什么时候用类什么时候用结构体?
虽然类和结构体没有太大的区别,但是它们还是各自有各自的应用场景,不是随便通用的。比如当我们想管理很多变量时,那你用结构体,你的代码就非常清晰。如果你还想写一些函数,甚至是使用继承,建议还是写成类的形式,因为这会涉及到更多的内容。比如当一个结构体继承一个类的时候,编译器就会警告,虽然程序还是可以运行,但还是有一些语义上的区别的。总之:结构体的定位是数据的结构,就是只用结构体表示一些数据;如果你还实现更复杂的功能,那就用类。
3、类定义、类声明、数据成员、成员函数、可见性
4、写一个最简单的类
(1)梳理需求
我打算写一个类,类的功能是实现日志信息管理。
由于日志系统可大可小,可简可繁,不仅是打印信息到控制台,还可以打印不同的颜色、或者通过网络输出日志信息到一个文件。所以一个log系统可十行代码也可上万行。现在我就写一个最最简单的Log系统,实现向控制台写入文本的能力,并且区分日志级别(错误、警告、信息或跟踪)的功能即可。
(2)分解需求
把日志信息分3个级别:错误、警告、信息或跟踪
当我把级别设置为"信息",就打印信息、警告、错误3种日志;
当我把级别设置为"警告",就打印警告、错误2种日志;
当我把级别设置为"错误",就打印错误1种日志;
(3)代码写作过程:
上面的Log类中的公共变量我用了两次public是因为:我喜欢把类中不同的部分分开来写,比如,public方法写在一部分,public变量又放另一部分,public静态变量又会放其他一部分。这只是每个人的编程风格而已,只是为了更清晰一点而已。
说明:上面的步骤只是展示了如何逻辑清晰的写一个类,但事实上上面的代码是非常糟糕的,后面我们将使用更多的概念来改进这个类,使其达到专业生产级水平的代码。
5、静态static
C++中的静态static关键字有3个意思:
一是,当你在类或者结构体外部使用static关键字时,表示你static的符号,其链接只能在内部,也就是你static定义的符号只能在翻译单元可见。
二是,当你在类或结构体内部使用static关键字时,表示该符号将与类的所有实例共享内存。也就是说该静态变量是该类类型的所有对象共享访问的。同样的效果也适用于静态方法。
三是,函数内部使用static关键字时,就类似python中的闭包效果。
(1)static关键字在类或结构体外部时:
上图s_Variable是定义一个静态变量,s表示这个变量是静态的,意思是这个变量只会在这个翻译单元内部链接。
静态变量或静态函数意味着,当需要将这些变量或函数与实际定义的符号链接时,链接器不会在这个翻译单元的作用域之外,寻找那个符号定义。
(2)static关键字在类或结构体内部时:
如果我创建一个名字叫Entity的类,我不断创建Entity的实例,但是static变量或函数永远只有一个,所有实例共享这一个static变量或函数。
所以,如果某个实例更改了static变量或函数,那所有实例的这个变量和函数都会跟着改变。所以,我们一般都不会通过实例更改static变量或函数,没意义嘛。
所以设置static变量的目的只有一个:就是所有实例可以共享这个变量。而不是通过实例去改变这个变量,这样做毫无意义。
比如银行账户,每个账户的姓名和余额都不一样,但每个账户的利率是一样的。如果我们给每个账户都单独设置一个利率变量,是不是就非常浪费。所以使用static变量是有意义的。
静态方法无法访问类的实例。所以在静态方法内部是不能写引用到类实例的代码。静态方法也不能访问非静态变量。
在类中的每个非静态方法都是要获取当前类的一个实例作为其参数的。
静态方法可以被类实例调用、也可以通过命名空间调用。
总之,关键字static就是一个作用域的功能。static变量或函数是类外的变量和函数,虽然它也写在类内部;非static变量或函数是类内的变量和函数。类外的函数访问类外的变量,肯定访问不了类内的变量了。
(3)static在函数内部时:
static在函数内部时,也叫局部静态 local static
局部静态变量允许我们声明一个变量,这个变量的生存期相当于整个程序的生存期,但是作用域确实这个函数内部的。这一下就让我想起python中的闭包!是不是,你有没有同感!
小结:函数可以访问外部变量,也可以在函数体内更改外部变量。但是函数体内的变量只能在函数体内改变。但是如果函数体内的变量是static的,那这个变量就相当于是全局变量,但是这个全局变量不能在函数体外更改,只能被该函数更改。
6、枚举类
枚举ENUM,enumeration的缩写。枚举就是一个数值集合。就是给一个名字赋值,但不是平时我们说的就赋一个值的那种,是另外一种赋值方法。
7、构造函数
类在每次实例化对象时,都会运行一个特殊的方法就是构造函数。
构造函数的名称必须与类的名称相同、必须没有返回值、可以有参数也可以没有!
所以构造函数就是类中的一种特殊方法,就是每次实例化一个实例的时候,就自动调用的一个方法。上图是我们手动写了一个构造函数,也就是我们自己指定了一个构造函数。如果我们在类中不写构造函数,那就会有一个已经写好的构造函数默认让你用了,就是默认就已经执行了一个构造函数。
如果不实例化对象,那构造函数就不会被运行。
总之,构造函数就是创建类实例时运行。有人知道是为什么吗?
有心的小伙伴估计都已经猜出来了:比如上图你现在写的类Entity,即使你不写Entity这个特殊成员函数————构造函数,其实你还是要执行一个初始化的构造函数的!因为即使你的Entity类不是继承的子类,但只要你写类,肯定就默认有一个父类,就是默认你已经继承了一个父类,这个父类就是所有类的父亲,不管啥类都是这个父类的子类,所以当我们把Entity类写完后,初始化Entity时,其实首先运行的代码就是父类的代码(在上面小标题3-可见性的最后一个小例子就有演示!),而父类的代码中就肯定有一个父类的构造函数,所以父类的这个构造函数首先被执行,所以你实例化Entity时,其实构造函数就已经被在你看不见的地方被执行了。
但是这里还有一个道道就是,如果我在Entity中也写了我自己的构造函数,那此时如果你的构造函数和父类的构造函数是同名同参,那就不会执行父类的构造函数了,只执行Entity的构造函数,这叫函数重载。而且这也是允许的,所以不仅仅是构造函数,任何函数都适用。如果你决得父类中哪个方法不顺眼,那你可以在子类中再写一个同名的方法,那子类中的方法就自动替换了父类中方法。当然对于构造函数,它比较特殊,因为它要和类名同名,所以一般情况是,父类的构造函数一般和子类的构造函数不同名(因为我们写类不可能写一个和父类一样名字的类),那此时子类实例化对象时,其实是执行了2次初始化,一次是父类的初始化代码,一次是子类的初始化代码。
或者这么说吧,我们显式的看到你写的类Entity只有类体中的那些代码,其实当你实例化这个类时,系统给这个类分配的内存是大于这个类本身拥有的变量的长度的,就是编译器自动给你写的类拷贝了父类中的代码,所以你现在写的Entity类编译完毕,其实前半部分是其父类的代码,后半部分才是Entity的代码。而前半部分父类的代码中又有父类的同名构造函数,所以是不是就先执行了父类的构造函数,这是第一次初始化。然后运行到后半部分代码,也就是Entity代码部分,Entity中又有和Entity同名的函数,也就是Entity自己的构造函数,此时就要执行Entity函数了,也就是第二次初始化了。
说明:此处如果你使用的是类的静态方法那就没法实现了,因为类的静态方法无法访问类中的非静态变量。
以后还会讨论堆内存的分配问题。当我们使用new关键字创建一个对象实例时,也会调用构造函数。
也就是因为这种特殊函数,或者说因为这种重载特性,除了用于初始化的构造函数外,我们还可以写一些,比如,删除构造函数、复制构造函数、移动构造函数等等。。。
8、重载、多态
本来讲完构造函数就应该开始讲析构函数了,因为它们是一对儿的。但是前面频频提到重载和多态,所以这里把重载和多态先讲了。
重载也叫函数重载,意思就是你写类的时候,即使你没有继承任何父类,其实底层也是默认你继承了一个元类,就是类的祖宗。所以你写的类编译后的代码其实是把祖宗的代码也复制了一份后的代码,也就是加入了祖宗的代码指令。当然如果你写类的时候有继承,那你的代码在编译时,编译器就拷贝了祖宗的代码+你继承的类的代码。这才是你的类的全部指令。
所以,这里就出现一个问题,当你看祖宗或者你父类中哪个方法不顺眼时,你可以写一个同名同参的方法,此时你自己的方法就替换了祖宗或者父类中的同名方法,这就叫做函数重载。C++的类是支持函数重载的。
如果你不写类,你直接写两个同名同参的函数,那编译器会毫不客气地给你报错,不给你编译。但是你在类中写两个同名同参的方法,ok,没问题,后面的方法直接覆盖前面的方法。一切都顺利。
那么多态是什么呢?就是有相同的函数(方法)名,但有不同的参数的不同函数(方法)版本。前面我们说如果你写两个同名同参函数,编译器会报错,但是如果你写的是两个同名不同参的函数,那是没关系的,不会报错。当函数调用时,编译器会根据参数判断调用哪个函数。这就是多态。同理,如果同名不同参的函数写在了类里面,那就是两个同名不同参的方法,编译器也是不会报错的,更不会像同名同参那样覆盖的!也是根据参数判断调用哪个方法。
下面一个小例子演示一下什么是重载和多态:
可能大家有些晕了,这里再来一波小结:
同名同参函数只能写到类里面,否则编译器就报错。写到类里面的同名同参函数(方法)肯定是一个在父类里面,一个在子类里面,因为子类看不惯父类的这个函数的功能,所以自己再写了一个同名同参的,替换了父类中的。这就叫重载。就是自己在子类中写的同名同参函数把父类中的函数替换了,或者说截胡了,所以叫重载。
同名不同参的函数,不管是写在类里面还是写在类外面,编译器都不会报错!!编译器是接受同名不同参的。这叫多态现象。编译器会根据参数来决定调用哪个函数。
构造函数是在类里面写的一个和类名一样的函数,但是这个函数没有返回值,就是连void也不能写!而且这个函数可带参数也可不带参数。当编译器看到这样的函数时,类实例化时,这个函数就直接一起执行了。所以实例化后的实例对象都是已经初始化过的了。
说明:后面还有虚函数,也是和这些概念搅合在一起的,建议本小部分和后面的11虚函数部分一起看,后面的虚函数也用的是这里的案例。
9、析构函数
构造函数是你创建一个新的实例对象时运行的。就是是在创建新的实例对象时,自动被调用的,通常用于设置变量或者一些初始化功能。
而析构函数则是在销毁对象时运行的。一个对象要被销毁时,析构函数就自动被调用,通常是卸载变量、清理你使用过的内存等功能。
析构函数同样适用于栈和堆分配的对象。如果你用new分配一个对象,当你调用delete时,析构函数就会被调用。如果只是一个栈对象,当作用域结束时,栈对象将被删除,此时析构函数也会被调用。
你在构造函数中初始化了一些变量,你就得在析构函数中卸载或销毁这些变量,否则就容易内存泄漏。
比如如果你在堆上手动分配了任何类型的内存,那么你得手动清理。
10、继承
重写、多态、继承是类的几个重要特点。重写和多态前面反复演示过了,所以这里讲继承也都简单多了。
类之间的继承,就是相互关联的类的层次结构,这些继承关系是一个虚表(V-table)来维护的。
继承最主要的好处就是避免代码重复编写。包含公共功能的基类--从基类中分离,从最初的父类中创建子类
我们可以把类之间的公共功能放到一个父类(基类)中,然后从父类中创建的子类就免去了相同的代码一遍遍的复制。也就是好像不用一遍遍写模板了。
11、虚函数
虚函数是面向对象编程中非常重要的概念。
虚函数和前面的重载、多态都是有联系和区别的。把前面的重载和多态都彻底弄明白了,这里也就很容易明白。
下面这个例子是对指针和类的详细拆解:
明白了指针后,我们再接上面的8(重载和多态),继续看:重载并不是覆盖,也不是删除,只是替换执行而已。所以我们用指针还是可以找到被重载的函数的:
从上例也可以看到,子类中同名同参的方法虽然可以通过重载,替换父类中的相应方法,但并不意味着父类中的那个同名同参的方法不存在了,其实还是存在的。如果想让它等同不存在,就要用虚函数,也就是使用关键字virtual和override:
说明: 虚函数是引入了一种叫动态联编(Dynamic Dispatch)的概念,就是通过维护一个V表(虚函数表)来实现的,基类中有一个成员指针,指向V表,所以生成V表是需要额外的内存来存储V表的,是要增加一点开销的。
V表是一个表,它包含基类中所有虚函数的映射,这样在virtual方法运行时,就将它们映射到正确的覆写(voerride)的函数。也就是如果你覆写了一个基类中的函数,那就将基类中的基函数标记为虚函数virtue,然后把你写的那个函数标记为覆写override,就表示是重写了。
小结:当类中出现同名同参的方法时,就会自动启动重载机制,就是自动执行子类中的同名同参方法。但是这并不表示父类中的同名同参方法被删除了、不存在了,通过指针还是可以调用的。所以如果你永远不想用父类中的方法,那你就用虚函数,即使指针调用了父类中的同名同参方法,也会自动跳转到被覆写的子类方法上。
12、C++接口(纯虚函数)
纯虚函数是一种特殊类型的虚函数。其本质上与其他语句(如Java或C)中的抽象方法或接口相同。
纯虚函数允许我们在基类中定义一个没有实现的函数,然后强制子类去实现该函数。
其实这种做法在面向对象编程中是非常常见的,这通常被称为接口interface,其他语言有interface关键字,而C++中的接口是一个类,这个类中只包含一个未实现的方法:
说明:
一是,纯需函数必须被实现,才能创建这个类的实例。
上例中的Entity必须得重写GetClassName,因为Entity继承了Printable,而Printable中有纯需函数,所以类Entity是必须得重写得,否则无法实例化。
但是Player类是继承的Entity类,而Entity类中没有纯虚函数,所以类Player是没有像Entity类中GetClassName那样的必重写函数。但是Entity中有虚函数GetName,意思就是函数GetName可以被重写,所以Player中重写了两个GetName,这里是展示多态这个知识点。
二是,接口只是C++的一个类而已。有了这个类,我们就可以将这个类(抽象基类)作为参数(类型)放入一个通用的函数中。
上例中,如果类Player也重写了类Printable中的纯需函数GetClassName后,是不是就和Entity类一样,当然其他更多的类都同理,都可以作为Print函数的实参了。所以纯虚函数所在的类就是一个接口,它让所有重写它的类都可以变成一个统一的实参。这样类就可以当实参传递了,就可以调用这个方法或做其他的事情了。反观函数PrintName,其参数只能限制在Entity的长度,而利用Printable类中的纯虚函数GetClassName,就可以实现任意长度的实参传递了。因为通过virtual和override,也就是V表进行映射了,让代码执行跳转了。