7-9 内联函数与变量
假设你需要编写代码来执行某个离散任务,例如读取用户输入、向文件输出内容或计算特定值。在实现这段代码时,你基本有两种选择:
- 将代码作为现有函数的一部分编写(称为“原地编写”或“内联编写”)。
- 创建新函数(可能还包含子函数)来处理该任务。
将代码放入新函数中具有诸多潜在优势,因为小型函数:
- 在整体程序上下文中更易于阅读和理解。
- 更易于复用,因为函数本身具有模块化特性。
- 更易于更新,因为只需在单一位置修改代码。
然而,使用新函数的一个缺点是每次函数调用都会产生一定的性能开销。请考虑以下示例:
#include <iostream>
int min(int x, int y)
{
return (x < y) ? x : y;
}
int main()
{
std::cout << min(5, 6) << '\n';
std::cout << min(3, 2) << '\n';
return 0;
}
当遇到对 min() 的调用时,CPU 必须存储当前正在执行的指令地址(以便后续返回时定位),同时保存多个 CPU 寄存器的值(以便返回时恢复)。随后需实例化形参 x 和 y 并进行初始化。接着执行路径需跳转至 min() 函数内部代码。函数结束时,程序必须跳回函数调用位置,并将返回值复制以便输出。每次函数调用都需执行这些步骤。
为完成某项任务(此处指函数调用)而必须进行的额外准备、执行及清理工作统称为开销overhead。
对于大型函数或执行复杂任务的函数,其调用开销通常远低于函数运行时间。但对于小型函数(如上文的min()),开销成本可能超过函数代码实际执行所需时间!当频繁调用小型函数时,使用函数可能导致显著性能损失,反而不如直接编写相同代码高效。
内联展开
幸运的是,C++编译器有一个技巧可以避免这种开销:内联展开Inline expansion是指将函数调用替换为被调用函数定义中的代码的过程。
例如,如果编译器展开了上述示例中的min()调用,生成的代码将如下所示:
#include <iostream>
int main()
{
std::cout << ((5 < 6) ? 5 : 6) << '\n';
std::cout << ((3 < 2) ? 3 : 2) << '\n';
return 0;
}
请注意,两个对 min() 函数的调用已被 min() 函数主体中的代码所替代(其中参数值已被实参值替换)。这使我们能够避免这些调用的开销,同时保留代码的结果。
内联代码的性能表现
除了消除函数调用开销外,内联展开还能让编译器更高效地优化生成的代码——例如,由于表达式((5 < 6) ? 5 : 6)现在已成为常量表达式,编译器可进一步将main()中的首条语句优化为std::cout << 5 << ‘\n’;。
然而内联展开本身存在潜在代价:若被展开函数的本体指令数多于被替换的函数调用,则每次内联展开都会导致可执行文件增大。较大的可执行文件往往运行更慢(因无法有效适配内存缓存)。
是否将函数内联化(即移除函数调用开销是否大于可执行文件增大的代价)的决策并非简单明了。内联展开可能带来性能提升、性能下降或完全无变化,这取决于函数调用的相对成本、函数规模以及可执行的其他优化措施。
内联展开最适合简单短小的函数(例如不超过几条语句),尤其适用于单次函数调用会被多次执行的场景(例如循环内部的函数调用)。
当发生内联展开时
每个函数都属于以下两类之一,其中函数调用:
- 可能被展开(大多数函数属于此类)。
- 无法展开。
多数函数属于“可展开”类别:当展开能带来性能收益时,其调用即可被展开。对于这类函数,现代编译器会评估每个函数及其调用,判断特定调用是否适合内联展开。编译器可能决定对某个函数的调用完全不展开、部分展开或全部展开。
提示
现代优化编译器会自动决定何时将函数展开为内联函数。
最常见的无法展开为内联函数的情况是:函数定义位于另一个翻译单元中。由于编译器无法看到该函数的定义,因此无法确定用什么来替换函数调用!
inline 关键字的历史沿革
历史上,编译器要么不具备判断内联展开是否有利的能力,要么在这方面表现欠佳。因此,C++ 提供了 inline 关键字,其初衷是作为提示,告知编译器某个函数(可能)会因内联展开而受益。
使用 inline 关键字声明的函数称为内联函数inline function。
以下是使用 inline 关键字的示例:
#include <iostream>
inline int min(int x, int y) // inline keyword means this function is an inline function
{
return (x < y) ? x : y;
}
int main()
{
std::cout << min(5, 6) << '\n';
std::cout << min(3, 2) << '\n';
return 0;
}

