惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V
V2EX
C
Check Point Blog
博客园_首页
B
Blog
D
Docker
U
Unit 42
量子位
I
InfoQ
有赞技术团队
有赞技术团队
Martin Fowler
Martin Fowler
GbyAI
GbyAI
L
LangChain Blog
云风的 BLOG
云风的 BLOG
博客园 - Franky
美团技术团队
T
The Blog of Author Tim Ferriss
阮一峰的网络日志
阮一峰的网络日志
月光博客
月光博客
Vercel News
Vercel News
Recent Announcements
Recent Announcements
雷峰网
雷峰网
大猫的无限游戏
大猫的无限游戏
小众软件
小众软件
Google DeepMind News
Google DeepMind News

博客园 - thammer

03 — std::vector 进阶篇 02 — 迭代器基础 01 — std::vector 基础篇 Day 17:Promise 基础 Day 16 - 读懂 TypeScript 声明文件(.d.ts) Day 15 - ArkTS 模块系统 Day 14 - ArkTS 错误处理 Day 13 - ArkTS 类型系统进阶 Day 12 - 泛型与阶段三总结 Day 11 - Interface 与类型契约 Day 10 - 继承与多态 Day 09 - Class 基础 Day 08 - 函数进阶与阶段总结 Day 07 - 函数类型与高阶函数 Day 06 - 函数基础 Day 05 - 数组和元组深入 Day 04 - 循环与迭代 Day 03 - 条件控制 Day 02 - 运算符与表达式 Day 01 - 变量与类型基础 Day 00 - 环境准备 C++ 多继承 C++成员访问权限以及继承权限 C++ 成员初始化机制总结笔记
C++虚函数
thammer · 2026-02-26 · via 博客园 - thammer

什么是虚函数?

虚函数是C++中实现多态性的一种机制,它允许派生类重写基类的虚函数,从而在运行时根据对象的实际类型调用相应的函数。虚函数通过在函数声明前加上virtual关键字来定义。知道什么是虚函数好像并没什么卵用,要了解虚函数解决了什么问题才有意义!

虚函数解决了什么问题?

假设你正在设计一个图形绘制系统。你有各种形状:圆形、矩形。它们都继承自一个基类 Shape。

class Shape {
public:
    void draw() { cout << "绘制形状" << endl; }
};

class Circle : public Shape {
public:
    void draw() { cout << "绘制圆形" << endl; }
};

class Rectangle : public Shape {
public:
    void draw() { cout << "绘制矩形" << endl; }
};

假设需要定义接口,功能是把形状渲染出来,即调用对应类对象的draw方法,实现方式有哪些呢?首先想到的就是函数重载:

void render(Circle& c) {
    c.draw();
}

void render(Rectangle& r) {
    r.draw();
}

int main() {
    Circle c;
    render(c);
}

或者是函数模板也可以实现同样的效果。他们都是多态的体现,只不过这种多态是静态多态。并且它们都有一个缺陷,如果我新增了一种形状,那么需要对统一接口render新增一个重载函数或者对render的实现文件重新编译(函数模块的情况,不过本质都是新增一个对新形状支持的重载接口,区别是手动生成和编译器自动生成)。这样一来模块之间的耦合度就比较高了,修改A模块,必须同时修改B模块。而虚函数的出现提供了一种动态多态的方式,可以降低模块间的这种耦合度。具体如下:

  • 底层的图形绘制实现文件 shape.c
class Shape
{
public:
    virtual void draw()
    {
        cout << "绘制形状" << endl;
    }
};

class Circle : public Shape
{
public:
    void draw()
    {
        cout << "绘制圆形" << endl;
    }
};

class Rectangle : public Shape
{
public:
    void draw()
    {
        cout << "绘制矩形" << endl;
    }
};
  • 渲染实现文件 render.c
void render(Shape *s)
{
    s->draw();
}
  • 业务逻辑实现文件 main.c
