C++ 多态机制与虚函数实现原理

前言

很多 C++ 开发者都觉得多态很简单:基类加个 virtual,派生类重写一下,用基类指针调用就能自动执行派生类版本。

直到被问到:

虚函数底层到底怎么实现的?

vtable 和 vptr 是什么?

为什么构造函数里调用虚函数永远是基类版本?

才发现自己只是"会用",却从未真正理解其原理。

本文将从多态的本质出发,层层递进,详细讲解 C++ 虚函数的完整实现机制 ------ vtable(虚表) + vptr(虚指针),并深入构造函数、析构函数、多继承等关键细节。

读完这篇,你会对"运行时多态"有一个从"知道怎么用"到"彻底懂为什么"的质的飞跃。

首先,我们先要学习:

一、为什么需要多态?

多态的字面意思是"多种形态"。在C++中,它的核心思想是:用同一套接口(函数名),让不同类型的对象表现出不同的行为。

C++提供了两种多态:

类型 绑定时机 实现方式 场景
编译时多态 编译期 函数重载、模板 泛型编程、运算符重载
运行时多态 运行时 虚函数 面向对象设计、框架/插件

这篇文章重点讲运行时多态,因为它是C++最具"面向对象"特色的机制。

没有虚函数时会发生什么?

cpp 复制代码
class Base {
public:
    void show() { std::cout << "Base::show()\n"; }
};

class Derived : public Base {
public:
    void show() { std::cout << "Derived::show()\n"; }
};

int main() {
    Derived d;
    Base* p = &d;// 基类指针指向派生类对象
    p->show(); 
}

输出为:

为什么输出的时Base的版本?

因为编译器在编译期就根据指针的静态类型(Base*)决定了要调用哪个函数------这叫早绑定(静态绑定)。

我们想要的是:指针/引用指向什么对象,就调用什么对象的函数------这就是晚绑定(动态绑定),即运行时多态。

这时候就需要虚函数了:

二、虚函数的语法与基本使用

只需要在基类的函数声明前加上virtual关键字:

cpp 复制代码
class Base {
public:
    virtual void show() { std::cout << "Base::show()\n"; }
    virtual ~Base() {} 
};

class Derived : public Base {
public:
    void show() override 
    {   
        std::cout << "Derived::show()\n";
    }
};

int main() {
    Derived d;
    Base* p = &d;// 基类指针指向派生类对象
    p->show(); 
}

现在p->show() 会输出 Derived::show() ------ 实现了运行时多态。

其中:

  • virtual 只需在基类中声明一次,派生类自动继承虚属性;
  • 派生类可以不重写(保持基类行为),也可以重写(覆盖);
  • override 关键字让编译器帮你检查是否真的覆盖了基类虚函数,强烈推荐使用。

现在,我们了解了虚函数的语法与基本使用,接下来学习:

三、虚函数的实现原理:vtable(虚表) + vptr(虚指针)

C++编译器通过两个隐藏的机制实现了动态绑定:

  1. 虚表(virtual table,vtable)
  • 每个含有虚函数的类(或其派生类),编译器都会在编译期为其生成一张虚函数指针表;
  • vtable 是一张数组,里面存放的是虚函数的地址;
  • 单继承时:派生类的vtable是基类vtable的"扩展 + 覆盖"版。
  1. 虚指针(virtual pointer,vptr)
  • 每个对象实例在内存中都会有一个隐藏的指针(通常放在对象最前面);
  • vptr指向本对象所属类的vtable;
  • 对象构造时,构造函数会自动把vptr设置为正确类的vtable。

用一句话总结实现原理:通过对象里的vptr找到类的vtable,再通过vtable中的函数指针间接调用函数->运行时决定调用哪个版本。

接下来我用内存布局图解(单继承)帮你更好理解这一部分:

假设有下面代码:

cpp 复制代码
class Base {
public:
    virtual void f1() {}
    virtual void f2() {}
    int x;
};

class Derived : public Base {
public:
    void f1() override {}   // 覆盖
    virtual void f3() {}    // 新增虚函数
    int y;
};

Base对象的内存布局(以32位为例,指针4字节):

bash 复制代码
[ vptr ]  → 指向 Base 的 vtable
[ x     ]

Base的vtable(编译器生成):

索引 函数地址
0 Base::f1()
1 Base::f2()

Derived对象的内存布局:

bash 复制代码
[ vptr ]  → 指向 Derived 的 vtable
[ x     ]
[ y     ]

Derived的vtable:

