C++代码优化(五):虚函数的开销和优化方式
目录
3.3.使用 CRTP(Curiously Recurring Template Pattern)替代虚函数
1.虚函数的工作原理
C++ 中,虚函数提供了动态多态性,让程序能够在运行时通过基类指针或引用调用派生类的重载函数。尽管虚函数在设计上灵活,但它也带来了额外的性能开销。
1.1.虚函数表(vtable)的结构和作用
虚函数表(vtable),是C++中实现多态的关键数据结构。每个多态类都拥有一个vtable指针,通常隐藏在对象内存布局的最开始处。vtable包含了类中所有虚函数的指针,当通过基类指针或引用调用派生类中的虚函数时,程序会查看vtable来确定应调用哪个函数。
虚函数表的结构对程序的性能有重要影响。在对象实例化时,vtable会被创建,并在运行时通过它来解析虚函数调用。这涉及额外的内存访问,可能会带来缓存不命中的问题,特别是在大规模对象系统中。然而,使用vtable的好处在于,它允许在不改变代码编译的情况下动态地改变程序的行为,这为C++的多态特性提供了基础。
虚函数表的结构
虚函数表是一个数组,每个元素都是一个指向虚函数的指针。以下是一个简单的虚函数表结构示例:
class Base {
public:
virtual void func1() { /* implementation */ }
virtual void func2() { /* implementation */ }
virtual ~Base() { /* destructor */ }
};
class Derived : public Base {
public:
void func1() override { /* overridden implementation */ }
void func2() override { /* overridden implementation */ }
void func3() { /* new function */ }
};
对于上述类结构,编译器可能生成如下的虚函数表:
- Base类的vtable:
- Derived类的vtable:
注意:
- 虚函数表中的每个条目都是指向虚函数的指针。
- 虚析构函数也是虚函数表中的一部分,确保在删除派生类对象时能够正确调用析构函数链。
- 如果一个类没有显式声明虚函数,但它从有虚函数的基类继承,它仍然会有虚函数表。
1.2.虚函数调用的内部过程
当一个虚函数被调用时,程序会执行以下步骤:
- 查看对象的虚函数表指针。
- 通过虚函数表找到要调用函数的实际地址。
- 跳转到该地址执行函数代码。
这个过程比普通函数调用多了一层间接性,因此会有一定的性能开销。特别是在频繁的虚函数调用场景下,这些开销可能会累积成为显著的性能瓶颈。理解这一过程可以帮助开发者在设计和实现类层次结构时,更加明智地使用多态特性,优化系统性能。
2.虚函数的开销
-
虚函数表 (vtable) 开销:每个包含虚函数的类通常会生成一个虚函数表,用于存储指向虚函数实现的指针。每个多态对象在内存中包含一个指向虚表的指针,这个指针称为虚表指针 (vptr)。因此,每个对象会多占用一个指针大小的内存(通常为 4 或 8 字节,具体取决于系统架构)。
-
间接调用开销:调用虚函数需要先访问虚表,通过虚表指针找到具体函数的地址,然后进行调用。这种间接寻址比普通函数调用更慢,尤其在频繁调用虚函数的场景中,开销会显著增加。
-
内存布局影响:对象内的虚表指针增加了对象大小,并且在某些情况下会导致内存布局的不连续性,增加了 CPU 缓存未命中的概率,降低缓存命中率。
-
编译器优化受限:编译器对虚函数的优化受限。例如,普通的非虚函数调用可以内联 (inline) 优化,但虚函数由于其动态特性无法在编译期确定实际的调用目标,因此无法轻易内联。
为了了解虚函数调用开销的实际大小,我们可以进行一些简单的性能测试。下面是一个示例程序,用于比较直接调用函数和通过虚函数指针调用函数的性能:
class Base {
public:
virtual void func() {
// 虚函数的实现
}
};
class Derived : public Base {
public:
void func() override {
// 派生类中虚函数的实现
}
};
void directCall(Base& obj) {
obj.func();
}
void indirectCall(Base* obj) {
obj->func();
}
int main() {
Base b;
Derived d;
clock_t start, end;
double directTime, indirectTime;
start = clock();
for (int i = 0; i < 10000000; i++) {
directCall(b);
}
end = clock();
directTime = (double)(end - start) / CLOCKS_PER_SEC;
start = clock();
for (int i = 0; i < 10000000; i++) {
indirectCall(&b);
}
end = clock();
indirectTime = (double)(end - start) / CLOCKS_PER_SEC;
std::cout << "直接调用函数的时间:" << directTime << " 秒" << std::endl;
std::cout << "通过虚函数指针调用函数的时间:" << indirectTime << " 秒" << std::endl;
return 0;
}
在这个程序中,我们定义了一个基类 Base 和一个派生类 Derived ,它们都有一个虚函数 func 。我们还定义了两个函数 directCall 和 indirectCall ,分别用于直接调用函数和通过虚函数指针调用函数。在 main 函数中,我们分别测量了直接调用函数和通过虚函数指针调用函数的时间,并输出结果。
通过运行这个程序,我们可以得到直接调用函数和通过虚函数指针调用函数的时间。在我的测试环境中,直接调用函数的时间大约为 0.05 秒,而通过虚函数指针调用函数的时间大约为 0.15 秒。这表明,虚函数的调用开销大约是直接调用函数的三倍。
需要注意的是,这个测试结果只是一个示例,实际的虚函数调用开销会受到很多因素的影响,如硬件平台、编译器优化、虚函数的实现复杂度等。因此,在实际应用中,我们需要根据具体情况进行性能测试,以确定虚函数的调用开销是否可以接受。
3.如何减少虚函数调用开销
3.1.使用final和override关键字
在C++11及以后的版本中,可以使用final关键字来防止类被继承或虚函数被重写。这有助于编译器进行更彻底的优化,因为它消除了动态绑定的需要。
override关键字用于显式指出派生类中的函数将覆盖基类中的虚函数。这不仅提高了代码的可读性,还允许编译器在编译时进行类型检查,从而减少潜在的错误。
示例:
class Base {
public:
virtual void process() { /* 虚函数 */ }
};
class Derived final : public Base {
public:
void process() final override { /* 最终实现,不允许进一步派生 */ }
};
在此例中,标记 Derived
为 final
类,编译器可以针对 process
做更多的优化,因为它明确知道没有进一步的派生类会重写 process
。注意:即便使用了 final
,虚表仍然存在,但编译器可以在具体类型调用时优化掉虚函数的间接调用。
3.2.非虚接口(NVI)模式
非虚接口模式是一种设计模式,它将虚函数的使用限制在基类中,并通过非虚成员函数来调用这些虚函数。这样,派生类只需要重写基类中的少数几个虚函数,而大部分逻辑则保持在基类的非虚成员函数中。
这种模式有助于减少虚函数的调用次数,因为大部分工作都在基类的非虚成员函数中完成,而派生类只需要提供必要的重写。
3.3.使用 CRTP(Curiously Recurring Template Pattern)替代虚函数
对于需要静态多态的情况,CRTP 是一种可以替代虚函数的高效方法。CRTP 利用模板参数在编译时实现静态多态性,避免了运行时的虚表查找。示例如下:
template <typename Derived>
class Base {
public:
void process() {
static_cast<Derived*>(this)->process();
}
};
class Derived : public Base<Derived> {
public:
void process() { /* 具体实现 */ }
};
在此例中,Base
中的 process
是一个非虚函数,通过模板参数在编译期静态分发到 Derived::process
。CRTP 适用于不需要动态多态的场景,可以有效减少虚函数带来的开销。
3.4.使用函数指针替代虚函数
当只需动态绑定单个方法的调用时,可以考虑使用函数指针代替虚函数。这样既能提供动态多态性,又能减少虚表带来的额外内存开销。
示例如下:
class Base {
public:
using FuncPtr = void(*)(Base*);
Base(FuncPtr func) : func_(func) {}
void process() { func_(this); }
private:
FuncPtr func_;
};
void processImplementation(Base* obj) {
// 实现逻辑
}
int main() {
Base obj(processImplementation); // 使用函数指针调用
obj.process();
return 0;
}
在这种情况下,通过构造函数传入的函数指针实现了类似虚函数的效果,但不需要虚表,从而减少了内存和运行时开销。
3.5.使用内联函数
如果虚函数的实现非常简单,可以将其声明为内联函数。内联函数可以在编译时将函数体插入到调用点,避免了函数调用的开销。但是,内联函数也有一些限制,如函数体不能太大,否则会导致代码膨胀。
3.6.考虑使用其他设计模式
在某些情况下,可以考虑使用其他设计模式来替代虚函数的使用。例如,使用策略模式、访问者模式或状态模式等,这些模式可以在不牺牲性能的情况下实现类似的多态性。但是,这些技术也有一些复杂性,需要根据具体情况进行选择。
3.7.微软的下一代多态库
C++20到来了,更好的符合零成本抽象的多态实现方式来了——微软Proxy库。
Proxy说是一个库,其实就只是一个头文件而已。
那么基于微软的的Proxy,应该如何编写上面的多态呢?
看下面的代码,特别是开头的两个struct类。 没错,C++里面struct也是类。
#include <iostream>
#include "proxy.h"
// 声明一个代理类,最终会通过这个代理类去调用真正的类对象的成员函数
struct Show : pro::dispatch<void()>
{
template <class T>
void operator()(T& self) { self.show(); }
};
struct Model : pro::facade<Show> {};
class Who {
public:
void show() {
std::cout << "model run!\n";
}
};
class Boy {
public:
void show() {
std::cout << "boy run!\n";
}
};
class Girl {
public:
void show() {
std::cout << "girl run!\n";
}
};
class Man {
public:
void show() {
std::cout << "man run!\n";
}
};
void justrun(pro::proxy<Model> m) {
m.invoke<Show>();
}
int main() {
Who who;
Girl girl;
Boy boy;
Man man;
justrun(&who);
justrun(&girl);
justrun(&boy);
justrun(&man);
}
重点是开头的Show和Model两个类。 按照这样的方式,定义这样子的两个类,就描述了一个类的基本行为。
之后你只需要用proxy调用这个行为就可以了。 用proxy就完美的用零成本抽象的方式极致的解决了C++的多态问题。
那么,很多人会说,我哪里记得住开头两个类的定义方法啊,定义方式长得那么猥琐。
别急,微软还贴心的用宏定义了简化定义方式:
#include<iostream>
#include "proxy.h"
DEFINE_MEMBER_DISPATCH(Show, show, void());
DEFINE_FACADE(Model, Show);
class Who {
public:
void show() {
std::cout << "model run!\n";
}
};
class Boy {
public:
void show() {
std::cout << "boy run!\n";
}
};
class Girl {
public:
void show() {
std::cout << "girl run!\n";
}
};
class Man {
public:
void show() {
std::cout << "man run!\n";
}
};
void justrun(pro::proxy<Model> m) {
m.invoke<Show>();
}
int main() {
Who who;
Girl girl;
Boy boy;
Man man;
justrun(&who);
justrun(&girl);
justrun(&boy);
justrun(&man);
}
现在,整个代码就变得清爽宜人了。
可见,在C++20时代了,C++长期被人诟病的性能损失问题:虚函数实现多态,这一终极短板,也终于被克服了。
4.总结
虚函数在提供动态多态性的同时,也引入了内存和性能开销。在高性能场景中,可以通过以下优化策略有效减少虚函数的开销:
-
使用
final
关键字优化派生链:避免进一步派生,允许编译器内联优化。 -
使用 CRTP 替代虚函数:在需要静态多态的场景中,CRTP 是高效的替代方案。
-
使用函数指针实现动态调用:可在不引入虚表的情况下实现动态多态。
通过这些优化方法,C++ 程序可以在保持多态灵活性的同时,最大化减少虚函数带来的性能开销。你在编程中,为了极致的性能,会用哪些方式来避免避免虚函数的开销?欢迎留言讨论。
原文地址:https://blog.csdn.net/haokan123456789/article/details/143867770
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!