int main()
{
    Circle c;
    render(&c);
    Rectangle r;
    render(&r);
}

如果现在新增三角形,那么只需要在shape.c中新增相关实现即可,然后在业务逻辑文件main.c里面定义三角形对象,调用render函数,而不需要对render函数作任何改动,甚至都不需要render.c重新编译,这就是模块间解耦合的体现。如果这是一个实际的工程项目,将它分为3个模块,底层图形绘制模块中间层绘制模块业务模块,分别将它们编译成两个动态库+一个可执行文件,那么新增形状进行版本发布时,中间层绘制模块这个动态库甚至都不需要更新,这就是它的实际意义。

虚函数是怎么解决问题的?

基于上面的例子,在不加virtual修饰时:

class Shape
{
public:
    void draw()
    {
        cout << "绘制形状" << endl;
    }
};

class Circle : public Shape
{
public:
    void draw()
    {
        cout << "绘制圆形" << endl;
    }
};

int main()
{
    // Circle是Shape的子类,是is-a关系,可以向上转型为Shape指针
    Shape *s = new Circle();
    // 虽然s是指向Circle对象的指针,但是它本身是Shape类型的指针,所以调用的是Shape类的draw方法,而不是Circle类的draw方法
    s->draw();
    delete s;
}

一旦在基类的方法上使用virtual修饰,上面例子的行为就会发生改变:

class Shape
{
public:
    virtual void draw()
    {
        cout << "绘制形状" << endl;
    }
};

class Circle : public Shape
{
public:
    void draw()
    {
        cout << "绘制圆形" << endl;
    }
};

int main()
{
    Shape *s = new Circle();
    s->draw();
    delete s;
}

基类draw方法是虚函数,这里虽然用Shape类型指针指向Circle对象,但是调用draw方法时,却调用的是这片内存原本属于的对象类型的方法,即Circle::draw

上面说到的这个规则,是由C++标准规范定义的,没啥道理可讲。它为什么要这么定义?都是为了实现动态多态,动态多态的优势在前文已提到过。

这里的演示程序比较奇怪,为什么要用基类指针指向派生类对象,然后利用虚函数特性,调用派生类方法?直接用派生类对象调用派生类方法,免去虚函数这个概念不好吗?当然不好!回到最初的例子:

class Shape
{
public:
    virtual void draw()
    {
        cout << "绘制形状" << endl;
    }
};

class Circle : public Shape
{
public:
    void draw()
    {
        cout << "绘制圆形" << endl;
    }
};

void render(Shape *s)
{
    s->draw();
}

int main()
{
    Circle c;
    render(&c);
}

这种写法体现在渲染函数render上,如果render的参数用派生类,那么就变成类函数重载实现的静态多态,带来的问题就是前面提到的模块间耦合问题。而基类恰好可以安全的指向其任意子类,所以直接用基类指针作为参数就免去了多种类型参数构成的函数重载问题,而剩下的问题就是如何让这个指针智能的调用原始对象的方法,达到多态效果,而虚函数机制恰好就是为这个目的设计的。

这里需要额外解释一下多态同样的接口,不同的行为——用统一的调用方式,根据对象的实际类型执行不同的操作。结合实际的例子来理解,多态体现在render函数上,对于业务逻辑,它只关心何时进行渲染,根本不关心渲染的是个啥,渲染的是个啥由下一层的设计来区分。

虚函数工作机制剖析

从源码的角度分析

#include <iostream>

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

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

int main() {
    Base b;
    Derived d;
    
    // 打印对象地址
    std::cout << "Base 对象地址: " << &b << std::endl;
    std::cout << "Derived 对象地址: " << &d << std::endl;
    
    // 打印对象大小
    std::cout << "Base 大小: " << sizeof(b) << " 字节" << std::endl;
    std::cout << "Derived 大小: " << sizeof(d) << " 字节" << std::endl;
    
    return 0;
}

运行上面的程序得到输出:

