【c++】全面理解C++多态:虚函数表深度剖析与实践应用

CSDN 2024-06-11 08:35:03 阅读 93

Alt

🔥个人主页Quitecoder

🔥专栏c++笔记仓

Alt

朋友们大家好,通过本篇文章,来详细理解多态的内容

目录

`1.多态的定义及实现``1.1多态的构成条件``1.2虚函数的重写``1.3 C++11 override 和 final``1.4重载、覆盖(重写)、隐藏(重定义)的对比` `2.多态的原理``2.1虚函数表``2.2多态的原理``2.3单继承的虚函数表` `3.抽象类``3.1接口继承与实现继承``3.2静态多态与动态多态``3.3例题` `4.多继承中的虚函数表``4.1菱形继承和菱形虚拟继承``4.2菱形虚拟继承:` `5.虚表的存储位置`

1.多态的定义及实现

多态的基本概念:多态指的是对象可以通过指向它们的基类的引用或指针被操纵,同时还能保持其派生类部分的特性。将派生类对象当作基类对象来对待,这允许不同类的对象响应相同的消息以不同的方式,换句话说,同一个接口,使用不同的实例而执行不同操作

比如买票,普通人买票时,是全价买票;学生买票时,是半价买票

class Person { public:virtual void BuyTicket() { cout << "买票-全价" << endl; }};class Student : public Person { public:virtual void BuyTicket() { cout << "买票-半价" << endl; }};void Func(Person& p){ p.BuyTicket();}int main(){ Person ps;Student st;Func(ps);Func(st);return 0;}

在这里插入图片描述

普通人全价,学生半价

1.1多态的构成条件

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象买票半价

那么在继承中要构成多态还有两个条件

必须通过基类的指针或者引用调用虚函数被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

在这里插入图片描述

指向谁调用谁

void Func(Person p){ p.BuyTicket();}

如果这样调用,就不是指针或引用了,现在就不是多态

在这里插入图片描述

1.2虚函数的重写

虚函数:即被virtual修饰的类成员函数称为虚函数

class Person { public:virtual void BuyTicket() { cout << "买票-全价" << endl; }};

虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数

class Person { public:virtual void BuyTicket() { cout << "买票-全价" << endl; }};class Student : public Person { public:virtual void BuyTicket() { cout << "买票-半价" << endl; }};

虚函数重写的三个例外

协变(基类与派生类虚函数返回值类型不同):

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。

class A { };class B : public A { };class Person { public:virtual A* f() { return new A; }};class Student : public Person { public:virtual B* f() { return new B; }}; 析构函数的重写(基类与派生类析构函数的名字不同)

如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor

只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数

class Person { public:virtual ~Person() { cout << "~Person()" << endl; }};class Student : public Person { public:virtual ~Student() { cout << "~Student()" << endl; }};int main(){ Person* p1 = new Person;Person* p2 = new Student;delete p1;delete p2;return 0;}

当我们通过基类的指针来删除一个派生类的对象时,如果基类的析构函数没有被声明为虚拟的(virtual),将会发生对象的不完全析构。这意味着只有基类的析构代码会被执行,而派生类的析构逻辑不会调用,可能导致资源泄露或其他问题。

在给定的代码中,Person 类的析构函数被声明为虚拟的:

virtual ~Person() { cout << "~Person()" << endl; }

这意味着任何从 Person 派生的类,像 Student,都应该提供析构函数的一个覆盖版本:

virtual ~Student() { cout << "~Student()" << endl; }

delete p2; 被执行的时候(其中 p2 是一个基类 Person 类型的指针,指向一个 Student 对象),Student 的析构函数首先会被调用(子类),然后是 Person 的析构函数(基类)

因此,重写基类的虚拟析构函数确保了当通过基类指向派生类对象的指针进行 delete 操作时,能够按照正确的顺序调用派生类和基类的析构函数

派生类可以不写virtual

