【C/C++ 多态核心 20240115更新】C++虚函数表:让多态成为可能的关键

简介: 【C/C++ 多态核心 20240115更新】C++虚函数表:让多态成为可能的关键

引言

📌为了实现C++的多态,C++使用了一种动态绑定的技术,这个技术的核心是虚函数表。
每个包含了虚函数的类都包含一个虚表,同一个类的所有对象都使用同一个虚表。


概述

对于一个类来说,如果类中存在虚函数,那么该类的大小就会多一个指针的大小,这个指针指向虚函数表。

所以,如果对象存在虚函数,那么编译器就会生成一个指向虚函数表的指针,所有的虚函数都存在于这个表中,虚函数表就可以理解为一个数组,每个单元用来存放虚函数的地址

对于多重继承的派生类来说,它含有多个虚函数指针

虚函数(Virtual Function)是通过一张虚函数表来实现的。简称为V-Table。 在这个表中,主要是一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其真实反应实际的函数

这样,在有虚函数的类的实例中分配了指向这个表的指针的内存,所以,当用父类的指针来操作一个子类的时候,这张虚函数表就显得尤为重要了,它就像一个地图一样,指明了实际所应该调用的函数。


特点

  • 每一个基类都会有自己的虚函数表,派生类的虚函数表的数量根据继承的基类的数量来定。
  • 派生类的虚函数表的顺序,和继承时的顺序相同
  • 派生类自己的虚函数放在第一个虚函数表的后面,顺序也是和定义时顺序相同。
  • 对于派生类如果要覆盖父类中的虚函数,那么会在虚函数表中代替其位置。
  • 没有虚函数的C++类,是不会有虚函数表的。
  • 虚函数表是在编译的过程中创建

虚表指针

  • 为了指定对象的虚表,对象内部包含一个虚表的指针,来指向自己所使用的虚表。
  • 为了让每个包含虚表的类的对象都拥有一个虚表指针,编译器在类中添加了一个指针,用来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表。
  • 一个继承类的基类如果包含虚函数,那个这个继承类也有拥有自己的虚表**,故这个继承类的对象也包含一个虚表指针,用来指向它的虚表。**

当代码试图调用一个虚函数时,编译器首先查找对象的vptr。 编译器是怎么查找的呢?

当编译器处理代码中的虚函数调用时,它遵循一定的规则和机制来定位对象的虚指针(vptr)。这个过程主要涉及到对象的内存布局和编译器的内部机制。以下是这一过程的概述:

  1. 对象内存布局:
  • 在C++中,一个有虚函数的类的对象通常会在其内存布局的开始部分包含一个指向虚函数表(vtable)的指针,即虚指针(vptr)。
  • 这个布局是由编译器自动管理的。当类被定义为包含虚函数时,编译器会自动为这个类的每个对象添加一个vptr。
  1. vptr的位置:
  • 通常,vptr被放置在对象内存布局的最开始的部分。这意味着在对象的内存地址的起始处可以找到vptr。
  1. 编译器的角色:
  • 当编译器遇到虚函数的调用时,它生成代码来首先访问对象的vptr。
  • 编译器知道vptr在对象内存布局中的确切位置,因为这是根据编译器的规则来安排的。
  1. 访问vtable:
  • 一旦获得了vptr,编译器接着通过vptr访问vtable。
  • 在vtable中,每个虚函数都有一个特定的偏移量。编译器根据虚函数在类定义中的顺序来确定这个偏移量。
  1. 动态绑定:
  • 编译器通过vptr和vtable来实现虚函数的动态绑定,即在运行时根据对象的实际类型确定要调用的具体函数。
  1. 多态性:
  • 这种机制使得当通过基类指针或引用调用派生类的虚函数时,能够正确地调用到

派生类中重写的版本,从而实现多态性。

  1. 编译时准备:
  • 编译器在编译时期就已经准备好了所有必要的信息,以确保运行时能正确地处理虚函数调用。这包括虚函数的偏移量、vptr在对象中的位置等。
  1. 优化:
  • 编译器可能还会实施一些优化措施,例如在确定的情况下(如非多态调用),它可能直接调用虚函数而不是通过vptr和vtable。

