内联函数和变量
本节阅读量:设想这样一种情形:你需要编写一段代码来完成某个相对独立的任务,例如读取用户的输入、将内容输出到文件,或是计算某个特定的值。实现这段代码时,基本上有两种选择:
- 在当前函数中直接实现这些功能(这些代码被“内联”写在使用点处)。
- 创建一个新的函数来实现对应的功能。
将代码写成函数有许多潜在的好处:
- 在整个程序的上下文中更易于阅读和理解。
- 更易于使用,因为你可以在不了解其实现细节的情况下调用该函数。
- 更易于更新,因为修改函数中的代码后,所有调用的地方都能获得新的逻辑。
- 更易于复用,因为函数天然就是模块化的。
然而,使用函数的一个缺点是:每次调用函数都有一定的性能开销。考虑下面这个例子:
|
|
当遇到对 min() 的调用时,CPU 必须保存当前正在执行的指令地址(以便之后能够返回到此处),以及各种 CPU 寄存器的值(以便返回后可以恢复)。接着,参数 x 和 y 必须被实例化并初始化。然后,执行路径必须跳转到 min() 函数内部的代码。当函数结束时,程序又要跳回到函数调用处,并且必须复制返回值,以便将其输出。每次函数调用都必须执行这一整套操作。
对于规模较大或执行较复杂任务的函数来说,相比函数运行本身所需的时间,函数调用的开销通常微不足道。然而对于像上面 min() 这样的小函数,函数调用的开销甚至可能比函数体实际执行的代码所耗的时间还要多!如果这样的小函数被频繁调用,使用函数可能会带来显著的性能损失。
内联展开(Inline expansion)
幸运的是,C++ 编译器有一种手段可以避免这种开销:内联展开就是将函数调用替换为函数体代码的过程。
例如,如果编译器将上例中的 min() 调用进行了内联展开,生成的代码看起来会是这样:
|
|
注意,对 min() 函数的两次调用已被函数体代码所替换(参数也被相应的值替换了)。这使我们在保留原有逻辑的同时,避免了函数调用的开销。
内联代码的性能
除了消除函数调用的开销外,内联展开还能让编译器更高效地优化代码——例如,由于表达式 (5 < 6) ? 5 : 6 现在是一个常量表达式,编译器可以将 main() 中的第一条语句进一步优化为 std::cout << 5 << '\n';。
然而,内联展开自身也有潜在的代价:如果替换后的函数体所需的指令比函数调用更多,那么每次内联展开都会使可执行文件变大。可执行文件越大往往运行越慢(因为不容易装入内存缓存)。
判断一个函数是否能从内联中受益(即消除函数调用开销带来的收益是否超过可执行文件变大的成本)并不容易。内联展开可能使性能提升,也可能使性能下降,还可能完全没有变化。
内联展开最适合那些简单、简短的函数(例如不超过几条语句的函数),尤其是那些对同一函数的调用会被执行许多次(例如在循环中调用的函数)的场景。
内联展开何时发生
在函数调用发生时,函数可以分为两类:
- 可以被展开(大多数函数都属于这一类)。
- 无法被展开。
大多数函数都属于“可以”这一类:如果内联展开有益,编译器就可以将对它们的函数调用展开。对于这一类函数,现代编译器会评估每个函数及其函数调用的成本,以判断某次调用是否能从内联展开中受益。编译器可以决定将对该函数的部分、全部或一次调用都不展开。
提示
现代编译器会自行决定何时对函数进行内联展开。
历史上的 inline 关键字
历史上,编译器要么没有判断内联展开是否有益的能力,要么并不擅长执行内联展开。因此,C++ 提供了 inline 关键字,最初是作为向编译器提示“该函数(可能)会从内联展开中获益”的一种方式。
用 inline 关键字声明的函数称为内联函数。
下面是使用 inline 关键字的示例:
|
|
然而,在现代 C++ 中,inline 关键字已不再用于请求对函数进行内联展开。原因有以下几点:
- 使用内联展开是一种过早优化,滥用反而可能损害性能。
- inline 关键字只是一个提示——如果你尝试对一个冗长的函数使用 inline,编译器完全可以忽略这个请求!另一方面,编译器也可以自由地对没有使用 inline 关键字的函数进行部分内联展开。
- inline 关键字作用的粒度并不合适。我们在函数定义处使用 inline 关键字,但内联展开实际上是针对每次函数调用进行判断的。展开某些函数调用可能有益,而展开另一些则可能有害,语法上却无法精确控制这一点。
现代编译器通常善于判断哪些函数应该被内联——在大多数情况下比人类判断得更好。因此,编译器可能会忽略函数上的 inline 关键字,或者降低其权重。
最佳实践
不要使用 inline 关键字来让函数做内联展开。
现代的 inline 关键字
在前面的章节中,我们提到过不应在头文件中实现(具有外部链接的)函数,因为当该头文件被多个 .cpp 文件包含时,函数定义会被复制到多个 .cpp 文件中。接着在编译链接这些文件时,链接器会发现同一个函数被定义了多次,从而抛出错误,这违反了单一定义规则。
在现代 C++ 中,inline 这个术语的含义已经演变为“允许多次定义”。因此,内联函数就是允许在多个文件中被定义的函数。C++17 引入了内联变量,允许变量在多个文件中被定义。
内联函数和内联变量有两个要求:
- 编译器需要能够在使用该函数或变量的每一个翻译单元中看到内联函数或变量的完整定义(仅有前向声明是不够的)。
- 同一个内联函数或变量的每一份定义必须完全相同,否则会导致未定义行为。
链接器会将同一标识符对应的所有内联函数或内联变量的定义合并为一份(这样就仍然满足单一定义规则的要求)。
内联函数和内联变量通常定义在头文件中,然后被包含到任何需要看到该标识符完整定义的源文件顶部。这样可以确保该标识符的所有内联定义都是一致的。
这对于仅含头文件的库(header-only libraries)特别有用,这类库只有头文件,没有 .cpp 文件。仅含头文件的库之所以受欢迎,是因为使用它时不需要把源文件添加到项目中,也不需要额外链接源文件,你只需 #include 对应的头文件即可使用。
在大多数情况下,除非你在头文件中定义函数或变量(且它们尚未被隐式内联),否则不应将函数或变量标记为 inline。
相关内容
我们在后续章节——在多个文件中共享全局常量(使用内联变量)——中介绍了内联变量的常见用法。
规则
编译器必须能够在任何使用内联函数或变量的位置看到它们的完整定义,且所有定义必须相同(否则会导致未定义行为)。
进阶读者
下列情况下的内容是隐式内联的:
- 在类、结构体或 union 类型定义内部定义的函数(参见后续——成员函数)。
- constexpr/consteval 函数(参见后续——constexpr 和 consteval 函数)。
- 从函数模板隐式实例化出的函数(参见后续——函数模板实例化)。
- constexpr 静态变量(注意不是 constexpr 非静态变量)。
最佳实践
除非你有特定且令人信服的理由(例如你在头文件中定义函数或变量),否则不要使用 inline 关键字。