【C/C++】函数与参数——你未曾知道的那些事情

一说到函数,大家就会想到各种调用、封装。但是你知道吗,函数及其参数也有很多学问,今天我就带大家走进它的“秘密”。

函数的本质

在汇编里,C/C++的函数实际上是过程(或者叫子程序),通过call/ret来控制函数的调用/结束。而参数就更有意思了,因为对于汇编来说,并不存在高级语言所说的参数,汇编只有操作数的说法。这里就不展开说了,有兴趣的可以自己查一查在汇编实现函数的资料。

参数与栈

事实上,在汇编中,函数的参数是通过入栈/出栈的形式来传递的。我们都知道,参数分为形参和实参,形参就是函数定义/声明时指定的虚拟变量,实参则是调用时实际传入的变量。在汇编里,入栈的时候使用的就是实际的变量,出栈的时候使用的是虚拟变量(这个说法并不严谨,仅为了好理解)。那么,参数究竟遵循什么样的规则来入栈/出栈呢?

从右至左入栈

当调用一个函数时,编译器会按照从右至左的顺序将参数的值压入调用栈中。这意味着最右边的参数会首先被推入栈中,然后是次右边的参数,依此类推,直到最左边的参数被推入栈中。

这种顺序有助于处理可变参数列表(如C语言中的printf函数),因为最后一个参数(通常是格式字符串)首先入栈,而后续的实际参数随后入栈。这样,在处理可变参数时,可以通过遍历栈来访问这些参数,而无需知道参数的确切数量(尽管对于printf这样的函数,格式字符串本身会指示参数的数量和类型)。

参数计算

在参数入栈之前,如果参数表达式包含计算(如算术运算、函数调用等),这些计算会首先被执行。然而,需要注意的是,具体的计算顺序(即从左至右还是从右至左)可能因编译器而异,并且C++标准并没有明确规定这一点。

需要注意的是,某些特定的操作(如自增/自减操作符)可能会导致额外的复杂性,因为编译器可能需要为这些操作的结果创建临时变量,以确保参数传递的正确性。

事实上,在VC中,参数计算遵顼入栈顺序,例如我们写的这个程序就是这样:
屏幕截图 2025-04-12 224823.png

可以明显看到,在调用Test时,首先计算的是Test2,而后才是Test1,而结果则是先Test1再Test2,这说明入栈时逆序,出栈时顺序。

可变参数的秘密

说到可变参数,大家应该会想起printf函数,这个函数除了第一个参数以外没有定义其他参数,而是用...来涵盖后面的参数。事实上,可变参数依旧遵循上面的原则来入栈/出栈,并且我们可以根据需要“动态”出栈,而不需要提前声明/定义具体的参数类型。可变参数的用法可以自己查,这里不再多提。

然而,许多教科书并没有告诉我们,可变参数是有“坑”的,例如这篇文章就提到,实际上并不是所有的类型都可以用可变参数获取,这是因为在C语言中,对可变长参数列表超出最后一个有类型声明的形式参数之后的每一个实际参数,调用者会对每个参数执行“默认实际参数提升(default argument promotions)”。默认实际参数提升的定义,大家可以自己查资料,我这里只简单介绍一下。

默认实际参数提升会按以下规则进行提升:

  • float类型的实际参数将提升到double
  • char、short和相应的signed、unsigned类型的实际参数提升到int,bool也会提升到int
  • 如果int不能存储原值,则提升到unsigned int

实际上,在VC环境中,我们直接通过va_arg获取上述类型的参数也是可以的,但是存在隐患,你不能保证编译器给你自动对齐,从而规避默认实际参数提升带来的影响。因为默认实际参数提升是写死在标准中的,任何编译器都应遵守,而内存对齐则不是标准,因此不能保证诸如va_arg(valist, bool)这样用法没有问题。

因此,如果需要使用到以上类型,则推荐调用va_arg时,优先使用提升后的类型,再强制转换为想要的类型,这样比较保险。

思考题:阅读以下代码,推断出最后的结果

void Test(int n, int m)
{
    cout << n << "\t" << m << endl;
}

void VarTest(int n, ...)
{
    va_list valist;
    va_start(valist, n);

    if (n == 1)
    {
        Test(va_arg(valist, int), va_arg(valist, int));
    }

    va_end(valist);
}

int main()
{
    VarTest(1, 112, 113);

    return 0;
}

评论已关闭