class Person { public:virtual void BuyTicket() { cout << "买票-全价" << endl; }};class Student : public Person { public:void BuyTicket() { cout << "买票-半价" << endl; }};

在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用

1.3 C++11 override 和 final

C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写

final:修饰虚函数,表示该虚函数不能再被重写

class Car{ public:virtual void Drive() final { }};class Benz :public Car{ public:virtual void Drive() { cout << "Benz-舒适" << endl; }};

在这里插入图片描述

用final修饰的类叫做最终类,不能被继承

class Car final{ public:virtual void Drive() { }};class Benz :public Car { public:virtual void Drive() { cout << "Benz-舒适" << endl; }};

在这里插入图片描述

override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错

class Car { public:virtual void Drive() { }};class Benz :public Car { public:virtual void Drive() override { cout << "Benz-舒适" << endl; }};

在这里插入图片描述

1.4重载、覆盖(重写)、隐藏(重定义)的对比

重载发生在同一作用域内。当两个或者更多的函数拥有相同的名字,但是**参数列表不同(参数类型、参数个数或者参数顺序不同)**时,这些函数被称为重载函数。

class MyClass { public: void func() void func(int i) void func(double d) };

重写仅在基类和派生类之间发生,且只针对虚函数。当派生类定义一个与基类中虚函数签名完全相同的函数时(即函数名、参数列表和返回类型相同),派生类函数会覆盖(重写)基类中对应的虚函数。这是多态的基础,使得在运行时可以通过基类的指针或引用调用派生类的函数实现

示例:

class Base { public: virtual void func() { /* ... */ }};class Derived : public Base { public: void func() override { /* ... */ } // 覆盖(重写)基类中的func};

隐藏也是在类的继承关系中发生,但它和是否为虚函数无关。在派生类中定义了一个新的函数,如果这个函数的名字与基类中的某个函数的名字相同,但是参数列表不同,那么它会隐藏(也称为重定义)所有与它同名的基类函数,不论基类中同名函数参数列表如何

示例:

class Base { public: void func() { /* ... */ } void func(int i) { /* ... */ }};class Derived : public Base { public: void func(double d) { /* ... */ } // 隐藏了基类的func() // 注意:现在Base的func()和func(int)都被隐藏,只能通过Derived的对象访问新的func(double)};

在继承的类中隐藏了基类中的同名函数(不论是重载还是同签名的函数),如果想要调用被隐藏的函数,需要显式地指明作用域:

Derived obj;obj.Base::func(); // 显式调用Base类中被隐藏的func()obj.Base::func(42); // 显式调用Base类中被隐藏的func(int)obj.func(3.14); // 调用Derived类中的func(double)

两个基类和派生类的同名函数,不构成重写就是隐藏

2.多态的原理

2.1虚函数表

class Base{ public:virtual void Func1(){ cout << "Func1()" << endl;}private:int _b = 1;};

sizeof(Base)是多少?

答案是8,我们进行测试观察:

在这里插入图片描述

除了_b成员,还多一个__vfptr放在对象的前面,对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表

用内存窗口观察:

在这里插入图片描述

它是占八个字节的

2.2多态的原理

class Person { public:virtual void BuyTicket() { cout << "买票-全价" << endl; }private:int _i = 1;};class Student : public Person { public:virtual void BuyTicket() { cout << "买票-半价" << endl; }int _j = 2;};void Func(Person* p){ p->BuyTicket();}int main(){ Person Mike;Func(&Mike);Student Johnson;Func(&Johnson);return 0;}

在这里插入图片描述

这里的指向父类调父类,指向子类调子类是怎么实现的呢? 我们进行调试

在这里插入图片描述

在这里插入图片描述

Johnson首先继承了父类的部分,有虚表和虚表指针,这两个虚表指针不一样,他们指向内容不一样,一个指向父类的Buyticket,另一个指向子类的

p是指向mike对象时,p->BuyTicket在mike的虚表中找到虚函数Person::BuyTicket

p是指向johnson对象时,p->BuyTicket在johson的虚表中找到虚函数是Student::BuyTicket

这样就实现出了不同对象去完成同一行为时,展现出不同的形态

反过来思考我们要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调用虚函数。反思一下为什么

满足多态条件,这里的调用生成的指令就会指向对象的虚表中找对应的虚函数调用

满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的

p->BuyTicket();009924E1 mov eax,dword ptr [p] 009924E4 mov edx,dword ptr [eax] 009924E6 mov esi,esp 009924E8 mov ecx,dword ptr [p] 009924EB mov eax,dword ptr [edx] 009924ED call eax 009924EF cmp esi,esp 009924F1 call __RTC_CheckEsp (09912B2h)

满足多态的情况下

p中存的是mike对象的指针,将p移动到eax中

[eax]就是取eax值指向的内容,这里相当于把mike对象头4个字节(虚表指针)移动到了edx

[edx]就是取edx值指向的内容,这里相当于把虚表中的头4字节存的虚函数指针移动到了eax

call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来以后到对象的中取找的

同类型共用一个虚表

Person Mike;Func(&Mike);Person p1;Func(&p1);

在这里插入图片描述

现在如果不满足多态呢?

我将父类进行修改

class Person { public:void BuyTicket() { cout << "买票-全价" << endl; }private:int _i = 1;};

p->BuyTicket();005B24E1 mov ecx,dword ptr [p] 005B24E4 call Person::BuyTicket (05B149Ch)

它在编译链接时就确定了

2.3单继承的虚函数

来看下面的类:

class Base { public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }private:int a = 1;};class Derive :public 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 = 2;};int main(){ Base b;Derive d;return 0;}

