当前位置:首页 » 《随便一记》 » 正文

【C++】多态与虚函数:深入理解对象的动态行为(万字长文详解)

26 人参与  2024年11月03日 09:20  分类 : 《随便一记》  评论

点击全文阅读


文章目录

1.多态的概念2.多态的定义及实现2.1构成条件1.为什么 `Func(Person& p)` 可以传入 `Student` 对象 `s`?2.为什么 `Func(Person& p)` 使用引用?如果不用会怎么样? 2.2虚函数2.3虚函数的重写虚函数重写的两个例外:1.协变(基类与派生类虚函数返回值类型不同)2. 析构函数的重写(基类与派生类析构函数的名字不同)delete的工作机制 2.4重载、覆盖(重写)、隐藏(重定义)的对比2.5override、final 3.抽象类4.多态的原理——重点!4.1虚函数表4.2多态的原理4.3动态绑定和静态绑定 5.单继承和多继承关系的虚函数表5.1单继承中的虚函数表 5.2多继承中的虚函数表5.3 菱形继承、菱形虚拟继承

1.多态的概念

多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会

产生出不同的状态。

举个栗子:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人

买票时是优先买票。

2.多态的定义及实现

2.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 p;Student s;Func(p);Func(s);return 0;}

注意:

在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因

为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议

这样使用

基类函数声明为 **virtual**: 如果基类中的函数被声明为 virtual,那么所有继承这个基类的子类在重写该函数时,函数依然具有动态绑定的性质。即使子类中的函数声明中没有显式地再次加上 virtual 关键字,它依然是虚函数,仍然支持动态绑定。**子类函数可以省略 ****virtual**: 一旦基类中的函数被声明为 virtual,在继承链中,派生类对这个函数的重写都会保留虚函数的性质,无需再显式添加 virtual。换句话说,virtual 的性质是从基类传递给子类的,只要基类的函数是虚函数,派生类的重写函数就是虚函数。

1.为什么 Func(Person& p) 可以传入 Student 对象 s

在C++中,派生类(Student)的对象可以隐式转换为基类(Person)的对象,称为**“is-a”关系,即每个Student对象都是一个Person对象**。Func函数的参数是Person&,即引用基类对象。通过引用传递,我们实际上是将Student对象的地址传递给了基类的引用p。由于BuyTicket()是虚函数,实际调用的BuyTicket()版本取决于引用所指向的对象的实际类型,即Student,因此调用了Student::BuyTicket()

2.为什么 Func(Person& p) 使用引用?如果不用会怎么样?

1. 引用的意义

引用是一种别名,它指向传递进来的实际对象,没有创建新的对象,保证了函数内部对对象的操作直接作用于原始对象。在这个例子中,Person& p作为参数,p引用的是传递进来的实际对象PersonStudent,因此可以实现多态

2. 如果不用引用会怎样?
假设Func函数参数是Person而不是Person&

void Func(Person p) // 值传递{    p.BuyTicket();}
此时,传递给FuncStudent对象s会被拷贝成一个Person对象。在函数内部p是一个纯粹的Person对象,不再与原始的Student对象相关联。即使BuyTicket()是虚函数,调用的仍然是Person类的版本,因为多态机制要求引用或指针才能正常工作,无法通过值传递实现。

结果对比:

传递方式是否实现多态原因
Person& p引用指向实际对象,调用实际类型的虚函数
Person p值传递切断对象与派生类的关联,无法实现多态

2.2虚函数

虚函数:即被virtual修饰的类成员函数称为虚函数。比如上述的基类函数

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

2.3虚函数的重写

虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的

返回值类型函数名字参数列表完全相同),称子类的虚函数重写了基类的虚函数。

参数列表完全相同:包括参数的类型、数量、顺序,甚至参数的名字并不重要。

基类是int age,派生类是int cat。 可以

基类是int age,派生类是double age。 不可以

虚函数重写的两个例外:

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

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指

针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。(了解)

思考一下,代码运行结果是什么呢?

Person* p = new Student;多态的应用。p是一个Person类的指针,但是它实际指向的是Student对象。

当调用p->f()时,程序会根据p指向的实际对象(即Student对象)来调用Student类中的f(),因此输出:B::f()。Student::f()返回的是B*,符合协变的规则。

其他

关于 return new A; 的解释 new A在堆上分配一个A类型的对象,并返回指向该对象的指针return new A;的意思是返回指向新创建的A对象的指针

所以f()函数的返回值类型是A*,而new A生成一个指向A对象的指针,二者类型匹配。