Base 对象地址: 0x7ffc81186f38
Derived 对象地址: 0x7ffc81186f40
Base 大小: 8 字节
Derived 大小: 8 字节

关键问题:Base 和 Derived 类里没有任何数据成员(int、char 等),为什么大小是 8 字节?这是因为包含虚函数的类或者其派生类,会有一个隐藏成员——vptr(虚表指针)。我的测试电脑是64位的,所以指针大小为8字节。这个指针指向一片内存区域,也就是这个对象所属类型的虚函数列表。

graph TB subgraph "Base对象" vptr["vptr"] end subgraph "vtable" slot0["Base::f指针(index 0)"] slot1["Base::g指针(index 1)"] end vptr --> slot0

上图就是Base对象的内存布局,虚函数按照顺序在内存中依次排布。接下来再观察虚表里面的内容:

#include <iostream>

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

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

int main() {
    Base b;
    Derived d;
    
    // 虚表指针可以理解为指向函数数组的指针,取 vptr
    void** b_vptr = *(void***)&b;
    void** d_vptr = *(void***)&d;
    
    std::cout << "=== Base 的 vtable ===" << std::endl;
    std::cout << "vtable 地址: " << b_vptr << std::endl;
    std::cout << "  [0]: " << b_vptr[0] << std::endl;  // 第一个虚函数
    std::cout << "  [1]: " << b_vptr[1] << std::endl;  // 第二个虚函数
    
    std::cout << "\n=== Derived 的 vtable ===" << std::endl;
    std::cout << "vtable 地址: " << d_vptr << std::endl;
    std::cout << "  [0]: " << d_vptr[0] << std::endl;
    std::cout << "  [1]: " << d_vptr[1] << std::endl;
    
    return 0;
}

运行输出:

=== Base 的 vtable ===
vtable 地址: 0x56640021cd48
  [0]: 0x56640021a472
  [1]: 0x56640021a4ae

=== Derived 的 vtable ===
vtable 地址: 0x56640021cd28
  [0]: 0x56640021a4ea
  [1]: 0x56640021a526

根据前面的分析:

  • vtable[0] 应该是 f() 的地址
  • vtable[1] 应该是 g() 的地址

再对elf文件进行符号分析:

nm cpptest | grep -E "Base.*f|Derived.*f"
0000000000001472 W _ZN4Base1fEv
00000000000014ea W _ZN7Derived1fEv

nm cpptest | grep -E "Base.*g|Derived.*g"
00000000000014ae W _ZN4Base1gEv
0000000000001526 W _ZN7Derived1gEv

可以看到地址的尾部是和预期能匹配上的,这是因为elf文件里面的符号是relocable的,所以地址是相对于elf文件的起始位置的偏移量。这个起始位置就是虚拟地址的基址,它们的关系是:

运行时地址 = 基地址 + 偏移地址
0x56640021a472 = 0x56640021900 + 0x1472

再确认下虚表中的地址就是虚函数的地址:

#include <iostream>

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

    virtual void g()
    {
        std::cout << "Base::g()" << std::endl;
    }
};

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

    void g() override
    {
        std::cout << "Derived::g()" << std::endl;
    }
};

// 定义函数指针类型,匹配成员函数签名
using VoidFunc = void(*)(void *);

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

    std::cout << "=== 正常调用 p->f() ===" << std::endl;
    p->f();

    std::cout << "\n=== 手动模拟虚函数调用 ===" << std::endl;

    // 第一步:从对象取 vptr
    void **vptr = *(void ***)p;
    std::cout << "1. 取 vptr: " << vptr << std::endl;

    // 第二步:从 vtable 取 f() 的地址(索引 0)
    void *func_addr = vptr[0];
    std::cout << "2. 取 vtable[0]: " << func_addr << std::endl;

    // 第三步:调用这个函数(传入 this 指针)
    std::cout << "3. 调用该函数:" << std::endl;
    VoidFunc func = (VoidFunc)func_addr;
    func(p); // 传入 this 指针

    return 0;
}

