问小白 wenxiaobai
资讯
历史
科技
环境与自然
成长
游戏
财经
文学与艺术
美食
健康
家居
文化
情感
汽车
三农
军事
旅行
运动
教育
生活
星座命理

C++ 中多态的实现原理-虚函数表详解

创作时间:
作者:
@小白创作中心

C++ 中多态的实现原理-虚函数表详解

引用
CSDN
1.
https://blog.csdn.net/weixin_43903639/article/details/138971625

多态(Polymorphism)是面向对象编程中的一个重要概念,它允许以统一的方式处理不同类型和形态的对象。在C++中,多态性通过虚函数(virtual functions)来实现。虚函数的实现就必须依赖虚函数表。本文将详细介绍C++中多态的实现原理,特别是虚函数表的概念,并通过多个代码示例来说明这些概念。

一、什么是多态

1.1、多态性的类型

  • 编译时多态(Compile-time Polymorphism):也称为静态多态性,是在编译时确定的多态性。C++中的函数重载和模板(Templates)是编译时多态的例子。函数重载允许定义多个同名函数,但它们的参数列表不同,编译器根据参数类型或个数来决定调用哪个函数。模板允许编写通用的代码,以适用于不同类型的数据。

  • 运行时多态(Run-time Polymorphism):也称为动态多态性,是在运行时确定的多态性。C++中的虚函数和继承是运行时多态的例子。虚函数允许在基类中定义一个接口,并在派生类中重新实现该接口,运行时根据对象的实际类型来调用相应的函数。

1.2、虚函数及其实现

  • 在基类中,使用 virtual 关键字声明虚函数。
  • 派生类可以选择性地覆盖(override)基类的虚函数,使用 override 关键字来明确指示这一点。
  • 调用虚函数时,实际调用的是对象的实际类型所对应的函数,而不是指针或引用的类型。
#include <iostream>
class Animal {
public:
    virtual void makeSound() const {
        std::cout << "Some generic sound\n";
    }
};
class Dog : public Animal {
public:
    void makeSound() const override {
        std::cout << "Woof!\n";
    }
};
class Cat : public Animal {
public:
    void makeSound() const override {
        std::cout << "Meow!\n";
    }
};
int main() {
    Animal* ptr1 = new Dog();
    Animal* ptr2 = new Cat();
    ptr1->makeSound();  // 输出 "Woof!"
    ptr2->makeSound();  // 输出 "Meow!"
    delete ptr1;
    delete ptr2;
    return 0;
}

在这个示例中,Animal 类有一个虚函数 makeSound()DogCat 类都覆盖了这个函数。在 main() 函数中,我们使用基类指针指向派生类对象,并调用虚函数 makeSound(),实际上根据对象的实际类型调用了相应的函数,实现了多态性。

二、虚函数的实现-虚函数表

2.1、零成本抽象

先讨论一点C++的设计思想,0成本抽象

2.1.1、数据在类中存放

对于下面的这个类:

class A {
public:
    int x;
};

这个类的大小为4,也就是一个int值的大小,我们在跑这个类,等同于在跑一个单独的int。可以验证一下:

int main() {
    cout << sizeof(A) << endl;
    A a;
    int* p = (int*)&a;
    *p = 23333;
    cout << a.x << endl;
    return 0;
}

站在汇编的角度更好理解:

所以,类这个概念,只存在于编译期,数据就像C语言中结构体那样,存放在内存的一个区域而已,这也是为什么我们可以写出修改私有变量的代码的原因。

class A {
    int x;
public:
    int get_x() {
        return x;
    }
};
int main() {
    cout << sizeof(A) << endl;
    A a;
    int* p = (int*)&a;
    *p = 23333;
    cout << a.get_x() << endl;
}

而且,可以发现,函数不占类的空间。对于有继承关系的类:

class A {
public:
    int x, y;
    void show() { cout << "show" << endl; }
};
class B :public A {
public:
    int z;
};
int main(){
    cout << sizeof(A) << endl;
    cout << sizeof(B) << endl;
    return 0;
}

函数也是不占用空间的,数据会有一份数据的拷贝。

2.1.2、函数在类中存放

我们写个带虚函数的类:

class A {
public:
    virtual void a() { cout << "A a()" << endl; }
    virtual void b() { cout << "A b()" << endl; }
    virtual void c() { cout << "A c()" << endl; }
};

这个类的大小是8,很怪是吧,我使用的是64位系统,这里最容易想到的就是,这个类中存放了一个指针。实际上类A的内存模型为:

我们可以拿到指向中间表的指针,然后使用函数指针直接调用函数

