继承
哪些成员可以被子类继承:
1.基类中的普通成员函数和成员变量都会被继承在子类中–代码复用
2.静态成员变量–会被子类继承,并且在整个继承体系中只有一份
3.友元:因为友元不是类的成员,因此友元函数不能被子类继承
4.构造函数、拷贝函数、赋值运算符重载、析构函数是否被子类继承
不同继承方式下派生类的对象模型
对象模型:对象中各个成员变量在内存中的存储方式
单继承:
多继承:一个类可以有多个基类(类如自己有亲爹,也有干爹)
菱形继承:
单继承+多继承复合起来
关于菱形继承二义性问题的解决:
1.从表层去解决–最顶层成员在派生类中有两份,直接通过派生类对象访问时最终会造成派生类对象不知道应该访问哪一个
需要使访问明确化:d.C1::b=1; d.C2::test();
代码可以通过编译,但是最顶层成员变量在派生类对象中仍然是有多份的,会浪费空间
2.从核心上解决–如果能够将最顶层成员变量在派生类对象中只存储一份–二义性问题解决,空间浪费解决
3.从本质上解决–采用菱形虚拟继承的方式解决
虚拟继承
虚拟继承和普通继承方式:
1.派生类的对象模型是倒立:派生部分在上,基类部分在下
2.虚拟继承的对象模型中多了4个字节:保存虚基表指针—>偏移量表格(虚基表)
3.通过派生类对象访问基类成员的不同
普通继承方式:直接访问
菱形继承:
d.b=1;
mov eax.dword ptr [d] //取对象前4个字节中的内容--拿到虚基表的地址
mov ecx.dword ptr [eax+4] //获取虚基表指向向后偏移4个字节之后的空间的内容:即获取相对于基类部分的偏移量
mov dword ptr d[ecx] //赋值,将d对象的起始地址向后偏移ecx(8)个字节,即基类中的成员b
4.
普通的继承方式:编译器可能会生成默认的构造函数
虚拟继承方式:编译器一定会为派生类生成默认的构造函数。原因是因为在创建派生类对象时,编译器必须要将虚基表指针填写在对象前4个字节,而该步操作必须在创建对象期间完成。因此:该步骤不能在构造函数中完成
类和阶段:
1.语法–如果一个类没有显式定义自己的构造函数,编译器将会生产一份默认的无参构造函数
2.实际情况:编译器可能没有严格按照语法去做,构造函数释放不一定会生成
3.生成条件:如果编译器感觉自己需要,就会生出一份默认的构造函数
以下4种情况,编译器一定会生成默认的构造函数
- a.类和对象阶段:如果A类定义了无参的构造函数或是全缺省的构造函数
B类没有显式定义构造函数,但是B类中包含了一个A类的对象,编译器一定会给B类生成一份默认的构造函数 - b.继承体系中:如果积累显式定义无参的构造函数或是全缺省的构造函数,派生类没有显式定义构造函数,编译器一定会给派生类生成一份默认的构造函数,目的是为了调用基类构造函数以完成基类部分成员的初始化
- c.虚拟继承中:编译器一定会为派生类生成默认构造函数,目的是在构造派生类对象时,需要将虚基表的地址填写在对象的前4个字节中
- d.包含有虚函数的类:编译器一定会为派生类生成默认构造函数,目的是在构造派生类对象时,需要将虚基表的地址填写在对象的前4个字节中
菱形虚拟继承:
因为菱形虚拟继承中,最顶层基类B的成员部分在派生类对象中只存储了一份,因此就不会存在二义性问题
D d;
d.b=1;
C1&c1=d;
C2&c2=d;
c1.b=2;
c2.b=3;
//c1和c2访问的是派生类对象中的同一个b
通过虚基表中的偏移量访问最顶层B类中的成员
d.b=1;
mov eax.dword ptr [d] //取对象前4个字节中的内容--拿到虚基表的地址
mov ecx.dword ptr [eax+4] //获取虚基表指向向后偏移4个字节之后的空间的内容:即获取相对于基类部分的偏移量
mov dword ptr d[ecx] //赋值,将d对象的起始地址向后偏移ecx(8)个字节,即基类中的成员b
多态
1.多态的概念:
多态:同一个事物,在不同场景下表现出的不同状态
比如:见人说人话,见鬼说鬼话
2.多态的分类:
- 静态多态(早绑定、静态联编):在编译期间,根据所传递的实参类型或者实例化的类型,来确定到底应该调用哪个函数。即:在编译期间确定函数的行为,例如重载,模板
- 动态多态(晚绑定、动态联编):在程序运行时,确定具体应该调用哪个函数
3.动态多态的实现条件
a.基类中必须包含有虚函数(被virtual修饰的成员函数),派生类必须要对基类的虚函数进行重写
b.关于虚函数调用:必须通过基类的指针或引用调用虚函数
体现:在程序运行时,基类的指针或引用指向哪个子类的对象,就会调用哪个子类的虚函数
4.重写
a.基类中的函数一定是虚函数
b.派生类虚函数必须与基类虚函数的原型一致:返回值类型 函数名字(参数列表)
例外:
协变–基类虚函数返回基类的指针或引用,返回值类型不同。派生类虚函数返回派生类的指针或引用,基类虚函数和派生类虚函数的返回值类型可以不同
析构函数:如果将基类中析构函数设置成虚函数,派生类的析构函数如果提供了,两个析构函数就可以构成重新
c.基类虚函数可以和派生类虚函数的访问权限不一样
C++中构成重写的条件非常严格:虚函数并且原型一致
基类虚函数:virtual void TestFunc()
派生类虚函数:virtual void TetsFunc()
细节性不同不易发现,导致重写失败
为了让编译器在编译期间帮助用户检测是否重写,C++提供关键字:override
:专门让编译器帮助用户检测派生类是否重写了基类的虚函数
如果重写成功:编译通过
如果重写失败:编译失败
final
:如果用户不想要子类重写基类的虚函数,可以使用final修饰该关键字
函数重载、同名隐藏(重定义)、重写(覆盖)区别?
函数重载概念:相同作用域、函数名字相同、参数列表不同(类型、个数、类型次序)
不同:
条件 | 同名隐藏 | 重写 |
---|---|---|
基类中函数是否为虚函数 | 没有要求 | 一定是虚函数 |
函数原型是否相同 | 只要名字相同即可,其他没有要求 | 原型必须相同 |
相同:
一个函数在基类中,一个函数在子类中
重写的限制条件比同名隐藏更加严格:
a.如果满足同名,没有满足重写条件:一定是同名隐藏
b.同名隐藏和重写是两个不同的概念,不能说同名隐藏是一种特殊的重写
5.例子
class Person
{
public:
virtual void Buy(){…}
};
class Student:public Person
{
public:
virtual void Buy(){半价票}
};
class Solder:public Person
{
public:
virtual void Buy(){免费}
};
void Ticket(Person& p)
{
p.Buy();
}
int mian()
{
Student stu;
Solder sol;
Ticket(stu);
Ticket(sol);
}
注意:在编译阶段,编译器根本不知道p.Buy()应该调用哪个类的虚函数,必须等到程序运行时,根据p实际所指向类的对象,来确定到底应该调用哪个类的虚函数
6.多态的实现原理
编译器在编译时会将类中的虚函数按照一定的规则存储在虚标中,在创建对象时,只需要将虚表的地址储存在对象的前4个字节
a.虚表构建过程
- 基类:编译器按照各个虚函数在类中声明的先后次序依次将虚函数保存在虚表中
class B
{
public:
virtual void TestFunc1();
virtual void TestFunc2();
virtual void TestFunc3();
int b;
};
- 子类虚表的构建过程
1.将基类虚表中内容拷贝一份到子类虚表中
2.如果子类重写了基类中哪个虚函数,编译器会用子类自己虚函数的地址覆盖相同偏移量位置的基类虚函数地址
3.如果派生类新增加自己的虚函数,编译器会将派生类新增加的虚函数按照其在派生类中的声明次序依次放在虚表的最后
class D:public B
{
public:
virtual void TestFunc4();
virtual void TestFunc1();
virtual void TestFunc3();
virtual void TestFunc5();
int b;
};