在这里插入图片描述

我们发现Derive少了两个虚表指针,它只有重写的func1和继承的func2,没有func3,func4,这里是监视窗口的问题

Derive 类的虚表中,会有以下指向虚函数的指针:

指向 Derive::func1 的指针 (重写了 Base::func1)指向 Base::func2 的指针 (继承自 BaseDerive 没有重写)指向 Derive::func3 的指针 (Derive 新增的虚函数)指向 Derive::func4 的指针 (Derive 新增的虚函数)

我们通过内存来确认:

在这里插入图片描述

我们不是很确认后面两个地址就是func3和func4的地址

那么我们如何查看d的虚表呢?下面我们使用代码打印出虚表中的函数

这里我们用到函数指针数组来实现:

虚函数表的本质就是函数指针数组

void(*p[10])();

这个就定义了一个函数指针数组,我们用typedef来进行优化一下:

typedef void(*VFPTR)();VFPTR p2[10];

我们定义一个打印虚表的函数

void PrintVFT(VFPTR* vft){ for (size_t i = 0; i < 4; i++){ printf("%p->", vft[i]);VFPTR pf = vft[i];(*pf)();//pf();}}

依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数

函数写好后,关键是我如何取到它的地址?

Derive d;int ptr = (int)d;

上面是不支持转换的,只有有关联的类型才能互相转换

但是,指针可以随意转换

VFPTR* ptr = (VFPTR*)(*((int*)&d)); &d 取得 d 对象的地址。(int*)&dd 对象的地址转换为 int* 类型的指针。这里假定 int 大小足够存储指针*((int*)&d) 对转换后的指针进行解引用,得到的是 d 对象内存起始处的值。由于在C++中,一个包含虚函数的对象在内存起始地址处通常存储着指向虚表的指针,因此这步操作实际上获取的是指向 Derive 虚表的指针(VFPTR*)int 类型的值强制转换为 VFPTR* 类型,也就是指向函数指针的指针。最终,ptr 就是指向 Derive 类的虚表的指针

因此,VFPTR* ptr 就是指向目标对象 d 的虚表的指针。之后调用 PrintVFT(ptr); 就可以遍历虚表中的每个条目并调用对应的函数(这里的函数都是通过函数指针 VFPTR 调用的)

在这里插入图片描述

3.抽象类

在虚函数的后面写上 =0 ,则这个函数为纯虚函数包含纯虚函数的类叫做抽象类(也叫接口类)抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承

class Car{ public:virtual void Drive() = 0;};

在这里插入图片描述

某种意义上说,抽象类强制派生类去完成重写

class Benz :public Car{ public:virtual void Drive(){ cout << "Benz-舒适" << endl;}};class BMW :public Car{ public:virtual void Drive(){ cout << "BMW-操控" << endl;}};

3.1接口继承与实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数

3.2静态多态与动态多态

静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态

3.3例题

下面函数输出结果是什么?

class A{ public:virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }virtual void test() { func(); }};class B : public A{ public:void func(int val = 0) { std::cout << "B->" << val << std::endl; }};int main(int argc, char* argv[]){ B* p = new B;p->test();return 0;}

A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确

正确答案是B

B 继承自类 A 并且 复写了 A 中的虚函数 func

首先,复写(覆盖)的本质是派生类提供基类虚函数的一个新的实现。基类中的虚函数定义了一个接口,而派生类通过覆盖这个虚函数,提供了这个接口的特定实现

当创建了派生类 B 的实例,并通过它调用 test() 时,过程如下:

test() 是在基类 A 中定义的,因此它会调用 func 时使用 A 中定义的默认参数,即 1。由于 func 是虚函数,并且我们实际上是在操作 B 类的对象,因此调用的是 B 类中覆盖的 func 版本。被调用的 B 类的 func 输出 “B->”,然后使用传递给它的参数值,此时是基类的默认参数值 1

综上所述,输出是 B->1

要明白一个重要的细节:虚函数的默认参数是静态绑定的,而非动态绑定。也就是说,虚函数的默认参数会在编译时根据函数的静态类型决定,而函数的动态类型会决定在运行时实际调用哪个版本的覆盖函数。这意味着即使 B::func 定义了一个默认值 0,在 A::test 中调用 func() 时,由于它在编译时是视为 A 类型的函数调用,所以使用的是 A::func 定义的默认参数 1。这就是为什么是 B->1 而不是 B->0

4.多继承中的虚函数表

class Base1 { public:virtual void func1() { cout << "Base1::func1" << endl; }virtual void func2() { cout << "Base1::func2" << endl; }private:int b1;};class Base2 { public:virtual void func1() { cout << "Base2::func1" << endl; }virtual void func2() { cout << "Base2::func2" << endl; }private:int b2;};class Derive : public Base1, public Base2 { public:virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func3" << endl; }private:int d1;};

在这里插入图片描述

这里有两个虚表指针,继承了两个父类,两个父类的虚表不能合在一起,这里对两张虚表都进行了重写,那么这里func3放在哪个虚表中了呢,是都放呢还是只放一个呢?

我们可以用上面的打印虚表的函数进行打印

void PrintVTable(VFPTR vTable[]){ cout << " 虚表地址>" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){ printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);VFPTR f = vTable[i];f();}cout << endl;}void test(){ Derive d;VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);PrintVTable(vTableb1);VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));PrintVTable(vTableb2);}

