Skip to content

6.13 - 内联函数

Key Takeaway
  • 现代编译器可以决定函数是否应当被展开。
  • 有些函数是隐式的内联函数,包括:
  • inline关键字是一种提示,编译器可以忽略它。同时编译器也可以展开没有标记inline的函数。
  • 现代inline关键字的含义是允许多处定义
  • 内联函数定义在头文件中:为了能够展开函数,编译器必须能够在函数调用处看到函数的定义。因此,内联函数通常被定义在头文件中,这样它们就可以被包含到使用它们的源文件中,以便编译器看到函数的完整定义。

有些时候我们需要编写代码完成一些独立的任务,例如从用户读取输入、输出到文件或者计算特定的值。通常情况下,我们有两种选择:

  1. 将代码作为已有函数的一部分(称为嵌入);
  2. 创建一个单独的函数(或者子函数)完成该任务。

将这些代码作为函数能够带来很多潜在的好处,因为函数中的代码:

  • 可读性更好,更容易理解;
  • 可用性更好,可以在不了解函数实现细节的前提下调用函数;
  • 更容易更新,因为这些代码可以在一处修改处处更新;
  • 可复用性更好,因为函数天生是模块化的。

不过,函数调用也是也有不好的地方,因为它会带来额外的性能开销。例如下列函数:

 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寄存器的值(用于返回时恢复上下文)。然后实参 xy 必须被实例化和初始化。然后跳转到 min()函数执行。当函数执行完毕后,程序又会跳转回到主调函数,同时返回值被拷贝返回。换言之,每次函数调用都会产生很大的开销。

对于那些复杂的函数来说,这些开销相对于函数执行实际来讲可能是微不足道的。不过,对于很多小函数(例如这里的min())。函数调用的开销甚至可能比函数内部代码执行耗时还要高。如果这些函数被频繁调用,那么和不使用代码来比性能差异会非常大。

内联展开

好在,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';

不过,内联展开也有它的开销:如果展开函数体所需的指令比函数调用本身还多,那么每一处内联展开将会导致可执行文件的膨胀。可执行程序越大,速度也可能会越差(由于不能匹配缓存的大小)。

确定函数是否应该被定义为内联(移除函数调用开销带来的好处大于可执行文件大小增长带来的坏处)的依据并不明显。内联展开可能会导致性能的提升、性能的下降或者对性能完全没有影响,这取决于函数调用的开销,函数大小和编译器能够执行哪些优化。

内联展开最适合简单、小的函数(例如只有几行语句的函数),特别是会被多次调用的函数(例如循环中的函数)。

内联代码的展开时机

所有的函数都可以归为以下三类:

  • 必须被展开;
  • 可能会被展开(很多函数都属于这一类).
  • 不能被展开。

能够被展开的函数称为内联函数。

大多数函数都属于第二类:“可能会被展开”。对于这些函数来说,如果展开能够带来好处,那么就可能被展开。对于这一类函数,现代编译器可以分析每个函数和每处函数调用来确定对应的函数是不是被内联展开后的收益更高。编译器最终会决定是否将它们中一些、或者全部展开。

小贴士

现代编译器可以决定函数是否应当被展开。

扩展阅读

有些函数是隐式的内联函数,包括: - 定义在类、结构体或联合体中的函数; - Constexpr / consteval 函数 6.14 - Constexpr 和 consteval 函数

历史上的 inline 关键字

回顾 C++ 的历史,编译器曾经要么不具备判断函数展开是否能够带来好处的能力,要么不能够很好的执行上述判断。因此,C++特意提供了 inline 关键字,用于提示编译器该函数应该被内联展开:

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

inline int min(int x, int y) // hint to the compiler that it should do inline expansion of this function
{
    return (x < y) ? x : y;
}

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

这就是所谓“内联函数”的来历(因为这些函数的声明语法中包含inline 关键字)。

但是,现代 C++ 中,inline 关键字已经不再被用于告诉编译器展开对应的函数,这么做的原因有很多:

  • 使用 inline 要求内联展开是一种过早优化行为,滥用inline有反倒会伤害程序性能;
  • inline 关键字只是一个提示——编译器完全可以忽略它,尤其是当你要求编译器内联展开一个很大的函数时。同时,编译器也可以展开那些并没有被标记为inline的函数。
  • inline 关键字的定义处于一种不合适的粒度。我们在函数声明中使用该关键字,但是实际上函数的展开是基于每个函数调用的。对于一个函数的各处调用,它们中的一部分可能被展开,而另一些则不被展开,然而这个关键字却不能对此产生影响。

现代编译器通常很擅长决定那些函数应该被作为内联函数——它的这种能力在大多数情况下都比人类强。 因此,编译器通常会忽略你为函数添加的inline标记。

最佳实践

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

现代 inline 关键字

在之前的章节中,我们介绍过,头文件中不应该放置函数的实现,因为这些头文件中的内容会被包含到多个.cpp文件中,那么这些函数的定义也就会被复制多份。因此,这些文件编译后,链接器就会报错,因为函数的定义违反了单一定义规则(one-definition-rule)

6.9 - 使用 inline 变量共享全局常量中我们介绍了,在现代 C++ 中,inline 概念发展出了新的含义:允许多重定义。对于函数和变量都是适用的。因此,如果你将函数标记为内联后,该函数就可以被多次定义了(在不同的函数中),只要这些定义是一致的即可。

为了能够展开函数,编译器必须能够在函数调用处看到函数的定义。因此,内联函数通常被定义在头文件中,这样它们就可以被包含到使用它们的源文件中,以便编译器看到函数的完整定义。

关键信息

编译器必须能够在内联函数调用处看到函数的完整定义。

大多数情况下,不需要将函数定义为内联,但是我们在将来的课程中会看到它有用的场景。

最佳实践

除非有特殊的、令人信服的理由,否则不要使用inline关键字。