索引 函数地址
0 Derived::f1()(覆盖)
1 Base::f2()(继承)
2 Derived::f3()(新增)

调用过程(Base* p = new Derived(); p->f1();):

1.取 p 的指向对象的vptr (偏移0);

2.沿着vptr找到Derived的vtble;

3.取vtable[0](因为f1是第一个虚函数);

4.跳转到Derived::f1()的地址。

整个过程在运行时完成,与指针的静态类型完全无关。

四、构造函数中vptr的初始化过程

构造函数执行顺序决定了vptr的指向:

cpp 复制代码
Base::Base() {
    // 这里 vptr 已经被设置为 Base::vtable
    // 如果在基类构造函数里调用虚函数,调用的是 Base 版本!
}

Derived::Derived() : Base() {
    // 基类构造完成后,vptr 被修改为 Derived::vtable
}

重要规则:

  • 在基类构造函数中调用虚函数,永远调用基类版本(因为此时 vptr 还没指向派生类 vtable);
  • 析构函数顺序相反,先派生类析构,再基类析构。
cpp 复制代码
class Base {
public:
    virtual ~Base() {}   // 如果不加 virtual,delete 通过基类指针会只调用 Base 析构,内存泄漏!
};

五、多继承下的虚表

多继承时,对象会有多个 vptr(每个基类一个):

cpp 复制代码
class Base1 { virtual void f(); };
class Base2 { virtual void g(); };
class Derived : public Base1, public Base2 { ... };

Derived 对象内存布局大致为:

bash 复制代码
[ vptr1 → Base1::vtable(调整后) ]
[ Base1 的成员 ]
[ vptr2 → Base2::vtable(调整后) ]
[ Base2 的成员 ]
[ Derived 的成员 ]

调用 Base1* p = &d; p->f(); 走 vptr1;

调用 Base2* p = &d; p->g(); 走 vptr2。

菱形继承(钻石问题)必须用虚继承(virtual public),否则会有两份基类子对象和两份 vptr,导致二义性。虚继承会额外生成虚基类指针(vbptr)。(下篇文章详细讲解)

六、纯虚函数与抽象类

cpp 复制代码
class Abstract {
public:
    virtual void pure() = 0;   // 纯虚函数
};
  • 含有纯虚函数的类称为抽象类,不能实例化。
  • 派生类必须实现所有纯虚函数,否则仍是抽象类。
  • 纯虚函数在 vtable 中对应位置通常填 nullptr 或特殊的"纯虚函数调用失败"函数(调用会直接抛异常)。

七、性能开销与注意事项

1.空间开销

每个含虚函数的类:一张 vtable(函数数量 × 指针大小)。

每个对象:额外一个 vptr(通常 4/8 字节)。

2.时间开销

普通函数调用:直接跳转。

虚函数调用:两次间接寻址(vptr → vtable → 函数),现代 CPU 预测分支后开销很小,但仍比非虚函数慢一点。

3.常见陷阱

通过基类指针 delete 非虚析构 → 未定义行为。

在构造函数/析构函数中调用虚函数 → 永远调用当前类的版本。

值传递对象(切片)→ 多态失效。

不要在 vtable 里放非虚函数。

(下篇文章详细展开讲解)

总结

编译器为每个有虚函数的类生成 vtable(虚函数地址表),为每个对象偷偷插入 vptr(指向本类 vtable)。调用虚函数时:对象.vptr → vtable[函数索引] → 实际函数地址,从而实现"运行时根据真实类型绑定"。

相关推荐
m0_569881472 小时前
跨语言调用C++接口
开发语言·c++·算法
2501_924952692 小时前
C++中的过滤器模式
开发语言·c++·算法
zhixingheyi_tian2 小时前
gdb 之 attach
c++
2401_873204652 小时前
C++中的组合模式实战
开发语言·c++·算法
2401_831824963 小时前
高性能压缩库实现
开发语言·c++·算法
2401_874732533 小时前
C++中的策略模式进阶
开发语言·c++·算法
steins_甲乙3 小时前
C# 通过共享内存与 C++ 宿主协同捕获软件窗口
开发语言·c++·c#·内存共享
j_xxx404_3 小时前
蓝桥杯基础--时间复杂度
数据结构·c++·算法·蓝桥杯·排序算法
学嵌入式的小杨同学3 小时前
STM32 进阶封神之路(二十五):ESP8266 深度解析 —— 从 WiFi 通信原理到 AT 指令开发(底层逻辑 + 实战基础)
c++·vscode·stm32·单片机·嵌入式硬件·mcu·智能硬件