这里更多的是记录自己的理解、逻辑和内容扩展,且不一定对,也不会详细说明,本文主要内容来源是其他博主的文章,原文来自https://www.cnblogs.com/qg-whz/p/4909359.html
个人笔记记录,有个人理解,逻辑及内容不一定对。观看时需紧记。
C中并没有class,也没有private 、public、protected关键词,也没有构造函数与析构函数等概念。
C中只有struct和函数。struct只是多个数据类型再封装后重新定义的复合类型,和数组有点类似。
struct是定义了新的类型,相当于给一组数据类型封装后给起了个正式名字。
typedef是给已有的数据类型起别名,相当于外号或小名。typedef规则只有一个,把变量名处改为别名即可。
struct和typedfe是有着本质的不同。struct+TagName是定义新类型并给出了类型的正式名,这个正式名在同一作用域下只能有一个,否则重定义。而typedef是别名,别名是可以相同的,就像马甲一样,套不同的马甲和相同的马甲,它里面的东西是没变的。
说了那么多,其实就只有一个,struct只是类型定义,所以不能成员赋值,或使用static修饰成员,更不能放函数。而在C++中struct这些都可以,而struct和class只是作用域不同,一个是public,一个是private。
C中struct和C++ struct在作用域这一点上是相同,用C++的描述就是它们都是public。
这三个关键词是C++新增加的,在C中没有。为什么引入它们呢?它们起什么作用呢?
C中数据的保存靠变量(类型),而数据变更靠函数完成。
假设有个struct Person和strcut Dog
struct Person{ int age; char sex; void (*pSleep)(); void (*pSetAge)(struct Person *,int); }; struct Dog{ int age; char sex; void (*pSleep)(); void (*pSetAge)(struct Dog *,int); }; void PersonSleep() { printf("Person sleep!"); } void SetPersonAge(struct Person *p,int age) { p->age=age; } void DogSleep() { printf("Dog sleep!"); } void SetDogAge(struct Dog *d,int age) { d->age=age; }
如上所示,我们正常调用函数指针,是想Person使用PersonSleep/SetPersonAge的函数,Dog使用DogSleep/SetDogAge的函数来完成各自的动作。
但是C中函数是都可见的,Dog可以使用PersonSleep的,Person也可以使用DogSleep。也可以通过指针强转让setDogAge修改Person中的成员年龄,SetPersonAge来修改Dog中的成员年龄。这里我们的例子还很简单,名字也好区分。但是在大型项目中就容易出错,导致问题。这种错误,需要我们人为的检查,一个个确认是否有用错(人肉Debug)。public/private/protected就是为了解决这个问题引入的。
成员和函数在同一作用域下都是可见的。实际上,是成员/函数的“作用域”(函数不存在作用域概念,用于理解)太广了。我们是否能够将成员/函数“作用域”收窄呢?在C语言中是不可能的,那么类C语言就也不可能,解决方法就是将人为的检查让程序(编译器)帮我们来完成,通过增加语法糖,让我们从这个繁琐的任务解放出来,有更多的时间专注软件业务逻辑的实现。
public/private/protected就是编译器帮我们检查我们成员变量或函数有没有用在对的地方。通过类似函数重载的方法,对变量或函数增加修饰名,让编译器通过语法分析,来能确定我们是否正确使用;一旦我们在写程序中违反了规则,编译器就能发现错误,在编译前期就能暴露问题。
所以在本质上和在C语言中是没有区别的,所有的成员变量和成员函数只要我们有地址,我们都可以在运行时通过指针调用。
C中每个函数是独一份的,都是放在代码区中。那么C++中的函数,通过分析我们就知道,更多的是想表示这个函数是我这个类所独有的,除了我这个类可以使用,其他不同类型的不要使用。表示的是函数的作用范围。
所以在实际实现中,类中函数都是和C语言一样。只是我们通过public/private/protected保证了函数能被正确使用。所以在C++中的对象模型,我们就可以确定对于成员函数,我们只需每个类有一份成员函数即可,不需要每个函数对象都产生成员函数。
成员函数每个类独一份即可,那么成员变量呢?成员变量是用来表示每个结构的特性,用C++的描述就是表示每个类的对象的属性,每个对象的属性是不一样的。所以我们成员变量每个对象都需要保存一份。
面向对象的本质是封装抽象,通过结构体、函数及三个权限关键字我们可以实现了面向对象的封装和抽象,也完成了静态绑定(每个类的创建的对象都绑定了固定的函数)。
如果没有虚函数(实现多态)和虚继承(解决菱形继承造成的二义性(冗余)),C++对象模型是简单的,和C是差不多的,因为只是将成员变量/函数的使用权人为的限制而已。所谓的继承也是减少代码的冗余,类似C中一个结构体使用另外一个结构体,只是C++中增加了父结构体对的成员子结构体的成员的使用权限控制。
多态的实现是利用类型转换和虚函数实现的。
多态的类型转换是向上类型转换,父类的属性是小于等于子类的属性的,意味着使用父类的指针指向子类的对象时不会发生内存越界的问题,是一种安全的类型转换。
向上类型转换解决了让子类可以使用父类的内容,但是由于每个类的函数是在编译的时候就确定好了的(即静态绑定),子类只能使用父类的东西,如果我们重写父类的相关函数,由于静态绑定无法使用已重写的子类函数。那么解决思路就是如果子类重写父类的函数,我们让子类的函数覆盖掉父类的函数就可以了。由于我们可能创建父类的对象,这时候就要用到父类原来的函数。所以不能写死,那么只能通过什么方法根据创建的对象来确定具体哪个类的函数。解决方法就是使用函数指针,由于子类重写了多个父类的函数,所以函数指针不是一个而是多个,于是我们数组来保存这些函数指针,并给这张表取名为虚函数表。那么是不是直接将虚函数表放入到类的内存中?不是的。因为虚函数表的大小是变化的(后续可能增删),那么类的大小也会跟着变化。于是我们引入了虚函数指针,让虚函数指针指向虚函数表,指针的大小是固定的,那么不管后续虚函数怎么变化,类的大小可以保持不变。
重载
重载本质上是对函数名增加修饰符来实现的,在底层上是不同的函数。如果子类存在对父类的函数重载,那么子类可以直接访问重载的函数,父类的函数会被隐藏,但是可以通过作用域运算符和父类来指明访问父类中被重载的函数。
重定义(隐藏)
如果子类的函数名和参数列表和父类的函数一样,就只有返回值类型不一样或者一样;那么此时不是重载,如果在同一作用域下,那么编译器会报重定义错误;但是由于类是有自己的命名空间(作用域)的,而在不同作用域下是可以有同名函数的。此时子类直接访问的是自己的函数,如果想访问父类的同名函数,需要和重载的方法一样。只是由于不同作用域,隐藏了父类的重定义而已(就近原则)。
重写(覆盖)
重写在函数形式上类似重定义的子集,此时函数名,参数列表,返回值类型都一样,如果不增加其他方法,编译器只会把这类函数认为是重定义,由于子类父类各自的命名空间,可以正常编译。而且还是静态绑定,无法实现多态。此时为了让编译器区分重定义和重写,引入了virtual关键字来区分。所以如果要实现多态,就要对子父类中重名的函数增加virtual关键字及虚函数的概念。引入virtual和虚函数是为了区分重写和重定义,那么为了避免繁琐,只要父类写了virtual关键字,让编译器知道这个函数是虚函数,不和重定义产生二义性即可,所以子类的重写的函数可以不加virtual关键字,当然加了没错。
单继承在C下相当于子类的结构体成员有父类的结构类类型,而多继承相当于子类的结构体中有多个不同父类的结构体,加上类都有各自的命名空间,重名问题可以通过作用域运算符来解决。虽然可以解决,但是太容易让人产生歧义,所以不建议使用多继承。
菱形继承是单继承和多继承混合使用所产生的结果,菱形继承不但有多继承的重名(二义性)的问题,还有空间的浪费。重名的问题可以通过增加作用域运算符来解决,但是两个超父类的数据内容,只能通过其他方式解决。解决方式就是只在类中保留一份超父类的数据内容,对于一重菱形继承,我们要么放在类中最前面或最后面都可以,但是两个父类如何找到超父类的位置呢,因为父类的位置是有先后的,且子类的属性也是不确定的。所以我们需要增加东西来定位超父类的位置。于是引入了虚基类表、虚基类表指针、虚基类和虚继承的概念。
虚继承产生虚基类的概念,又因为虚基类而产生了虚基类表的概念,从而又引出了虚基类表指针。
虚继承是为了和正常继承区分,是让编译器不产生二义性,知道是虚继承。类似虚函数与函数。
一旦编译器知道是虚继承,那么这个类的父类就变成了虚基类,于正常的基类(父类)区分。
一旦编译器知道虚继承,那么这个基类就只会保存一份数据。一般可以将这个虚基类的数据放最前或最后,但是之前我们知道存在虚函数表,由于面向对象大量使用到了多态,所以我们在类的最前面用来保存虚函数指针,所以我们虚基类的数据会放到类的最后。
那么虚基类的数据如何让类中的其他子类或孙子类知道,其实只要记录每个类最开的属性到虚基类的最开始的属性的内存偏移地址就可以让每个类都能定义到虚基类。在简单的二重多继承的菱形继承(两个子类分别继承虚基类)就需要记录三个类到虚基类的偏移量,如果存在三个子类及以上分别继承虚基类的菱形继承,那么偏移量就不止三个了。所以同虚函数一样,需要引入表和指针。虚基类表可以保存多个偏移量来定位虚基类的数据,而虚基类表指针解决类的大小容易变化的问题。
此时继承和多态的解决思路理清了,那么就是具体的C++面向对象模型的实现。
这部分类型见图说C++对象模型内存布局详解