C++ 虚函数深度剖析:从 “同名函数“ 到 “多态魔法“,这篇讲透底层原理

目录

一、开篇:为什么基类指针总是 "认不出" 派生类对象?

二、虚函数基础:加个virtual,世界都不一样了

2.1 虚函数的核心特性

特性 1:派生类可以 "重写"(Override)虚函数

特性 2:通过基类指针 / 引用调用时,根据对象实际类型执行函数

特性 3:虚函数具有 "传递性"

2.2 虚函数 vs 普通函数:调用机制的本质区别

三、底层原理:虚函数表(vtable)和虚指针(vptr)是如何工作的?

3.1 虚函数表(vtable):类的 "函数地址目录"

3.2 虚指针(vptr):对象指向 vtable 的 "指针"

3.3 动态绑定的完整流程:从对象到函数调用

3.4 用代码验证 vtable 的存在

四、虚析构函数:避免多态场景下的内存泄漏

4.1 非虚析构函数的隐患

4.2 虚析构函数的解决方案

五、纯虚函数与抽象类:定义接口的 "模板"

5.1 纯虚函数:没有实现的虚函数

5.2 抽象类的实战价值:定义接口规范

5.3 抽象类可以有非纯虚函数

六、虚函数的 "禁区":这些情况不能用虚函数

6.1 构造函数不能是虚函数

6.2 静态成员函数不能是虚函数

6.3 内联函数不能是虚函数

6.4 友元函数不能是虚函数

七、虚函数的性能开销:该不该用虚函数?

7.1 内存开销

7.2 时间开销

7.3 优化建议

八、虚函数常见面试题与坑点解析

8.1 面试题 1:重写、重载、隐藏的区别?

8.2 面试题 2:虚函数表在什么时候创建?存在哪里?

8.3 坑点 1:派生类重写时改变参数列表,导致隐藏而非重写

8.4 坑点 2:构造函数中调用虚函数,不会触发多态

九、总结:虚函数是 C++ 多态的 "基石"


class 卑微码农:
    def __init__(self):
        self.技能 = ['能读懂十年前祖传代码', '擅长用Ctrl+C/V搭建世界', '信奉"能跑就别动"的玄学']
        self.发量 = 100  # 初始发量
        self.咖啡因耐受度 = '极限'
        
    def 修Bug(self, bug):
        try:
            # 试图用玄学解决问题
            if bug.严重程度 == '离谱':
                print("这一定是环境问题!")
            else:
                print("让我看看是谁又没写注释...哦,是我自己。")
        except Exception as e:
            # 如果try块都救不了,那就...
            print("重启一下试试?")
            self.发量 -= 1  # 每解决一个bug,头发-1
 
 
# 实例化一个我
我 = 卑微码农()

一、开篇:为什么基类指针总是 "认不出" 派生类对象?

刚学 C++ 那会,我写过这样一段代码:定义了一个Shape基类和Circle派生类,想用基类指针调用派生类的draw方法,结果却始终执行基类的版本。当时对着屏幕一脸懵:明明指针指向的是Circle对象,为什么它就 "认不出来" 呢?

#include <iostream>
using namespace std;

class Shape {
public:
    void draw() {  // 普通成员函数
        cout << "画图形" << endl;
    }
};

class Circle : public Shape {
public:
    void draw() {  // 派生类同名函数
        cout << "画圆形" << endl;
    }
};

int main() {
    Shape* ptr = new Circle();  // 基类指针指向派生类对象
    ptr->draw();  // 输出"画图形",而不是预期的"画圆形"
    delete ptr;
    return 0;
}

后来才知道,这就是缺少虚函数导致的 —— 没有virtual关键字,C++ 编译器会根据 "指针类型" 而不是 "对象实际类型" 来调用函数。而虚函数的作用,就是让指针 "看穿" 自己指向的真实对象,执行正确的函数版本。

如果你也遇到过这些困惑:

  • 什么是虚函数?它和普通函数有什么本质区别?
  • 为什么加上virtual关键字就能实现多态?底层原理是什么?
  • 虚析构函数为什么能避免内存泄漏?
  • 纯虚函数和抽象类到底有什么用?

本文会从实际问题出发,用 18 个代码示例 + 内存结构图,帮你彻底搞懂虚函数的底层机制、使用场景和避坑指南。看完你会发现:虚函数不是语法糖,而是 C++ 多态的 "灵魂引擎"。

二、虚函数基础:加个virtual,世界都不一样了

解决开篇问题的方法很简单:在基类的draw函数前加virtual关键字,让它成为虚函数。