整个过程是自动的,由编译器在后台处理,程序员通常不需要(也无法)直接干预这个过程。重要的是理解虚函数的调用是通过一系列编译器控制的步骤在运行时动态解析的,这是C++支持多态的关键机制。


动态绑定

动态绑定的三个条件

  • 通过指针来调用函数
  • 指针upcast向上转型
  • 调用的是虚函数

动态绑定的流程

  1. 取出类的虚函数表的地址
  2. 根据虚函数表的地址找到虚函数表
  3. 根据找到的虚函数的地址调用虚函数。
  • 对象的虚表指针用来指向自己所属类的虚表,虚表中的指针会指向其继承的最近的一个类的虚函数
  • 非虚函数的调用不用经过虚表,故不需要虚表中的指针指向这些函数。

Upcasting 相关概念

"Upcasting"是面向对象编程中的一个概念,它指的是将一个子类对象的引用或指针转换为基类(也就是父类)类型的引用或指针。这种转换是安全的,因为子类对象是其基类对象的一个超集,这意味着子类对象包含其基类的所有成员。

为什么叫做"upcasting"呢?这是因为在面向对象的设计中,类的继承关系通常被形象地比喻为树状结构,其中基类位于顶部,子类位于下面。因此,从子类转换到基类就像是在这个树状结构中向上移动,所以叫做"upcasting"。

例如,如果我们有一个Animal类,以及一个从Animal继承的Dog类:

class Animal {
  public:
    void eat() {
        std::cout << "Eating...\n";
    }
};
class Dog : public Animal {
  public:
    void bark() {
        std::cout << "Barking...\n";
    }
};

我们可以创建一个Dog对象,并将它upcast为Animal类型:

Dog d;
Animal* a = &d;  // Upcasting
a->eat();         // OK: eat() is a member of Animal

在这个例子中,我们不能使用 a->bark(),因为我们正在将 Dog 类型的对象 d upcast 为 Animal 类型,而 bark()Dog 类型特有的方法,不是 Animal 类型的方法。

虚函数表对于多态的重要性

虚函数表在实现C++多态中起着至关重要的作用。多态是面向对象编程的一个核心特性,它允许我们使用基类指针或引用操作派生类对象,并根据对象的实际类型动态调用适当的成员函数。这种运行时的动态调用机制使得代码更加灵活和可扩展,从而有助于创建更具可维护性和可重用性的程序。

虚函数表(Virtual Function Table,简称V-Table)正是支持多态的关键所在。当一个类包含虚函数时,编译器会为该类生成一个虚函数表。虚函数表是一个包含指向虚函数地址的指针数组,其中每个指针都对应一个虚函数。同时,编译器还会在类的对象中添加一个指向虚函数表的指针,称为虚表指针。

在运行时,当我们使用基类指针或引用来调用虚函数时,程序会通过虚表指针找到正确的虚函数表。接着,它会根据虚函数在虚函数表中的索引定位到相应的虚函数地址,并调用该函数。这个过程称为动态绑定,它使得程序能够根据对象的实际类型来决定调用哪个类的成员函数。

举个简单的例子,假设我们有一个基类Shape和两个派生类Circle和Rectangle。假设Shape类中有一个虚函数area(),Circle和Rectangle类都重写了这个函数。当我们使用一个Shape指针或引用来调用area()函数时,虚函数表使得程序能够根据指针或引用指向的实际对象类型来调用Circle或Rectangle类的area()函数。这样一来,我们就可以在不了解具体对象类型的情况下,编写处理不同类型对象的通用代码。


总之,虚函数表对于实现C++多态至关重要,它允许我们在运行时确定要调用哪个类的成员函数,从而实现代码的灵活性和可扩展性。

虚析构函数的重要性