Student类中,return new B;返回了一个B*,由于B继承自A,这符合返回类型协变。

p->f();

->在C++中是指针访问运算符,不仅仅用于结构体,还用于指向类对象的指针来访问成员函数和成员变量。

2. 析构函数的重写(基类与派生类析构函数的名字不同)

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

先看看基类析构函数不加virtual的情况

打印结果为:

~Person()

~Person()

会有内存泄漏的问题,student没有调到自己的析构函数。

delete p1:
p1 是一个指向 Person 对象的指针,指向的是堆上的 Person 实例。
当执行 delete p1; 时,C++ 会调用 Person 类的析构函数,输出 ~Person()。
这是预期的行为,没有问题。delete p2(这里是问题的关键):
p2 是一个指向 Student 对象的 Person* 类型指针,即:p2 指向的是一个 Student 对象,但它被存储为 Person*。
当你执行 delete p2; 时,由于 Person 的析构函数不是虚函数,C++ 只会调用 Person 类的析构函数,而不会调用 Student 类的析构函数。这就导致了Student 部分的对象不会被正确析构,引发了潜在的资源泄漏问题。
因此,delete p2; 只会输出 ~Person(),但并不会输出 ~Student(),因为 Student 类的析构函数没有被调用。

加上virtual之后,可以正确析构。

delete的工作机制

在理解 delete 的工作机制时,关键在于两件事:

析构函数的调用

内存释放的顺序

调用析构函数

当我们使用 delete 来销毁对象时,C++ 会首先调用对象的析构函数。如果对象的指针类型是基类指针,且基类的析构函数不是虚函数,C++ 只会调用基类的析构函数,即静态绑定(普通调用)。这种情况下,派生类的析构函数不会被调用,导致派生类中分配的资源无法被释放(这就是为什么不使用虚析构函数会造成内存泄漏的原因)。

例如,在你提到的代码中:

Person* p2 = new Student;delete p2;  // 错误行为(如果没有虚析构函数)

如果基类的析构函数不是虚函数,delete p2; 只会调用 Person 的析构函数,而不会调用 Student 的析构函数,这会导致 Student 类中的资源没有被正确释放。

operator delete 的内存释放

在调用析构函数之后,delete 操作符会调用 operator delete(ptr) 来释放对象所占用的内存。内存释放是根据对象的动态类型来决定的——也就是说,即使是基类指针,只要对象是派生类,在析构函数调用后,最终的内存释放操作也会正确进行。

多态析构函数的解决方案——虚析构函数

为了解决以上问题,需要将基类的析构函数声明为虚函数。这样做的目的是实现析构函数的多态调用,确保删除派生类对象时,即使使用基类指针,派生类的析构函数也会被调用。

class Person {public:    virtual ~Person() {        cout << "~Person()" << endl;    }};class Student : public Person {public:    virtual ~Student() {        cout << "~Student()" << endl;    }};

在这个版本中,Person 的析构函数是虚函数,因此当 delete 操作发生时,会触发多态调用,即根据对象的实际类型(而不是指针类型)调用正确的析构函数。

虚析构函数的工作原理: 编译器会为类生成一个虚函数表(vtable),其中存放了指向虚函数的指针。当对象的析构函数被调用时,如果析构函数是虚函数,编译器会查找虚函数表,以便根据对象的动态类型调用合适的析构函数。如果对象的实际类型是 Student,那么 delete p2; 会调用 Student 的析构函数,而不是 Person 的析构函数。然后 Student 的析构函数调用完后,还会自动调用基类 Person 的析构函数,确保基类和派生类的资源都得到正确的释放。

delete 的两步操作:

析构对象: 如果基类有虚析构函数,C++ 会根据指针所指向对象的动态类型(如 Student),调用派生类的析构函数,并且在派生类的析构函数执行后,自动调用基类的析构函数。如果基类没有虚析构函数,只会调用基类的析构函数,不管对象实际是什么类型(即使是派生类对象)。 释放内存: 析构函数调用完毕后,delete 会调用 operator delete(ptr) 释放对象占用的内存。

特殊情况:编译器对析构函数的处理

虽然析构函数看起来像普通的成员函数,但是编译器对析构函数进行了特殊处理。当析构函数是虚函数时,编译器会将它加入虚函数表(vtable)中。虽然析构函数的名字在代码中不同(如 ~Person~Student),但编译器会将这些析构函数“隐藏”在虚函数表中,调用时根据对象的实际类型决定调用哪个析构函数。这就是为什么看起来析构函数违反了重写的规则(析构函数名称不同),但实际上是编译器进行了特别的处理。

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