然而,在现代C++中,inline关键字已不再用于请求函数进行内联展开。这背后存在诸多原因:
- 使用inline请求内联展开属于过早优化的行为,误用反而可能损害性能。
- inline关键字仅是提示编译器判断何处进行内联展开的线索。编译器完全可以忽略该请求,且很可能如此行事。编译器同样可自由地对未使用 inline 关键字的函数执行内联展开,这是其常规优化策略的一部分。
- inline 关键字的定义粒度存在偏差。我们虽在函数定义处使用该关键字,但内联展开实际需在每次函数调用时动态判断。某些函数调用的展开可能有益,某些则可能有害,而现有语法无法影响这种动态决策。
现代优化编译器通常擅长判断哪些函数调用应进行内联——多数情况下比人类更精准。因此编译器很可能忽略或降低对函数内联展开请求的重视程度。
最佳实践
请勿使用inline关键字来请求函数的内联展开。
inline 关键字,现代用法
在前几章中,我们提到不应在头文件中实现(具有外部链接的)函数,因为当这些头文件被包含到多个 .cpp 文件中时,函数定义会被复制到多个 .cpp 文件中。这些文件编译时,链接器会因检测到同一个函数被多次定义而报错——这违反了单定义规则。
在现代C++中,inline一词已演变为“允许多重定义”的含义。因此,inline函数是指允许在多个翻译单元中定义(且不违反ODR)的函数。
内联函数有两个主要要求:
- 编译器需要在函数使用的每个翻译单元中看到内联函数的完整定义(仅有前向声明是不够的)。每个翻译单元只能出现一个这样的定义,否则会发生编译错误。
- 若同时提供前向声明,定义可出现在使用点之后。但编译器通常需在看到定义后才能执行内联展开(因此声明与定义之间的使用点可能无法进行内联展开)。
- 每个内联函数的定义(具有外部链接,函数默认如此)必须完全一致,否则将导致未定义行为。
规则
编译器需要能够在内联函数被调用的任何位置看到其完整定义,且所有具有外部链接的内联函数定义必须完全一致(否则将导致未定义行为)。
相关内容
我们在第7.6节——内部链接中介绍了内部链接,并在第7.7节——外部链接与变量前向声明中探讨了外部链接。
链接器会将某个标识符的所有内联函数定义合并为单一定义(因此仍符合单一定义规则的要求)。
以下是一个示例:
main.cpp:
#include <iostream>
double circumference(double radius); // forward declaration
inline double pi() { return 3.14159; }
int main()
{
std::cout << pi() << '\n';
std::cout << circumference(2.0) << '\n';
return 0;
}
math.cpp
inline double pi() { return 3.14159; }
double circumference(double radius)
{
return 2.0 * pi() * radius;
}