虚析构函数在面向对象编程中起到了关键作用,特别是在涉及继承关系的类中。虚析构函数允许基类析构函数在删除派生类对象时正确调用,这有助于防止资源泄漏和其他潜在问题。以下是虚析构函数的重要性的详细解释:

防止资源泄漏

派生类可能会分配额外的资源,例如动态内存。当通过基类指针删除派生类对象时,如果基类的析构函数不是虚函数,那么只有基类的析构函数会被调用,而派生类的析构函数不会被调用。这可能导致派生类中分配的资源无法正确释放,从而引发资源泄漏。

class Base {
public:
    // 没有使用virtual关键字
    ~Base() { cout << "Base destructor called." << endl; }
};
class Derived : public Base {
public:
    ~Derived() {
        cout << "Derived destructor called." << endl;
        // 释放派生类分配的资源
    }
};
int main() {
    Base* basePtr = new Derived();
    delete basePtr; // 只会调用基类的析构函数,导致资源泄漏
    return 0;
}

保持多态行为一致

在面向对象编程中,多态是一个关键概念。当使用指向基类的指针或引用来操作派生类对象时,我们希望能够在运行时正确地调用派生类的成员函数。将基类的析构函数声明为虚函数,可以确保在删除派生类对象时,析构函数的行为与其他虚成员函数的行为保持一致。

class Base {
public:
    // 使用virtual关键字
    virtual ~Base() { cout << "Base destructor called." << endl; }
};
class Derived : public Base {
public:
    ~Derived() {
        cout << "Derived destructor called." << endl;
        // 释放派生类分配的资源
    }
};
int main() {
    Base* basePtr = new Derived();
    delete basePtr; // 调用派生类的析构函数,然后调用基类的析构函数
    return 0;
}

总之,虚析构函数是一个重要的概念,可以确保在删除派生类对象时正确地调用基类和派生类的析构函数,从而避免资源泄漏。在设计涉及继承关系的类时,如果预计基类可能被作为接口使用,那么将基类的析构函数声明为虚函数是一种良好的编程实践。

虚函数表如何影响程序性能

虽然虚函数表使多态成为可能,但它也会引入一些额外的开销。以下是一些虚函数表对程序性能的影响:

  • 内存开销:每个包含虚函数的类都会有一个虚函数表,虚函数表中存储了指向类的虚函数的指针。此外,每个包含虚函数的类的对象都会有一个指向虚函数表的指针。这意味着每个对象的大小会增加,因为它需要额外的空间来存储虚函数表指针。同时,虚函数表本身也会占用内存。
  • 运行时开销:当调用一个虚函数时,编译器需要通过虚函数表来查找正确的函数地址,然后才能调用它。这个过程比直接调用非虚函数要慢,因为需要额外的间接寻址操作。虽然这个开销相对较小,但在性能关键的场景中,这可能会成为一个瓶颈。

在某些情况下,我们可以考虑使用其他方法来实现多态,如模板。模板可以在编译时实现多态,而不需要在运行时查找虚函数表。以下是模板的一些优势:

  • 编译时多态:模板是一种编译时多态技术,这意味着函数调用是在编译时确定的,而不是在运行时。这可以消除运行时的间接寻址开销。
  • 无额外内存开销:由于模板是在编译时实例化的,所以不需要虚函数表和虚函数表指针。这可以减少内存开销。

然而,模板也有一些缺点,如代码膨胀(每个模板实例都会生成一份代码)和编译时间增加。因此,在选择使用虚函数还是模板时,需要权衡它们的优缺点,并根据具体情况来决定。

总之,虚函数表确实会对程序性能产生一定影响,但在很多情况下,这种开销是可以接受的。在性能关键的场景中,可以考虑使用其他技术,如模板来实现多态。在选择合适的多态实现方法时,需要根据实际需求和场景来权衡。


代码示例: 使用虚函数表实现多态