重载:

函数名相同参数列表必须不同(包括参数类型、个数或顺序)。与返回值类型无关
class Example {public:void func(int a) {     cout << "func(int a) called" << endl;}void func(double a) {     cout << "func(double a) called" << endl;}void func(int a, double b) {     cout << "func(int a, double b) called" << endl;}};

重写(覆盖)

继承关系:发生在基类和派生类之间。基类函数必须是虚函数virtual)。派生类的函数名、参数列表、返回类型必须完全相同。通过基类指针或引用来实现多态调用。
class Base {public:    virtual void show() {         cout << "Base class show()" << endl;     }};class Derived : public Base {public:    void show() override {  // 覆盖基类的虚函数        cout << "Derived class show()" << endl;     }};

重定义(隐藏)

函数名相同参数列表可以相同或不同基类函数不需要是虚函数(但也可以是虚函数)。即使参数列表不同,只要函数名相同,基类函数会被隐藏。
class Base {public:    void show() {         cout << "Base class show()" << endl;     }};class Derived : public Base {public:    void show(int a) {   // 隐藏了基类的 show()        cout << "Derived class show(int a)" << endl;     }};int main() {    Derived d;    d.show(10);  // 调用 Derived::show(int)    d.show();    // 错误:无法调用基类的 show(),基类函数被隐藏        d.Base::show();  // 调用基类的 show()    return 0;}
class Base {public:    virtual void show() {         cout << "Base class show()" << endl;     }};class Derived : public Base {public:    void show() {   // 非虚函数,隐藏了基类虚函数        cout << "Derived class show()" << endl;     }};int main() {    Base* basePtr = new Derived;    basePtr->show();  // 调用 Base::show(),隐藏导致无法多态调用    delete basePtr;    return 0;}

2.5override、final

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

final:修饰虚函数,表示该虚函数不能再被重写
class Car{public:    virtual void Drive() final // 该函数不能在派生类中被重写    {        cout << "Car-驾驶" << endl;    }};class Benz : public Car{public:    // 这里的代码将会报错,因为基类的 Drive() 被 final 修饰,不能重写    virtual void Drive()    {        cout << "Benz-舒适" << endl;    }};
override

override 用于修饰派生类中的虚函数,明确表示这个函数是用来重写基类中的某个虚函数的。如果派生类中的函数与基类的虚函数不匹配(例如函数名或参数列表不同),编译器会报错。这在防止由于拼写错误或参数错误而导致的意外错误时非常有用。

class Car{public:    virtual void Drive() // 基类中的虚函数    {        cout << "Car-驾驶" << endl;    }};class Benz : public Car{public:    virtual void Drive() override // 使用 override 明确指出是在重写基类的函数    {        cout << "Benz-舒适" << endl;    }};class Benz : public Car{public:    // 编译器会报错,因为没有重写基类的虚函数(函数名拼错了)    virtual void Dive() override    {        cout << "Benz-潜水" << endl;    }};

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;//}};int main(){Car mycar; // 错误:不能实例化抽象类Car* pBenz = new Benz;  // 实例化 Benz 对象pBenz->Drive();         // 调用 Benz 的 Drive 函数,输出 "Benz-舒适"Car* pBMW = new BMW;// 错误:Benz 仍然是抽象类pBMW->Drive();    return 0;}

接口继承和实现继承

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

普通函数继承:实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数的具体实现。如果派生类没有重新定义这个函数,它将直接使用基类中的实现。

例子:

#include <iostream>using namespace std;class Car {public:    void Start() {        cout << "Car is starting..." << endl;    }};class Benz : public Car {    // 没有重写 Start 函数,直接继承了基类 Car 的实现};int main() {    Benz myBenz;    myBenz.Start();  // 输出:Car is starting...    return 0;}

解释:

在这个例子中,Benz 类没有重写 Start 函数,所以直接继承了 Car 类的 Start 函数实现。调用 myBenz.Start() 时,输出的结果是基类中的实现。这种情况适用于我们希望子类能直接使用基类已有的实现,而不需要任何修改。 虚函数继承:接口继承

虚函数的继承是一种接口继承,基类提供的是一个接口,而派生类的目的是重写这个接口以实现不同的行为。这样做的目的是为了实现多态,即使指针或引用指向基类对象,仍然可以根据实际对象的类型调用正确的重写函数。

例子:

#include <iostream>using namespace std;class Car {public:    virtual void Start() {        cout << "Car is starting..." << endl;    }};class Benz : public Car {public:    void Start() override {        cout << "Benz is starting in comfort mode..." << endl;    }};int main() {    Car* car1 = new Car();    Car* car2 = new Benz();    car1->Start();  // 输出:Car is starting...    car2->Start();  // 输出:Benz is starting in comfort mode...    delete car1;    delete car2;    return 0;}

解释:

这里,Car 类的 Start 函数是虚函数,表示派生类可以重写它。Benz 类重写了 Start 函数,提供了自己的实现。当我们使用 Car* car2 = new Benz() 时,尽管 car2 是一个指向 Car 的指针,但因为 Start 是虚函数,调用时会根据实际对象类型(即 Benz)调用 Benz 的实现。这就是多态的体现。如果不需要多态行为,使用普通函数即可,但当我们需要不同的对象类型有不同的行为表现时,虚函数是必要的。

4.多态的原理——重点!

4.1虚函数表

先来看一道题

// 这里常考一道笔试题:sizeof(Base)是多少?class Base{public:virtual void Func1(){cout << "Func1()" << endl;}private:int _b = 1;};int main(){Base b;cout << sizeof(Base) << endl;return 0;}

答案是:8

通过观察测试我们发现b对象是8bytes,除了_b成员,还多一个__vfptr放在对象的前面(注意有些

平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代

表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数

的地址要被放到虚函数表中,虚函数表也简称虚表,

看看下面的代码

class Base{public:virtual void Func1(){cout << "Base::Func1()" << endl;}virtual void Func2(){cout << "Base::Func2()" << endl;}void Func3(){cout << "Base::Func3()" << endl;}private:int _b = 1;};class Derive : public Base{public:virtual void Func1(){cout << "Derive::Func1()" << endl;}private:int _d = 2;};int main(){Base b;Derive d;return 0;}

通过观察和测试,我们发现了以下几点问题:

派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚
表指针也就是存在这部分的,另一部分是自己的成员。基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表
中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数
的覆盖。重写是语法的叫法,覆盖是原理层的叫法。另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函
数,所以不会放进虚表。虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。总结一下派生类的虚表生成: 先将基类中的虚表内容拷贝一份到派生类虚表中如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。 这里还有一个童鞋们很容易混淆的问题:虚函数存在哪的?虚表存在哪的? 答:虚函数存在
虚表,虚表存在对象中。注意上面的回答的错的。但是很多童鞋都是这样深以为然的。注意
虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是
他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的
呢?实际我们去验证一下会发现vs下是存在代码段的,

4.2多态的原理

class Base{public:virtual void Func1(){cout << "Base::Func1()" << endl;}virtual void Func2(){cout << "Base::Func2()" << endl;}void Func3(){cout << "Base::Func3()" << endl;}private:int _b = 1;char _ch;};class Derive :public Base{public:virtual void Func1(){cout << "Derive::Func1()" << endl;}void Func3(){cout << "Derive::Func3()" << endl;}private:int _d = 2;};int main(){Base b;Derive d;//普通调用——编译时/静态 绑定Base* ptr = &b;ptr->Func3();ptr = &d;ptr->Func3();//多态调用——运行时/动态 绑定ptr = &b;ptr->Func1();ptr = &d;ptr->Func1();return 0;}
普通调用:静态绑定

静态绑定是编译时确定的绑定方式,函数调用的目标函数在编译时就已经确定,这种调用方式通常针对非虚函数。

Base* ptr = &b;ptr->Func3();  // 调用Base::Func3()ptr = &d;ptr->Func3();  // 调用Base::Func3()

解释:

这里的 Func3()非虚函数,所以它属于静态绑定。也就是说,无论 ptr 指向的是 Base 类型对象还是 Derive 类型对象,调用的都是基类 Base 中的 Func3()。原因是 Func3() 没有声明为 virtual 函数,C++ 会在编译阶段决定应该调用 Base 类的实现,而不考虑 ptr 指向的实际对象类型。

总结:

静态绑定在编译时确定,函数调用和指针类型相关,和指针实际指向的对象类型无关。非虚函数都属于静态绑定。

2. 多态调用:动态绑定

动态绑定是通过 虚函数 实现的,这种绑定发生在运行时,函数调用的目标函数根据指针或引用实际指向的对象类型决定。

ptr = &b;ptr->Func1();  // 调用Base::Func1()ptr = &d;ptr->Func1();  // 调用Derive::Func1()

解释:

Func1() 是虚函数(virtual),这意味着它属于动态绑定。调用时,函数的选择是在运行时决定的,而不是在编译时决定的。当 ptr 指向 Base 对象时,调用 Base::Func1()。当 ptr 指向 Derive 对象时,调用 Derive::Func1()

这就是多态的核心——虽然 ptr 的类型是 Base*,但是当它指向 Derive 对象时,调用的是 Derive::Func1()。这是因为 Func1() 是一个虚函数,C++ 会根据指针实际指向的对象类型来调用相应的函数实现。

虚函数表(vtable)机制:

当类中定义了虚函数时,编译器会为该类生成一个虚函数表(vtable)。虚函数表是一个函数指针数组,指向类中的虚函数实现。每个带有虚函数的对象实例都有一个指向虚函数表的指针(称为vptr)。当通过指针调用虚函数时,程序会通过这个 vptr 查找对应的虚函数表,再找到实际要调用的函数。Base 类有自己的虚函数表,指向 Base::Func1()Base::Func2()Derive 类也有自己的虚函数表,Func1() 被重写了,所以 Derive 的虚函数表中对应的位置指向 Derive::Func1(),而 Func2() 仍然指向 Base::Func2(),因为 Derive 没有重写它。

代码执行过程:

在这个例子中,当执行 ptr->Func1() 时,C++ 首先会检查 ptr 指向的对象类型,根据对象的 vptr 查找虚函数表,从而调用正确的函数。假如 ptr 指向 Base 对象,调用的是 Base::Func1();假如 ptr 指向 Derive 对象,则调用的是 Derive::Func1()

代码输出:
Base* ptr = &b;ptr->Func3();  // 输出: Base::Func3()ptr = &d;ptr->Func3();  // 输出: Base::Func3()ptr = &b;ptr->Func1();  // 输出: Base::Func1()ptr = &d;ptr->Func1();  // 输出: Derive::Func1()

4.3动态绑定和静态绑定

静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,
比如:函数重载动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体
行为,调用具体的函数,也称为动态多态。本小节之前(5.2小节)买票的汇编代码很好的解释了什么是静态(编译器)绑定和动态(运行时)绑
定。

5.单继承和多继承关系的虚函数表

5.1单继承中的虚函数表

来看下面的类:

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的虚表呢?下面我们使用代码打印出虚表中的函数

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

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

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

typedef void(*VFPTR)();VFPTR p2[10];
代码定义了一个类型 VFPTR,它是一个函数指针类型,该指针指向返回类型为 void,且没有参数的函数。 void(*)() 表示一个指向函数的指针,这个函数没有参数,返回值为 voidVFPTR 作为该函数指针的别名,之后可以使用 VFPTR 来代替 void(*)(),简化代码的书写。 定义了一个函数指针数组 p2,其中可以存储 10 个函数指针。数组中的每个元素都是 VFPTR 类型的函数指针,也就是说,p2 数组的每个元素都指向一个返回 void 且没有参数的函数

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

void PrintVFT(VFPTR* vft){for (size_t i = 0; i < 4; i++){printf("%p->", vft[i]);VFPTR pf = vft[i];(*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 调用的)

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

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);}

5.2多继承中的虚函数表

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放入第一个虚表中

5.3 菱形继承、菱形虚拟继承

class A{public:virtual void func1() { cout << "A::func1" << endl; }int _a;};class B : public A{public:virtual void func2() { cout << "B::func2" << endl; }int _b;};class C : 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的虚表中

菱形虚拟继承

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)  , C(s1, s3)  , A(s1)      {cout << 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)  , C(s1, s3)  , A(s1)      {

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

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

首先,最底层的派生类 D 的构造器被调用。因为 **A** 是通过虚继承被 **B****C** 继承的,所以 **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

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

实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的
模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看
了,一般我们也不需要研究清楚,因为实际中很少用。如果好奇心比较强的宝宝,可以去看下面
的两篇链接文章。

C++ 虚函数表解析 | 酷 壳 - CoolShellC++ 对象的内存布局 | 酷 壳 - CoolShell

在这里插入图片描述


? [ 声明 ] 由于作者水平有限,本文有错误和不准确之处在所难免,本人也很想知道这些错误,恳望读者批评指正!我是:勇敢滴勇~感谢大家的支持!

点击全文阅读


本文链接:http://zhangshiyu.com/post/181742.html

<< 上一篇 下一篇 >>

  • 评论(0)
  • 赞助本站

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

关于我们 | 我要投稿 | 免责申明

Copyright © 2020-2022 ZhangShiYu.com Rights Reserved.豫ICP备2022013469号-1