MST

星途 面试题库

面试题:C++虚函数机制在对象性能方面的深度剖析

C++的虚函数机制为多态性提供了支持,但也会对对象性能产生一定影响。请深入分析虚函数表的实现原理,以及在对象调用虚函数过程中,从编译期到运行期,涉及到哪些性能开销?在复杂的继承体系中,如何优化虚函数调用的性能?
24.3万 热度难度
编程语言C++

知识考点

AI 面试

面试题答案

一键面试

虚函数表的实现原理

  1. 概念
    • 当一个类包含虚函数时,编译器会为这个类生成一个虚函数表(vtable)。虚函数表是一个指针数组,数组中的每个元素是一个指向虚函数的指针。
    • 每个包含虚函数的类对象都有一个隐含的指针,称为虚指针(vptr),它指向该类的虚函数表。
  2. 生成过程
    • 编译器在编译阶段,为每个包含虚函数的类构建虚函数表。在虚函数表中,按照虚函数在类中声明的顺序,依次填入虚函数的地址。
    • 对于派生类,如果重写了基类的虚函数,那么在派生类的虚函数表中,相应位置会填入派生类重写的虚函数地址;如果没有重写,就填入基类虚函数的地址。同时,派生类可能会在虚函数表末尾添加自己新定义的虚函数。
  3. 示例代码说明
class Base {
public:
    virtual void func1() {}
    virtual void func2() {}
};

class Derived : public Base {
public:
    void func1() override {}
    virtual void func3() {}
};

在上述代码中,Base类有func1func2两个虚函数,编译器会为Base类生成一个虚函数表,表中两个元素分别指向Base::func1Base::func2的地址。Derived类重写了func1,其虚函数表中func1位置指向Derived::func1的地址,func2位置仍指向Base::func2的地址,并且在虚函数表末尾添加了指向Derived::func3的地址。

对象调用虚函数过程中的性能开销

  1. 编译期开销
    • 生成虚函数表:编译器需要额外的工作来为每个包含虚函数的类生成虚函数表,这增加了编译时间和生成的目标代码大小。
    • 生成虚指针:编译器要为每个包含虚函数的类对象添加一个虚指针,这会增加对象的大小。例如,在32位系统中,一个虚指针占4字节,64位系统中占8字节。
  2. 运行期开销
    • 虚指针寻址:每次通过对象调用虚函数时,首先要通过对象的虚指针找到虚函数表,这需要一次额外的内存间接寻址操作。例如,假设对象地址为obj_addr,虚指针偏移量为offset,要获取虚函数表地址vtable_addr,需要执行vtable_addr = *(void**)(obj_addr + offset),这种间接寻址会增加指令执行时间。
    • 虚函数表索引:找到虚函数表后,还要根据虚函数在表中的索引找到具体的虚函数地址,然后才能调用该函数,这也增加了一定的开销。

在复杂继承体系中优化虚函数调用性能的方法

  1. 使用非虚接口(NVI)惯用法
    • 原理:在基类中提供一个非虚的公共接口函数,该函数内部调用一个虚函数来实现具体的功能。这样,外部调用者调用的是非虚函数,避免了虚函数调用的间接开销,同时又能利用虚函数的多态性。
    • 示例代码
class Shape {
public:
    void draw() {
        doDraw();
    }
private:
    virtual void doDraw() = 0;
};

class Circle : public Shape {
private:
    void doDraw() override {
        // 具体的画圆逻辑
    }
};

在上述代码中,Shape::draw是非虚函数,外部通过Shape对象或Circle对象调用draw时,直接执行该函数,然后在内部调用虚函数doDraw实现多态。 2. 使用静态多态(模板)

  • 原理:模板是在编译期实现多态,通过模板参数的不同实例化不同的代码,避免了运行期的虚函数调用开销。
  • 示例代码
template <typename T>
void draw(T& obj) {
    obj.draw();
}

class Rectangle {
public:
    void draw() {
        // 具体的画矩形逻辑
    }
};

Rectangle rect;
draw(rect);

这里通过模板函数draw,在编译期根据传入对象的类型确定具体调用的draw函数,没有运行期虚函数调用的开销。 3. 减少不必要的虚函数

  • 原理:如果某些函数在整个继承体系中不会被重写,那么将其定义为非虚函数,避免虚函数调用的开销。
  • 示例:假设Shape类中有一个getArea函数,在所有派生类中实现方式相同,就可以将其定义为非虚函数。
class Shape {
public:
    double getArea() {
        // 通用的计算面积逻辑
        return 0;
    }
};
  1. 使用缓存
    • 原理:在某些情况下,可以缓存虚函数的调用结果。例如,如果虚函数的返回值在一定条件下不会改变,可以将第一次调用的结果缓存起来,后续直接返回缓存值,减少虚函数调用次数。
    • 示例代码
class ComplexCalculation {
public:
    virtual double calculate() {
        if (cachedResult.has_value()) {
            return cachedResult.value();
        }
        double result = /* 复杂计算 */;
        cachedResult = result;
        return result;
    }
private:
    std::optional<double> cachedResult;
};

在上述代码中,calculate虚函数在第一次调用后缓存结果,后续调用直接返回缓存值,减少了虚函数调用带来的开销。