首先在介紹可變參數表函數的設計之前,我們先來介紹一下最經典的可變參數表printf函數的實作原理。
一、printf函數的實作原理
在C/C++中,對函數參數的掃描是從後向前的。C/C++的函數參數是通過壓入堆棧的方式來給函數傳參數的(堆棧是一種先進後出的資料結構),最先壓入的參數最後出來,在計算機的記憶體中,資料有2塊,一塊是堆,一塊是棧(函數參數及局部變量在這裡),而棧是從記憶體的高位址向低位址生長的,控制生長的就是堆棧指針了,最先壓入的參數是在最上面,就是說在所有參數的最後面,最後壓入的參數在最下面,結構上看起來是第一個,是以最後壓入的參數總是能夠被函數找到,因為它就在堆棧指針的上方。printf的第一個被找到的參數就是那個字元指針,就是被雙引号括起來的那一部分,函數通過判斷字元串裡控制參數的個數來判斷參數個數及資料類型,通過這些就可算出資料需要的堆棧指針的偏移量了,下面給出printf("%d,%d",a,b);(其中a、b都是int型的)的彙編代碼
.section
.data
string out = "%d,%d"
push b
push a
push $out
call printf
你會看到,參數是最後的先壓入棧中,最先的後壓入棧中,參數控制的那個字元串常量是最後被壓入的,是以這個常量總是能被找到的。
二、可變參數表函數的設計
标準庫提供的一些參數的數目可以有變化的函數。例如我們很熟悉的printf,它需要有一個格式串,還應根據需要為它提供任意多個“其他參數”。這種函數被稱作“具有變長度參數表的函數”,或簡稱為“變參數函數”。我們寫程式中有時也可能需要定義這種函數。要定義這類函數,就必須使用标準頭檔案<stdarg.h>,使用該檔案提供的一套機制,并需要按照規定的定義方式工作。本節介紹這個頭檔案提供的有關功能,它們的意義和使用,并用例子說明這類函數的定義方法。
C中變長實參頭檔案stdarg.h提供了一個資料類型va-list和三個宏(va-start、va-arg和va-end),用它們在被調用函數不知道參數個數和類型時對可變參數表進行測試,進而為通路可變參數提供了友善且有效的方法。va-list是一個char類型的指針,當被調用函數使用一個可變參數時,它聲明一個類型為va-list的變量,該變量用來指向va-arg和va-end所需資訊的位置。下面給出va_list在C中的源碼:
typedef char * va_list;
void va-start(va-list ap,lastfix)是一個宏,它使va-list類型變量ap指向被傳遞給函數的可變參數表中的第一個參數,在第一次調用va-arg和va-end之前,必須首先調用該宏。va-start的第二個參數lastfix是傳遞給被調用函數的最後一個固定參數的辨別符。va-start使ap隻指向lastfix之外的可變參數表中的第一個參數,很明顯它先得到第一個參數記憶體位址,然後又加上這個參數的記憶體大小,就是下個參數的記憶體位址了。下面給出va_start在C中的源碼:
#define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )
#define va_start(ap,v) ( ap = (va_list)&v + _INTSIZEOF(v) ) //得到可變參數中第一個參數的首位址
type va-arg(va-list ap,type)也是一個宏,其使用有雙重目的,第一個是傳回ap所指對象的值,第二個是修改參數指針ap使其增加以指向表中下一個參數。va-arg的第二個參數提供了修改參數指針所必需的資訊。在第一次使用va-arg時,它傳回可變參數表中的第一個參數,後續的調用都傳回表中的下一個參數,下面給出va_arg在C中的源碼:
#define va_arg(ap,type) ( *(type *)((ap += _INTSIZEOF(type)) - _INTSIZEOF(type)) ) //将參數轉換成需要的類型,并使ap指向下一個參數
在使用va-arg時,要注意第二個參數所用類型名應與傳遞到堆棧的參數的位元組數對應,以保證能對不同類型的可變參數進行正确地尋址,比如實參依次為char型、char * 型、int型和float型時,在va-arg中它們的類型則應分别為int、char *、int和double.
void va-end(va-list ap)也是一個宏,該宏用于被調用函數完成正常傳回,功能就是把指針ap指派為0,使它不指向記憶體的變量。下面給出va_end在C中的源碼:
#define va_end(ap) ( ap = (va_list)0 )
va-end必須在va-arg讀完所有參數後再調用,否則會産生意想不到的後果。特别地,當可變參數表函數在程式執行過程中不止一次被調用時,在函數體每次處理完可變參數表之後必須調用一次va-end,以保證正确地恢複棧。
一個變參數函數至少需要有一個普通參數,其普通參數可以具有任何類型。在函數定義中,這種函數的最後一個普通參數除了一般的用途之外,還有其他特殊用途。下面從一個例子開始說明有關的問題。
假設我們想定義一個函數sum,它可以用任意多個整數類型的表達式作為參數進行調用,希望sum能求出這些參數的和。這時我們應該将sum定義為一個隻有一個普通參數,并具有變長度參數表的函數,這個函數的頭部應該是(函數原型與此類似):
int sum(int n, ...)
我們實際上要求在函數調用時,從第一個參數n得到被求和的表達式個數,從其餘參數得到被求和的表達式。在參數表最後連續寫三個圓點符号,說明這個函數具有可變數目的參數。凡參數表具有這種形式(最後寫三個圓點),就表示定義的是一個變參數函數。注意,這樣的三個圓點隻能放在參數表最後,在所有普通參數之後。
下面假設函數sum裡所用的va_list類型的變量的名字是vap。在能夠用vap通路實際參數之前,必須首先用宏a_start對這個變量進行初始化。宏va_start的類型特征可以大緻描述為:
va_start(va_list vap, 最後一個普通參數)
在函數sum裡對vap初始化的語句應當寫為:
va_start(vap, n); 相當于 char *vap= (char *)&n + sizeof(int);
此時vap正好指向n後面的可變參數表中的第一個參數。
在完成這個初始化之後,我們就可以通過另一個宏va_arg通路函數調用的各個實際參數了。宏va_arg的類型特征可以大緻地描述為:
類型 va_arg(va_list vap, 類型名)
在調用宏va_arg時必須提供有關實參的實際類型,這一類型也将成為這個宏調用的傳回值類型。對va_arg的調用不僅傳回了一個實際參數的值(“目前”實際參數的值),同時還完成了某種更新操作,使對這個宏va_arg的下次調用能得到下一個實際參數。對于我們的例子,其中對宏va_arg的一次調用應當寫為:
v = va_arg(vap, int);
這裡假定v是一個有定義的int類型變量。
在變參數函數的定義裡,函數退出之前必須做一次結束動作。這個動作通過對局部的va_list變量調用宏va_end完成。這個宏的類型特征大緻是:
void va_end(va_list vap);
三、棧中參數分布以及宏使用後的指針變化說明如下:

下面是函數sum的完整定義,從中可以看到各有關部分的寫法:
#include<iostream>
using namespace std;
#include<stdarg.h>
int sum(int n,...)
{
int i , sum = 0;
va_list vap;
va_start(vap , n); //指向可變參數表中的第一個參數
for(i = 0 ; i < n ; ++i)
sum += va_arg(vap , int); //取出可變參數表中的參數,并修改參數指針vap使其增加以指向表中下一個參數
va_end(vap); //把指針vap指派為0
return sum;
}
int main(void)
int m = sum(3 , 45 , 89 , 72);
cout<<m<<endl;
return 0;
這裡首先定義了va_list變量vap,而後對它初始化。循環中通過va_arg取得順序的各個實參的值,并将它們加入總和。最後調用va_end結束。
下面是調用這個函數的幾個例子:
k = sum(3, 5+8, 7, 26*4);
m = sum(4, k, k*(k-15), 27, (k*k)/30);
函數sum中首先定義了可變參數表指針vap,而後通過va_start ( vap, n )取得了參數表首位址(指派給了vap),其後的for循環則用來周遊可變參數表。這種周遊方式與我們在資料結構教材中經常看到的周遊方式是類似的。
函數sum看起來簡潔明了,但是實際上printf的實作卻遠比這複雜。sum函數之是以看起來簡單,是因為:
1、sum函數可變參數表的長度是已知的,通過num參數傳入;
2、sum函數可變參數表中參數的類型是已知的,都為int型。
而printf函數則沒有這麼幸運。首先,printf函數可變參數的個數不能輕易的得到,而可變參數的類型也不是固定的,需由格式字元串進行識别(由%f、%d、%s等确定),是以則涉及到可變參數表的更複雜應用。
在這個函數中,需通過對傳入的格式字元串(首位址為lpStr)進行識别來獲知可變參數個數及各個可變參數的類型,具體實作展現在for循環中。譬如,在識别為%d後,做的是va_arg ( vap, int ),而獲知為%l和%lf後則進行的是va_arg ( vap, long )、va_arg ( vap, double )。格式字元串識别完成後,可變參數也就處理完了。
在編寫和使用具有可變數目參數的函數時,有幾個問題值得注意。
第一:調用va_arg将更新被操作的va_list變量(如在上例的vap),使下次調用可以得到下一個參數。在執行這個操作時,va_arg并不知道實際有幾個參數,也不知道參數的實際類型,它隻是按給定的類型完成工作。是以,寫程式的人應在變參數函數的定義裡注意控制對實際參數的處理過程。上例通過參數n提供了參數個數的資訊,就是為了控制循環。标準庫函數printf根據格式串中的轉換描述的數目确定實際參數的個數。如果這方面資訊有誤,函數執行中就可能出現嚴重問題。編譯程式無法檢查這裡的資料一緻性問題,需要寫程式的人自己負責。在前面章節裡,我們一直強調對printf等函數調用時,要注意格式串與其他參數個數之間一緻性,其原因就在這裡。
第二:編譯系統無法對變參數函數中由三個圓點代表的那些實際參數做類型檢查,因為函數的頭部沒有給出這些參數的類型資訊。是以編譯進行中既不會生成必要的類型轉換,也不會提供類型錯誤資訊。考慮标準庫函數printf,在調用這個函數時,不但實際參數個數可能變化,各參數的類型也可能不同,是以不可能有統一方式來描述它們的類型。對于這種參數,C語言的處理方式就是不做類型檢查,要求寫程式的人保證函數調用的正确性。
假設我們寫出下面的函數調用:
k = sum(6, 2.4, 4, 5.72, 6, 2);
編譯程式不會發現這裡參數類型不對,需要做類型轉換,所有實參都将直接傳給函數。函數裡也會按照内部定義的方式把參數都當作整數使用。編譯程式也不會發現參數個數與6不符。這一調用的結果完全由編譯程式和執行環境決定,得到的結果肯定不會是正确的。
四、簡單的練習
問題1:可變長參數的擷取
有這樣一個具有可變長參數的函數,其中有下列代碼用來擷取類型為float的實參:
va_arg (argp, float);
這樣做可以嗎?
答案與分析:
不可以。在可變長參數中,應用的是"加寬"原則。也就是float類型被擴充成double;char、 short類型被擴充成int。是以,如果你要去可變長參數清單中原來為float類型的參數,需要用va_arg(argp, double)。對char和short類型的則用va_arg(argp, int)。
問題2:定義可變長參數的一個限制
為什麼我的編譯器不允許我定義如下的函數,也就是可變長參數,但是沒有任何的固定參數?
int f(...)
......
答案與分析:
不可以。這是ANSI C 所要求的,你至少得定義一個固定參數。這個參數将被傳遞給va_start(),然後用va_arg()和va_end()來确定所有實際調用時可變長參數的類型和值。
問題3:如何判别可變參數函數的參數類型?
函數形式如下:
void fun(char *str ,...)
若傳的參數個數大于1,如何判别第2個以後傳參的參數類型???
這個是沒有辦法判斷的,例如printf( "%d%c%s ", ....)是通過格式串中的%d、 %c、 %s來确定後面參數的類型,其實你也可以參考這種方法來判斷不定參數的類型。
最後,奉獻上自己寫的一個printf函數
#include<stdio.h>
void myitoa(int n, char str[], int radix)
int i , j , remain;
char tmp;
i = 0;
do
{
remain = n % radix;
if(remain > 9)
str[i] = remain - 10 + 'A';
else
str[i] = remain + '0';
i++;
}while(n /= radix);
str[i] = '\0';
for(i-- , j = 0 ; j <= i ; j++ , i--)
tmp = str[j];
str[j] = str[i];
str[i] = tmp;
}
void myprintf(const char *format, ...)
char c, ch, str[30];
va_list ap;
va_start(ap, format);
while((c = *format))
switch(c)
{
case '%':
ch = *++format;
switch(ch)
{
case 'd':
{
int n = va_arg(ap, int);
myitoa(n, str, 10);
fputs(str, stdout);
break;
}
case 'x':
myitoa(n, str, 16);
case 'f':
double f = va_arg(ap, double);
int n;
n = f;
putchar('.');
n = (f - n) * 1000000;
case 'c':
putchar(va_arg(ap, int));
case 's':
char *p = va_arg(ap, char *);
fputs(p, stdout);
case '%':
putchar('%');
default:
fputs("format invalid!", stdout);
}
break;
default:
putchar(c);
}
format++;
va_end(ap);
myprintf("%d, %x, %f, %c, %s, %%,%a\n", 10, 15, 3.14, 'B', "hello");