typedef long long u64;
typedef void(*func)();
int main(){
    A a;
    u64* p = (u64*)&a;
![](https://wy-static.wenxiaobai.com/chat-rag-image/1656662766276030720)
    u64* arr = (u64*)*p;
    func fa = (func)arr[0];
    func fb = (func)arr[1];
    func fc = (func)arr[2];
    fa(); fb(); fc();
    return 0;
}

如果是两个对象,函数也不会拷贝,中间的对应表也不会拷贝,其结构如下

如果存在继承关系的两个对象,例如:

class B :public A {
public:
    virtual void b() { cout << "B b()" << endl; }
};

多生成一些B,让父类指针指向子类对象。

class B :public A {
public:
    virtual void b() { cout << "B b()" << endl; }
};
A* a1 = new A;
A* a2 = new A;
A* b1 = new B;
B* b2 = new B;
cout << hex << *(u64*)a1 << endl;
cout << hex << *(u64*)a2 << endl;
cout << hex << *(u64*)b1 << endl;
cout << hex << *(u64*)b2 << endl;

a1 和 a2 ,b1 和 b2 的表指向的地址是一样的,画成图就是下面的样子

2.1.3、花活

其实上面的中间表就是虚函数表,明白了编译器是怎么操作虚函数的,我们就可以整个花活,生成一个类C,让C的虚函数指针指向类A的虚函数表。

class A {
public:
    virtual void a() { cout << "A a()" << endl; }
    virtual void b() { cout << "A b()" << endl; }
    virtual void c() { cout << "A c()" << endl; }
};
class C {
![](https://wy-static.wenxiaobai.com/chat-rag-image/4630495161822264236)
public:
    virtual void a() { cout << "C a()" << endl; }
    virtual void b() { cout << "C b()" << endl; }
    virtual void c() { cout << "C c()" << endl; }
};
int main() {
    A* a = new A;
    C* c = new C;
    *(u64*)c = *(u64*)a;
    c->a(); c->b(); c->c();
}

由于对象c的虚函数表指向一开始为黑色,后面通过 *(u64*)c = *(u64*)a; 变为了红色,所以再调用相应的函数就成了调用类A中的相应函数。编译器只知道函数 a() 需要去找 arr[0],但是数组变成什么样子就由不得编译器了。

2.2、虚表

虚表属于动态绑定技术,每个包含了虚函数的类都包含一个虚表。 虚表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。 为了指定对象的虚表,对象内部包含一个虚表的指针,来指向自己所使用的虚表。为了让每个包含虚表的类的对象都拥有一个虚表指针,编译器在类中添加了一个指针,__vptr,用来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表。

非虚函数的调用不用经过虚表,故不需要虚表中的指针指向这些函数。

  • 为什么父类指针指向子类对象时,父类指针只能调用子类中的父类部分,但是还可以调用子类中的虚函数
  • 因为子类中的虚函数是通过虚表指针进行调用的,而虚表指针也是父类的一部分

其实上述函数在类中的存放已经解释了什么时动态绑定,为什么父类指针可以调用子类函数。这里不再赘述。

三、一些面试常见问题

3.1、实现多态的两种方式

  • 覆盖(override): ⼦类重新定义⽗类的虚函数
  • 重载(overload): 是指允许存在多个同名函数,⽽这些函数的参数表不同

3.2、多态的作用

  • 同⼀事物表现出不同事物的能⼒,即向不同对象发送同⼀消息,不同的对象在接收时会产⽣不同的⾏为(重载实现 编译时多态,虚函数实现运⾏时多态)

3.3、inline 函数可以是虚函数吗

  • 虚函数和 inline 函数之间存在一些矛盾。inline 函数的特性是在编译时展开,而虚函数的调用是在运行时动态确定的。将虚函数声明为 inline 可能会导致一些问题。因为虚函数的动态调度特性可能会使 inline 失效。对于普通调用,inline 特性会起作用,对于多态调用,inline 不起作用。

3.4、静态函数可以是虚函数吗

  • 不能,因为他没有this指针

3.5、构造函数可以是虚函数吗

  • 不能,因为虚函数要到虚表去找到虚函数指针再进行函数调用,调用构造函数之前是没有生成虚表的

3.6、析构函数可以是虚函数吗

  • 可以,甚至有一个场景必须是虚函数,否则的话无法正确释放指针

  • Person p = new Person;
    delete p;
    p = new Child;
    delete p;
    

3.7、对象访问普通函数快还是虚函数更快

  • 首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。

3.8、虚表在什么阶段生成

  • 在编译阶段生成,一般存放在代码段(常量区)

3.9、什么是抽象类?抽象类的作用?

  • 带有纯虚函数的类为抽象类
  • 抽象类的子类必须重写纯虚函数,抽象类体现出了接口的继承关系,只提供接口不提供实现。

3.10、什么是多态

  • 用一个名字定义多个函数,这些函数执行不同但相似的工作。最简单的多态性的实现方式就是函数重载和模板,这两种属于静态多态性。还有一种是动态多态性,其实现方式就是继承虚函数重写。

3.11、什么是纯虚函数

  • 虚函数和纯虚函数两者的区别在于纯函数尚未被实现,定义纯虚函数是为了实现一个接口。在基类中定义纯虚函数的方法是在函数原型后加=0

3.12、为什么默认的析构函数不是虚函数

  • 既然基类的析构函数如此有必要被定义成虚函数,为何类的默认析构函数却是非虚函数呢?
  • 因为虚函数不同于普通成员函数,当类中有虚成员函数时,类会自动进行一些额外工作。这些额外的工作包括生成虚函数表和虚表指针,虚表指针指向虚函数表。这样一来,就会占用额外的内存,当们定义的类不被其他类继承时,这种内存开销无疑是浪费的。

3.13、如何让父类的虚函数无法被重写

  • 使用final关键字
© 2023 北京元石科技有限公司 ◎ 京公网安备 11010802042949号
C++ 中多态的实现原理-虚函数表详解