class Shape {
public:
    virtual void draw() {  // 虚函数
        cout << "画图形" << endl;
    }
};

class Circle : public Shape {
public:
    void draw() {  // 派生类重写虚函数
        cout << "画圆形" << endl;
    }
};

int main() {
    Shape* ptr = new Circle();
    ptr->draw();  // 输出"画圆形",符合预期
    delete ptr;
    return 0;
}

仅仅一个virtual,就让基类指针 "认对" 了对象 —— 这就是虚函数的魔力。但背后的逻辑远不止 "加个关键字" 这么简单。

2.1 虚函数的核心特性

特性 1:派生类可以 "重写"(Override)虚函数

"重写" 指的是派生类定义一个与基类虚函数同名、同参数、同返回值的函数(协变返回值除外),从而覆盖基类的实现。

class Shape {
public:
    virtual void draw() { cout << "Shape::draw" << endl; }
    virtual int getArea(int a) { return a * a; }  // 带参数的虚函数
};

class Circle : public Shape {
public:
    void draw() override {  // 用override显式声明重写(C++11)
        cout << "Circle::draw" << endl;
    }
    int getArea(int r) override {  // 重写带参数的虚函数
        return 3 * r * r;  // 简化的圆面积计算
    }
};

建议:重写时加上override关键字,编译器会检查是否真的重写了基类虚函数,避免拼写错误或参数不匹配导致的隐藏(比如把draw写成drwa)。

特性 2:通过基类指针 / 引用调用时,根据对象实际类型执行函数

这是虚函数实现多态的核心:调用哪个版本的函数,取决于指针 / 引用指向的 "对象类型",而不是指针 / 引用本身的类型。

void render(Shape& shape) {  // 基类引用
    shape.draw();  // 调用哪个draw?取决于shape的实际类型
}

int main() {
    Shape s;
    Circle c;
    render(s);  // 传入Shape对象,输出"Shape::draw"
    render(c);  // 传入Circle对象,输出"Circle::draw"
    return 0;
}
特性 3:虚函数具有 "传递性"

基类的虚函数被声明后,派生类中重写的函数自动成为虚函数(即使不加virtual关键字)。

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

class Circle : public Shape {
public:
    void draw() { cout << "Circle" << endl; }  // 自动为虚函数
};

class RedCircle : public Circle {
public:
    void draw() { cout << "RedCircle" << endl; }  // 继承虚函数特性
};

int main() {
    Shape* ptr = new RedCircle();
    ptr->draw();  // 输出"RedCircle",多态传递生效
    delete ptr;
    return 0;
}

2.2 虚函数 vs 普通函数:调用机制的本质区别

普通函数的调用在编译期就确定了(静态绑定),而虚函数的调用在运行期才确定(动态绑定)。

  • 普通函数:编译器根据 "指针 / 引用的类型" 找到函数地址,直接调用;
  • 虚函数:编译器在运行时根据 "对象的实际类型" 找到函数地址,动态调用。

用代码验证:

class Base {
public:
    void normalFunc() { cout << "Base普通函数" << endl; }  // 静态绑定
    virtual void virtualFunc() { cout << "Base虚函数" << endl; }  // 动态绑定
};

class Derived : public Base {
public:
    void normalFunc() { cout << "Derived普通函数" << endl; }
    void virtualFunc() { cout << "Derived虚函数" << endl; }
};

int main() {
    Base* ptr = new Derived();
    ptr->normalFunc();  // 静态绑定:根据ptr类型(Base)调用Base版本
    ptr->virtualFunc(); // 动态绑定:根据对象类型(Derived)调用Derived版本
    delete ptr;
    return 0;
}

运行结果:

Base普通函数
Derived虚函数

三、底层原理:虚函数表(vtable)和虚指针(vptr)是如何工作的?

虚函数的动态绑定不是凭空实现的,背后依赖两个关键结构:虚函数表(vtable) 和虚指针(vptr)。理解这两个概念,才算真正懂了虚函数。

3.1 虚函数表(vtable):类的 "函数地址目录"

每个包含虚函数的类(或其派生类)会有一个虚函数表,本质是一个存储虚函数地址的数组。这个表在编译期创建,每个类只有一份,所有对象共享。

  • 基类有自己的 vtable,存储基类的虚函数地址;
  • 派生类如果重写了基类的虚函数,vtable 中会用派生类的函数地址覆盖对应位置;
  • 派生类新增的虚函数,会被添加到 vtable 的末尾。

ShapeCircle举例,vtable 结构如下:

Shape类的vtable:
[0] → &Shape::draw

Circle类的vtable(继承自Shape):
[0] → &Circle::draw  // 覆盖基类的draw

3.2 虚指针(vptr):对象指向 vtable 的 "指针"

每个包含虚函数的对象,都会有一个虚指针(vptr),指向所属类的 vtable。vptr 在对象创建时(构造函数执行期间)被初始化,存储 vtable 的地址。

对象的内存布局(简化):

Shape对象:
+----------+
| vptr     | → 指向Shape的vtable
+----------+
| 其他成员变量 |
+----------+

Circle对象(继承Shape):
+----------+
| vptr     | → 指向Circle的vtable
+----------+
| 从Shape继承的成员变量 |
+----------+
| Circle自己的成员变量 |
+----------+

3.3 动态绑定的完整流程:从对象到函数调用

当我们用基类指针调用虚函数时,步骤如下:

  1. 通过基类指针找到对象的 vptr(对象内存的第一个位置);
  2. 通过 vptr 找到该对象所属类的 vtable;
  3. 在 vtable 中找到对应虚函数的地址(按索引);
  4. 调用该地址的函数。

用代码对应的流程图:

Shape* ptr = new Circle();  // ptr指向Circle对象
ptr->draw();  // 调用流程:

// 1. 取对象的vptr:*ptr → vptr(指向Circle的vtable)
// 2. 查vtable的第0项:Circle::draw的地址
// 3. 调用该地址的函数 → 输出"画圆形"

3.4 用代码验证 vtable 的存在

我们无法直接访问 vtable,但可以通过指针操作间接证明它的存在:

#include <iostream>
using namespace std;

class Base {
public:
    virtual void func1() { cout << "Base::func1" << endl; }
    virtual void func2() { cout << "Base::func2" << endl; }
};

class Derived : public Base {
public:
    void func1() override { cout << "Derived::func1" << endl; }
    virtual void func3() { cout << "Derived::func3" << endl; }  // 新增虚函数
};

int main() {
    Base* b = new Derived();
    
    // 步骤1:获取对象的vptr(假设vptr是对象的第一个成员)
    // 用long*指针指向对象,取第一个值(vptr的值)
    long* vptr = (long*)b;
    
    // 步骤2:通过vptr获取vtable(数组)
    long* vtable = (long*)*vptr;
    
    // 步骤3:调用vtable中的函数(通过函数指针)
    // 定义函数指针类型(无参数无返回值)
    typedef void (*Func)();
    
    Func f1 = (Func)vtable[0];  // vtable[0]是func1(已被Derived覆盖)
    Func f2 = (Func)vtable[1];  // vtable[1]是func2(基类版本)
    Func f3 = (Func)vtable[2];  // vtable[2]是Derived新增的func3
    
    f1();  // 输出"Derived::func1"
    f2();  // 输出"Base::func2"
    f3();  // 输出"Derived::func3"
    
    delete b;
    return 0;
}

这段代码通过指针直接访问 vtable 并调用函数,验证了 vtable 的结构 —— 基类虚函数被覆盖,派生类新增虚函数被添加到表中。

四、虚析构函数:避免多态场景下的内存泄漏

当用基类指针指向派生类对象并删除时,如果析构函数不是虚函数,会导致派生类部分内存泄漏。这是虚函数最容易被忽视的实战场景。

4.1 非虚析构函数的隐患

#include <iostream>
using namespace std;

class Base {
public:
    Base() { cout << "Base构造" << endl; }
    ~Base() { cout << "Base析构" << endl; }  // 非虚析构函数
};

class Derived : public Base {
private:
    int* data;  // 派生类自己的动态内存
public:
    Derived() { 
        data = new int[10]; 
        cout << "Derived构造" << endl; 
    }
    ~Derived() { 
        delete[] data;  // 释放动态内存
        cout << "Derived析构" << endl; 
    }
};

int main() {
    Base* ptr = new Derived();  // 基类指针指向派生类对象
    delete ptr;  // 只会调用Base的析构函数,Derived的析构函数不执行
    return 0;
}

运行结果:

Base构造
Derived构造
Base析构  // Derived的析构函数未被调用,data内存泄漏!

原因:析构函数不是虚函数时,delete ptr会根据ptr的类型(Base)调用 Base 的析构函数,忽略 Derived 的析构函数,导致派生类中动态分配的内存(如data)无法释放。

4.2 虚析构函数的解决方案

只需把基类的析构函数声明为虚函数,派生类的析构函数会自动成为虚函数:

class Base {
public:
    Base() { cout << "Base构造" << endl; }
    virtual ~Base() { cout << "Base析构" << endl; }  // 虚析构函数
};

// Derived类不变
int main() {
    Base* ptr = new Derived();
    delete ptr;  // 先调用Derived析构,再调用Base析构
    return 0;
}

运行结果:

Base构造
Derived构造
Derived析构  // 正确执行,释放data
Base析构

结论:只要类可能被继承,且存在动态内存分配,就应该把析构函数声明为虚函数。

五、纯虚函数与抽象类:定义接口的 "模板"

有时候我们希望基类只定义接口(函数名和参数),而不提供实现,强制派生类自己实现具体逻辑。这时就需要纯虚函数抽象类

5.1 纯虚函数:没有实现的虚函数

纯虚函数的定义方式是在虚函数声明后加= 0

class Shape {
public:
    virtual void draw() = 0;  // 纯虚函数:只有声明,没有实现
    virtual int getArea() = 0;
};

包含纯虚函数的类称为抽象类,抽象类有两个核心特性:

  1. 不能实例化对象(无法new Shape());
  2. 派生类必须重写所有纯虚函数,否则派生类也是抽象类。

5.2 抽象类的实战价值:定义接口规范

抽象类的本质是 "接口",它规定了派生类必须实现的功能,却不限制具体实现方式。比如定义一个 "图形" 接口,强制所有图形实现 "绘制" 和 "计算面积":

// 抽象类(接口)
class Shape {
public:
    virtual void draw() = 0;    // 必须实现绘制
    virtual int getArea() = 0;  // 必须实现面积计算
    virtual ~Shape() = default; // 虚析构函数(避免泄漏)
};

// 派生类实现接口
class Square : public Shape {
private:
    int side;
public:
    Square(int s) : side(s) {}
    void draw() override {
        cout << "绘制正方形" << endl;
    }
    int getArea() override {
        return side * side;
    }
};

class Triangle : public Shape {
private:
    int base, height;
public:
    Triangle(int b, int h) : base(b), height(h) {}
    void draw() override {
        cout << "绘制三角形" << endl;
    }
    int getArea() override {
        return (base * height) / 2;
    }
};

这样,所有继承Shape的类都必须实现drawgetArea,保证了接口的一致性,也方便用基类指针统一管理:

#include <vector>

int main() {
    vector<Shape*> shapes;
    shapes.push_back(new Square(5));
    shapes.push_back(new Triangle(4, 5));
    
    for (auto s : shapes) {
        s->draw();          // 多态调用:绘制不同图形
        cout << "面积:" << s->getArea() << endl;  // 多态调用:计算不同面积
        delete s;
    }
    return 0;
}

运行结果:

绘制正方形
面积:25
绘制三角形
面积:10

5.3 抽象类可以有非纯虚函数

抽象类并非只能有纯虚函数,也可以包含普通成员函数(带实现),供派生类直接使用:

class Shape {
public:
    virtual void draw() = 0;  // 纯虚函数(接口)
    void printArea() {  // 普通函数(提供默认实现)
        cout << "面积是:" << getArea() << endl;  // 调用纯虚函数(多态)
    }
    virtual int getArea() = 0;
};

class Square : public Shape {
    // ... 实现draw和getArea ...
};

int main() {
    Shape* s = new Square(5);
    s->printArea();  // 调用基类的printArea,内部多态调用getArea
    delete s;
    return 0;
}

这种设计既规定了接口(纯虚函数),又提供了通用功能(普通函数),是 "模板方法模式" 的典型应用。

六、虚函数的 "禁区":这些情况不能用虚函数

虚函数虽好,但并非万能,以下场景禁止使用虚函数,否则会导致编译错误或未定义行为。

6.1 构造函数不能是虚函数

构造函数的作用是初始化对象,包括设置 vptr。如果构造函数是虚函数,需要 vptr 找到对应的函数,但此时 vptr 还未初始化(鸡生蛋问题),因此 C++ 禁止构造函数为虚函数。

class Base {
public:
    virtual Base() {}  // 编译报错:构造函数不能是虚函数
};

6.2 静态成员函数不能是虚函数

静态成员函数属于类,不属于对象,没有this指针,而虚函数的调用依赖this指针找到 vptr。因此静态函数不能是虚函数。

class Base {
public:
    static virtual void func() {}  // 编译报错:静态函数不能是虚函数
};

6.3 内联函数不能是虚函数

