函数内联(Inlining)
大约 3 分钟
函数内联(Inlining)
函数内联是一种代码重构技术,它可以减少函数调用的开销,提高程序的运行效率。函数内联的原理是将函数调用处的代码替换为被调用函数的实现代码。函数内联的优点是可以减少函数调用的开销,缺点是会增加代码的体积。
直观感受函数内联
函数调用并不便宜:要保存现场、跳转、执行函数体、再恢复现场。如果被调函数只有一两行,这笔「过路费」可能比函数本身还贵。内联就是把调用点直接替换成函数体,省掉来回折腾。
假设有:
inline int square(int x) {
return x * x;
}
int sum_of_squares(int a, int b) {
return square(a) + square(b);
}
内联之后,逻辑上等价于:
int sum_of_squares(int a, int b) {
return (a * a) + (b * b);
}
调用消失了,编译器还能在此基础上继续做常量传播、死代码消除等优化。这也是为什么 C++ 里小函数常写成 inline——不是让人手动复制粘贴,而是给编译器一个「可以考虑内联」的提示。
递归函数的内联
递归函数能不能内联?能,但通常只内联有限次。
int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
int f5() { return factorial(5); }
如果编译器在编译 f5 时能看到 factorial(5) 的实参是常量 5,就可能做递归内联(recursive inlining):一层层展开,直到变成:
int f5() { return 5 * 4 * 3 * 2 * 1; }
这本质上是一种部分求值。但无限递归没法全展开,所以编译器会设深度上限。尾递归有时还能被优化成循环,那就不是内联,而是另一种变换。
我们应该在哪个阶段进行函数内联?
内联通常发生在中端优化阶段,也就是 IR 生成之后、代码生成之前。典型流程:
- 前端:把函数调用翻译成 IR 里的
call指令 - 中端:分析调用关系,决定哪些
call展开成被调函数的 IR 块 - 后端:对内联后的代码做寄存器分配、指令选择
也可以在更早或更晚做,但各有取舍:
| 阶段 | 优点 | 缺点 |
|---|---|---|
| 中端(主流) | 内联后还能跑 SSA、常量传播、死代码消除等 IR 级优化 | 需要维护 IR 的正确性 |
| 后端 | 能看到目标平台特性 | 优化窗口变窄,代码体积更难控制 |
| 链接期(LTO) | 跨翻译单元内联,突破 .cpp 文件边界 | 编译时间显著增加 |
实践中,GCC/LLVM 都在中端做主要内联,再配合 LTO 做跨模块内联。
如何判断一个函数调用是否应该内联?
内联不是越多越好——代码膨胀会导致指令缓存失效率上升。编译器一般用一组启发式规则做权衡:
倾向于内联:
- 函数体很小(几条指令)
- 调用点在热路径上(循环内、频繁调用)
- 实参是常量,内联后能触发常量折叠
- 函数只被一处调用(
static、匿名命名空间、always_inline) - 内联能消除不可达分支(比如
if (false)的调试代码)
倾向于不内联:
- 函数体很大,或含复杂控制流
- 函数地址被取走(
&func),需要保留符号 - 递归深度未知或可能很深
- 虚函数调用,目标在编译期不确定(去虚化后才能内联)
- 内联后代码体积增长超过阈值
现代编译器还会用收益模型(cost model):估算内联节省的调用开销,对比代码体积增长,净收益为正才内联。当然有些属性如 __attribute__((always_inline)) 和 __attribute__((noinline)) 可以人为覆盖默认决策。