#include <iostream>
// 基类 Shape
class Shape {
public:
    virtual void area() const {
        std::cout << "This is the area method in the base class Shape." << std::endl;
    }
};
// 派生类 Circle
class Circle : public Shape {
public:
    void area() const override {
        std::cout << "This is the area method in the derived class Circle." << std::endl;
    }
};
// 派生类 Rectangle
class Rectangle : public Shape {
public:
    void area() const override {
        std::cout << "This is the area method in the derived class Rectangle." << std::endl;
    }
};
void printArea(const Shape& shape) {
    shape.area(); // 调用虚函数,实现动态绑定
}
int main() {
    Shape shape;
    Circle circle;
    Rectangle rectangle;
    printArea(shape);      // 输出:This is the area method in the base class Shape.
    printArea(circle);     // 输出:This is the area method in the derived class Circle.
    printArea(rectangle);  // 输出:This is the area method in the derived class Rectangle.
    return 0;
}

在上面的示例中,我们有一个基类Shape和两个派生类Circle和Rectangle。基类Shape中定义了一个虚函数area(),而派生类Circle和Rectangle都重写了这个函数。

我们定义了一个printArea函数,该函数接受一个Shape引用作为参数。在printArea函数中,我们调用了area()函数,由于area()是虚函数,这里的调用会实现动态绑定。

在main函数中,我们创建了Shape、Circle和Rectangle对象,并分别将它们传递给printArea函数。虽然printArea函数接受的参数类型是Shape引用,但由于多态的存在,我们可以传递Circle和Rectangle对象。

当我们调用printArea函数时,程序会根据传入对象的实际类型,通过虚函数表来动态调用合适的成员函数。因此,printArea(shape)会调用基类Shape的area()函数,printArea(circle)会调用派生类Circle的area()函数,而printArea(rectangle)会调用派生类Rectangle的area()函数。


多重继承下虚函数表的结构和实现

在C++中,多重继承允许一个派生类继承多个基类。在这种情况下,虚函数表的结构和实现将更加复杂。本段将深入探讨多重继承下虚函数表的结构和实现,以及这对程序员在设计类层次结构时的影响。


虚函数表结构

在多重继承的情况下,派生类需要维护一个虚函数表数组。数组中的每个元素都指向一个虚函数表,这些虚函数表分别对应每个基类。由于派生类需要处理多个基类的虚函数,虚函数表数组的顺序与基类的声明顺序相同。

以下是一个简单的多重继承示例,说明虚函数表数组的结构:

class Base1 {
public:
    virtual void func1() {}
    virtual void func2() {}
};
class Base2 {
public:
    virtual void func3() {}
    virtual void func4() {}
};
class Derived : public Base1, public Base2 {
public:
    virtual void func1() override {}
    virtual void func4() override {}
};

在这个例子中,Derived 类继承了 Base1 和 Base2。Derived 类将有两个虚函数表,一个来自 Base1,另一个来自 Base2。Derived 类覆盖了基类的部分虚函数。最终,Derived 类的虚函数表数组如下:

Derived::vftable[0]:
    Derived::func1()
    Base1::func2()
Derived::vftable[1]:
    Base2::func3()
    Derived::func4()

类层次结构设计影响

多重继承和虚函数表数组对程序员在设计类层次结构时产生了以下影响:

性能考虑

由于多重继承引入了额外的虚函数表指针和数组,这可能会导致额外的内存开销和间接访问成本。因此,程序员需要在性能和设计灵活性之间做出权衡。

代码维护

在多重继承的情况下,代码的维护可能变得更加复杂。程序员需要仔细跟踪每个基类的虚函数,以确保正确覆盖和调用它们。此外,修改基类可能会影响多个派生类,需要谨慎处理。

避免菱形继承问题

在多重继承中,可能会遇到菱形继承问题,即一个类从两个或多个类继承,而这些类又从同一个基类继承。这会导致二义性和资源浪费。为解决这个问题,C++在多重继承下,派生类可能继承多个基类,这样就需要为每个基类创建一个虚函数表。因此,派生类的虚函数表数量取决于它继承的基类数量。