请注意,两个文件都定义了函数 pi()——但由于该函数已被标记为内联函数,这种情况是允许的,链接器会自动消除重复定义。若从两个 pi() 定义中移除 inline 关键字,则会引发 ODR 违规(因为非内联函数的重复定义是不被允许的)。
可选阅读
虽然内联(inline)的历史用法(用于执行内联展开)与现代用法(允许多重定义)看似关联不大,但二者实则紧密相连。
假设我们有一个简单函数,非常适合进行内联展开,于是将其标记为内联。要实现函数调用的内联展开,编译器必须能在每个使用该函数的翻译单元中看到其完整定义——否则无法确定用什么替换每个函数调用。定义在其他翻译单元中的函数无法在当前编译的翻译单元中进行内联展开。
常见情况是:简单内联函数需在多个翻译单元中使用。但若按前述要求将函数定义复制到每个翻译单元,就会违反ODR(单一定义要求)——即每个程序中函数只能存在单一定义。解决此问题的最佳方案,就是让内联函数豁免于ODR要求。
因此历史上,我们使用
inline关键字请求内联展开,而ODR豁免只是实现跨翻译单元内联展开所需的细节。如今我们使用inline实现ODR豁免,并将内联展开机制交由编译器处理。内联函数的工作原理并未改变,只是我们的关注点发生了转变。您或许会疑惑:为何内联函数可豁免 ODR 要求,而非内联函数仍需遵守?对于非内联函数,我们期望函数仅在单个翻译单元中定义一次。若链接器遇到非内联函数的多重定义,则视为两个独立定义函数间的命名冲突。而对具有多个定义的非内联函数的调用,将导致无法确定应调用哪个定义的潜在歧义。但对于内联函数,所有定义都被视为同一个内联函数,因此该翻译单元内的函数调用可进行内联展开。若函数调用未被内联展开,则无需担心多个定义中哪个是正确的匹配对象——它们都可以!
pi.h:
#ifndef PI_H
#define PI_H
inline double pi() { return 3.14159; }
#endif
main.cpp
#include "pi.h" // will include a copy of pi() here
#include <iostream>
double circumference(double radius); // forward declaration
int main()
{
std::cout << pi() << '\n';
std::cout << circumference(2.0) << '\n';
return 0;
}
math.cpp
#include "pi.h" // will include a copy of pi() here
double circumference(double radius)
{
return 2.0 * pi() * radius;
}

这对于纯头文件库header-only libraries尤为有用,这类库由一个或多个实现特定功能的头文件构成(不含任何.cpp文件)。纯头文件库之所以广受欢迎,是因为使用时无需向项目添加源文件,也无需进行任何链接操作。只需通过#include包含该头文件库,即可直接使用其功能。
相关内容
我们在第8.15节——全局随机数(Random.h)中展示了一个实际案例,该案例将inline用于实现仅头文件的随机数生成库。
对于进阶读者
以下函数默认被隐式内联:
- 在类、结构体或联合体类型定义内部定义的函数(14.3节——成员函数)。
- constexpr/consteval函数(F.1节——constexpr函数)。
- 由函数模板隐式实例化的函数(11.7节——函数模板实例化)。
在绝大多数情况下,你不应将函数或变量标记为内联(inline),除非你在头文件中定义它们(且它们本身并非隐式内联)。
最佳实践
除非存在特定且充分的理由(例如你在头文件中定义这些函数或变量),否则应避免使用 inline 关键字。
为什么不把所有函数都定义为内联函数并放在头文件中?
主要原因在于这样会显著增加编译时间。
当包含内联函数的头文件被#include到源文件时,该函数定义将作为该翻译单元的一部分进行编译。若一个内联函数被#include到6个翻译单元中,其定义将被编译6次(链接器消除重复定义前)。相反,源文件中定义的函数仅需编译一次,无论其前向声明被包含到多少个翻译单元中。
其次,源文件中定义的函数若发生变更,仅需重新编译该源文件。而头文件中的内联函数变更时,所有包含该头文件(无论是直接包含还是通过其他头文件间接包含)的代码文件都需重新编译。在大型项目中,这可能引发连锁重编译效应,造成严重影响。
内联变量 C++17
在上例中,pi() 被编写为返回常量值的函数。若将 pi 实现为 (const) 变量会更直观。但在 C++17 之前,此类实现存在技术障碍和效率问题。
C++17引入了内联变量inline variables,这类变量允许在多个文件中定义。其工作原理与内联函数类似,且具有相同的要求(编译器必须能在变量使用的所有位置看到完全相同的完整定义)。
对于进阶读者
以下变量默认隐式内联:
- 静态 constexpr 数据成员 15.6 -- 静态成员变量。
与 constexpr 函数不同,constexpr 变量默认不支持内联(上述例外情况除外)!
我们在第 7.10 节将演示内联变量的常见用途——跨多个文件共享全局常量(使用内联变量)。

浙公网安备 33010602011771号