对比正常调用虚函数和手动模拟虚函数调用,从虚表取出虚函数f,然后传入成员函数默认的this指针参数。运行得到如下输出:

=== 正常调用 p->f() ===
Derived::f()

=== 手动模拟虚函数调用 ===
1. 取 vptr: 0x61ab8849ad48
2. 取 vtable[0]: 0x61ab884983c2
3. 调用该函数:
Derived::f()

注意每次运行基址是会变化的,所以虚表指针,虚函数表内的函数指针都会变化,不用管它。可以看到模拟调用也正常调用了虚函数f,这也就印证了虚表指针,虚表的内存布局。

从汇编的角度分析

简化一下测试程序,仅保留一个虚函数

class Base
{
public:
    virtual void f(){}
};

class Derived : public Base
{
public:
    void f() override{}
};

int main()
{
    Derived d;
    Base *b = &d;
    b->f();
    return 0;
}

对应的汇编为Compiler Explorer这个网站生成的

"Derived::f()":
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
"main":
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     eax, OFFSET FLAT:"vtable for Derived"+16
        mov     QWORD PTR [rbp-16], rax
        lea     rax, [rbp-16]
        mov     QWORD PTR [rbp-8], rax
        mov     rax, QWORD PTR [rbp-8]
        mov     rax, QWORD PTR [rax]
        mov     rdx, QWORD PTR [rax]
        mov     rax, QWORD PTR [rbp-8]
        mov     rdi, rax
        call    rdx
        mov     eax, 0
        leave
        ret
"vtable for Derived":
        .quad   0
        .quad   "typeinfo for Derived"
        .quad   "Derived::f()"
"typeinfo for Derived":
        .quad   "vtable for __cxxabiv1::__si_class_type_info"+16
        .quad   "typeinfo name for Derived"
        .quad   "typeinfo for Base"
"typeinfo name for Derived":
        .string "7Derived"
"typeinfo for Base":
        .quad   "vtable for __cxxabiv1::__class_type_info"+16
        .quad   "typeinfo name for Base"
"typeinfo name for Base":
        .string "4Base"

重点关注的是main函数前3个语句一一对应的汇编派生类虚表的内存布局

派生类虚表的内存布局

"vtable for Derived":
        .quad   0
        .quad   "typeinfo for Derived"
        .quad   "Derived::f()"

.quad:是一个伪指令,用于在内存中分配并初始化一个或多个8字节(64位)的数据。它常用于定义64位整数或地址。

  • 第一个是虚基类偏移(virtual base offset)。在无虚继承的单继承场景下为0。
  • 第二个是Derived的typeinfo信息表的地址,这个和RTTI机制相关,暂时不管它。
  • 第三个是虚表首地址了,如果有多个虚函数,就会依次排布在下面,比如:
"vtable for Derived":
        .quad   0
        .quad   "typeinfo for Derived"
        .quad   "Derived::f1()"
        .quad   "Derived::f2()"
        .quad   "Derived::f3()"

完整布局为:

vtable for Derived:
  [-2]: 0                    ← 虚基类偏移(本例无虚继承,为0)
  [-1]: typeinfo for Derived  ← RTTI 信息
  [0]:  Derived::f()          ← 第一个虚函数(vptr实际指向这里)

逐行分析汇编

  • 进入main函数后,先移动栈指针
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16

这里分配了16字节的栈空间,因为下面需要2个栈变量,一个是Derived d;,一个是Base *b;。它们都是8字节的,所以一个需要16字节。

  • Derived d;
        mov     eax, OFFSET FLAT:"vtable for Derived"+16 ;虚表地址偏移16,即将索引为0的虚函数地址加载到eax
        mov     QWORD PTR [rbp-16], rax ;将虚表地址加载到栈变量d中

这里需要了解x86-64汇编中,寄存器eaxrax的关系,它们关系如下:

H                                                               L
|---+---|---+---|---+---|---+---|---+---|---+---|---+---|---+---|
|<-----------------------------rax----------------------------->|
                                |<-------------eax------------->|
                                                |<-----ax------>|
                                                |<-ah-->|<-al-->|
  • rax = 64位
  • eax = 低32位
  • ax = 低16位
  • ah = ax 的高8位,al = ax 的低8位

为什么一个用32位的eax,一个又用完整的64位的rax?这是在编译为汇编这个阶段,如果后面的立即数不超过32位时,它就先倾向用短一点的,先可以不管这些细节。对应的这2句汇编就是将索引为0的虚函数地址加载到栈变量d,即变量d的地址就是虚表中第一个虚函数指针所在的地址。

  • Base *b = &d;
        lea     rax, [rbp-16] ;将栈变量d的地址加载到rax中
        mov     QWORD PTR [rbp-8], rax ;将rax的值(即栈变量d的地址)赋值给到栈变量b

这句好像没什么好分析的,很简单。

  • b->f()
        mov     rax, QWORD PTR [rbp-8] ;rax = b,即栈变量b的值,即栈变量d的地址
        mov     rax, QWORD PTR [rax]   ;rax = *rax = *b = &d,即虚表中第一个虚函数指针所在的地址
        mov     rdx, QWORD PTR [rax]   ;rdx = *rax = *(&d) = vtable[0] = Derived::foo 地址
        mov     rax, QWORD PTR [rbp-8] ;rax = b,即栈变量b的值,即栈变量d的地址
        mov     rdi, rax               ;rdi = rax,即栈变量b的值,即栈变量d的地址,作为this参数传给Derived::f()
        call    rdx                    ;调用 Derived::f()

整个过程中的汇编都中规中矩,没啥特别的,但是最为关键的其实是:

Derived d;

        mov     eax, OFFSET FLAT:"vtable for Derived"+16 ;虚表地址偏移16,即将索引为0的虚函数地址加载到eax
        mov     QWORD PTR [rbp-16], rax ;将虚表地址加载到栈变量d中

d存储的内容就是虚表中第一个虚函数指针所在的地址。有了这个机制,不管你新增任何继承自Base的派生类,都可以正常工作,相当于寻址过程会随着对象类型而变化,因此实现了动态多态。

纯虚函数和抽象类

纯虚函数是用= 0来声明的,抽象类是包含纯虚函数的类。抽象类不能被实例化,只能被继承。

class Base
{
public:
    virtual void f() = 0;
};

这个例子中的Base类包含一个纯虚函数f,因此Base是一个抽象类,不能被实例化,只能被继承,派生类必须实现纯虚函数f,否则派生类也成为抽象类,不能被实例化。

class Derived : public Base
{
};

这里例子中,Derived类继承自Base类,但没有实现纯虚函数f,因此Derived类也是一个抽象类,不能被实例化。

虚函数的副作用

虚函数的调用过程要经过:取 vptr → 取函数地址 → 间接 call(2次内存访问 + 间接跳转),而普通函数直接调用固定地址(1次跳转),所以虚函数调用性能略低于普通函数。

虚函数陷阱

如果一个类中包含虚函数,建议将其析构函数也声明为虚函数。这个可以作为一个规范来遵守。

为什么要这样呢?看下面的例子:

#include <iostream>

class Base
{
public:
    ~Base()
    {
        std::cout << "~Base()" << std::endl;
    }
};

class Derived : public Base
{
public:
    Derived()
        : data(new int[100])
    {
    }

    ~Derived()
    {
        delete []data;
        std::cout << "~Derived()" << std::endl;
    }

private:
    int *data;
};

int main()
{
    Base *p = new Derived();
    delete p; // 只调用了 ~Base(),~Derived() 没被执行!
    return 0;
}

假设没有将Base的析构函数声明为虚函数,而派生类Derived的构造函数中又分配了资源,那么在delete p时,只会调用Base的析构函数,而不会调用Derived的析构函数,导致资源泄漏。