这里第一个虚表已经讲过,找第二个虚表先强转为char,再进行字节相加*

在这里插入图片描述

func3放入第一个虚表中

在这里插入图片描述

4.1菱形继承和菱形虚拟继承

class A{ public:virtual void func1() { cout << "A::func1" << endl; }int _a;};class B : public A//class B : virtual public A{ public:virtual void func2() { cout << "B::func2" << endl; }int _b;};class C : public A//class C : virtual public A{ public:virtual void func3() { cout << "C::func3" << endl; }int _c;};class D : public B, public C{ public:virtual void func4() { cout << "D::func4" << endl; }int _d;};int main(){ D d;cout << sizeof(d) << endl;return 0;}

在这里插入图片描述

在这里插入图片描述

菱形继承与多继承相似,d里面的虚函数放在B的虚表中

4.2菱形虚拟继承:

class B : virtual public Aclass C : virtual public A

在这里插入图片描述

在这里插入图片描述

这里除了虚表指针,还有上篇文章讲解的存储偏移量的虚基表指针

int main(){ D d;cout << sizeof(d) << endl;d.B::_a = 1;d.C::_a = 2;d._b = 3;d._c = 4;d._d = 5;return 0;}

在这里插入图片描述

在这里插入图片描述

菱形虚拟继承,每个类都有一个虚函数,这里ABC都有自己的虚表,但是BC的虚函数不能放在A的虚表中,因为这里虚基类A是共享的

