章节目录

内联函数和变量

本节阅读量:

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

  1. 将这些功能实现在当前的函数中(这些代码是“内联”在使用位置)。
  2. 创建一个新的函数,来实现对应的功能。

编写函数提供了许多潜在的好处:

  1. 在整个程序的上下文中更容易阅读和理解。
  2. 更易于使用,因为您可以在不了解其实现方式的情况下调用该函数。
  3. 更容易更新,因为函数中的代码更新后,所有调用的地方都能感知到新的逻辑。
  4. 更易于重用,因为函数自然是模块化的。

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#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() 函数中的代码。当函数结束时,程序必须跳回到函数调用的位置,并且必须复制返回值,以便可以输出它。必须为每次函数调用执行此操作。

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


内联展开(Inline expansion)

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

例如,如果编译器在上例中展开了min() 调用,则生成的代码如下所示:

1
2
3
4
5
6
7
8
#include <iostream>

int main()
{
    std::cout << ((5 < 6) ? 5 : 6) << '\n';
    std::cout << ((3 < 2) ? 3 : 2) << '\n';
    return 0;
}

注意,对函数min() 的两个调用已被函数体中的代码替换(用对应的值替换参数)。这允许我们避免这些调用的开销,同时保留代码的逻辑。


内联代码的性能

除了消除函数调用的开销外,内联展开还允许编译器更有效地优化代码——例如,因为表达式( (5 < 6) ? 5 : 6 )现在是常量表达式,编译器可以将main() 中的第一条语句进一步优化为std::cout « 5 « ‘\n’;。

然而,内联展开有自己的潜在成本:如果替换的函数体比函数调用需要更多的指令,则每个内联展开都将导致可执行文件变大。较大的可执行文件往往运行速度较慢(由于不适合内存缓存)。

判断函数是否会从内联中受益的(因为删除函数调用开销超过了更大的可执行文件的成本)并不简单。内联展开可能会导致性能改进、性能降低或根本不改变性能。

内联展开最适合于简单、简短的函数(例如,不超过几个语句),特别是单个函数调用被执行多次的情况(例如,循环内的函数调用)。


发生内联展开的时机

函数分为两类,发生函数调用时:

  1. 可以被展开(大多数函数都在此类别中)。
  2. 无法展开。

大多数函数都属于“可以”类别:如果这样做是有益的,则可以展开对它们的函数调用。对于这一类别中的函数,现代编译器将评估每个函数以及函数调用的成本,以确定该特定函数调用是否将受益于内联展开。编译器可能决定将任何、部分或所有函数调用扩展到调用的函数。


历史上的inline关键字

在历史上,编译器要么没有能力确定内联展开是否有益,要么不擅长内联展开。由于这个原因,C++提供了关键字inline,它最初旨在用作向编译器提示函数将(可能)从内联展开中受益。

使用inline关键字声明的函数称为inline函数。

下面是使用inline关键字的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <iostream>

inline int min(int x, int y) // inline 关键字,意味着函数时内联函数
{
    return (x < y) ? x : y;
}

int main()
{
    std::cout << min(5, 6) << '\n';
    std::cout << min(3, 2) << '\n';
    return 0;
}

然而,在现代C++中,inline关键字不再用于请求内联函数。这有许多原因:

  1. 使用内联展开是过早优化的一种形式,误用实际上可能会损害性能。
  2. inline关键字只是一个提示——如果您试图内联一个冗长的函数,编译器完全可以忽略内联函数的请求!编译器还可以自由地对不使用inline关键字的部分函数执行内联展开。
  3. inline关键字在错误的粒度定义。我们在函数定义上使用inline关键字,但内联展开实际上是根据函数调用确定的。扩展某些函数调用可能是有益的,而扩展其他函数调用可能有害,并且没有语法能控制这一点。

现代编译器通常善于确定哪些函数应该内联——在大多数情况下比人类更好。因此,编译器可能会忽略或降低对内联的任何使用,以及设置的函数inline关键字。


现在的inline关键字

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

在现代C++中,术语inline已经演变为“允许多个定义”。因此,内联函数是允许在多个文件中定义的函数。C++17引入了内联变量,这些变量允许在多个文件中定义。

内联函数和变量有两个要求:

  1. 编译器需要能够在使用函数的每个转换单元中看到内联函数或变量的完整定义(前向声明并不足够)。
  2. 内联函数或变量的每个定义都必须相同,否则将导致未定义的行为。

链接器将把标识符对应的所有内联函数或内联变量定义合并到单个定义中(因此仍然满足单定义规则的要求)。

内联函数和变量通常在头文件中定义,对应的头文件可以被包含在需要查看标识符的完整定义的任何代码文件的顶部。这确保标识符的所有内联定义都是相同的。

这对于仅含头文件的库(header-only libraries)特别有用,这种类型的库只有头文件,不含有.cpp文件。仅含头文件的库很受欢迎,因为没有需要添加到项目中才能使用的源文件,也没有需要链接的源文件。您只需#include 对应的头文件,然后就可以使用它。

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


5.5 条件运算符

上一节

5.7 Constexpr和consteval函数

下一节