一、什麼是可變參數
我們在C語言編程中有時會遇到一些參數個數可變的函數,例如printf()函數,其函數原型為:
int printf( const char* format, ...);
它除了有一個參數format固定以外,後面跟的參數的個數和類型是可變的(用三個點“…”做參數占位符),實際調用時可以有以下的形式: printf("%d",i);
printf("%s",s);
printf("the number is %d ,string is:%s", i, s);
以上這些東西已為大家所熟悉。但是究竟如何寫可變參數的C函數以及這些可變參數的函數編譯器是如何實現?本文就這個問題進行一些探討,希望能對大家有些幫助.
二、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> #include<stdarg.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': { int n = va_arg(ap, int); myitoa(n, str, 16); fputs(str, stdout); break; } case 'f': { double f = va_arg(ap, double); int n; n = f; myitoa(n, str, 10); fputs(str, stdout); putchar('.'); n = (f - n) * 1000000; myitoa(n, str, 10); fputs(str, stdout); break; } case 'c': { putchar(va_arg(ap, int)); break; } case 's': { char *p = va_arg(ap, char *); fputs(p, stdout); break; } case '%': { putchar('%'); break; } default: { fputs("format invalid!", stdout); break; } } break; default: putchar(c); break; } format++; } va_end(ap); } int main(void) { myprintf("%d, %x, %f, %c, %s, %%,%a\n", 10, 15, 3.14, 'B', "hello"); return 0; }