MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++多态底层虚函数表的原理

2023-09-093.7k 阅读

C++ 多态与虚函数表基础概念

多态性概述

在 C++ 中,多态性是面向对象编程的重要特性之一。它允许我们以统一的方式处理不同类型的对象,使得代码更加灵活和可维护。多态性主要通过虚函数(virtual function)和指针或引用(pointer/reference)来实现。当我们使用基类的指针或引用调用虚函数时,实际调用的函数取决于指针或引用所指向的对象的实际类型,而不是指针或引用本身的类型。这种机制使得我们能够根据对象的实际类型来执行不同的行为,实现运行时多态。

虚函数的定义与使用

在 C++ 中,我们通过在函数声明前加上 virtual 关键字来将一个函数定义为虚函数。例如:

class Animal {
public:
    virtual void speak() {
        std::cout << "Animal speaks" << std::endl;
    }
};

class Dog : public Animal {
public:
    void speak() override {
        std::cout << "Dog barks" << std::endl;
    }
};

class Cat : public Animal {
public:
    void speak() override {
        std::cout << "Cat meows" << std::endl;
    }
};

在上述代码中,Animal 类中的 speak 函数被声明为虚函数。DogCat 类继承自 Animal 类,并对 speak 函数进行了重写(override)。这里需要注意的是,C++11 引入了 override 关键字,用于明确标识子类中重写的虚函数,有助于避免因函数签名不一致而导致的意外错误。

我们可以通过以下方式来使用多态:

int main() {
    Animal* animal1 = new Dog();
    Animal* animal2 = new Cat();

    animal1->speak();
    animal2->speak();

    delete animal1;
    delete animal2;
    return 0;
}

main 函数中,我们创建了 DogCat 对象,并通过 Animal 类型的指针来调用 speak 函数。实际调用的是 DogCat 类中重写的 speak 函数,而不是 Animal 类中的 speak 函数,这就是多态性的体现。

虚函数表概念引入

虚函数表(Virtual Table,简称 vtable)是 C++ 实现多态性的关键底层机制。当一个类中定义了虚函数时,编译器会为这个类生成一个虚函数表。虚函数表本质上是一个函数指针数组,其中每个元素都是一个指向该类虚函数的指针。对于每个包含虚函数的类对象,编译器会在对象的内存布局中添加一个隐藏的指针,称为虚函数表指针(Virtual Table Pointer,简称 vptr),它指向该类的虚函数表。

虚函数表的内存布局

单一继承下的虚函数表布局

以之前定义的 AnimalDogCat 类为例,我们来分析单一继承情况下虚函数表的内存布局。假设 Animal 类的内存布局如下(为简化说明,暂不考虑其他成员变量):

+----------------+
| vptr           |
+----------------+

其中 vptr 指向 Animal 类的虚函数表,该虚函数表的布局可能如下:

+----------------+
| &Animal::speak |
+----------------+

Dog 类继承自 Animal 类时,Dog 类的内存布局会在 Animal 类的基础上扩展,如下:

+----------------+
| vptr           |
+----------------+

这里的 vptr 指向 Dog 类的虚函数表,由于 Dog 类重写了 speak 函数,Dog 类的虚函数表布局如下:

+----------------+
| &Dog::speak    |
+----------------+

同理,Cat 类继承自 Animal 类时,Cat 类的内存布局为:

+----------------+
| vptr           |
+----------------+

Cat 类的虚函数表布局为:

+----------------+
| &Cat::speak    |
+----------------+

当我们通过 Animal 类型的指针调用 speak 函数时,实际上是通过指针所指向对象的 vptr 找到对应的虚函数表,然后根据虚函数表中存储的函数指针来调用实际的函数。

多重继承下的虚函数表布局

多重继承是指一个类从多个基类继承而来。例如:

class Shape {
public:
    virtual void draw() {
        std::cout << "Shape is drawn" << std::endl;
    }
};

class Color {
public:
    virtual void setColor() {
        std::cout << "Color is set" << std::endl;
    }
};

class ColoredShape : public Shape, public Color {
public:
    void draw() override {
        std::cout << "ColoredShape is drawn" << std::endl;
    }
    void setColor() override {
        std::cout << "ColoredShape color is set" << std::endl;
    }
};

在多重继承情况下,ColoredShape 类的内存布局会更加复杂。它会包含从 Shape 类和 Color 类继承来的 vptr,其内存布局大致如下:

+----------------+
| vptr (from Shape)|
+----------------+
| vptr (from Color)|
+----------------+

ColoredShape 类对应的虚函数表布局如下:

  • 对应 Shape 类的虚函数表:
+----------------+
| &ColoredShape::draw |
+----------------+
  • 对应 Color 类的虚函数表:
+----------------+
| &ColoredShape::setColor |
+----------------+

当通过 Shape 类型的指针调用 draw 函数时,会通过 vptr (from Shape) 找到对应的虚函数表并调用 ColoredShape::draw 函数;当通过 Color 类型的指针调用 setColor 函数时,会通过 vptr (from Color) 找到对应的虚函数表并调用 ColoredShape::setColor 函数。

