虚函数原理

虚函数原理。详解虚函数表结构,虚函数指针,多重继承下的虚函数表等内容。

Posted by CongYu on February 1, 2022

虚函数原理

1. 虚函数表(Virtual Table)

什么是虚函数表?

虚函数表(vtable) 是C++编译器为每个包含虚函数的类创建的一个静态数组,用于存储该类所有虚函数的地址。这是实现动态多态的关键机制。

虚函数表的结构

1
2
3
4
5
6
7
8
9
10
11
12
13
class Base {
public:
    virtual void func1() { std::cout << "Base::func1\n"; }
    virtual void func2() { std::cout << "Base::func2\n"; }
    virtual ~Base() = default;
};

class Derived : public Base {
public:
    void func1() override { std::cout << "Derived::func1\n"; }
    // func2 继承自 Base,没有重写
    virtual void func3() { std::cout << "Derived::func3\n"; }
};

vtable 结构示意:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Base类的vtable:
+-------------------+
| Base::func1       |  <- vtable[0]
| Base::func2       |  <- vtable[1] 
| Base::~Base       |  <- vtable[2]
+-------------------+

Derived类的vtable:
+-------------------+
| Derived::func1    |  <- vtable[0] (重写了Base::func1)
| Base::func2       |  <- vtable[1] (继承Base::func2)
| Derived::~Derived |  <- vtable[2] (重写析构函数)
| Derived::func3    |  <- vtable[3] (新增虚函数)
+-------------------+

虚函数指针(vptr)

每个包含虚函数的类的对象都会包含一个隐藏的指针——虚函数指针(vptr),它指向该类的虚函数表。

1
2
Base b;      // b对象包含一个vptr,指向Base的vtable
Derived d;   // d对象包含一个vptr,指向Derived的vtable

对象内存布局:

1
2
3
4
5
6
7
8
9
10
11
12
Base对象内存布局:
+-------------------------+
| vptr (指向Base vtable)   |  <- 8字节(64位系统)
| 其他成员变量              |
+-------------------------+

Derived对象内存布局:
+-----------------------------+
| vptr (指向Derived vtable)   |  <- 8字节(64位系统)
| Base部分的成员变量            |
| Derived新增的成员变量         |
+-----------------------------+

2. 动态绑定原理详解

动态绑定(也称为后期绑定或运行时绑定)是通过虚函数表实现的。

动态绑定的执行过程

1
2
Base* ptr = new Derived();  // 基类指针指向派生类对象
ptr->func1();               // 动态绑定调用

执行步骤分解:

  1. 编译时准备:
    • 编译器识别到func1()是虚函数调用
    • 不直接生成函数调用指令,而是生成通过vtable查找的代码
  2. 运行时执行:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    // 伪代码展示底层执行过程
    // ptr->func1() 实际执行的步骤:
       
    // 1. 获取对象的vptr
    void** vtable_ptr = *(void***)ptr;  // ptr指向的对象的前8字节
       
    // 2. 通过vtable查找func1的地址 (func1在vtable的索引0位置)
    void (*func_ptr)() = (void(*)())vtable_ptr[0];
       
    // 3. 调用实际的函数
    func_ptr();  // 这里会调用Derived::func1
    

与静态绑定的对比

静态绑定(编译时绑定):

1
2
Base obj;
obj.func1();  // 编译时就确定调用Base::func1,直接函数调用

动态绑定(运行时绑定):

1
2
Base* ptr = getObject();  // 运行时才知道具体类型
ptr->func1();             // 运行时通过vtable确定调用哪个函数

3. 性能考虑

虚函数调用的开销

  1. 内存开销:每个对象多占用一个指针大小(8字节)
  2. 时间开销:
    • 一次额外的内存访问(读取vptr)
    • 一次vtable查找
    • 间接函数调用(可能影响CPU分支预测)
1
2
3
4
5
6
7
8
9
#include <chrono>

