内联函数与有参数宏
函数调用的开销
函数调用不是免费 的,CPU执行函数调用时需要做一系列 “准备工作” 和 “收尾工作”,这些额外操作就是调用开销。
可以把函数调用类比成:你正在书房写作业(执行主函数),需要找一本工具书(调用函数)。你需要:
- 放下手里的笔,记住当前写到哪一页(保存当前执行状态);
- 走到书架前,找到对应的书(跳转到函数地址);
- 翻到需要的页码(执行函数参数传递);
- 看完后,回到书房,找到刚才写作业的页码(恢复执行状态);
- 重新拿起笔继续写(返回主函数)。
这些 “非写作业本身” 的动作,就是额外开销。对应到计算机中,函数调用的开销主要分为以下几类:
- 栈帧操作(核心开销)
程序运行时会有一块内存区域叫栈(Stack),每个函数调用都会在栈上创建一个独立的 “栈帧”(Stack Frame),用于存储:
- 函数的参数
- 局部变量(函数内部定义的变量)
- 返回地址(函数执行完后要回到主调函数的哪个位置继续执行)
- 寄存器上下文(CPU 寄存器当前的值,执行完函数后要恢复)
栈帧的创建(函数调用时)和销毁(函数返回时)需要执行多条 CPU 指令,这是最主要的开销。
- 指令跳转开销
CPU 执行指令是按顺序的,函数调用需要:
- 跳转到函数的入口地址(call指令)
- 函数执行完后跳回原地址(ret指令)
这种跳转会打断 CPU 的指令流水线(CPU 提前预取、解码指令的优化机制),导致流水线 “清空” 或 “回滚”,降低执行效率。
- 其他微小开销
- 参数传递:把参数值复制到函数的栈帧中(比如把int a的值压入栈)
- 返回值传递:把函数的返回值存入指定寄存器或栈中,供主调函数读取
简言之,栈帧的创建/销毁、指令跳转、参数传递,这些都是函数调用时的“额外操作”,不参与函数核心逻辑计算;却构成了函数调用开销的核心。
内联
函数调用的开销,对于简单函数影响极大,如求平方函数square,核心逻辑只有一个乘法,却要背负栈帧创建/销毁等操作。
int square(int a)
{
return a * a;
}
为了避免这些开销,我们可以使用inline关键字告诉编译器,这是一个内联函数,在调用处展开函数。如
void foo()
{
int a = 3;
int b = square(a);
}
编译期可能会将其改写为:
void foo()
{
int a = 3;
int b = a * a;
}
注意,我们说的是可能,因为inline是建议而非强制,编译器可能根据优化策略决定是否真正内联;通常对于复杂的函数,即使声明为内联,编译器大概率也会选择忽略。
带参数的宏(有参宏)
宏定义 #define 指示在预处理阶段对代码进行文本替换,它可以定义无参数宏,如定义常量,如
#define NUM 5
也可以定义带参数的宏,如:
#define SQUARE(x) x * x
对于代码:
void foo()
{
int a = 3;
int b = SQUARE(a);
}
将被替换为
void foo()
{
int a = 3;
int b = a * a;
}
这也是减少函数调用开销的另一个法门!不过,宏定义很容易出现一个陷阱:缺少括号会导致运算优先级问题。
对于 SQUARE(2 + 3) 会被替换为 2 + 3 * 2 + 3 这显然违背了初衷,解决方法是为宏参数加上括号,保证优先级。
#define SQUARE(x) (x) * (x)
现在,上面的宏会被替换为 (2 + 3) * (2 + 3),一切正常。
相比如内联函数,SQUARE宏还可以应用到多种类型,而相应的函数只能square()函数只能针对特定t类型。
SQUARE(3.14); // ok double * double
不过,我们不能因此就一切皆宏,复杂的宏阅读困难,另一个极大的缺点就是:不能调试!
在实际开发中,应该优先使用内联函数而非宏定义实现简单计算逻辑。