可變參數

C語言的可變參數的實現非常巧妙: 大師只用了 3 個宏就解決了這個難題。

一、可變參數的應用

  這裡實現一個簡單的可變參數函數 sum: 它將個數不定的多個整型參數求和後返回, 其第 1 個參數指明瞭要相加的數的個數(va.c):

#include <stdio.h>
#include <stdarg.h>

// 要相加的整數的個數為 n
int sum(int n, ...)
{
    va_list ap;
    va_start(ap, n);

    int ans = 0;
    while(n--)
        ans += va_arg(ap, int);

    va_end(ap);
    return ans;
}

int main()
{
    int ans = sum(2, 3, 4);
    printf("%d\n", ans);

    return 0;
}

sum 函數的第一個參數是 int n, 逗號後面是連續的 3 個英文句點, 表示參數 n 之後可以跟 0、1、2…… 個任意類型的參數。 sum 可以這麼用:

sum(0);
sum(1, 2);
sum(3, 1, 1, 1);

二、可變參數的實現

可以看到在 sum 函數中用到了 3 個函數一樣的東西: va_start、va_arg、va_end, 它們是標準庫(意味著各種平臺都有)頭文件 stdarg.h 中 定義的宏,這 3 個宏經過清理後是下面這個樣子:

typedef char* va_list;
#define va_start(ap,v)  ( ap = (va_list)(&v) + sizeof(v) )
#define va_arg(ap,t)    ( *(t *)((ap += sizeof(t)) - sizeof(t)) )
#define va_end(ap)      ( ap = NULL )
  • va_start 將 ap 定位到可變參數列表的起始地址
  • va_arg 每次返回一個參數,並後移 ap 指針
  • va_end 將 ap 置 NULL(避免非法使用)

這 3 個宏的實現就是基於 C語言默認調用慣例是從右至左 將參數壓棧的事實,比如說 va.c 中調用 sum 函數, 參數壓棧的順序為:4->3->2, 又因為 x86 CPU 的棧是向低地址增長的, 所以參數的排列順序如下:

args

  va_start(n, ap) 就是 ( ap = (char*)(&n) + 4 ) 因此 ap 被賦值為 ebp+12 也就是變參列表的起始地址。

之後 va_arg 取出每一個參數:

( *(int *)((ap += 4) - 4) )

它首先將變參指針 ap 右移到下一個參數的起始地址, 再將加賦操作的返回值減到之前的位置取出一個參數。 這樣,用一條語句既取出了當前參數,又後移了指針 ap, 真是神了!

sum 中循環使用 va_arg 就取出了 n 個要相加的整數。

三、變參函數的可行性

一個變參函數能接受個數、類型可變的參數, 需要滿足以下兩個條件:

  1. 能定位到可變參數列表的起始地址
  2. 能獲知可變參數的個數、每個參數的大小(類型)

條件 1 只要有個前置參數就能滿足, 而對於這樣的變參函數:void func(...); 編譯能通過,但是不能用 va_start 取到變參列表的起始地址, 所以基本不可行。


  sum 函數中參數 n 被用來定位可變參數列表的起始地址 (滿足條件1);n 的值是可變參數的個數, 類型默認全部是 int 型(滿足條件2), 因此 sum 能正常工作。


  再看看 printf 函數是如何滿足以上兩個條件的, printf 函數的原型是:

int printf(const char *fmt, ...);

printf 的第1個參數 fmt(格式串)被用來定位其後 的可變參數的起始地址(滿足條件1); fmt 指向的字符串中的各個格式描述符如:%d、%lf、%s 等 告訴了 printf fmt 之後參數的個數、各個參數的類型 (滿足條件2),因此 printf 能正常工作。


  當然,sum、printf 能正常工作是設計者一廂情願的期望, 如果使用者不按規矩傳入參數、格式串,函數能正常工作才怪! 比如:

sum(2, "111", "222");
printf("%s", 0);

編譯器可不會進行可變參數的類型檢查、格式串-參數匹配, 後果將會在運行的時候出現……