内联函数的特点是 "编译期展开",而虚函数需要 "运行期动态绑定",两者矛盾。虽然语法允许virtual inline,但编译器会忽略inline,按普通虚函数处理。

class Base {
public:
    virtual inline void func() {  // inline被忽略
        cout << "Base::func" << endl;
    }
};

6.4 友元函数不能是虚函数

友元函数不是类的成员函数,没有this指针,无法访问 vptr,因此不能是虚函数。

class Base {
    friend virtual void func();  // 编译报错:友元不能是虚函数
};

七、虚函数的性能开销:该不该用虚函数?

虚函数的动态绑定不是没有代价的,主要开销体现在三个方面:

7.1 内存开销

  • 每个包含虚函数的对象会多一个 vptr(4 字节或 8 字节,取决于系统位数);
  • 每个类会有一个 vtable(存储虚函数地址,大小为虚函数数量 × 指针大小)。

对于小对象(如只包含几个成员变量),vptr 可能增加 20%-50% 的内存占用;但对于大对象,这点开销通常可以忽略。

7.2 时间开销

虚函数调用比普通函数多了两步操作:

  1. 通过 vptr 找到 vtable;
  2. 在 vtable 中查找函数地址。

这会导致虚函数调用比普通函数慢约 10%-20%(实测数据)。但在大多数场景下,这点性能损失远小于代码可读性和可维护性的收益。

7.3 优化建议

  • 不需要多态的函数,不要声明为虚函数;
  • 类层次结构不要过深(过深的继承会增加 vtable 查找成本);
  • 频繁调用的热点函数(如循环内),尽量避免用虚函数(可用模板或函数指针替代)。

八、虚函数常见面试题与坑点解析

8.1 面试题 1:重写、重载、隐藏的区别?

  • 重写:派生类重写基类的虚函数,要求函数名、参数、返回值相同(协变除外),依赖虚函数实现多态;
  • 重载:同一作用域内的同名函数,参数列表不同,编译期绑定;
  • 隐藏:派生类函数与基类同名但不构成重写,派生类函数会隐藏基类函数,调用时需显式指定。

8.2 面试题 2:虚函数表在什么时候创建?存在哪里?

  • vtable 在编译期创建,属于类的元信息;
  • 存储在只读数据段(.rodata),和常量、代码存放在一起,程序启动时加载到内存。

8.3 坑点 1:派生类重写时改变参数列表,导致隐藏而非重写

class Base {
public:
    virtual void func(int a) { cout << "Base::func(int)" << endl; }
};

class Derived : public Base {
public:
    void func(double a) {  // 参数不同,不构成重写,而是隐藏
        cout << "Derived::func(double)" << endl;
    }
};

int main() {
    Base* ptr = new Derived();
    ptr->func(10);  // 调用Base::func(int)(因为Derived没有重写)
    delete ptr;
    return 0;
}

解决:重写时确保参数列表完全相同,并用override关键字检查。

8.4 坑点 2:构造函数中调用虚函数,不会触发多态

class Base {
public:
    Base() {
        func();  // 构造函数中调用虚函数
    }
    virtual void func() { cout << "Base::func" << endl; }
};

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

int main() {
    Derived d;  // 输出"Base::func",而非"Derived::func"
    return 0;
}

原因:构造派生类对象时,先调用基类构造函数,此时派生类部分尚未初始化,vptr 仍指向基类 vtable,因此调用基类版本。永远不要在构造 / 析构函数中调用虚函数

九、总结:虚函数是 C++ 多态的 "基石"

虚函数的本质是通过 vtable 和 vptr 实现的动态绑定机制,它让 C++ 能够写出 "以不变应万变" 的代码 —— 用基类指针统一管理不同派生类对象,调用时自动匹配正确的实现。

从实战角度看,虚函数的核心价值体现在三个方面:

  1. 多态性:同一接口,不同实现,简化代码逻辑;
  2. 接口定义:通过纯虚函数构建抽象类,规范派生类行为;
  3. 内存安全:虚析构函数避免多态场景下的内存泄漏。

最后,记住虚函数的 "使用三原则":

  • 需要多态时才用虚函数,不滥用;
  • 基类析构函数必须是虚函数(除非类不被继承);
  • 重写时用override关键字,避免隐藏陷阱。

理解虚函数,你就掌握了 C++ 面向对象编程的 "半壁江山"。下次再遇到基类指针调用函数的场景,不妨想一想:vptr 此刻指向哪个 vtable?要调用的函数地址在 vtable 的第几项?想清楚这些,你写的代码会更健壮、更优雅。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值