0%

C++ inline 内联函数

inline 的作用是建议编译器在调用点把函数体直接展开,省掉函数调用开销。它发生在编译期,运行期没有额外逻辑。

函数调用的开销

1
2
3
4
int sum(int x, int y)
{
return x + y;
}

调用一次 sum函数的完整流程包括:

  • 从右向左压实参
  • call 压入返回地址
  • 进入函数后保存调用方 ebp、开辟栈帧
  • 执行函数体
  • 回退栈帧、恢复 ebp、执行 ret
  • 回到调用方后清理参数

完整指令序列见《从汇编指令角度分析函数调用堆栈的详细过程》。

函数体越简单、调用越频繁,调用开销的占比就越高。假如在循环里调用 sum 一千万次,大部分时间可能都花在压栈、建栈帧和退栈帧上,而不是 x + y 本身。所以这种比较简单的函数,适合使用inline

inline 在编译期展开

给函数加上 inline:

1
2
3
4
5
6
7
8
9
10
11
12
inline int sum(int x, int y)
{
return x + y;
}

int main()
{
int a = 10;
int b = 20;
int ret = sum(a, b);
return ret;
}

编译器看到 sum(a, b),直接把函数体放到调用点,相当于:

1
int ret = a + b;

调用点的汇编是:(MSVC ,Release(/O2)。符号名做了简化)

1
2
3
4
mov ecx, DWORD PTR [b]   ; 取 b
mov eax, DWORD PTR [a] ; 取 a
add eax, ecx ; sum 的函数体被展开到这里
ret 0 ; main 返回

对比不带inline的调用点:

1
2
3
4
5
6
mov eax, DWORD PTR [b]
push eax ; 压实参 b
mov ecx, DWORD PTR [a]
push ecx ; 压实参 a
call ?sum@@YAHHH@Z ; 调用 sum
add esp, 8 ; 清理参数

inline下少了压栈、函数栈帧的创建、回退。不带inline的函数的调用细节见《C++ 形参带默认值的函数》。

inline 只是建议

inline 不是命令,编译器可以拒绝展开。常见的不展开情况:

情况 原因
递归函数 编译期不知道要递归多少层,没法确定展开几次
函数体太大 展开后代码膨胀,收益抵不过体积

递归函数:展开的前提是编译期能确定函数体的全部内容,而递归的层数要等运行期看递归条件。编译器在编译阶段不执行代码,自然不知道要展开多少层。

另外,不写 inline 的小函数也可能被自动内联。现代编译器会自己判断成本收益,inline 更多是表达意图。最终是否展开,由编译器决定。

Debug 下不会内联

调试时需要在函数里打断点、看栈帧。如果函数体被展开,断点就没地方放,栈帧也没有了,调试器无法按函数单步。

怎么确认真的内联了

inline 只是建议,写不写和最终展不展开是两回事。要确认,只能看编译器生成的产物:反汇编,或者目标文件的符号表。

看反汇编:调用点还有没有 call

call 是函数调用的标志。只要调用点还有 call,函数就是被调用,而不是被展开;内联成功的调用点,会直接出现函数体的指令。

实际验证时,拿反汇编来看:

  • MSVC:项目属性 → C/C++ → 输出文件 → 汇编输出,选带源代码的汇编(命令行对应 /FAs),编译后中间目录会生成 .asm 文件。要选 Release 配置,或者确认优化是开启的——Debug 默认不优化,看到的永远是普通的函数调用。
  • GCC/Linux:把前面的代码存成 test.cpp,先 g++ -O2 -c test.cpp 生成目标文件,再 objdump -d test.o。-O2 是开启优化的级别;不写的话默认是 -O0,不会内联。

反汇编看的是调用点,这是最直接的证据。

查符号表:函数有没有保留独立定义

符号表记录目标文件里的函数、全局变量等符号,链接器靠它把调用方和被调方对上(详见《从编译器角度理解 C++ 代码的编译和链接》)。

GCC 上可以用 objdump -t test.o | grep sum 查看。以前面的代码为例,三组对照的结果是:

  • 给函数加 inline,用 -O2 优化编译:符号表里找不到 sum。函数体已经并进调用方,不需要再留一份独立定义。
  • 不加 inline,同样用 -O2 编译:sum 的符号还在。非 inline 函数即使被编译器自动内联,也要保留一份定义,供别的编译单元链接。
  • 加 inline,但用 -O0(不优化)编译:sum 的符号也还在。没有优化,函数不会被展开。

符号表只能说明函数有没有保留独立定义,不能单独证明调用点是否展开——第二种情况就是已经自动内联,符号却还在。

MSVC 上符号表不能作数

在 MSVC 上给函数加 inline,用 /O2 优化编译,目标文件里仍然保留 sum 的符号。原因是 inline 函数是外部链接,通常写在头文件里、被多个 .cpp 包含;每个 .cpp 都会生成一份定义,链接器通过 COMDAT(pick any)只挑一份,避免重复定义。所以符号保留是正常的,不能看到符号就认为没内联。

所以,如果想确认某个调用点有没有内联,看反汇编里还有没有 call是比较好的方式,因为这个判据在 MSVC 和 GCC 上都成立。而符号表在 GCC 上可以作为辅助验证,在 MSVC 上会因为 COMDAT 保留符号而误判。

内联函数和普通函数的区别

  1. 调用开销:普通函数调用要压实参、压返回地址、开辟和回退栈帧;内联函数在编译期把函数体展开到调用点,这些开销都没有。
  2. 适用场景:函数体简单、调用频繁时收益最大。函数体复杂时,调用开销占比小,展开还会让代码膨胀,通常不值得。
  3. 符号:GCC 上内联成功后不再为函数生成独立符号;MSVC 上仍会保留 COMDAT 符号。所以符号表只能辅助判断,不能跨编译器通用。
  4. 强制性:inline 只是建议。递归函数、大函数通常不会被展开;是否展开最终由编译器决定,并且要有优化——Debug 配置默认不优化,不会展开,Release 配置默认开启优化,才可能展开。

小结

  • inline 是给编译器的建议:内联成功后,函数体在编译期展开到调用点,省掉压参、call、栈帧开辟和回退。
  • 递归函数和函数体过大的函数通常不会被内联,写了 inline 也不保证。
  • 内联依赖优化开关:Debug 默认不优化,不会展开;Release 默认优化,才可能展开。
  • 验证时看反汇编的调用点还有没有 call,这个判据通用;符号表在 GCC 上可用,在 MSVC 上会因为 COMDAT 保留符号而误判。