高效调用函数
调用函数存在许多开销 —— 包括获取函数地址并跳转、传递参数和返回结果、设置栈帧、保存和恢复寄存器、异常处理、代码缓存未命中所带来的延迟等。
在将代码拆分为函数时,若要最大化性能,应该考虑以下几点:
慎重创建过多的函数
只有在函数具有足够的可复用性时才应创建函数。创建函数的标准应基于程序逻辑流程和可复用性,而不是代码长度。因为如前所述,函数调用不是免费的,过度拆分成函数并不是一个好主意。
将相关函数放在一起
类成员函数和非类成员函数在内存中通常按它们定义的顺序分配地址。因此,把频繁互相调用或操作同一数据集的性能关键函数放在一起是一个好主意,这有助于提升代码和数据缓存的性能。
链接时优化(LTO)或全程序优化(WPO)
在编写性能关键函数时,尽可能将其放在与其使用位置相同的模块中。这样可以启用许多编译器优化,最重要的是可以内联函数调用。
使用 static
关键字声明函数,相当于将其放入匿名命名空间,从而将其限定为当前翻译单元。使用 inline
关键字也可以达到类似目的,稍后我们会详细探讨。
为编译器指定 WPO 和 LTO 参数,可指示它将整个代码库视为一个单一模块,从而启用跨模块的优化。否则,优化只在同一模块内部的函数间生效,而无法跨模块进行,这在大型代码库中尤其低效,因为它们通常包含许多源文件和模块。
宏、内联函数和模板元编程
宏表达式是一种预处理指令,在编译前就被展开,从而消除了运行时函数调用和返回的开销。但宏也有许多缺点,例如命名冲突、难以理解的编译错误、不必要的条件或表达式求值等。
内联函数(无论是否是类成员)类似于宏,但解决了宏的大多数问题。内联函数在编译和链接时在调用处展开,从而消除了函数调用的开销。
使用模板元编程可以将大量计算工作从运行时转移到编译时。这涉及部分或完全模板特化和递归模板循环。但模板元编程通常较为笨拙,编写、编译和调试都较困难,只有在性能提升值得额外开发成本时才应使用。我们将在后文进一步探讨模板和模板元编程。
避免使用函数指针
通过函数指针调用函数的开销比直接调用更大。若指针值可能变化,编译器无法预测将调用哪个函数,因此无法预取指令和数据。此外,这也阻止了编译器进行内联等优化。
现代 C++ 提供了功能更强大的 std::function
,但除非必要应避免使用,因为其相比直接内联函数多出几个时钟周期的开销,且容易被误用。std::bind
也是一个需要谨慎使用的工具,也应仅在确有必要时使用。
如果必须使用 std::function
,建议优先考虑使用 lambda 表达式替代 std::bind
,因为 lambda 通常调用更快。总之,使用 std::function
和 std::bind
时要格外小心,因为它们可能在内部触发虚函数调用甚至动态内存分配,常常让开发者感到意外。
函数参数按引用或指针传递
对于基本类型,按值传参非常高效。对于复合类型,优选的方式是使用 const
引用。const
限定符表明对象不可修改,便于编译器进行优化;引用形式也可能让编译器直接内联对象。如果函数需要修改传入对象,则应使用非常量引用或指针。
返回简单类型
返回基本类型的函数非常高效。而返回复合类型则效率低下,有时会产生一两个拷贝操作,尤其当对象较大或拥有慢速拷贝构造函数、赋值操作符时,这种操作极为低效。
当编译器可以应用返回值优化(RVO)时,可以省略中间对象的创建,直接将结果写入调用者的对象中。最优方式是由调用者创建目标对象,并通过引用或指针传入函数,由函数修改它。
例如,下面的代码演示了 RVO 的作用(位于 GitHub 的 Chapter3 中的 rvo.cpp 文件):
#include <iostream> struct LargeClass { int i; char c; double d; }; auto rvoExample(int i, char c, double d) { return LargeClass{i, c, d}; } int main() { LargeClass lc_obj = rvoExample(10, 'c', 3.14); }
在启用 RVO 的情况下,rvoExample()
函数不会创建一个临时的 LargeClass
对象再复制给 lc_obj
,而是会直接构造 lc_obj
,避免了临时对象和复制操作。
避免递归函数或用循环替代
递归函数由于反复调用自身而效率低下。它们也可能导致调用栈过深,占用大量栈空间,最坏情况甚至引发栈溢出。这会带来大量缓存未命中,因为涉及新的内存区域,也使得返回地址难以预测且效率低下。
在这种情况下,将递归改为循环可以显著提升效率,避免递归函数带来的缓存性能问题。
如需我帮助你总结或整理成学习笔记式内容,也可以告诉我!
系统当前共有 427 篇文章