欢迎光临
我们一直在努力

C++虚函数

C++虚函数 学习笔记

核心定位:C++面向对象编程里最核心的特性之一,主要用来实现“运行时多态”,简单说就是能让同一个函数调用,根据不同的对象,执行不同的逻辑。这也是面试里经常考的内容,重点要记住怎么用、底层是怎么实现的,以及哪些地方容易出错。

一、虚函数基础定义

核心:在基类里,用virtual关键字声明的成员函数,允许子类重新写这个函数的实现。当我们用基类的指针或者引用,去调用这个函数时,不会固定执行基类的版本,而是看这个指针/引用实际指向的是哪个子类的对象,就执行哪个子类的函数版本——这就是“动态多态”。

通俗类比:比如定义一个“交通工具”基类,里面有一个“运行”函数;再定义“汽车”和“飞机”两个子类,分别重新写“运行”函数(汽车是公路跑,飞机是天上飞)。我们用“交通工具”类型的指针,不管指向汽车还是飞机,调用“运行”函数时,都会自动执行对应交通工具的运行方式,不用手动判断。

基础语法

#include <iostream>
using namespace std;

// 基类:交通工具
class Vehicle {
public:
virtual void run() { // 用virtual声明虚函数
cout << "未知交通工具运行" << endl;
}
virtual ~Vehicle() {} // 虚析构函数(后面详细说)
};

// 子类:汽车,重写基类的run函数
class Car : public Vehicle {
public:
void run() override { // override关键字,能帮我们检查是不是真的重写对了
cout << "汽车在公路上飞驰" << endl;
}
};

// 子类:飞机,重写基类的run函数
class Plane : public Vehicle {
public:
void run() override {
cout << "飞机在蓝天上翱翔" << endl;
}
};

int main() {
// 用基类指针,分别指向子类对象
Vehicle* v1 = new Car();
Vehicle* v2 = new Plane();
// 调用run函数,自动执行对应子类的版本
v1->run(); // 实际指向Car,输出汽车的运行方式
v2->run(); // 实际指向Plane,输出飞机的运行方式
// 释放内存
delete v1;
delete v2;
return 0;
}

关键笔记:子类重写基类虚函数时,有个硬性要求——函数名、参数列表(比如参数的类型、个数)、返回值,必须和基类的虚函数完全一样(有个特殊情况叫“协变”,暂时不用关注)。建议一定要加override关键字,这样如果不小心写错了(比如函数名拼错、参数写错),编译器会直接报错,避免犯低级错误。

二、虚函数实现多态的4个必要条件

