C++的多态 - 下
目录
多态的原理
虚函数表
1.计算包含虚函数类的大小
2.虚函数表介绍
多态底层原理
1.父类引用调用
2.父类指针调用
3.动态绑定与静态绑定
单继承和多继承关系的虚函数表
函数指针
1.函数指针变量
(1)函数指针变量创建
(2)函数指针变量的使用
(3)两段有趣的代码
2.函数指针数组
3.转移表
判断大小端字节序的两种方法
1.方法一:通过指针类型转换判断
2.方法二:使用联合体判断
单继承中的虚函数表
多继承中的虚函数表
1.单继承子类虚表生成规则
2.多继承子类虚表生成规则
3.多继承子类虚表的打印
3.1.多继承中指针偏移问题
3.2.多继承子类虚表的打印
菱形继承最终派生类的虚表
多态常见问题
多态的原理
虚函数表
1.计算包含虚函数类的大小
(1)案例1
解析:通过观察测试我们发现b对象是8bytes,除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针__vfptr我们叫做虚函数表指针(v代表virtual,f代表function)。
虚函数表指针(简称:虚表指针)介绍:每个包含虚函数的类对象都有一个隐藏的成员变量,即虚函数表指针(简称:虚表指针)。虚表指针指向该类对象所属类的虚函数表(简称:虚表),而该虚函数表是用来存放该类的虚函数地址。
注意:一个含有虚函数的类中都至少都有一个虚函数表指针,而虚函数表是用来存放虚函数地址,而且虚函数表也简称虚表。
(2)案例2
解析:在计算类的大小时要考虑内存对齐问题。bb对象除了_b和_ch成员变量,还有隐藏的成员变量虚函数表指针__vfptr,所以通过监视观察测试bb对象是12bytes。
2.虚函数表介绍
(1)通过观察和测试,我们可以得出以下几点结论:
①子类对象的构成:子类Derive
的对象d
同样包含一个虚函数表指针。从内存布局上看,d
对象由两部分组成。一部分是从父类Base
继承下来的成员,包括父类的虚函数表指针以及父类的数据成员(这里是_b
) ;另一部分是子类自己定义的成员,即_d
。
②虚函数的重写与覆盖:父类Base
对象b
和子类Derive
对象d
拥有不同的虚函数表。这是因为子类Derive
对父类Base
中的虚函数Func1
进行了重写(在 C++ 多态的原理层面,这种行为被称为覆盖 ) 。在子类d
的虚函数表中,原本对应父类Base::Func1
的位置被替换为子类的Derive::Func1
。这意味着,当通过父类指针或引用调用虚函数实现多态时,运行阶段程序首先依据指针或引用所指向对象的实际类型,找到对应的虚函数表。然后,直接从该虚函数表中检索出需调用的虚函数地址,进而实现正确的函数调用。例如,如果有
Base* ptr = &d; ptr->Func1(); ,此时调用的将是Derive::Func1 ,而非Base::Func1 。
Base* ptr = &b; ptr->Func1(); ,此时调用的将是Base::Func1 。
③虚函数与普通函数在虚表中的情况:父类中的Func2
作为虚函数,在子类继承后,依然是虚函数,因此它也被放入了子类的虚函数表中。而Func3
由于不是虚函数,不会被放入虚函数表。这体现了虚函数表只存储虚函数地址的特性,普通函数的调用是在编译时确定的,不需要通过虚函数表进行动态查找。
④虚函数表的结构:虚函数表本质上是一个存放虚函数指针的指针数组。在一般情况下,为了便于标识虚函数表的结束,在数组的末尾会放置一个nullptr
。当程序通过虚函数表指针遍历虚函数表时,遇到nullptr
就知道已经到达了表的末尾。
⑤子类虚表的生成规则:
- 首先,子类虚函数表会将父类虚函数表的内容拷贝一份。这保证了子类能够继承父类虚函数的函数接口。(注:函数接口包括函数名、参数列表和返回值类型)
- 接着,如果子类重写了父类中的某个虚函数,那么子类虚函数表中对应的位置会被替换为子类自己的虚函数地址。这就是前面提到的覆盖机制。
- 最后,子类自己新增加的虚函数会按照在子类中声明的顺序,依次添加到虚函数表的末尾。
(2)关于虚函数和虚函数表,存在一些容易混淆的问题
- 虚函数的存储位置:虚函数本身并不直接存储在对象中,对象中存储的是虚函数表指针
__vptr
。虚函数和普通函数一样,都存放在代码段。虚函数与普通函数的区别在于,虚函数的地址被额外存储到了虚函数表中,以便在运行时根据对象的实际类型进行动态调用。 - 虚函数表的存储位置:虚函数表的存储位置在不同的编译器和平台下有所不同。在 Visual Studio(VS)环境下,经测试虚函数表存在于代码段。而在 Linux 下使用 g++ 编译器时,也可以通过相关调试手段(如使用
nm
命令查看目标文件的符号表等 )来验证虚函数表的存储位置特性。需要注意的是,虽然存储位置可能不同,但虚函数表的工作原理是一致的,都是为了实现多态的运行时绑定机制。 -
内存代码段机制剖析
在计算机系统中,我们编写的源代码在编译阶段,编译器会对其进行词法分析、语法分析以及语义分析,进而生成对应的汇编指令。随后,汇编器会将汇编指令进一步转换成 CPU 能够直接执行的二进制指令。这些二进制指令,最终会被存储到内存的代码段中。代码段,作为内存的特定区域,专门用于存放程序运行所需的可执行代码,其具备只读和可执行属性,这就确保了 CPU 能够直接从该区域读取并执行二进制指令。
从计算机启动程序的流程来看,操作系统负责将可执行文件加载到内存中,其中可执行代码部分就会被映射到代码段。整个过程确保了程序在运行时,CPU 可以高效访问和执行指令,维持系统的正常运转。
多态底层原理
1.父类引用调用
(1)满足多态时,函数调用地址是在运行时确定。
(2)不满足多态,编译时就根据调用者的本身类型确定虚函数调用的地址即确定调用那个对象的虚函数地址。
(3)用父类指针/引用调用虚函数实现多态时,只要父类指针/引用指向父类对象就调用父类虚函数,只要父类指针/引用指向子类对象就调用子类虚函数,底层原理解析:
①注意事项:
- 满足多态时,父类对象的虚函数表(虚表)中存放的是父类虚函数的地址;子类对象的虚函数表(虚表)中存放的是子类虚函数的地址。 、
- 实现多态时,对象类型决定调用那个类型的虚函数:通过调用者调用虚函数实现多态时,调用那个类型的虚函数跟调用者本身的类型无关,而是和调用者所指向对象的类型有关。在通过继承与虚函数实现多态的场景下,调用者通常为父类指针或引用。
- 不满足多态时虚函数调用:当不满足多态时,父类指针或引用调用虚函数,就如同调用普通成员函数,其函数调用地址在编译期间便已确定。编译器依据代码中父类指针或引用的类型,确定要调用的虚函数地址,多态特性在此情形下无法发挥作用。
- 满足多态时虚函数调用地址确定:当满足多态条件,即通过父类指针或引用调用虚函数时,函数调用地址在运行期间确定。编译器会为每个包含虚函数的类生成虚函数表,每个对象都有一个指向所属类虚函数表的指针。运行时,程序先根据父类指针或引用所指向对象的实际类型,找到对应的虚函数表。之后,从虚函数表中检索出需调用的虚函数地址,实现正确的函数调用。也就是若父类指针或引用指向父类对象,调用父类虚函数;若指向子类对象,调用子类虚函数。
-
普通成员函数调用地址确定:对于普通成员函数,编译器在编译期间就能明确其调用地址。这是因为普通成员函数的调用关系在编译时是固定的,编译器可以根据函数名和参数类型等信息准确地找到函数的实现代码。
-
普通调用、特殊调用方式:
普通调用方式:不满足多态,看调用者的类型,就调用这个类型的成员函数。
特殊调用方式:满足多态,看调用者指向的对象的类型,调用这个对象类型的成员函数。 -
虚函数表是在编译期间就确定好的,这意味着在程序编译阶段,编译器会根据类的定义以及其中虚函数的声明和重写情况,为每个包含虚函数的类创建虚函数表。以下是具体解析:
类定义检查:编译器在编译过程中,会检查每个类的定义。当发现类中包含虚函数时,它会为该类生成一个虚函数表。
虚函数信息记录:对于类中的每个虚函数,编译器会在虚函数表中记录其相关信息,主要是虚函数的地址。如果子类重写了父类的虚函数,编译器会在子类的虚函数表中把对应虚函数的地址替换为子类重写后的函数地址。
类层次结构分析:编译器会分析类的继承层次结构,以确定虚函数表的内容。在多继承等复杂情况下,会根据继承关系和虚函数的重写情况,正确构建虚函数表。例如,在多继承中,子类可能从多个父类继承虚函数,编译器会按照一定的规则将这些虚函数的地址正确地放入子类的虚函数表中。
通过在编译期间确定虚函数表,程序在运行时就能够根据对象的实际类型,通过虚函数表快速找到要调用的虚函数地址,实现多态性。 - 虚函数表布局:声明顺序主导地址编排
当某个类包含多个虚函数时,该类虚函数的声明顺序,决定了其函数地址在虚函数表中的位置。编译器在为类生成虚函数表时,会严格按照虚函数在类定义中声明的先后顺序,将虚函数地址依次填入虚函数表。
②底层原理解析
在 C++ 中,通过父类指针 / 引用调用虚函数时,编译器依据是否构成多态的规则,来确定调用哪个对象的虚函数。
若不构成多态,无论是父类指针、引用还是对象调用虚函数,编译器都根据调用者(父类指针 / 引用 / 对象)本身的类型去调用该类型的虚函数(成员函数),这属于普通调用方式,函数调用地址在编译期确定。
若构成多态,在编译期编译器无法明确调用哪个对象的虚函数。运行时,编译器会依据父类指针 / 引用指向的对象类型来决定调用相应对象的虚函数,此为特殊调用方式。
具体过程为:满足多态时,父类和子类都有各自的虚函数表,父类虚函数表存储父类虚函数地址,子类虚函数表存储子类虚函数地址。当子类重写父类虚函数,子类虚函数表中对应位置的父类虚函数地址会被替换为子类虚函数地址。
- 若父类指针 / 引用指向父类对象,编译器就通过父类对象隐藏的虚函数表指针(虚表指针),找到父类虚函数表中存放的父类虚函数地址,然后直接 call 父类虚函数地址。例如上面不满足多态图片中的汇编指令call Person::BuyTicket(父类虚函数BuyTicker的地址)。
- 若父类指针 / 引用指向子类对象,虽然父类指针 / 引用实质指向子类对象中的父类部分,但因子类继承父类虚函数表内容且重写虚函数,子类虚函数表中对应位置已替换为子类自己的虚函数地址。所以编译器通过子类对象隐藏的虚表指针,找到子类虚函数表中存放的子类虚函数地址,然后直接 call 子类虚函数地址。例如上面满的多态图片中的汇编指令call eax。
③对于虚函数 “重写” 与 “覆盖” 的理解
在 C++ 中,虚函数的 “重写” 和 “覆盖” 是紧密相关的概念。
“重写” 主要是从语法层面描述:子类虚函数继承父类虚函数的接口(函数名、参数列表、返回值类型需保持一致 ),在此基础上,子类重新定义虚函数的实现逻辑,也就是重写函数体部分。
“覆盖” 则侧重于原理层面解释:当子类重写父类虚函数后,子类会继承父类虚函数表,即把父类虚函数表的内容拷贝到子类虚函数表中。由于子类重写了父类的某个虚函数,在子类虚函数表中,对应位置原本存放指向父类虚函数的地址会被替换为指向子类自己虚函数的地址。这一过程就称为 “覆盖” 。从底层原理看,正是因为这种覆盖,使得在运行时通过父类指针或引用调用虚函数时,能根据对象实际类型调用到子类重写后的虚函数。
总体而言,“重写” 是代码编写时遵循的语法规则,而 “覆盖” 是基于这种语法规则在底层虚函数表机制上发生的变化,二者相辅相成,共同实现了 C++ 中虚函数多态的特性。
④下面依据多态底层原理,分析多态两个必要条件为什么是虚函数重写和父类指针 / 引用调用虚函数的原因:
- 多态时需要重写虚函数:当子类重写父类的虚函数时,子类继承父类虚函数表后,子类虚函数表对应位置,原本存放的父类虚函数地址会被替换成子类虚函数地址。这种替换十分关键,它使得编译器在运行阶段,能够通过子类虚函数表,找到子类虚函数的地址,进而正确调用子类重写后的虚函数。假如子类没有重写父类虚函数,子类虚函数表中的函数地址与父类相同,无法体现子类的独特行为,多态也就无法实现。
- 多态时需通过父类指针 / 引用调用虚函数:指针和引用具有灵活指向性,既可以指向父类对象,也可以指向子类对象。当它们指向子类对象时,实际上指向的是子类对象中的父类部分。这种特性使得在多态调用中,无论指针 / 引用指向父类对象还是子类对象,底层汇编指令都保持一致(注:底层汇编指令都保持一致指的是在编译阶段,通过父类指针 / 引用调用虚函数的汇编指令不依赖于具体对象类型(父类还是子类),运行时根据对象实际类型去虚函数表找对应函数地址执行)。若使用父类对象调用虚函数,对象类型在编译阶段就已确定,无法在运行阶段根据实际对象类型动态选择虚函数,多态机制也就无法生效。
综上所述,虚函数重写和父类指针 / 引用调用虚函数,是实现多态的必要条件,前者保证了子类能拥有独特的虚函数实现,后者则确保了在运行阶段能够根据对象的实际类型,正确调用相应的虚函数 。
⑤无法通过父类对象调用虚函数实现多态的原因剖析
在 C++ 中,无法通过父类对象调用虚函数实现多态,主要有以下两方面原因:
原因1,:多态条件不满足
实现多态的必要条件之一是通过父类指针或引用调用虚函数。而使用父类对象调用虚函数,并不满足这一条件,因此无法触发多态机制。
原因2:指针 / 引用切片与对象切片存在本质区别
- 父类指针 / 引用实现多态的原理:
在 C++ 中,将父类或子类对象地址传给父类指针,或将父类或子类对象本身传给父类引用,就能触发多态机制。
父类指针 / 引用指向父类对象:当父类指针或引用指向父类对象时,系统会直接访问父类对象的虚函数表。由于表内存储的是父类虚函数的地址,所以调用的是父类虚函数。
父类指针 / 引用指向子类对象:当父类指针或引用指向子类对象时,会发生切片操作。切片时,子类对象的父类部分被切出,父类指针或引用便指向这部分。关键在于,即便父类指针或引用指向的是子类对象的父类部分,该部分关联的却是子类虚函数表,而非父类虚函数表。这是因为,子类重写父类虚函数时,子类虚函数表相应位置已更新为子类虚函数的地址。所以,通过父类指针或引用调用虚函数时,调用的是子类重写后的虚函数,进而实现多态。
凭借这种机制,程序运行时能依据父类指针或引用所指对象的实际类型,调用相应的虚函数,极大增强了程序的灵活性与扩展性 。
- 父类对象无法实现多态的原因:
在 C++ 中,父类对象无法实现多态,可从以下两种传参场景分析:
父类对象传参给父类对象形参:当把父类对象作为实参传递给父类对象形参,随后通过该形参调用虚函数时,由于父类对象关联的虚函数表中存储的是父类虚函数地址,所以必然调用父类虚函数,在这一场景下,不会出现运行时根据对象实际类型进行动态调度的情况,也就无法产生多态行为。
子类对象传参给父类对象形参:当子类对象作为实参传递给父类对象形参时,传值过程中会触发切片操作。在切片过程中,子类对象会调用父类的拷贝构造函数。在这一机制下,子类对象中从父类继承而来的成员变量会被拷贝到父类对象形参中。但需要特别注意的是,编译器并不会将子类对象的虚函数表拷贝给父类对象形参。下面通过反证法来进一步说明:假设编译器允许将子类对象的虚函数表拷贝给父类对象形参,那么父类对象形参的虚函数表将陷入一种模糊不清的状态。我们将难以判断该虚函数表究竟属于父类还是子类,这无疑会严重破坏 “父类对象关联父类虚函数表,子类对象关联子类虚函数表” 的基本规则。由于在实际的传参过程中,父类对象形参的虚函数表依然保持为父类虚函数表,表中存储的始终是父类虚函数的地址。当通过父类对象形参调用虚函数时,程序会按照该虚函数表中的地址进行调用,因此无法在父类对象的虚函数表中找到子类虚函数的地址,进而无法实现对子类虚函数的调用。
综上所述,当将子类对象赋值给父类对象时,只会拷贝子类对象中父类部分的成员变量,不会拷贝子类对象的虚函数表,父类对象的虚函数表在赋值前后维持不变,这就决定了无法通过父类对象调用虚函数实现多态。
⑥当类包含虚函数时,在 Visual Studio 环境中,类的底层物理空间头部存放的是虚函数表指针,而非虚函数地址,这背后有其合理的设计考量:
- 优化内存使用:一个类可能包含多个虚函数。若将所有虚函数地址直接存放在类的内存空间头部,会极大地增加内存消耗。采用虚函数表的设计,将所有虚函数地址存放在虚函数表中,类只需在头部存放一个虚函数表指针,通过该指针就能访问所有虚函数地址,极大地节省了内存空间。虚函数表本质是一个虚函数指针数组,数组中的每个元素都是指向一个虚函数的地址。
- 同类型对象虚函数表一致性:同类型对象的虚函数表内容相同。这是因为类的虚函数存放在代码段,只需存储一份。所以,同类型对象的虚函数表都指向相同的虚函数地址。
综上,在类中使用虚函数表指针来间接访问虚函数地址,不仅优化了内存的使用,还保证了同类型对象虚函数调用机制的统一。
2.父类指针调用
3.动态绑定与静态绑定
- 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
- 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
单继承和多继承关系的虚函数表
函数指针
1.函数指针变量
(1)函数指针变量创建
什么是函数指针变量呢?函数指针变量应该是用来存放函数地址的,未来通过地址能够调用函数的。那么函数是否有地址呢?我们做个测试:
确实打印出来了地址,所以函数是有地址的,函数名就是函数的地址,当然也可以通过&函数名的方式获得函数的地址。如果我们要将函数的地址存放起来,就得创建函数指针变量咯,函数指针变量的写法其实和数组指针非常类似。如下:
//函数指针变量的创建
void test()
{
printf("hehe\n");
}
void (*pf1)() = &test;
void (*pf2)() = test;
int Add(int x, int y)
{
return x + y;
}
int(*pf3)(int, int) = Add;
int(*pf3)(int x, int y) = &Add;//x和y写上或者省略都是可以的
函数指针类型解析:
(2)函数指针变量的使用
通过函数指针调用指针指向的函数。
(3)两段有趣的代码
①代码1
(*(void (*)())0)();
下面来详细解析(*(void (*)())0)();
这段 C 语言代码。此代码的核心功能是调用位于内存地址为 0 处的函数。接下来会逐步拆解其具体含义:
(void (*)())
:这是一个强制类型转换操作符。void (*)()
代表的是一个函数指针类型,此类型的函数不接收任何参数,同时也没有返回值。所以,(void (*)())
的作用是把某个值强制转换为这种函数指针类型。(void (*)())0
:这一步是把整数0
强制转换为void (*)()
类型的函数指针。也就是说,它将内存地址0
当作一个指向无参数、无返回值函数的指针。*(void (*)())0
:这里的*
是解引用操作符,它的功能是获取函数指针所指向的函数。经过这一步,就得到了位于内存地址0
处的函数。(*(void (*)())0)()
:最后这部分代码调用了解引用得到的函数。因为该函数不接收参数,所以括号内为空。
注意事项:
- 在实际运行中,对内存地址
0
处的函数进行调用是极其危险的操作,因为该地址往往是受操作系统保护的,直接访问会引发段错误,从而致使程序崩溃。 - 此代码主要用于演示 C 语言里函数指针和强制类型转换的运用,在实际编程时不应使用。
②代码2
void (*signal(int, void(*)(int)))(int);
下面来详细解析void (*signal(int, void(*)(int)))(int);
这段 C 语言代码。此代码定义了一个名为signal
的函数,下面会逐步拆解其具体含义:
- 从整体结构来看,这是一个函数声明。
signal
是函数名。 - 查看函数参数:
- 第一个参数是
int
类型,表明这个函数接收一个整数作为参数。 - 第二个参数是
void(*)(int)
类型,这是一个函数指针类型。此类型的函数接收一个int
类型的参数,并且没有返回值。
- 第一个参数是
- 分析函数返回值:
void (*)(int)
同样是一个函数指针类型。也就是说,signal
函数的返回值是一个指向接收int
类型参数、无返回值函数的指针。
(4)typedef 关键字
typedef 是用来类型重命名的,可以将复杂的类型,简单化。
①比如,你觉得unsigned int 写起来不方便,如果能写成uint 就方便多了,那么我们可以使用:
typedef unsigned int uint;
//将unsigned int 重命名为uint
②如果是指针类型,能否重命名呢?其实也是可以的,比如,将int* 重命名为ptr_t ,这样写:
typedef int* ptr_t;
③但是对于数组指针和函数指针稍微有点区别:比如我们有数组指针类型int(*)[5] ,需要重命名为parr_t ,那可以这样写:
typedef int(*parr_t)[5]; //新的类型名必须在*的右边
④函数指针类型的重命名也是一样的,比如,将void(*)(int) 类型重命名为pf_t ,就可以这样写:
typedef void(*pfun_t)(int);//新的类型名必须在*的右边
⑤简化代码
//代码2
void (*signal(int, void(*)(int)))(int);
//那么要简化代码2,可以这样写:
typedef void(*pfun_t)(int);
pfun_t signal(int, pfun_t);
2.函数指针数组
数组是一个存放相同类型数据的存储空间,我们已经学习了指针数组,
比如:
int * arr[10];
//数组的每个元素是int*
那要把函数的地址存到一个数组中,那这个数组就叫函数指针数组,那函数指针的数组如何定义呢?
int (*parr1[3])();
int *parr2[3]();
int (*)() parr3[3];
答案是:parr1
parr1 先和[] 结合,说明 parr1是数组,数组的内容是什么呢?是int (*)() 类型的函数指针。
3.转移表
函数指针数组的用途:转移表
举例:计算器的一般实现:
#include <stdio.h>
int add(int a, int b)
{
return a + b;
}
int sub(int a, int b)
{
return a - b;
}
int mul(int a, int b)
{
return a * b;
}
int div(int a, int b)
{
return a / b;
}
int main()
{
int x, y;
int input = 1;
int ret = 0;
do
{
printf("*************************\n");
printf(" 1:add 2:sub \n");
printf(" 3:mul 4:div \n");
printf(" 0:exit \n");
printf("*************************\n");
printf("请选择:");
scanf("%d", &input);
switch (input)
{
case 1:
printf("输入操作数:");
scanf("%d %d", &x, &y);
ret = add(x, y);
printf("ret = %d\n", ret);
break;
case 2:
printf("输入操作数:");
scanf("%d %d", &x, &y);
ret = sub(x, y);
printf("ret = %d\n", ret);
break;
case 3:
printf("输入操作数:");
scanf("%d %d", &x, &y);
ret = mul(x, y);
printf("ret = %d\n", ret);
break;
case 4:
printf("输入操作数:");
scanf("%d %d", &x, &y);
ret = div(x, y);
printf("ret = %d\n", ret);
break;
case 0:
printf("退出程序\n");
break;
default:
printf("选择错误\n");
break;
}
} while (input);
return 0;
}
使用函数指针数组的实现:
#include <stdio.h>
int add(int a, int b)
{
return a + b;
}
int sub(int a, int b)
{
return a - b;
}
int mul(int a, int b)
{
return a * b;
}
int div(int a, int b)
{
return a / b;
}
int main()
{
int x, y;
int input = 1;
int ret = 0;
int(*p[5])(int x, int y) = { 0, add, sub, mul, div }; //转移表
do
{
printf("*************************\n");
printf(" 1:add 2:sub \n");
printf(" 3:mul 4:div \n");
printf(" 0:exit \n");
printf("*************************\n");
printf("请选择:");
scanf("%d", &input);
if ((input <= 4 && input >= 1))
{
printf("输入操作数:");
scanf("%d %d", &x, &y);
ret = (*p[input])(x, y);
printf("ret = %d\n", ret);
}
else if (input == 0)
{
printf("退出计算器\n");
}
else
{
printf("输入有误\n");
}
} while (input);
return 0;
}
判断大小端字节序的两种方法
在计算机系统中,字节序分为大端字节序 和小端字节序 。大端字节序指的是数据的高位字节存于低地址,低位字节存于高地址;小端字节序则是数据的低位字节存于低地址,高位字节存于高地址。下面为你详细介绍判断大小端字节序的两种方法。
1.方法一:通过指针类型转换判断
(1)思路:创建一个整型变量i
并初始化为1
,该变量在内存中占据多个字节。利用指针类型转换,把i
的地址转换为char*
类型,接着访问这个char
类型指针所指向的内存地址。由于char
类型只占一个字节,所以访问的是i
在内存中低地址处的第一个字节。若该字节的值为1
,则表示是小端字节序;若为0
,则是大端字节序。
(2)代码实现
#include <stdio.h>
//方法1:通过指针类型转换判断大小端字节序
int check_sys_1()
{
//定义一个整型变量i并初始化为1
int i = 1;
//将i的地址强制转换为char*类型,然后解引用获取低地址处的第一个字节的值
return (*(char*)&i);
}
2.方法二:使用联合体判断
(1)思路:联合体(union
)的所有成员共享同一块内存空间。定义一个包含int
类型成员i
和char
类型成员c
的联合体。将i
初始化为1
,这样i
在内存中占据多个字节。因为c
和i
共享同一块内存,所以c
访问的是i
在内存中低地址处的第一个字节。若c
的值为1
,则是小端字节序;若为0
,则是大端字节序。
(2)代码实现
//方法2:使用联合体判断大小端字节序
int check_sys_2()
{
//定义一个联合体,其中成员i和c共享同一块内存空间
union
{
int i;
char c;
} un;
//将联合体的成员i初始化为1
un.i = 1;
//返回联合体的成员c的值,即i在内存中低地址处的第一个字节的值
return un.c;
}
(3)测试代码
int main()
{
int ret = check_sys_2();
//int ret = check_sys_1();
if (ret == 1)
{
printf("小端\n");
}
else
{
printf("大端\n");
}
return 0;
}
单继承中的虚函数表
1.注意事项:
- 需要注意的是在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类的虚表模型前面我们已经看过了,没什么需要特别研究的。
-
单继承虚函数表理论基础:在 C++ 中,当类包含虚函数时,会为该类生成虚函数表(虚表)。虚函数表是一个存储虚函数地址的数组,类的每个对象都有一个隐藏的虚函数表指针(虚表指针),指向所属类的虚函数表。在单继承关系中,派生类会继承基类的虚函数表,并根据自身对虚函数的重写情况对虚函数表进行调整。
2.案例
//父类
class Base
{
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
private:
int a;
};
//子类
class Derive :public Base //子类Derive继承父类Base
{
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
virtual void func4() { cout << "Derive::func4" << endl; }
private:
int b;
};
int main()
{
Base b;
Derive d;
return 0;
}
(1)监视窗口:从监视窗口可以看到 Base
类对象 b
和 Derive
类对象 d
的虚表指针以及虚函数表中的部分内容。对于 Base
类对象 b
,虚函数表中存储的是 Base::func1
和 Base::func2
的地址;对于 Derive
类对象 d
,虚函数表中 func1
的地址被替换为 Derive::func1
的地址,同时理论上还应有 func3
和 func4
的地址,但由于编译器监视窗口的问题(即故意隐藏了这两个函数),不一定能完整显示。这一现象也可以认为是监视窗口的一个小bug。
(2)那么我们如何查看d的虚表呢?下面我们使用代码打印出虚表中的函数,代码如下所示:
①代码
#include <iostream>
using namespace std;
//父类(基类)
class Base
{
public:
//虚函数func1
virtual void func1()
{
cout << "Base::func1" << endl;
}
//虚函数func2
virtual void func2()
{
cout << "Base::func2" << endl;
}
private:
int a = 1;
};
//子类(派生类),公有继承自Base
class Derive :public Base
{
public:
//重写父类的虚函数func1
virtual void func1()
{
cout << "Derive::func1" << endl;
}
//子类新增虚函数func3
virtual void func3()
{
cout << "Derive::func3" << endl;
}
//子类新增虚函数func4
virtual void func4()
{
cout << "Derive::func4" << endl;
}
private:
int b = 2;
};
//函数指针类型重命名,因为父类Base、子类Derive的所有虚函数地址类型都是void(*)()
//为了方便使用void(*)()类型,这里对void(*)()进行类型重命名,简化后续代码书写。
typedef void(*vfptr) ();
//打印虚函数表的函数
//参数vftable是虚函数表指针数组,本质是存放虚函数指针的数组
//也可写成void PrintVftable(vfptr* vftable),两种写法等价
void PrintVftable(vfptr vftable[])
{
//打印虚函数表地址
cout << " 虚表地址>" << vftable << endl;
//虚函数表本质上是一个存放虚函数指针的指针数组
//在一般情况下(如VS系列编译器),为了便于标识虚函数表的结束,在数组的末尾会放置一个nullptr
//当程序通过虚函数表指针遍历虚函数表时,遇到nullptr就知道已经到达了表的末尾(注:g++编译器没有此机制 )
for (int i = 0; vftable[i] != nullptr; ++i)
{
//打印虚函数在表中的序号和地址
printf("[%d]:%p->", i, vftable[i]);
//获取虚函数指针(即虚函数地址)并调用虚函数
vfptr f = vftable[i];
f();
}
cout << endl;
}
int main()
{
Base b;
Derive d;
//思路:取出b、d对象的头4bytes(32位下)或头8bytes(64位下),就是虚表的指针
//虚函数表本质是存虚函数指针的数组,数组最后面可能有nullptr(VS系列编译器有,g++没有)。
//以下是几种获取虚函数表指针并传递给PrintVftable函数打印虚函数表的方法。
//写法1:
//32位下,取对象内存头部存放的虚函数表指针的方式
//先将对象地址转换为int*指针,再解引用获取前4字节值(即虚函数表指针),最后转换为vfptr*类型
//因为PrintVftable函数期望接收vfptr*类型的参数,这样才能正确操作虚函数表指针数组。
//注意事项:数组传参等价于传数组名/首元素地址/虚函数表指针(因为虚函数表指针指向虚函数表即指向函数指针数组)
PrintVftable((vfptr*)(*(int*)&b));
PrintVftable((vfptr*)(*(int*)&d));
//64位下,取对象内存头部存放的虚函数表指针的方式
//与32位类似,只是这里需要先转换为long long*指针来获取前8字节值(即虚函数表指针)
//PrintVftable((vfptr*)(*(long long*)&b));
//PrintVftable((vfptr*)(*(long long*)&d));
//写法2:在32位、64位都可以使用
//&b获取对象地址,(vfptr**)&b将其转换为虚函数表指针的地址
//再通过*(vfptr**)&b解引用,获取虚函数表指针(类型为vfptr*)
//这种方式避免了根据位数不同而进行不同的指针类型转换
PrintVftable((*(vfptr**)&b));
PrintVftable((*(vfptr**)&d));
return 0;
}
②获取对象虚函数表指针的三种方式及原理详解
代码1:(vfptr*)((int)&b) 和 (vfptr*)((int)&d) (32 位系统适用)
PrintVftable((vfptr*)(*(int*)&b));
PrintVftable((vfptr*)(*(int*)&d));
原理
- 获取对象地址:首先,
&b
(对于d
对象则是&d
)获取Base
类对象b
(或Derive
类对象d
)在内存中的地址。这是一个常规的取地址操作,得到的是对象在内存中的起始位置。 - 指针类型转换:
(int*)&b
(或(int*)&d
)将获取到的对象地址强制转换为int*
类型的指针。在 32 位系统中,指针的大小是 4 个字节。这样做的目的是为了后续能以 4 字节为单位去访问内存内容,因为在 32 位系统下虚函数表指针恰好存放在对象内存头部的前 4 个字节中。 - 解引用取值:
*(int*)&b
(或*(int*)&d
)对int*
类型的指针进行解引用操作。由于int*
指针一次访问 4 字节空间,所以通过这个操作就取到了对象b
(或d
)内存前 4 个字节的值,而这个值就是指向虚函数表的指针。 - 最终类型转换:
(vfptr*)(*(int*)&b)
(或(vfptr*)(*(int*)&d
),因为PrintVftable
函数期望接收的参数类型是vfptr*
(即指向虚函数指针的指针,也就是虚函数表指针数组的类型 ),而前面取到的虚函数表指针类型是int*
,两者不匹配,并且int*
类型不会自动转换为vfptr*
类型,所以需要通过强制类型转换(vfptr*)
将其转换为vfptr*
类型,这样得到的结果就能作为参数正确传递给PrintVftable
函数了。
注意事项
- 仅适用于 32 位系统:这种取虚函数表指针的方式依赖于 32 位系统中指针占 4 字节的特性。如果在 64 位系统中使用,由于 64 位系统指针大小为 8 字节,虚函数表指针存放在对象内存头部的前 8 个字节,使用
int*
去访问 4 字节就无法正确获取虚函数表指针,会导致错误。 - 类型不匹配问题:一定要注意
int*
类型的虚函数表指针不能直接传递给PrintVftable
函数,必须进行强制类型转换,否则会出现编译错误,因为函数参数类型要求是vfptr*
。
代码2:(vfptr*)((long long)&b) 和 (vfptr*)((long long)&d) (64 位系统适用)
PrintVftable((vfptr*)(*(long long*)&b));
PrintVftable((vfptr*)(*(long long*)&d));
原理
- 获取对象地址:同样,
&b
(或&d
)先获取对象b
(或d
)在内存中的地址,这是操作的起始点。 - 指针类型转换:
(long long*)&b
(或(long long*)&d
)将对象地址强制转换为long long*
类型的指针。在 64 位系统中,指针大小为 8 字节,long long
类型在大多数系统下也是 8 字节,使用long long*
类型指针可以以 8 字节为单位去访问内存。这样做是为了能够正确访问 64 位系统下对象内存头部存放虚函数表指针的前 8 个字节。 - 解引用取值:
*(long long*)&b
(或*(long long*)&d
)对long long*
类型的指针进行解引用操作。由于long long*
指针一次访问 8 字节空间,所以通过这个操作就取到了对象b
(或d
)内存前 8 个字节的值,也就是指向虚函数表的指针。 - 最终类型转换:
(vfptr*)(*(long long*)&b)
(或(vfptr*)(*(long long*)&d
),和前面 32 位系统的情况类似,因为要传递给PrintVftable
函数,而该函数要求参数类型为vfptr*
,所以需要将获取到的long long*
类型的虚函数表指针通过强制类型转换(vfptr*)
转换为vfptr*
类型,以便符合函数参数要求。
注意事项
- 仅适用于 64 位系统:此方法是基于 64 位系统指针占 8 字节设计的。如果在 32 位系统中使用,因为 32 位系统指针只需 4 字节表示,使用
long long*
去访问 8 字节可能会超出对象实际存储虚函数表指针的范围,导致获取错误的指针值,进而引发程序错误。 - 类型转换的必要性:同第一种方式,转换后的指针类型必须符合
PrintVftable
函数的参数要求,否则编译无法通过。
代码3:*(vfptr**)&b 和 *
(vfptr**)&d
(32 位和 64 位系统通用)
PrintVftable((*(vfptr**)&b));
PrintVftable((*(vfptr**)&d));
原理
- 获取对象地址并转换为虚函数表指针地址:
(vfptr**)&b
(或(vfptr**)&d
),这里&b
(或&d
)先获取对象b
(或d
)的地址,然后(vfptr**)
将其强制转换为vfptr**
类型,也就是虚函数表指针的地址类型。因为虚函数表指针本身是vfptr*
类型,那么它的地址就是vfptr**
类型。这一步操作是为了后续能准确访问到虚函数表指针在内存中的存储位置。 - 解引用获取虚函数表指针:
*(vfptr**)&b
(或*(vfptr**)&d
),对前面得到的虚函数表指针的地址(vfptr**
类型 )进行解引用操作。由于虚函数表指针的类型是vfptr*
,所以这个解引用操作会以vfptr*
类型的大小(在 32 位系统下是 4 字节,在 64 位系统下是 8 字节 )去访问对象b
(或d
)内存前相应字节数的空间,从而获取到虚函数表指针。并且这个操作获取到的虚函数表指针类型就是vfptr*
,刚好符合PrintVftable
函数对参数类型的要求,可以直接传递给函数使用。
注意事项
- 指针类型理解:理解
vfptr*
和vfptr**
类型很关键。vfptr*
是虚函数表指针类型,指向存放虚函数指针的数组;vfptr**
是虚函数表指针的地址类型。如果对这两种类型概念混淆,可能无法理解该操作的原理,甚至写错代码。 - 系统兼容性优势:虽然这种方式在 32 位和 64 位系统都能使用,但也要明白其原理是基于指针类型的正确理解和操作,而不是随意使用。在不同系统下,它都是根据指针实际大小去正确获取虚函数表指针的。
③PrintVftable传参的本质
注意:在 C++ 中,虚函数表指针表示的是虚函数表的首元素地址。
- 数组传参原理:在 C++ 中,当数组作为函数参数传递时,数组名会隐式转换为指向数组首元素的指针。这意味着,数组传参本质传递的是数组首元素地址,而非数组本身。
- 虚函数表及指针原理:虚函数表本质是一个存放虚函数指针的数组,数组中的每个元素指向一个虚函数。虚函数表指针专门用于存储虚函数表的首元素地址,借此间接访问虚函数表的内容。需要明确,虚函数表指针并非虚函数表的数组名。
- PrintVftable 传参本质:
PrintVftable
函数传参的本质,是传递虚函数表的首地址。由于虚函数表是存储虚函数指针的数组,PrintVftable
接收指向虚函数表的指针作为参数。通过传递这个指针,函数便能访问虚函数表中的内容,对虚函数进行相关操作,进而在多态机制中发挥作用。凭借虚函数表指针,程序在运行阶段能依据对象的实际类型访问对应的虚函数,实现多态调用。
3.虚表生成的时间、对象中虚表指针初始化时间、虚表存放位置
注意:虚表生成和对象中的虚表指针初始化是编译器自己负责,我们不需要手动完成。
- 虚表生成时间:虚表在编译阶段生成。编译器扫描类定义时,若发现类包含虚函数,就会为该类创建虚函数表。它依据代码中虚函数的定义与声明确定地址信息,将每个虚函数地址按序存入表中,为运行时多态的动态绑定奠定基础。
- 对象中虚表指针初始化时间:对象中的虚表指针是在构造函数的初始化列表中进行初始化的。
4.虚表存放位置:代码段(常量区)
①内存布局:内存一般划分为栈、堆、数据段(静态区)、代码段(常量区)等区域。对象本身并不直接存储虚表,而是在对象内部包含一个虚表指针,该指针指向虚表。对于同类型的多个对象,它们的虚表指针都指向同一个虚表,这意味着同类型对象共享虚表。
②验证虚表存于代码段:通过打印存放在各个内存区域的变量地址,以及父、子类对象的虚表指针,可以发现虚表指针的值与字符串常量的地址较为接近,而与栈、堆和数据段中的变量地址相差甚远。这一现象表明,虚表存放在代码段(常量区)。
③虚表存于代码段的原因
- 共享性需求:由于同类型的所有对象共享同一个虚表,将虚表存放在代码段,能够确保所有对象都能正确访问到虚表。如果虚表被存放在栈或堆中,每个对象都需要单独维护一个虚表副本,这不仅会造成内存浪费,还会导致对象间的虚表不一致。
- 稳定性需求:虚表在编译完成后,通常不会被修改。代码段具有只读属性,能够有效防止虚表在运行时被意外修改,从而保证程序的稳定性和安全性。相比之下,栈和堆中的数据是可读写的,这增加了虚表被意外修改的风险。
多继承中的虚函数表
1.单继承子类虚表生成规则
单继承场景下,子类虚表生成需依次经历如下步骤:
- 首先,子类虚函数表会将父类虚函数表的内容拷贝一份。这保证了子类能够继承父类虚函数的函数接口。(注:函数接口包括函数名、参数列表和返回值类型)
- 接着,如果子类重写了父类中的某个虚函数,那么子类虚函数表中对应的位置会被替换为子类自己的虚函数地址。这就是前面提到的覆盖机制。
- 最后,子类自己新增加的虚函数会按照在子类中声明的顺序,依次添加到虚函数表的末尾。
2.多继承子类虚表生成规则
(1)多继承下子类虚表的个数由子类所继承的具有虚函数的父类个数确定。原因如下:
- 每个有虚函数的父类都有独立虚表:在多继承中,每个具有虚函数的父类都有自己的虚表,用于实现自身的多态性。子类为了能够正确地继承和调用各个父类的虚函数,需要为每个有虚函数的父类保留一份虚表信息。
- 子类需继承所有父类虚表:子类继承了多个父类,就需要将这些父类的虚表都继承下来,以便在运行时能够根据对象的实际类型正确地调用相应父类的虚函数。即使子类重写了某些父类的虚函数,也是在对应的父类虚表中进行修改,而不是创建新的虚表。所以,有几个具有虚函数的父类,子类就会有几个虚表。
以上面代码为例,Derive类继承自Base1和Base2,这两个父类都有虚函数,所以Derive类有两个虚表,分别对应Base1和Base2。如果还有一个没有虚函数的父类Base3,Derive类仍然只有两个虚表,因为Base3没有虚函数,不需要为其生成虚表。
(2)多继承下子类虚表的生成规则如下:
- 拷贝父类虚表:子类虚函数表会为每个父类分别拷贝一份其虚函数表的内容。这样可以确保子类能够继承各个父类虚函数的函数接口,保证多继承下对不同父类虚函数的访问。
- 重写虚函数处理:如果子类重写了某个父类中的虚函数,那么在对应父类虚函数表拷贝的相应位置,会被替换为子类自己的虚函数地址。这和单继承中的覆盖机制类似,实现了子类对父类虚函数的重写。
- 新增虚函数添加:子类自己新增加(未重写)虚函数的函数地址会存放到第一个继承的父类虚表的最后面。
以上面代码为例,Derive 类继承自 Base1 和 Base2,在生成 Derive 类的虚表时,会先拷贝 Base1 的虚表,再拷贝 Base2 的虚表。由于 Derive 类重写了 func1 函数,那么在 Base1 和 Base2 虚表拷贝中 func1 对应的位置会被替换为 Derive::func1 的地址。Derive 类新增的 func3 函数会添加到 Base1 虚表拷贝的最后面(注:Derive 类新增的 func3 虚函数的函数地址存放在那个父类虚表可以参考下面多继承子类虚表的打印结果)。
3.多继承子类虚表的打印
3.1.多继承中指针偏移问题
说明:在多继承场景下,当把子类对象地址赋值给不同父类指针时,会出现指针偏移现象。这是由于子类对象的内存布局,是按继承顺序依次存放各父类成员和自身成员。不同父类指针会指向子类对象内对应父类成员的起始位置,由此导致指针指向地址存在差异。并且,指针类型决定其对成员的访问范围,使用时需格外留意指针类型和对象内存布局,以防错误访问。
注意:p1
和 p3
的值在数值上恰好相等,均指向子类对象 d
的起始内存地址。但二者意义不同,p3
是子类类型指针,它将 d
视为完整的 Derive
类对象,可访问 Derive
类及其继承而来的所有成员;而 p1
是父类 Base1
类型指针,它仅将 d
中从 Base1
继承的部分视为有效对象,通过 p1
只能访问 Base1
类定义的成员,尽管其指向地址和 p3
相同,但访问权限和语义理解上存在差异。
3.2.多继承子类虚表的打印
(1)观察下图可以看出:子类自己新增加(未重写)虚函数的函数地址会存放到第一个继承的父类虚表的最后面。
#include <iostream>
using namespace std;
//父类 Base1
class Base1
{
public:
//声明虚函数 func1,用于实现多态性,当子类重写此函数时,会根据对象实际类型调用相应版本
virtual void func1() { cout << "Base1::func1" << endl; }
//声明虚函数 func2,同样用于多态,提供了一个可被子类重写的接口
virtual void func2() { cout << "Base1::func2" << endl; }
private:
int b1 = 1;
};
//父类 Base2
class Base2
{
public:
//声明虚函数 func1,与 Base1 中的 func1 同名,子类重写时会覆盖多个父类的同名虚函数
virtual void func1() { cout << "Base2::func1" << endl; }
//声明虚函数 func2,为子类提供了多态的实现机会
virtual void func2() { cout << "Base2::func2" << endl; }
private:
int b2 = 2;
};
//子类 Derive,公有继承自 父类Base1 和 父类Base2
class Derive : public Base1, public Base2
{
public:
//重写父类 Base1 和 Base2 的虚函数 func1,实现多态,当通过父类指针或引用调用 func1 时,
//若实际对象是 Derive 类型,会调用此版本
virtual void func1() { cout << "Derive::func1" << endl; }
//声明子类自己新增的虚函数 func3,根据多继承中虚表的生成规则,该函数的地址会添加到
//首个有虚函数的父类(Base1)虚表的最后面
virtual void func3() { cout << "Derive::func3" << endl; }
private:
int d1 = 3;
};
//定义函数指针类型 vfptr,用于指向虚函数表中的虚函数
typedef void(*vfptr) ();
//打印虚表的函数,接受一个函数指针数组 vftable 作为参数
void PrintVftable(vfptr vftable[])
{
//打印虚表的地址
cout << " 虚表地址>" << vftable << endl;
//遍历虚表中的函数指针,i 作为索引
for (int i = 0; vftable[i] != nullptr; ++i)
{
//打印函数指针在虚表中的索引和地址
printf("[%d]:%p->", i, vftable[i]);
//获取当前索引对应的函数指针
vfptr f = vftable[i];
//调用该虚函数
f();
}
cout << endl;
}
int main()
{
//创建 Derive 类的对象 d
Derive d;
//打印 Derive 类对象 d 的第一张虚表(即 Base1 对应的虚表),根据规则,
//Derive 类新增的 func3 虚函数地址已添加到这张虚表的最后面
PrintVftable((vfptr*)(*(int*)&d));
//找第二张虚表(Base2 对应的虚表)的虚表指针的两种方法
//方法 1:因为在多继承下,子类 Derive 对象的内存布局中,Base2 部分紧跟在 Base1 部分之后。
//要找到 Base2 对应的虚表指针,需要将指针从子类 Derive 对象的起始地址 &d 偏移 Base1 对象
//所占用的内存大小(sizeof(Base1))。由于指针类型决定了指针偏移时移动的字节数,char 类型
//指针每个元素占 1 字节,所以先将 &d 强制转换为 char* 类型((char*)&d),然后通过 (char*)&d + sizeof(Base1)
//的方式进行指针偏移,得到的地址就是 Base2 虚表指针的地址,最后通过解引用和类型转换来打印 Base2 的虚表。
PrintVftable((vfptr*)(*(int*)((char*)&d + sizeof(Base1))));// 打印第二张虚表
//方法 2:将子类 Derive 对象的指针 &d 赋值给父类 Base2 类型的指针 ptr2,利用多继承中指针偏移的特性,
//ptr2 会自动指向子类对象中父类 Base2 部分的起始地址,从而获取到 Base2 对应的虚表指针,然后打印 Base2 的虚表。
//Base2* ptr2 = &d; //注:这里涉及了多继承中指针偏移问题,将子类指针赋值给父类指针时,指针会根据父类在子类中的存储位置进行偏移。
//PrintVftable((vfptr*)(*(int*)(ptr2)));
return 0;
}
结论:在多继承中,子类新增虚函数的虚函数地址会添加到首个有虚函数的父类虚表最后面;子类重写某父类虚函数时父类虚表对应位置会被覆盖为子类自己重写的虚函数地址。
(2)在多继承中,子类对象继承的不同父类虚函数表中子类重写虚函数地址 “不同” 的底层原理分析
#include <iostream>
using namespace std;
//父类 Base1
class Base1
{
public:
//虚函数
virtual void func1() { cout << "Base1::func1" << endl; }
virtual void func2() { cout << "Base1::func2" << endl; }
private:
int b1 = 1;
};
//父类 Base2
class Base2
{
public:
//虚函数
virtual void func1() { cout << "Base2::func1" << endl; }
virtual void func2() { cout << "Base2::func2" << endl; }
private:
int b2 = 2;
};
//子类 Derive,公有继承自 父类Base1 和 父类Base2
class Derive : public Base1, public Base2
{
public:
//重写父类 Base1 和 Base2 的虚函数 func1,实现多态。
//当通过父类指针或引用调用 func1 时,将根据指针或引用实际指向对象的类型,决定调用的函数版本。
virtual void func1() { cout << "Derive::func1" << endl; }
//自己新增的虚函数 func3
virtual void func3() { cout << "Derive::func3" << endl; }
private:
int d1 = 3;
};
//打印虚表
typedef void(*vfptr) ();
void PrintVftable(vfptr vftable[])
{
cout << " 虚表地址>" << vftable << endl;
for (int i = 0; vftable[i] != nullptr; ++i)
{
printf("[%d]:%p->", i, vftable[i]);
vfptr f = vftable[i];
f();
}
cout << endl;
}
//多态的条件:
//1、虚函数的重写 -- 三同(函数名、参数、返回值)
// -- 例外(协变):返回值可以不同,必须是父子关系指针或者引用
// -- 例外:子类虚函数可以不加virtual
//2、父类指针或者引用去调用
int main()
{
Derive d;
//子类指针赋值给父类指针(切片)
Base1* ptr1 = &d;
//父类Base1指针ptr1指向子类Derive对象d的起始地址,由于多继承,Derive对象的起始部分是Base1的对象布局
Base2* ptr2 = &d;
//父类Base2指针ptr2指向子类Derive对象d中Base2部分的起始地址,这涉及多继承中的指针偏移机制
//调用规则:
// 1、不满足多态 -- 看调用者的类型,调用这个类型的成员函数
// 2、满足多态 -- 看指向的对象的类型,调用这个类型的成员函数
//注意:由于子类Derive虚函数func1重写了父类 Base1 和 Base2 的虚函数 func1,
//当使用父类指针/引用调用虚函数func1时,若父类指针/引用指向父类Base1/Base2对象,
//则调用父类虚函数Base1/Base2::func1;若父类指针/引用指向子类Derive对象,
//则调用子类虚函数Derive::func1。
//代码解析:由于满足多态且父类Base1指针ptr1、父类Base2指针ptr2都指向子类对象d,
//尽管ptr1和ptr2指向的地址不同,但都会调用子类虚函数Derive::func1。
//在Base1虚表和Base2虚表中,func1项均被替换为Derive::func1的地址,
//因此二者调用的本质上是同一个函数Derive::func1。
ptr1->func1();
ptr2->func1();
PrintVftable((vfptr*)(*(int*)&d));//打印第一张虚表
PrintVftable((vfptr*)(*(int*)((char*)&d + sizeof(Base1))));//打印第二张虚表
return 0;
}
①汇编角度分析:
汇编分析:
- 当
ptr1->func1()
执行时,call
指令调用的地址(如002E123F
)最终jmp
到Derive::func1
的函数栈帧建立指令起始位置(002E28C0
) 。这里eax
存放着虚函数地址,通过call
指令进行函数调用。由于ptr1
是Base1*
类型且指向子类对象起始位置,在多继承中,子类对象起始部分是Base1
的对象布局,此时this
指针(ecx
存放其值 )的值就是ptr1
的值,即指向子类对象起始位置,所以可以直接调用到重写后的Derive::func1
。 - 当
ptr2->func1()
执行时,情况有所不同。call
指令调用的地址(如002E1357
),先经过一些额外的汇编指令,其中sub ecx, 8
这条指令较为关键。因为ptr2
是Base2*
类型,它指向子类对象中Base2
部分的起始地址,当执行ptr2->func1()
时,ecx
被赋值为ptr2
的值,即此时this
指针指向子类对象中间(Base2
部分起始 )位置。而要调用子类成员函数,this
指针需要指向子类对象起始位置,所以sub ecx, 8
是让this
指针跳过Base1
类型大小(在 32 位下,Base1
包含虚表指针_vfptr
和成员变量b1
,共 8 字节 ),回到子类对象起始位置,然后再jmp
到Derive::func1
的函数栈帧建立指令起始位置(002E28C0
) 。这就导致了从汇编指令路径上看,ptr2->func1()
比ptr1->func1()
多了一些指令来修正this
指针位置。
②使用指针访问对象成员变量 / 成员函数和 this
指针关系分析
- 访问成员变量:当使用指针访问对象成员变量时,这个指针需要指向对象起始位置,编译器会将该指针的值当作
this
指针的值。编译器通过对this
指针进行运算和偏移,来找到要访问的成员变量。例如,若要访问Derive
类对象中的成员变量,指针必须指向Derive
对象起始位置,编译器才能基于此正确定位到各个成员变量。 - 访问成员函数:对于成员函数调用,
this
指针同样至关重要。以ptr1->func1()
和ptr2->func1()
为例,ptr1
恰好指向子类对象起始位置,所以可以直接调用成员函数,其值充当this
指针。而ptr2
指向子类对象中间位置,调用成员函数时需要先修正this
指针到对象起始位置,才能正确执行成员函数。因为成员函数内部可能会访问对象的其他成员,需要this
指针提供正确的对象起始地址作为基准来进行偏移访问。
③底层原理分析
在多继承下,子类对象的内存布局是按继承顺序依次存放各父类成员和自身成员。虚函数表是实现多态的关键数据结构,每个有虚函数的父类都有自己的虚函数表。当子类重写父类虚函数时,会在对应的父类虚函数表中替换虚函数地址。对于 Derive
类重写 Base1
和 Base2
的 func1
函数,在 Base1
虚函数表和 Base2
虚函数表中,func1
对应的位置都被替换为 Derive::func1
的地址。但由于父类指针在子类对象中的指向位置不同(ptr1
指向起始,ptr2
指向 Base2
部分起始 ),导致调用时对 this
指针的处理不同。为了保证成员函数能正确访问对象内部成员,需要 this
指针指向对象起始位置,所以 ptr2
调用时需要额外指令来修正 this
指针。
④在多继承中,子类对象继承的不同父类虚函数表中子类重写虚函数地址 “不同” 的原理
这里的 “不同” 并非指 Derive::func1
函数地址本身不同,而是指在不同父类虚函数表中,以及调用时汇编指令路径和对 this
指针处理方式不同。在 Base1
虚函数表中,func1
项对应地址是直接可以通过相对简单路径(较少汇编指令 )调用到 Derive::func1
;而在 Base2
虚函数表中,由于 Base2
指针指向子类对象内部位置,调用时需要额外汇编指令(如 sub ecx, 8
)来修正 this
指针位置,然后再调用到 Derive::func1
,从汇编指令执行过程角度体现出了差异,也就是看起来像是虚函数地址在不同虚函数表中的 “不同” 表现。 本质上都是调用同一个 Derive::func1
函数,只是调用路径和对 this
指针的处理因父类指针指向位置不同而有区别。
⑤当子类 Derive
由原先先继承 Base1
后继承 Base2
改为先继承 Base2
后继承 Base1
时,有以下注意事项:
内存布局变化:此时子类对象的内存布局会改变,先存放 Base2
类的成员(包括 Base2
的虚函数表指针和成员变量 b2
),接着是 Base1
类的成员(Base1
的虚函数表指针和成员变量 b1
) ,最后是 Derive
类自身成员(如 d1
)。
虚函数调用与 this
指针修正:
Base2
指针调用虚函数:当使用Base2*
类型的指针(假设为ptr
)指向子类Derive
对象并调用重写的虚函数(如func1
)时,因为ptr
指向子类对象起始位置(此时起始位置是Base2
成员布局处 ),this
指针初始值就是该指针的值,指向正确的对象起始位置,所以调用过程中一般不需要额外修正this
指针,可直接通过类似之前ptr1->func1()
的简单汇编指令路径调用到Derive::func1
。Base1
指针调用虚函数:若使用Base1*
类型的指针(假设为ptr
)指向子类Derive
对象并调用重写虚函数,由于Base1
成员在内存中不再处于起始位置,ptr
指向的是子类对象中Base1
部分的起始地址(即跳过了Base2
成员的位置 ),此时将ptr
的值赋给this
指针后,this
指针位置错误。就如同之前ptr2->func1()
的情况,需要通过额外的汇编指令来修正this
指针。在 32 位系统下,由于Base2
类的虚函数表指针和成员变量b2
等成员总计可能占用一定字节数(假设为n
字节 ) ,那么可能需要类似sub ecx, n
的指令让this
指针跳过Base2
类型大小,回到子类对象起始位置,之后才能正确调用到Derive::func1
。
总结:简而言之,继承顺序改变后,原先不需要修正 this
指针的父类指针(如原来的 Base1*
)可能需要修正,而原先需要修正的父类指针(如原来的 Base2*
)可能不再需要修正。在编写涉及多继承虚函数调用的代码以及分析汇编指令时,要充分考虑这种因继承顺序变化带来的内存布局和 this
指针处理方式的改变,避免因对对象内存结构和 this
指针行为的误解导致程序错误。
菱形继承最终派生类的虚表
注意事项:
- 虚函数表(虚表):用于存放虚函数地址。在包含虚函数的类中,虚函数表是一个存储虚函数地址的数组 。当通过基类指针或引用调用虚函数时,程序借助对象的虚表指针找到虚函数表,依据虚函数在表中的偏移量确定要调用的函数地址,实现动态绑定。
- 虚基类表(虚基表):在虚继承场景下,每个虚继承的子类都有虚基类指针(vbptr)指向虚基类表。虚基类表主要存放虚基类与本类的偏移地址 。其作用在于,当存在虚继承关系时,通过该偏移地址可以准确找到虚基类成员,解决菱形继承等情况下可能出现的基类成员访问二义性和数据冗余问题。同时,虚基类表中也记录虚基表指针距虚表指针的相对距离等相关信息,辅助对象内存布局管理和成员访问。
- 下面代码都是在VS2022下的x86程序中执行的。如果是在其他平台下,部分代码需要改动。比如:如果是x64程序,则需要考虑指针是8bytes问题。
- 实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。
- 下面菱形继承使用虚继承后每种情况最终派生类的虚表生成都是基于对应具体代码生成的。由于菱形继承使用虚继承后最终派生类的虚表生成太过复杂,下面只简单介绍3种情况。
单继承,子类虚表的生成规则:
- 首先,子类虚函数表会将父类虚函数表的内容拷贝一份。这保证了子类能够继承父类虚函数的函数接口。(注:函数接口包括函数名、参数列表和返回值类型)
- 接着,如果子类重写了父类中的某个虚函数,那么子类虚函数表中对应的位置会被替换为子类自己的虚函数地址。这就是前面提到的覆盖机制。
- 最后,子类自己新增加的虚函数会按照在子类中声明的顺序,依次添加到虚函数表的末尾。
多继承,子类虚表的生成规则:
- 拷贝父类虚表:子类虚函数表会为每个父类分别拷贝一份其虚函数表的内容。这样可以确保子类能够继承各个父类虚函数的函数接口,保证多继承下对不同父类虚函数的访问。
- 重写虚函数处理:如果子类重写了某个父类中的虚函数,那么在对应父类虚函数表拷贝的相应位置,会被替换为子类自己的虚函数地址。这和单继承中的覆盖机制类似,实现了子类对父类虚函数的重写。
- 新增虚函数添加:子类自己新增加(未重写)虚函数的函数地址会存放到第一个继承的父类虚表的最后面。
1.情况1:中间派生类和最终派生类自己没有新增虚函数,只有重写虚函数 (重写 A)
- 最终派生类的虚表个数:1 张。
- 虚表生成规则及内容:最终派生类
D
复用基类A
的虚表。在基类A
的虚表中,原本A
类虚函数func1
对应的函数指针位置,被更新为D
类重写后的func1
函数地址,其他基类A
未被重写虚函数仍保留A
类对应函数地址。
2.情况2:中间派生类有新增虚函数和重写虚函数(重写 A),最终派生类只有重写虚函数(重写 A)
- 最终派生类的虚表个数:3 张。
- 最终派生类虚表生成规则及存放内容:
- 基类 A 虚表:存放 D 对A 重写后的虚函数地址 D::func1
- 中间派生类 B 虚表:存放
B
自己新增虚函数地址B::func3
- 中间派生类 C 虚表:存放 C 自己新增虚函数地址C::
func3
3.情况3:中间派生类、最终派生类都有新增虚函数和重写虚函数 (重写 A)、基类自己新增虚函数
- 最终派生类的虚表个数:3 张。
- 最终派生类虚表生成规则及存放内容:
- 基类 A 虚表:存放 D 对A 重写后的虚函数地址 D::func1 、A 自己新增虚函数地址 A::func4
- 中间派生类 B 虚表:B 自己新增虚函数地址 B::func2 、D 自己新增虚函数地址 D::func5
- 中间派生类 C 虚表:C 自己新增虚函数地址 C::func3
多态常见问题
1. 什么是多态?
多态是面向对象编程的一个重要特性,通俗来说,多态就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态(结果)。简单来说,就是 “一种接口,多种实现”。在 C++ 中,多态主要分为静态多态和动态多态。
- 静态多态:也称为编译时多态,是在编译阶段就确定要调用的函数。函数重载和模板是实现静态多态的主要方式。
- 动态多态:也称为运行时多态,是在运行时才确定要调用的函数。通过继承和虚函数来实现,基类的指针或引用可以指向不同的派生类对象,当调用虚函数时,会根据实际指向的对象类型来决定执行哪个类的函数。
2. 什么是重载、重写 (覆盖)、重定义 (隐藏)?
- 重载(重定义):在同一个作用域内,函数名相同但参数列表不同(参数个数、类型或顺序不同)的多个函数构成重载。返回值类型不能作为函数重载的依据。重载是静态多态的一种体现,在编译时根据实参的类型和数量来确定调用哪个函数。
- 重写(覆盖):也称为覆盖,是指派生类中重新定义基类中的虚函数。要求函数名、参数列表和返回值类型都相同(协变返回类型除外)。重写是实现动态多态的关键,通过基类的指针或引用调用虚函数时,会根据实际指向的对象类型来调用相应派生类的函数。
- 重定义(隐藏):指派生类的函数屏蔽了基类中同名的函数。如果派生类中定义了与基类同名的函数,无论参数列表是否相同,基类的函数都会被隐藏。如果要调用基类的同名函数,需要使用作用域解析运算符
::
。
3.多态的实现原理?
C++ 的动态多态主要通过虚函数表(VTable)和虚表指针(VPTR)来实现。
- 虚函数表(VTable):每个包含虚函数的类都有一个对应的虚函数表,它是一个存储虚函数地址的数组。虚函数表在编译阶段生成,并且在程序的生命周期内保持不变。
- 虚表指针(VPTR):每个包含虚函数的类的对象都有一个虚表指针,它指向该对象所属类的虚函数表。虚表指针在对象的构造函数初始化列表阶段被初始化。
当通过基类的指针或引用调用虚函数时,编译器会通过对象的虚表指针找到对应的虚函数表,然后根据虚函数在表中的偏移量找到要调用的函数地址,从而实现动态绑定。
4. inline 函数可以是虚函数吗?
答:可以,不过当构成多态时编译器就忽略 inline
属性,这个函数就不再是内联函数,因为虚函数要放到虚表中去;当不构成多态且编译器将其作为内联函数处理时,它就不是虚函数。
解析:
内联函数的主要特点是在编译时,编译器会尝试将函数体直接嵌入到调用该函数的地方,而不是像普通函数那样通过建立函数栈帧来调用,这样可以减少函数调用的开销。不过,需要注意的是,内联函数不能将声明和定义分离,通常会直接在头文件中同时完成声明和定义。
从理论上来说,内联函数的设计初衷是没有独立的函数地址的,因为它是直接在调用处展开代码。而虚函数需要将其地址存放在虚函数表中,以便在运行时根据对象的实际类型来动态调用。所以从这方面看,内联函数似乎不能成为虚函数。
但实际上,当使用 inline
关键字声明成员函数为内联函数时,这仅仅是给编译器的一个建议,编译器会根据函数的具体情况来决定是否将其作为内联函数处理。一般而言,如果一个成员函数被成功当作内联函数处理,它就不会有独立的函数地址。
当一个成员函数同时被 virtual
声明为虚函数,并且被 inline
关键字声明为内联函数时,情况会根据是否构成多态而有所不同:
- 构成多态的情况:多态的实现依赖于虚函数表,运行时要通过对象的虚表指针去查找并调用合适的虚函数。当满足多态的两个必要条件(基类指针或引用指向派生类对象,且调用的是虚函数)时,编译器会忽略
inline
属性。因为多态需要函数有明确的地址存放在虚函数表中,所以此时该成员函数会被当作普通的虚函数处理,拥有函数地址并可以放入虚函数表,也就可以成为虚函数。 - 不构成多态的情况:如果不满足多态的条件,编译器会根据
inline
建议,尝试将该函数作为内联函数处理。一旦该函数被成功内联,它就没有独立的函数地址,也就无法将其地址放入虚函数表,此时编译器会忽略virtual
属性,该成员函数不能成为虚函数。
总结:一个函数只有有函数地址时才有可能成为虚函数,若没有函数地址就一定不是虚函数。当成员函数被 virtual
和 inline
关键字同时声明时,编译器会根据是否构成多态来决定忽略哪个属性,该成员函数只能拥有 virtual
和 inline
其中一个关键字的功能属性。
5.this
指针在多态调用中的作用
在成员函数内部,this
指针是一个隐含的指针参数(注:该指针参数是被编译器自动传递给成员函),this
指针始终指向调用该成员函数的对象。在多态调用虚函数的过程中,this
指针起到了关键作用:
- 确定虚表指针:
this
指针指向当前对象,而对象的虚表指针是对象的一部分。所以,通过this
指针可以访问到对象的虚表指针,进而找到对应的虚函数表。 - 动态绑定:在运行时,根据
this
指针所指向对象的实际类型,通过虚表指针找到正确的虚函数表,从而调用正确的虚函数。
总结:当使用基类指针或引用调用虚函数实现多态时,本质上是通过该指针或引用所指向对象的 this
指针来访问对象的虚表指针,进而找到正确的虚函数表,实现动态绑定。this
指针是连接对象和其虚函数表的桥梁,确保了在运行时能够根据对象的实际类型调用正确的虚函数。
6. 静态成员函数可以是虚函数吗?
答:不能,因为静态成员函数没有 this
指针,而虚函数的调用依赖于对象的 this
指针来访问虚函数表,所以静态成员函数无法放进虚函数表。
解析:虚函数的核心机制是通过虚函数表来实现动态绑定。每个包含虚函数的类的对象都有一个虚表指针,指向该类对应的虚函数表,虚函数的地址存放在这个虚函数表中。在调用虚函数时,需要通过对象的 this
指针找到对象中的虚表指针,进而通过虚表指针找到虚函数表,最终访问到虚函数的地址。
而静态成员函数属于类本身,不属于任何具体的对象,它没有 this
指针。静态成员函数通常使用类名加作用域解析运算符(::
)来调用,而不是通过对象调用。由于没有 this
指针,就无法通过对象找到虚表指针,也就不能找到虚函数表中可能存在的静态成员函数的地址。所以,将静态成员函数的地址放进虚函数表是不合适的,也是不可行的。
7. 构造函数可以是虚函数吗?
答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。在构造函数执行之前,对象还没有完全创建好,虚表指针也没有被初始化,此时无法通过虚表指针来调用虚函数。因此,构造函数不能是虚函数。
8. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
答:可以,并且最好把基类的析构函数定义成虚函数。当使用基类的指针或引用指向派生类对象时,如果基类的析构函数不是虚函数,那么在删除(delete)基类指针时,只会调用基类的析构函数,而不会调用派生类的析构函数,这可能会导致派生类对象的资源无法正确释放,从而造成内存泄漏。将基类的析构函数定义为虚函数后,在删除基类指针时,会先调用派生类的析构函数,再调用基类的析构函数,确保资源的正确释放。
9. 对象访问普通函数快还是虚函数更快?
答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。对于普通对象,函数调用在编译时就已经确定,直接跳转到函数的入口地址执行。而对于指针对象或引用对象,当调用虚函数时,需要通过对象的虚表指针找到虚函数表,再从虚函数表中查找要调用的函数地址,这个过程会增加一定的开销。
10. 虚函数表是在什么阶段生成的,存在哪的?
答:虚函数表是在编译阶段就生成的,一般情况下存在代码段 (常量区) 的。代码段是存储程序代码和常量数据的区域,具有只读属性,虚函数表在程序的生命周期内保持不变,因此适合存放在代码段。
11. C++ 菱形继承的问题?虚继承的原理?注意这里不要把虚函数表和虚基表搞混了。
- 菱形继承的问题:菱形继承是指在继承关系中,一个派生类通过多条路径继承同一个基类,形成一个菱形的继承结构。这种继承方式会导致数据冗余和二义性问题。
- 虚继承的原理:虚继承是为了解决菱形继承的问题而引入的一种继承方式。在虚继承中,基类的成员在派生类中只保留一份拷贝。当使用虚继承时,派生类会增加一个虚基表指针(VBPtr),它指向一个虚基表(VBTbl)。虚基表中记录了基类相对于虚基表指针的偏移量,通过这个偏移量可以找到基类的成员。这样,无论通过多少条路径继承基类,都只会访问到同一份基类的成员。
12.什么是抽象类?抽象类的作用?
抽象类是指包含纯虚函数的类。纯虚函数是在基类中声明但没有实现的虚函数,它的声明形式为 virtual 返回类型 函数名(参数列表) = 0;
。抽象类不能实例化对象,只能作为基类被派生类继承。
抽象类的作用主要有以下几点:
- 强制重写虚函数:抽象类中的纯虚函数要求派生类必须重写这些函数,从而保证派生类实现了基类规定的接口。
- 体现接口继承关系:抽象类定义了一组接口,派生类通过继承抽象类并实现其中的纯虚函数来实现这些接口。这样可以实现多态性,提高代码的可扩展性和可维护性。