虚函数表的组织方式也会因为多重继承而变得更加复杂。假设一个派生类继承了两个基类A和B,其中A有一个虚函数foo(),B也有一个虚函数foo()。那么,在派生类中定义的foo()将覆盖基类A和B中的函数。因此,派生类的虚函数表中将包含一个指向派生类自己的foo()函数的指针,以及指向基类A和B的其他虚函数的指针。

此外,在多重继承的情况下,虚函数表的顺序也很重要。派生类的虚函数表的顺序必须与它继承的基类的顺序相同,这保证了派生类对象在使用基类指针进行操作时能够正确地调用其虚函数。

在设计多重继承类层次结构时,需要考虑到虚函数表的结构和组织方式。如果继承了多个基类,并且这些基类都有虚函数,那么就需要仔细地考虑如何组织虚函数表。此外,还需要注意虚函数的名称和参数列表,以确保它们不会与其他基类中的虚函数发生冲突。

总之,多重继承下的虚函数表结构更加复杂,需要程序员更加仔细地设计和管理。


总结

虚函数表是C++实现多态的核心技术之一。每个包含虚函数的类都包含一个虚表,同一类的所有对象都使用同一个虚表。虚表可以理解为一个数组,每个单元用来存放虚函数的地址。对于多重继承的派生类,它含有多个虚函数指针,派生类的虚函数表的数量根据继承的基类的数量来定,派生类自己的虚函数放在第一个虚函数表的后面,顺序也是和定义时顺序相同。

动态绑定需要满足通过指针来调用函数、指针upcast向上转型(继承类向基类的转换)和调用的是虚函数三个条件,动态绑定的流程是取出类的虚函数表的地址,根据虚函数表的地址找到虚函数表,根据找到的虚函数的地址调用虚函数。虚函数表对于多态的实现非常重要,它允许我们在运行时确定要调用哪个类的成员函数,实现多态的效果。

虚析构函数允许基类析构函数在删除派生类对象时正确调用,避免了资源泄漏的问题。多重继承下虚函数表的结构和实现也需要特别关注,在设计类层次结构时需要考虑好继承顺序和虚函数表的排布。

虚函数表的使用确实会引入一些额外的开销,但对于实现多态来说是必要的。在性能要求较高的场景下,可以考虑使用模板等其他方法实现多态。在实际编程中,需要根据具体情况权衡利弊,选择合适的方法实现多态。

目录
相关文章
|
29天前
|
C++
9. C++虚函数与多态
9. C++虚函数与多态
27 0
|
1月前
|
安全 JavaScript 前端开发
C/C++面试题:如何理解多态?
C/C++面试题:如何理解多态?
24 0
|
1月前
|
编译器 C++
【C++】—— 多态的基本介绍
【C++】—— 多态的基本介绍
C++进阶--多态
C++进阶--多态
|
1月前
|
算法 Java 编译器
【C++ 关键字 virtual 】C++ virtual 关键字(将成员函数声明为虚函数实现多态
【C++ 关键字 virtual 】C++ virtual 关键字(将成员函数声明为虚函数实现多态
25 0
|
4天前
|
编译器 C++
c++的学习之路:23、多态(2)
c++的学习之路:23、多态(2)
17 0
|
30天前
|
编译器 C++
C++之多态
C++之多态
|
1月前
|
存储 程序员 编译器
【C++ 模板类与虚函数】解析C++中的多态与泛型
【C++ 模板类与虚函数】解析C++中的多态与泛型
46 0
|
1月前
|
设计模式 存储 安全
【C++ 基本概念】C++编程三剑客:模板、多态与泛型编程的交织与差异
【C++ 基本概念】C++编程三剑客:模板、多态与泛型编程的交织与差异
106 0
|
1月前
|
存储 安全 算法
【C++ 17 包裹类 泛型容器 std::any】深入理解与应用C++ std::any:从泛型编程到多态设计
【C++ 17 包裹类 泛型容器 std::any】深入理解与应用C++ std::any:从泛型编程到多态设计
50 1