继承和多态总结
一、继承
1.概念
1.概念:继承是面向对象程序代码设计中可以使代码进行复用的最重要的手段,它允许程序员在保持原有类的特性下进行了拓展,增加功能,这样产生的类叫派生类。继承呈现了面向对象设计的层次,由简单到复杂的认知过程。以前我们遇到的复用都是函数的复用,现在的继承都是类设计层次的复用。
class Person { public: void Func() { cout<<Person::Func()<<endl; } Person(string name="Peter",int id=001) :_name(name) ,_id(id) { cout << "Person()" << endl; } private: string _name; int _id; }; class Student :public Person{ public: Student(char sex = 'M') :_sex(sex) { cout << "Student()" << endl; } private: char _sex; }; int main() { Person p; Student s('F'); s.Func(); return 0; }
通过监视窗口我们可以看到Person的成员变量已经被Student继承下来了。
而通过下面的结果我们也可以看到属于Person的成员函数也可以被Student进行调用。
2.定义
2.1、继承关系分为三种:公有继承,私有继承,保护继承。
2.2、继承限定符分为三种:public访问,private访问,protected访问。
所以对于基类的访问方式和继承共有九种情况,其中最常见的就是圈出的两种。
这张表简单而言就是基类的成员遇到继承方式其的属性变成两者小的那个继承到派生类中。
例:原先在基类时,成员是protected,继承方式是private,所以此时在派生类中此变量就是private属性。
1、基类的派生类无论是哪一种继承方式都是不可见,这里的可见和不可见意思是在派生类中可以访问,但是这些不可见的私有成员还是被继承到了派生类中。
2、如果一个变量不想在类外面被访问的话就用protected进行修饰。
3、关键字class的默认继承方式是private,struct的默认继承方式是public。
3.基类与派生类互相进行赋值转换。
**1.派生类对象可以赋值给基类对象/基类指针/基类引用。**进行切片。
2.基类对象不可以赋值给派生类。
4.继承的作用域
1.继承中基类和派生类的作用域都是独立的。
2.子类与父类中有相同的成员,子类成员将屏蔽对父类成员的访问,叫做隐藏,也叫重定义。
class Person { public: void Func() { cout << "Person::Func()"<< endl; } Person(string name = "Peter", int id = 001) :_name(name) , _id(id) { cout << "Person()" << endl; } private: string _name; int _id; }; class Student :public Person { public: Student(char sex = 'M',string name = "good") :_sex(sex) ,_name(name) { cout << "Student()" << endl; } void Func() { cout << "Student::Func()" << endl; } private: char _sex; string _name; };
子类与父类中的Func()函数不是构成重载,因为重载的要求是两个函数要在同一个作用域中,而这是两个类就是两个作用域即构成重定义。
5.派生类的默认成员函数
1.默认构造函数,派生类在创建对象的时候,派生类会自动调用基类的默认构造函数,如果派生类没有默认构造函数的话,则必须在初始化列表进行调用。即调用基类的构造函数后再调用派生类。
2.派生类的拷贝构造函数要调用基类的拷贝构造函数进行对基类的拷贝构造。
3.派生类的operator==同理。
4.派生类的析构函数在基类的析构函数调用后再调用。
6.友元不能继承
7.由基类创建的静态变量无论派生出多少个子类都只有这一个静态变量。
8.继承的种类
继承分为多继承和单继承,其中多继承中又有菱形继承。
1.单继承
2.多继承
3.菱形继承
由于菱形继承的最后一个派生类同时继承了基类的基类的成员两次,所以会出现数据冗余和二义性的问题。
3.1菱形继承的解决方式
例:
class A { public: A(int a=10) :_a(a) {} public: int _a; }; class B :public A { public: B(int b=11,int b1=20) :_b(b) ,_b1(b1) {} private: int _b; int _b1; }; class C :public A { public: C(int c=12,int c1=21) :_c(c) ,_c1(c1) {} private: int _c; int _c1; }; class D :public B,public C { public: D(int d=13,int d1=22) :_d(d) ,_d1(d1) {} private: int _d; int _d1; }; int main() { D d1; d1.B::_a = 10; d1.C::_a = 100; return 0; }
1.解决二义性:
就如上面对应的D类而言,同时继承了B类与C类的_a,为了区分B类和C类可以使用域作用限定符解决二义性的问题。
2.解决数据的冗余性:
如图所示,这是d1对象在内存中的存储情况,_a在B与C中同时存在,造成了数据的冗余。
解决方法是在腰部的类加上virtual进行菱形虚拟继承。
右边就是虚基表,20(十六进制)就是B类与_a的距离(0x00EFFD90-0x00EFFD70)同理14。
1116222940622.png&pos_id=img-UyTsAXhF-1701610179984)
继承和组合(白箱和黑箱)
3.2菱形虚拟继承的优点
1.解决了数据冗余和二义性的问题
2.适当的节省了空间
采用虚拟继承:
未采用虚拟继承:
原因很简单,因为没有用虚拟继承是就B,C类会将A类的成员变量都复制一份,而采用虚拟继承的话机只是在B,C类中多两个指针。
二、多态
1.概念
1.1、概念:就是多种形态,具体就是不同对象完成某个行为会产生不同的效果。
2.多态的定义和实现
2.1多态的构成条件
多态的调用是在不同关系的继承对象,去调用同一函数,产生了不同的行为。
构成的条件:
1.必须是通过基类的指针或是引用调用虚函数。
2.调用的必须是虚函数,虚函数必须在派生类中进行重写。
class Person { public: virtual void Ticket() { cout << "Person::Ticket()" << endl; } }; class Student : public Person { public: virtual void Ticket() { cout << "Student::Ticket()" << endl; } }; //1.直接传值 void Func(Person s) { s.Ticket(); } //2.引用 void Func(Person& s) { s.Ticket(); } //3.指针 void Func(Person* s) { (*s).Ticket(); } int main() { Person p1; Student s1; Func(p1);//Person类 Func(s1);//Student类 return 0; }
直接传值:
引用传值:
<
指针传值:
如果是多态的话调用的函数就遵循多态的规则,如果是普通的对象的话(即不构成多态的条件)就看当前的类型
2.2虚函数
虚函数:被virtual关键字修饰函数就是虚函数。
virtual void Func();
2.3虚函数的重写
虚函数的重写(覆盖):派生类有一个与基类完全想同的虚函数(即派生类的虚函数与基类的虚函数返回值类型,函数名字,参数列表也完全相同)
class Person { public: virtual void Ticket() { cout << "Person::Ticket()" << endl; } }; class Student : public Person { public: virtual void Ticket()//派生类前也可以不加virtual(不过这样不是很规范,所以不是很推荐) { cout << "Student::Ticket()" << endl; } };
虚函数重写的两个例外:
1.协变(基类和派生类的返回值类型不同):
派生类重写虚函数的时候返回类型不同,基类返回的是基类的指针或引用,派生类返回的是派生类的指针或引用即被称为协变。
//基类: virtual Parent* Fun();//Parent也为基类 //派生类 virtual Son* Fun();//Son为派生类
2.析构函数的重写(每个类的析构函数的函数名都不同)
如果基类的析构函数是虚函数,而派生类进行重写的时候就算前面加virtual也没有用,因为函数名不同,所以这里编译器做了特殊处理将所有类的析构函数名都处理成destructor。
有可能会想为什么要重写派生类的析构函数,如下:
class Person { public: ~Person() { cout << "~Person()" << endl; } }; class Student :public Person{ public: Student() { int* p = new int; } ~Student() { cout << "~Student()" << endl; delete p; } private: int* p; }; int main() { Person* p = new Student(); delete p; return 0; }
这里派生类申请了p的指针,如果不处理的话就会内存泄漏所以要进行重写,如果不满足多态就会泄露。
在这里插入图片描述
2.4 C++11中override和final标识符
1 final:
final标识符有两个作用,一是禁止类的继承:
二是禁止函数重写:
2 override:
用来检查虚函数是否进行重写:
2.5 重写,重载,重定义的区别
3.抽象类
在虚函数后面加上**=0**,就是纯虚函数,**含有纯虚函数的类是抽象类(也叫接口类),不能实例化对象。**派生类继承后派生类也不能创建对象,只有派生类对纯虚函数进行重写派生类才能创建对象。
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
4.多态的原理
4.1虚函数表
class Person { public: void Fumc() {} private: char _id; }; int main() { cout << sizeof(Person) << endl; return 0; }
Cimage-20231121211044750.png&pos_id=img-NoSN7xht-1701610179986)显然这个结果是1,那么接下来的结果呢。
class Person { public: virtual void Fumc() {} private: char _id; }; int main() { cout << sizeof(Person) << endl; return 0; }
为什么结果是8呢,我们进入调试模式看一下Person类创建的对象的内容。
31121213408844.png&pos_id=img-MxWbGcqr-1701610179986)在对象p中,除了数据_id之外,前面还有一个虚函数指针,所以根据内存对齐原则共是八字节。一个含有虚函数的类都至少有一个虚函数表指针。因为虚函数的地址要放在虚函数表中,虚函数表也称为虚表。
class Person { public: virtual void Func1() {} virtual void Func2() {} void Func() {} private: char _id; }; class Student :public Person { public: virtual void Func1() {} void Func3(); private: char _sex; }; int main() { Person p; Student s; p.Func(); return 0; }
通过观察和发现,我们不难看出:1.基类的vfptr就是一个指针数组,存储着基类中的虚函数,同样派生类也有一个虚函数指针,如果没有对基类的虚函数进行重写,那么存储的就是基类的虚函数的地址。
2.只有虚函数才会放入虚表,就比如基类的Func()函数和派生类的Func()3都不是虚函数,所以虚函数表中都没有存有。
3.虚函数表中的最后一位存有nullptr。
4.虚表的形成过程:
a.首先将基类的虚函数拷贝一份。
b.如果对基类的虚函数有重写就将自己重写的虚函数进行覆盖。
c.如果自己有新写的虚函数,则在虚表后添加新的虚函数。
4.2虚表和虚函数的存储问题
1.首先说虚函数,虚函数与普通函数一样,都存在代码段中。
2.其次是虚表:
虚表存储的内容是虚函数指针。而虚表的位置我们需要进行大概得验证猜测。
因此我们写如下的代码进行验证:
int main() { Person p; Student s; //1.可能是栈区 int a = 10; cout << "a位于栈内的地址: " << &a << endl; //2.可能是堆区 int* b = new int; cout << "b位于堆内的地址: " << &b << endl; //3.可能是静态区 static int c = 10; cout << "c位于静态区内的地址: " << &c << endl; //4.可能是在常量区(代码段) const char* d = "hello world"; cout << "d位于常量区内的地址: " << &d << endl; printf("虚表1:%p\n", *((int*)&p)); printf("虚表2:%p\n", *((int*)&s)); return 0; }
进行对比之后我们可以观察到虚表的地址离静态区的地址最近,所以大概估计虚表应该存储在静态区。
4.3多态的原理
1.首先想多态为什么一定要是基类的指针或引用,为什么不能是子类的指针或引用,为什么不能是父类的对象?
为什么不能是是直接的切片呢,因为在切片的时候不会进行虚表的拷贝,为什么不会进行的虚表的拷贝呢,因为如果进行了虚表的拷贝的话就会分不清到底是是父类的虚函数还是子类的。
举个例子:
如果这样的话在调用父类p1的Ticket函数就会调用子类的函数(注:在内存中是没有Student:这样的东西标识),所以不能进行拷贝,不能的话就不可以采用父类对子类的切片。
可以看到,p1与p2属于同一类共用一张虚表(从_vfptr指针就可以看出)。
2.
class Base1 { public: Base1() :b1(1) {} virtual void func1() { cout << "Base1::func1" << endl; } virtual void func2() { cout << "Base1::func2" << endl; } private: int b1; }; class Base2 { public: Base2() :b2(2) {} virtual void func1() { cout << "Base2::func1" << endl; } virtual void func2() { cout << "Base2::func2" << endl; } private: int b2; }; class Derive : public Base1, public Base2 { public: Derive() :d1(10) {} virtual void func1() { cout << "Derive::func1" << endl; } virtual void func3() { cout << "Derive::func3" << endl; } private: int d1; }; int main() { Base1 b1; Base2 b2; Derive d; }
通过&d可得到d的地址,观察上述表可知:
通过前面继承的基础我们可以知道这是虚基表,因为我使用的是vs2022是小端机器。
0x01019d24:毫无疑问就是Base1的虚指针。
0x01019b6c:毫无疑问就是Base2的虚指针。
由图中的指针可以了解到b1和b2虚表的组成,但是我们观察到b1在给出自己两者的函数外还有另外的一个指针,根据前面的代码可知,Derive这个派生类还写了一个虚函数Func3,但在调试窗口我们未曾看见,所以我们可以猜测一下是否该指针对应的是Func3,所以我们接下来对我们的想法进行验证。
//因为虚表的本质是一个函数指针的数组,所以我们先typedef一个函数指针 typedef void(*VFTR)(); void Print(VFTR vftrtable[]) { int i = 0; cout << "虚表地址>" << vftrtable << endl; while (vftrtable[i])//虚表一般都是以空指针nullptr作为结束,所以这样可以很好的判断 { printf("第%d个虚函数地址:0x%x", i, vftrtable); VFTR f = vftrtable[i]; f(); i++; } cout << endl; } Derive d; //因为我使用的x86环境,所以指针位数是4个字节,所以我们打印虚表的话就需要Derive d的虚表的指针 //所以首先先去取d的地址,然后在强转成int*,在对其解引用就取到了前面的四个字节,然后在把这四个字节表示的内容,即函数指针 //在进行函数指针的强转,得到该函数的地址,再将其传到打印函数即可 VFTR* f = (VFTR*)*((int*)&d); VFTR* f1 = (VFTR*)*((int*)&b1); Print(f); Print(f1);
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
十分清晰的可以看出我们调试窗口没出现的就是func3因此我们的验证显然正确。
所以我们也可以得知,派生类的虚函数会被写到第一个继承的基类中。
4.4重写一样的函数地址不同问题
Derive d; Base1& b11 = d; b11.func1(); Base2& b22 = d; b22.func1();
当我们在运行这样一段代码的时候,根据前面的学习我们可以知道结果。
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
但在奇怪的是当我们打开监视窗口的时候却发现:
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
都是重写func1为什么函数的地址不同呢?
我们进行反汇编的观察:
1.
进行第一个多态的实践分析:
进行分析,首先当代码执行到b11->func1()这一行时有如上操作。
(1)、ecx存储b11 this指针的地址。eax存放下一次跳转的指令。
(2)、jmp指令执行后再跳转。
(3)、正常执行func1()函数。
2.
进行第二个多态的实践分析:
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
(1)、同样的将b22 this指针压入ecx中,下一个执行的指令压入eax寄存器中。
(2)、到jmp指令后不喝b11的一样而是去对ecx中的this指针减8,为什么会这样呢?下面给出说明.
(3)、后面就和b11执行的一样调用func1();
现在说明为什么要进行ecx的sub 8操作:
我们都知道d满足这个表,而在上述调用Func1中,Func1属于Derive的函数,所以要传d的地址,因为b11的地址与d的地址相同,所以正常的进行函数调用,而b22的地址比d的地址高8,所以需要进行调整才能调用Func1函数,因此前面的调用是对this指针的修正。
func1为什么函数的地址不同呢?**
我们进行反汇编的观察:
1.[外链图片转存中…(img-yGoXP1r4-1701610179989)]
进行第一个多态的实践分析:
[外链图片转存中…(img-5T0OCnsV-1701610179989)]
进行分析,首先当代码执行到b11->func1()这一行时有如上操作。
(1)、ecx存储b11 this指针的地址。eax存放下一次跳转的指令。
(2)、jmp指令执行后再跳转。
(3)、正常执行func1()函数。
2.[外链图片转存中…(img-Ivfb4bsy-1701610179989)]
进行第二个多态的实践分析:
[外链图片转存中…(img-NKNJEOxz-1701610179989)]
(1)、同样的将b22 this指针压入ecx中,下一个执行的指令压入eax寄存器中。
(2)、到jmp指令后不喝b11的一样而是去对ecx中的this指针减8,为什么会这样呢?下面给出说明.
(3)、后面就和b11执行的一样调用func1();
现在说明为什么要进行ecx的sub 8操作:
[外链图片转存中…(img-MENlWRRA-1701610179989)]
我们都知道d满足这个表,而在上述调用Func1中,Func1属于Derive的函数,所以要传d的地址,因为b11的地址与d的地址相同,所以正常的进行函数调用,而b22的地址比d的地址高8,所以需要进行调整才能调用Func1函数,因此前面的调用是对this指针的修正。