class Base {
public:
    virtual void virtualFunc() {}      // 虚函数
    void normalFunc() {}              // 普通函数
};

// 虚函数调用通常比普通函数调用慢2-3纳秒

如何优化?

  1. 多使用final

4. 多重继承中的vtable

在多重继承情况下:

情况1:C类只重写基类虚函数,不引入新的虚函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A {
public:
    virtual void funcA() {}
};

class B {
public:
    virtual void funcB() {}
};

class C : public A, public B {
public:
    void funcA() override {}
    void funcB() override {}
};

C类对象的内存布局:

1
2
3
4
5
6
7
8
9
10
C对象内存布局:
+---------------------------------+
| vptr(指向C-as-A的vtable)       |
| A的成员变量                      |
+---------------------------------+
| vptr(指向C-as-B的vtable)       |
| B的成员变量                      |
+---------------------------------+
| C的成员变量                      |
+---------------------------------+

对应的vtable布局:

1
2
3
4
5
6
7
8
9
10
11
C-as-A的vtable:
+-------------------+
| C::funcA          |  <- vtable[0] (重写了A::funcA)
| C::~C (调整版)     |  <- vtable[1] (析构函数)
+-------------------+

C-as-B的vtable:
+-------------------+
| C::funcB          |  <- vtable[0] (重写了B::funcB)
| C::~C (调整版)     |  <- vtable[1] (析构函数)
+-------------------+

情况2:C类引入新的虚函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class A {
public:
    virtual void funcA() {}
};

class B {
public:
    virtual void funcB() {}
};

class C : public A, public B {
public:
    void funcA() override {}
    void funcB() override {}
    virtual void funcC() {}     // C类新引入的虚函数
    virtual void funcC2() {}    // 另一个新虚函数
};
方案一:扩展主vtable

C类对象的内存布局(方案一 - 扩展主vtable):

1
2
3
4
5
6
7
8
9
10
C对象内存布局:
+----------------------------------------------------+
| vptr  (主vtable,指向C-as-A的vtable,包含C的新虚函数) |
| A的成员变量                                         |   
+----------------------------------------------------+
| vptr  (指向C-as-B的vtable)                        |
| B的成员变量                                         |
+----------------------------------------------------+
| C的成员变量                                         |
+----------------------------------------------------+

对应的vtable布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
C-as-A的扩展vtable(主vtable):
+-------------------+
| C::funcA          |  <- vtable[0] (重写)
| C::~C             |  <- vtable[1] (析构函数)
| C::funcC          |  <- vtable[2] (C新增的虚函数)
| C::funcC2         |  <- vtable[3] (C新增的虚函数)
+-------------------+

C-as-B的vtable(辅助vtable):
+-------------------+
| C::funcB          |  <- vtable[0] (重写)
| C::~C (调整版)     |  <- vtable[1] (需要地址调整的析构函数)
+-------------------+
方案二:独立的C vtable

C类对象的内存布局(方案二 - 独立的C vtable,某些编译器实现):

1
2
3
4
5
6
7
8
9
10
11
C对象内存布局:
+------------------------------------+
| vptr  (指向C-as-A的vtable)        |
| A的成员变量                         |
+------------------------------------+
| vptr  (指向C-as-B的vtable)        |
| B的成员变量                         |
+------------------------------------+
| vptr  指向C独有的vtable(含新增虚函数)|
| C的成员变量                         |
+------------------------------------+

虚函数调用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
C c;

// 通过不同的指针类型访问
A* pa = &c;
B* pb = &c;
C* pc = &c;

pa->funcA();  // 通过A部分的vptr调用C::funcA
pb->funcB();  // 通过B部分的vptr调用C::funcB
pc->funcC();  // 通过A部分的扩展vptr调用C::funcC(方案一)
              // 或通过C独有的vptr调用C::funcC(方案二)

