张银峰的编程课堂

内联函数与有参数宏

函数调用的开销

函数调用不是免费 的,CPU执行函数调用时需要做一系列 “准备工作” 和 “收尾工作”,这些额外操作就是调用开销。

可以把函数调用类比成:你正在书房写作业(执行主函数),需要找一本工具书(调用函数)。你需要:

  1. 放下手里的笔,记住当前写到哪一页(保存当前执行状态);
  2. 走到书架前,找到对应的书(跳转到函数地址);
  3. 翻到需要的页码(执行函数参数传递);
  4. 看完后,回到书房,找到刚才写作业的页码(恢复执行状态);
  5. 重新拿起笔继续写(返回主函数)。

这些 “非写作业本身” 的动作,就是额外开销。对应到计算机中,函数调用的开销主要分为以下几类:

  1. 栈帧操作(核心开销)

程序运行时会有一块内存区域叫栈(Stack),每个函数调用都会在栈上创建一个独立的 “栈帧”(Stack Frame),用于存储:

  • 函数的参数
  • 局部变量(函数内部定义的变量)
  • 返回地址(函数执行完后要回到主调函数的哪个位置继续执行)
  • 寄存器上下文(CPU 寄存器当前的值,执行完函数后要恢复)

栈帧的创建(函数调用时)和销毁(函数返回时)需要执行多条 CPU 指令,这是最主要的开销。

  1. 指令跳转开销

CPU 执行指令是按顺序的,函数调用需要:

  • 跳转到函数的入口地址(call指令)
  • 函数执行完后跳回原地址(ret指令)

这种跳转会打断 CPU 的指令流水线(CPU 提前预取、解码指令的优化机制),导致流水线 “清空” 或 “回滚”,降低执行效率。

  1. 其他微小开销
  • 参数传递:把参数值复制到函数的栈帧中(比如把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

不过,我们不能因此就一切皆宏,复杂的宏阅读困难,另一个极大的缺点就是:不能调试!

在实际开发中,应该优先使用内联函数而非宏定义实现简单计算逻辑。