7.9 — 内联函数和变量

考虑这样一种情况:你需要编写一些代码来执行离散任务,比如从用户那里读取输入,或者将某些内容输出到文件,或者计算特定值。在实现此代码时,你基本上有两种选择:

  1. 将代码作为现有函数的一部分编写(称为“原地”或“内联”编写代码)。
  2. 创建新函数(并可能创建子函数)来处理该任务。

将代码放入新函数中可以带来许多潜在好处,因为小型函数

  • 在整个程序的上下文中更容易阅读和理解。
  • 更易于重用,因为函数天生就是模块化的。
  • 更易于更新,因为代码只需要在一个地方修改。

然而,使用新函数的一个缺点是,每次函数被调用时,都会产生一定量的性能开销。考虑以下示例:

#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 寄存器的值(以便在返回时恢复它们)。然后必须实例化并初始化参数 xy。然后执行路径必须跳转到 min() 函数中的代码。当函数结束时,程序必须跳回函数调用的位置,并且必须复制返回值才能进行输出。每个函数调用都必须执行此操作。
为了设置、促进和/或在某些任务(在本例中为进行函数调用)之后进行清理而必须执行的所有额外工作称为开销

对于大型和/或执行复杂任务的函数,函数调用的开销通常与函数运行所需的时间相比微不足道。然而,对于小型函数(如上面的 min()),开销成本可能大于实际执行函数代码所需的时间!在小型函数经常被调用的情况下,使用函数可能会比原地编写相同的代码导致显著的性能损失。

内联展开

幸运的是,C++ 编译器有一个技巧可以用来避免这种开销:内联展开是一个过程,其中函数调用被替换为被调用函数的定义中的代码。

例如,如果编译器展开了上面示例中的 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 关键字的示例

#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 使用。

最佳实践

不要使用 inline 关键字来请求函数的内联展开。

内联关键字,现代

在前面的章节中,我们提到你不应该在头文件中实现函数(具有外部链接),因为当这些头文件包含到多个 .cpp 文件中时,函数定义将被复制到多个 .cpp 文件中。然后这些文件将被编译,链接器将抛出错误,因为它会注意到你多次定义了相同的函数,这违反了单定义规则。

在现代 C++ 中,术语 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() 的定义——但是,由于此函数已标记为 inline,因此这是可以接受的,链接器将对其进行去重。如果您从 pi() 的两个定义中删除 inline 关键字,您将得到一个 ODR 违规(因为不允许非内联函数的重复定义)。

选读

虽然 inline 的历史用法(用于执行内联展开)和现代用法(允许多个定义)可能看起来有些不相关,但它们是高度相互关联的。

历史上,假设我们有一些适合内联展开的微不足道的函数,所以我们将其标记为 inline。为了实际执行函数调用的内联展开,编译器必须能够在每个使用该函数的翻译单元中看到该函数的完整定义——否则它将不知道用什么来替换每个函数调用。在另一个翻译单元中定义的函数无法在当前正在编译的翻译单元中进行内联展开。

在多个翻译单元中需要琐碎的内联函数是很常见的。但是,一旦我们将函数定义复制到每个翻译单元中(根据之前的要求),这最终会违反 ODR 关于函数在每个程序中只能有一个定义的规定。解决此问题的最佳方案是简单地让内联函数免除 ODR 关于每个程序只能有一个定义的规定。

因此,历史上,我们使用 inline 来请求内联展开,而 ODR 豁免是一个为了使此类函数能够在多个翻译单元中内联展开所需的细节。现代,我们使用 inline 来进行 ODR 豁免,并让编译器处理内联展开的事情。内联函数的工作机制没有改变,我们关注的重点改变了。

您可能想知道为什么内联函数可以免除 ODR,而非内联函数仍然必须遵守 ODR 的这一部分。对于非内联函数,我们期望函数只定义一次(在一个翻译单元中)。如果链接器遇到非内联函数的多个定义,它会假定这是由于两个独立定义的函数之间的命名冲突。并且任何调用具有多个定义的非内联函数都可能导致哪个定义是正确调用定义的潜在歧义。但是对于内联函数,所有定义都被假定为同一内联函数,因此该翻译单元内的函数调用可以内联展开。如果函数调用没有内联展开,则对于调用应该与多个定义中的哪个定义匹配没有歧义——任何一个都可以!

内联函数通常定义在头文件中,可以在任何需要查看标识符完整定义的代码文件顶部 #include。这确保了标识符的所有内联定义都是相同的。

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;
}

这对于仅头文件库特别有用,它们是一个或多个实现某些功能的头文件(不包含 .cpp 文件)。仅头文件库之所以受欢迎,是因为它们不需要将任何源文件添加到项目中即可使用,也不需要链接任何内容。您只需 #include 仅头文件库即可使用它。

相关内容

我们将在课程 8.15 -- 全局随机数 (Random.h) 中展示一个实际示例,其中 inline 用于随机数生成仅头文件库。

致进阶读者

以下函数是隐式内联的:

在大多数情况下,除非您在头文件中定义函数或变量(并且它们尚未隐式内联),否则不应将它们标记为内联。

最佳实践

除非有特定、令人信服的理由(例如,您正在头文件中定义这些函数或变量),否则请避免使用 inline 关键字。

为什么不把所有函数都设为内联并在头文件中定义呢?

主要是因为这样做会显著增加编译时间。

当包含内联函数的头文件被 #include 到源文件中时,该函数定义将被编译为该翻译单元的一部分。一个被 #include 到 6 个翻译单元的内联函数,其定义将被编译 6 次(在链接器去重定义之前)。相反,在源文件中定义的函数,无论其前向声明被包含到多少个翻译单元中,其定义都只会被编译一次。

其次,如果源文件中定义的函数发生更改,则只需要重新编译该单个源文件。当头文件中的内联函数发生更改时,包含该头文件(直接或通过另一个头文件)的每个代码文件都需要重新编译。在大型项目中,这可能导致一系列重新编译,并产生巨大影响。

内联变量 C++17

在上面的示例中,pi() 被编写为一个返回常量值的函数。如果 pi 改为作为(const)变量实现,那会更直接。然而,在 C++17 之前,这样做存在一些障碍和效率低下。

C++17 引入了内联变量,它们是允许在多个文件中定义的变量。内联变量的工作方式与内联函数相似,并且具有相同的要求(编译器必须能够在变量使用的任何地方看到相同的完整定义)。

致进阶读者

以下变量是隐式内联的:

与 constexpr 函数不同,constexpr 变量默认不是内联的(除了上面提到的那些)!

我们将在课程 7.10 -- 在多个文件之间共享全局常量(使用内联变量) 中说明内联变量的常见用法。

guest
您的电子邮箱地址将不会被显示
发现错误?请在上方留言!
与勘误相关的评论在处理后将被删除,以帮助减少混乱。感谢您帮助使网站对每个人都更好!
来自 https://gravatar.com/ 的头像与您提供的电子邮箱地址相关联。
有回复时通知我:  
242 条评论
最新
最早 最多投票
内联反馈
查看所有评论