// 指针转换和地址调整
A* pa2 = pc;  // pa2 == pc,指向同一地址
B* pb2 = pc;  // pb2 != pc,编译器自动调整地址偏移

地址调整(Address Adjustment)

在多重继承中,当进行指针转换时可能需要地址调整:

1
2
3
4
5
6
7
8
9
10
11
12
C c;
std::cout << "C对象地址: " << &c << std::endl;

A* pa = &c;
B* pb = &c;

std::cout << "A*指针值: " << pa << std::endl;  // 与&c相同
std::cout << "B*指针值: " << pb << std::endl;  // 可能与&c不同,有偏移

// 转换回C*时需要反向调整
C* pc1 = static_cast<C*>(pa);  // 无需地址调整
C* pc2 = static_cast<C*>(pb);  // 需要地址调整

编译器实现差异

不同的编译器可能采用不同的vtable布局策略:

  1. GCC/Clang:通常采用方案一,扩展主vtable
  2. MSVC:可能采用不同的布局方式
  3. 优化考虑:编译器会根据类的复杂度选择最优方案

实际测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>

class A {
public:
    virtual void funcA() { std::cout << "A::funcA\n"; }
    virtual ~A() = default;
};

class B {
public:
    virtual void funcB() { std::cout << "B::funcB\n"; }
    virtual ~B() = default;
};

class C : public A, public B {
public:
    void funcA() override { std::cout << "C::funcA\n"; }
    void funcB() override { std::cout << "C::funcB\n"; }
    virtual void funcC() { std::cout << "C::funcC\n"; }
};

void testLayout() {
    C c;
    
    std::cout << "对象大小: " << sizeof(C) << " bytes\n";
    std::cout << "C对象地址: " << &c << std::endl;
    
    A* pa = &c;
    B* pb = &c;
    
    std::cout << "A*地址: " << pa << std::endl;
    std::cout << "B*地址: " << pb << std::endl;
    std::cout << "地址偏移: " << ((char*)pb - (char*)pa) << " bytes\n";
}

关键要点:

  1. 新虚函数的存储:通常存储在主vtable(第一个基类的vtable)中
  2. 地址调整:多重继承中的指针转换可能需要运行时地址调整
  3. 内存开销:每个基类的vtable都需要一个vptr,增加了内存开销
  4. 性能影响:虚函数调用的性能与单继承基本相同,但指针转换可能有额外开销

5. 实际应用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <iostream>
#include <vector>
#include <memory>

class Shape {
public:
    virtual void draw() const = 0;
    virtual double area() const = 0;
    virtual ~Shape() = default;
};

class Circle : public Shape {
private:
    double radius;
public:
    Circle(double r) : radius(r) {}
    
    void draw() const override {
        std::cout << "Drawing Circle with radius " << radius << std::endl;
    }
    
    double area() const override {
        return 3.14159 * radius * radius;
    }
};

class Rectangle : public Shape {
private:
    double width, height;
public:
    Rectangle(double w, double h) : width(w), height(h) {}
    
    void draw() const override {
        std::cout << "Drawing Rectangle " << width << "x" << height << std::endl;
    }
    
    double area() const override {
        return width * height;
    }
};

// 多态的威力
void processShapes(const std::vector<std::unique_ptr<Shape>>& shapes) {
    for(const auto& shape : shapes) {
        shape->draw();      // 动态绑定到具体的draw实现
        std::cout << "Area: " << shape->area() << std::endl;  // 动态绑定到具体的area实现
    }
}

关键要点总结:

  1. vtable是静态的:每个类只有一个vtable,所有该类的对象共享
  2. vptr是动态的:每个对象都有自己的vptr,指向对应类的vtable
  3. 继承关系影响vtable:派生类的vtable包含基类的虚函数(可能被重写)
  4. 性能权衡:虚函数提供灵活性,但有轻微的性能开销
  5. 编译器优化:现代编译器会尽可能优化虚函数调用,甚至在某些情况下将其内联