只有同时满足这4个条件,才能实现动态多态,少一个都不行:

  • 基类里的函数,必须用virtual关键字声明成虚函数——这是最基础的前提,要是基类没加virtual,不管子类怎么重写,都不能实现动态多态。

  • 子类必须重写基类的虚函数——这里要注意,“重写”和“重载”不一样:重载是在同一个类里,有多个函数名一样,但参数不一样的函数;重写是子类和基类之间,函数名、参数、返回值都一样,只是实现逻辑不同。

  • 必须用基类的指针或者引用,去调用这个虚函数——如果直接用子类的对象调用(比如Car c; c.run();),就和普通函数一样,不会触发多态,只会执行子类自己的版本。

  • 必须有基类和子类的继承关系——没有继承,就没有“基类指针指向子类对象”的情况,自然也就谈不上多态了。

  • 易错点:如果基类的函数没加virtual,就算子类重写了,用基类指针调用时,还是会执行基类的版本(这叫“静态绑定”);如果子类的函数参数和基类不一样,比如基类是void run(int),子类是void run(double),这不算重写,只是子类自己的重载函数,多态也不会生效。

    三、虚函数底层原理

    虚函数能实现动态多态,底层靠的是两个东西:虚函数表(简称vtable)和虚表指针(简称vptr)。这两个东西不是我们自己写的,是编译器在编译代码的时候自动生成的,运行的时候再通过它们找到要执行的函数。

    1. 核心结构详解

    (1)虚函数表(vtable)
    • 本质:就是编译器给每个包含虚函数的类(不管是基类还是子类),自动创建的一个“全局只读数组”,这个数组里存的,都是这个类里所有虚函数的内存地址(可以理解成“函数的门牌号”)。

    • 特点:每个类都有自己独立的虚函数表,不会和其他类共用;虚函数在数组里的顺序,和我们在类里声明虚函数的顺序是一样的。

    • 子类虚函数表的生成规则:先把基类的虚函数表完整复制一份,然后看子类重写了哪些虚函数,就把复制过来的、对应位置的“函数门牌号”,换成子类自己的虚函数地址;如果子类有新增的虚函数,就把新增虚函数的地址,追加到虚函数表的末尾。

    (2)虚表指针(vptr)
    • 本质:编译器给每个包含虚函数的对象,偷偷加的一个隐藏成员变量(我们写代码的时候看不到,但它确实存在)。这个变量是一个指针,在32位系统里占4个字节,64位系统里占8个字节,通常是对象在内存中存储的第一个成员。

    • 作用:这个指针就像一个“导航仪”,专门指向当前对象所属类的虚函数表。比如Car对象的vptr,就指向Car类的虚函数表;Plane对象的vptr,就指向Plane类的虚函数表。

    • 初始化时机:当我们创建一个对象(比如new Car())时,会先执行构造函数,而构造函数执行的第一步,就是给vptr赋值,让它指向自己类的虚函数表——也就是说,对象一创建,vptr就知道自己该找哪个虚函数表了。

    2. 虚函数调用完整流程

    以代码里的“Vehicle* v = new Car(); v-&gt;run();”为例,一步步看它是怎么找到Car类的run函数的:

  • 我们调用v-&gt;run(),编译器首先识别到run()是一个虚函数,知道不能直接确定要执行哪个版本。

  • 找到v指针实际指向的对象——这里v指向的是Car对象,从这个Car对象里,取出它隐藏的vptr指针。

  • 通过vptr指针,找到它指向的Car类的虚函数表(vtable)。

  • 在虚函数表里,按照run()函数在基类中声明的顺序,找到对应的“函数门牌号”——也就是Car::run()的内存地址。

  • 根据这个地址,执行Car类的run()函数,最终输出“汽车在公路上飞驰”。

  • 对比普通函数:普通函数在编译的时候,编译器就已经确定了它的“门牌号”,运行时直接去这个地址调用函数,一步到位;而虚函数多了“取vptr→找vtable→找函数地址”这三步,所以会有一点点性能开销,但几乎可以忽略。

    3. 子类vtable结构示例

    // 基类:有3个虚函数
    class Base {
    public:
    virtual void func1() {}
    virtual void func2() {}
    virtual void func3() {}
    };
    // 子类:重写了基类的func1和func3,还新增了一个虚函数func4
    class Derived : public Base {
    public:
    void func1() override {}
    void func3() override {}
    virtual void func4() {}
    };

    基类的虚函数表(vtable_Base):里面按顺序存着基类3个虚函数的地址,也就是 [&amp;Base::func1, &amp;Base::func2, &amp;Base::func3]

    子类的虚函数表(vtable_Derived):先复制基类的虚函数表,再把func1和func3的地址换成子类自己的,最后追加新增的func4地址,也就是 [&amp;Derived::func1, &amp;Base::func2, &amp;Derived::func3, &amp;Derived::func4]

    规律总结:子类虚函数表 = 复制基类虚函数表 + 替换重写函数地址 + 追加新增虚函数地址。

    四、虚函数避坑点

    1. 虚析构函数必须加

    适用场景:当基类有虚函数,并且我们用基类的指针去创建子类对象、最后用delete删除这个指针的时候。

    如果不加虚析构函数,会有什么后果:删除指针的时候,只会执行基类的析构函数,子类的析构函数不会执行。如果子类里有动态分配的资源(比如用new创建的数组、对象),这些资源就无法释放,会造成“内存泄漏”(也就是内存被占用,却不能再使用)。

    注意:只要基类的析构函数加了virtual,子类的析构函数可以加virtual,也可以不加——因为子类会继承基类析构函数的“虚属性”,不管加不加,删除基类指针时,都会先执行子类析构,再执行基类析构。

    2. 构造函数不能是虚函数

    原因很简单,主要有3点:

    • 虚函数的调用,需要靠vptr找到vtable,但vptr是在构造函数执行的时候才初始化的——也就是说,构造函数执行之前,vptr还不存在,根本无法找到虚函数表,自然也就无法实现虚函数的动态绑定。

    • 构造函数的作用,是创建一个具体的对象(比如new Car(),就是明确要创建Car对象),必须明确知道要创建的是哪个类的对象,不需要多态。

    • C++语法本身就禁止把构造函数声明成虚函数,强行写的话,编译器会直接报错。

    3. 纯虚函数与抽象类

    纯虚函数的格式:在虚函数的声明后面加“= 0”,比如 virtual 函数名() = 0; 。它的特点是,基类只声明这个函数的“接口”(告诉子类有这个函数),但不提供具体的实现(不用写函数体)。

    包含纯虚函数的类,叫做“抽象类”,它有两个关键特点:

    • 抽象类不能直接创建对象(比如Shape s; 或者 new Shape(); 都会报错),它只能被继承,作为“模板”使用。

    • 子类继承抽象类后,必须重写基类所有的纯虚函数;只要有一个纯虚函数没重写,这个子类也会变成抽象类,同样不能创建对象。

    用途:比如我们定义一个“图形”抽象类,里面有一个纯虚函数“计算面积”,然后让“圆形”“正方形”“三角形”等子类继承它,各自重写“计算面积”的方法——这样就能保证所有图形都有计算面积的功能,规范了子类的实现。

    4. 虚函数的性能开销

    虚函数确实有一点点性能开销,但日常开发中完全不用过度担心,只有在特殊场景下才需要考虑。

    开销主要体现在3个方面:

    • 多了两次间接的内存访问:调用虚函数时,要先取vptr,再通过vptr找vtable,这两步都是内存访问,比普通函数直接调用多花一点时间。

    • 可能触发Cache Miss:vptr和vtable都是存在内存中的,如果CPU的高速缓存(Cache)里没有这些数据,就需要从速度慢很多的主存中加载,会增加一点延迟(这就是之前学的缓存未命中)。

    • 无法被编译器内联优化:编译器可以把普通函数的代码,直接嵌入到调用它的地方(这叫内联优化),减少函数调用的开销;但虚函数是运行时才确定要调用哪个版本,编译器提前不知道,所以无法做内联优化。

    注意:单次虚函数调用的开销,大概只有1-3个CPU周期,现代CPU每秒能执行数亿次操作,所以普通业务代码(比如后台接口、管理系统)完全感觉不到这个差异。只有在高频循环(比如游戏每帧的核心逻辑)、嵌入式开发(CPU算力弱)这种对性能要求极高的场景,才需要考虑减少虚函数的使用。

    五、面试高频问题

  • 虚函数的作用?答:实现运行时多态,让基类指针或引用调用虚函数时,能根据实际指向的对象类型,执行对应子类的函数版本。

  • 虚函数实现多态的必要条件?答:基类用virtual声明虚函数、子类重写基类虚函数、用基类指针或引用调用虚函数、存在基类和子类的继承关系。

  • 虚函数的底层实现机制?答:靠虚函数表(vtable)和虚表指针(vptr)实现;编译期编译器为每个含虚函数的类生成vtable,为每个对象生成vptr;运行时通过vptr找到对应的vtable,再从vtable中找到要执行的虚函数地址,完成调用。

  • 为什么基类析构要声明为虚函数?答:为了避免用基类指针删除子类对象时,子类的析构函数不执行,导致子类中动态分配的资源泄漏。

  • 构造函数能是虚函数吗?为什么?答:不能;因为虚函数依赖vptr,而vptr是在构造函数执行时才初始化的,构造函数执行前vptr不存在,无法实现动态绑定,而且C++语法也禁止这样做。

  • 赞(0)
    未经允许不得转载:171主机测评 » C++虚函数
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址