子类有虚函数,继承的父类有虚函数就有虚表,子类对象中就不需要单独建立虚表

在这里插入图片描述

但是菱形虚拟继承就需要自己建立虚表,不能往父类中放

在这里插入图片描述

再看下面的代码:

class A { public:A(const char* s) { cout << s << endl; }~A() { }};class B :virtual public A{ public:B(const char* s1, const char* s2) :A(s1) { cout << s2 << endl; }};class C :virtual public A{ public:C(const char* s1, const char* s2) :A(s1) { cout << s2 << endl; }};class D :public B, public C{ public:D(const char* s1, const char* s2, const char* s3, const char* s4):B(s1, s2) //A B, C(s1, s3) //A C, A(s1) //A{ // Dcout << s4 << endl;}};int main() { D* p = new D("class A", "class B", "class C", "class D");delete p;return 0;}

当创建一个派生类的对象时,构造函数会按照特定的顺序执行,确保所有的基类和成员变量都被正确初始化。在多继承和虚继承的情况下,这个顺序变得更加复杂。上面代码涉及到虚继承,这意味着基类 A 只会有一个实例,即使它被多次包含在派生类层次结构中,在 BC

D(const char* s1, const char* s2, const char* s3, const char* s4):B(s1, s2) //A B, C(s1, s3) //A C, A(s1) //A{

D 的构造函数,我们发现它首先调用 B 的构造函数,然后是 C 的构造函数,最后调用 A 的构造函数。然而,在虚继承的情况下,共享的基类(在该例子中是 A)只会被初始化一次,而且是由最底层的派生类(D)来初始化。无论 BC 在其构造函数中怎么尝试初始化 A,它们的尝试都会被忽略

根据上述规则,执行 new D("class A", "class B", "class C", "class D"); 的过程如下:

首先,最底层的派生类 D 的构造器被调用。因为 A 是通过虚继承被 BC 继承的,所以 D 的构造器负责初始化 A。这里将输出 “class A”接下来,D 的构造器调用 B 的构造函数。虽然 B 试图先调用 A 的构造函数,但这个调用会被忽略,因为 A 已经被初始化了。然后,B 的构造器继续执行并输出 “class B”C 的构造函数也会被调用,但同样,其对 A 构造函数的调用被忽略,并且 C 的构造器继续执行,输出 “class C”最后,在 D 的构造函数中的代码执行之前,所有基类都已经初始化完成。最后输出 “class D”。

class Aclass Bclass Cclass D

所以,尽量不要写菱形虚拟继承,坑点十分多

5.虚表的存储位置

我们可以通过下面的代码来推断虚表在哪存储的:

class Person { public:virtual void BuyTicket() { cout << "买票-全价" << endl; }};class Student : public Person { public:virtual void BuyTicket() { cout << "买票-半价" << endl; }};void tese(){ int i = 0;static int j = 1;int* p1 = new int;const char* p2 = "xxxxxxxx";printf("栈:%p\n", &i);printf("静态区:%p\n", &j);printf("堆:%p\n", p1);printf("常量区:%p\n", p2);Person p;Student s;Person* p3 = &p;Student* p4 = &s;printf("Person虚表地址:%p\n", *(int*)p3);printf("Student虚表地址:%p\n", *(int*)p4);}

在这里插入图片描述

可以推断出存储位置在常量区

本节内容到此结束!!感谢大家阅读!!



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。