菱形继承下的虚函数表布局

菱形继承是多重继承的一种特殊情况,也称为钻石继承。例如:

class A {
public:
    virtual void func() {
        std::cout << "A's func" << std::endl;
    }
};

class B : public A {
public:
    void func() override {
        std::cout << "B's func" << std::endl;
    }
};

class C : public A {
public:
    void func() override {
        std::cout << "C's func" << std::endl;
    }
};

class D : public B, public C {
public:
    void func() override {
        std::cout << "D's func" << std::endl;
    }
};

在菱形继承中,D 类会从 BC 间接继承 A 类的成员,这可能会导致数据冗余和二义性问题。对于虚函数表,D 类的内存布局和虚函数表布局如下: D 类的内存布局大致如下:

+----------------+
| vptr (from B)  |
+----------------+
| ... (B's data) |
+----------------+
| vptr (from C)  |
+----------------+
| ... (C's data) |
+----------------+
  • 对应 B 类的虚函数表:
+----------------+
| &D::func       |
+----------------+
  • 对应 C 类的虚函数表:
+----------------+
| &D::func       |
+----------------+

这里需要注意,为了解决菱形继承中的数据冗余和二义性问题,C++ 引入了虚继承(virtual inheritance)。当使用虚继承时,虚基类的成员在最终派生类中只会存在一份,并且虚函数表的布局也会有所不同,但原理类似,都是通过 vptr 找到对应的虚函数表来实现多态。

虚函数表与运行时多态的实现

运行时多态的原理

在 C++ 中,运行时多态的实现依赖于虚函数表和 vptr。当我们定义一个基类指针并使其指向派生类对象时,通过该指针调用虚函数的过程如下:

  1. 首先,根据指针所指向对象的内存布局,找到对象的 vptr
  2. 然后,通过 vptr 找到对应的虚函数表。
  3. 最后,根据虚函数在虚函数表中的索引,找到实际要调用的函数指针并执行该函数。

以之前 AnimalDogCat 类的例子来说明,当执行 animal1->speak(); 时:

  1. animal1Animal 类型的指针,它指向 Dog 对象。Dog 对象的内存布局中包含 vptr
  2. 通过 vptr 找到 Dog 类的虚函数表。
  3. Dog 类的虚函数表中,找到 speak 函数的指针并调用,从而执行 Dog::speak 函数。

编译器在运行时多态中的作用

编译器在实现运行时多态过程中扮演着重要角色。编译器负责生成虚函数表和 vptr,并在编译阶段确定虚函数在虚函数表中的索引。同时,编译器会根据对象的类型生成相应的代码来通过 vptr 找到虚函数表并调用正确的函数。

例如,对于以下代码:

Animal* animal = new Dog();
animal->speak();

编译器会生成类似于以下的汇编代码(简化示意):

; 分配内存并创建 Dog 对象
mov eax, sizeof(Dog)
call operator new

; 将返回的指针赋值给 animal
mov dword ptr [animal], eax

; 获取 vptr
mov eax, dword ptr [animal]
mov eax, dword ptr [eax]

; 获取 speak 函数指针
mov eax, dword ptr [eax]

; 调用 speak 函数
call eax

从上述汇编代码可以看出,编译器通过 vptr 找到虚函数表,并根据虚函数表中的函数指针来调用实际的函数,从而实现运行时多态。

虚函数表的深入探讨

虚函数表与静态绑定和动态绑定

在 C++ 中,函数调用存在静态绑定(Static Binding)和动态绑定(Dynamic Binding)两种方式。静态绑定是指在编译阶段就确定要调用的函数,而动态绑定是指在运行阶段根据对象的实际类型来确定要调用的函数。

普通函数调用是静态绑定的,例如:

class Math {
public:
    void add(int a, int b) {
        std::cout << "Result: " << a + b << std::endl;
    }
};

Math m;
m.add(2, 3);

在上述代码中,调用 m.add(2, 3) 时,编译器在编译阶段就确定了要调用 Math::add 函数,这就是静态绑定。

而虚函数调用是动态绑定的,通过基类指针或引用调用虚函数时,实际调用的函数取决于对象的实际类型,这就是运行时多态,也是动态绑定的体现。虚函数表在动态绑定中起到关键作用,它使得程序能够在运行时根据对象的实际类型找到正确的函数。

虚函数表与对象生命周期

虚函数表与对象的生命周期密切相关。当一个对象被创建时,编译器会为其分配内存并初始化 vptr,使其指向该类的虚函数表。当对象被销毁时,vptr 也会随着对象内存的释放而消失。

例如,对于以下代码:

Animal* animal = new Dog();
delete animal;

当执行 new Dog() 时,会为 Dog 对象分配内存,并初始化 vptr 指向 Dog 类的虚函数表。当执行 delete animal 时,Dog 对象的内存被释放,vptr 也不再存在。

需要注意的是,在对象的构造和析构过程中,虚函数的行为与正常情况下有所不同。在构造函数中,虚函数不会表现出多态性,因为在构造函数执行时,对象的实际类型还没有完全确定。同样,在析构函数中,虚函数也不会表现出多态性,以避免在对象部分销毁时调用错误的虚函数。

虚函数表的优化与性能影响

虽然虚函数表为 C++ 实现多态性提供了强大的机制,但它也带来了一定的性能开销和内存消耗。

性能开销主要体现在通过 vptr 查找虚函数表和函数指针的间接调用过程。相比于直接调用普通函数,这种间接调用需要额外的内存访问,可能会影响程序的执行效率。不过,现代编译器通常会对虚函数调用进行优化,例如通过内联(inline)等技术来减少性能损失。

内存消耗方面,每个包含虚函数的对象都需要额外的空间来存储 vptr,并且每个类都有一个虚函数表。对于大量对象的场景,这可能会导致可观的内存开销。在设计程序时,需要权衡多态性带来的灵活性与性能和内存消耗之间的关系。

代码示例分析

单一继承示例代码

#include <iostream>

class Base {
public:
    virtual void print() {
        std::cout << "Base::print" << std::endl;
    }
};

class Derived : public Base {
public:
    void print() override {
        std::cout << "Derived::print" << std::endl;
    }
};

int main() {
    Base* basePtr1 = new Base();
    Base* basePtr2 = new Derived();

    basePtr1->print();
    basePtr2->print();

    delete basePtr1;
    delete basePtr2;
    return 0;
}

在上述代码中,Base 类定义了一个虚函数 printDerived 类继承自 Base 类并重写了 print 函数。在 main 函数中,我们创建了 Base 对象和 Derived 对象,并通过 Base 类型的指针来调用 print 函数。实际调用的是对象实际类型对应的 print 函数,这体现了多态性。从虚函数表的角度来看,Base 对象的 vptr 指向 Base 类的虚函数表,其中 print 函数指针指向 Base::printDerived 对象的 vptr 指向 Derived 类的虚函数表,其中 print 函数指针指向 Derived::print

多重继承示例代码

#include <iostream>

class Interface1 {
public:
    virtual void method1() {
        std::cout << "Interface1::method1" << std::endl;
    }
};

class Interface2 {
public:
    virtual void method2() {
        std::cout << "Interface2::method2" << std::endl;
    }
};

class Implementation : public Interface1, public Interface2 {
public:
    void method1() override {
        std::cout << "Implementation::method1" << std::endl;
    }
    void method2() override {
        std::cout << "Implementation::method2" << std::endl;
    }
};

int main() {
    Interface1* intf1Ptr = new Implementation();
    Interface2* intf2Ptr = new Implementation();

    intf1Ptr->method1();
    intf2Ptr->method2();

    delete intf1Ptr;
    delete intf2Ptr;
    return 0;
}

在这个多重继承的示例中,Implementation 类继承自 Interface1Interface2 两个接口类,并实现了它们的虚函数。Implementation 类的对象内存布局中包含两个 vptr,分别对应 Interface1Interface2。当通过 Interface1 类型的指针调用 method1 时,会通过对应的 vptr 找到 Implementation 类中 method1 的函数指针并调用;同理,通过 Interface2 类型的指针调用 method2 时,会通过另一个 vptr 找到相应的函数指针并调用。

菱形继承示例代码

#include <iostream>

class GrandParent {
public:
    virtual void show() {
        std::cout << "GrandParent::show" << std::endl;
    }
};

class Parent1 : public GrandParent {
public:
    void show() override {
        std::cout << "Parent1::show" << std::endl;
    }
};

class Parent2 : public GrandParent {
public:
    void show() override {
        std::cout << "Parent2::show" << std::endl;
    }
};

class Child : public Parent1, public Parent2 {
public:
    void show() override {
        std::cout << "Child::show" << std::endl;
    }
};

int main() {
    GrandParent* gpPtr = new Child();
    Parent1* p1Ptr = new Child();
    Parent2* p2Ptr = new Child();

    gpPtr->show();
    p1Ptr->show();
    p2Ptr->show();

    delete gpPtr;
    delete p1Ptr;
    delete p2Ptr;
    return 0;
}

在菱形继承的示例中,Child 类从 Parent1Parent2 间接继承 GrandParent 类。Child 类的内存布局包含两个 vptr,分别对应 Parent1Parent2。当通过不同类型的指针调用 show 函数时,会通过相应的 vptr 找到 Child 类中 show 函数的指针并调用。如果不使用虚继承,会出现数据冗余和二义性问题,而虚继承会改变虚函数表的布局来解决这些问题。

通过以上详细的分析和代码示例,我们对 C++ 多态底层虚函数表的原理有了较为深入的理解。虚函数表是 C++ 实现运行时多态的核心机制,掌握其原理对于编写高效、灵活的 C++ 代码至关重要。无论是单一继承、多重继承还是菱形继承,虚函数表都在背后默默地支持着多态性的实现,使得我们能够以统一的方式处理不同类型的对象,提高代码的可维护性和扩展性。在实际编程中,我们需要根据具体的需求和场景,合理地使用虚函数和多态性,同时也要注意虚函数表带来的性能和内存开销等问题。