函數是大多數編程語言都實現的編程要素,調用函數的實現原理就是:執行跳轉+參數傳遞。對於執行跳轉,所有的CPU都直接提供跳轉指令;對於參數傳遞,CPU會提供多種方式,最常見的方式就是利用棧來傳遞參數。C語言標准實現了函數調用,但是卻沒有限定實現細節,不同的C編譯器廠商可以根據底層硬件環境自行確定實現方式。
函數調用的一般實現原理,請參考我的博文 C語言中利用setjmp和longjmp做異常處理中的第一段。
我們以X86架構上的VC++編譯器為例進行舉例說明。例子代碼如下。
void f(int x, int y, int z)
{
printf("%p, %p, %p\n", &x, &y, &z);
}
int main()
{
f(100, 200, 300);
return 0;
}
可能的執行結果:
00FFF674, 00FFF678, 00FFF67C
VC++中函數的參數是通過堆棧傳遞的,參數按照從右向左的順序入棧。調用f時參數在堆棧中的情況如下圖所示:
可見,我們只要知道x的地址,就可以推算出y,z的地址,從而通過其地址取得參數y,z的值,而不用其參數名稱取值。如下代碼所示。
void f(int x, int y, int z)
{
char* px = (char*)&x;
char *py = px + sizeof(x);
char *pz = py + sizeof(int);
printf("x=%d, y=%d, z=%d\n", x, *(int*)py, *(int*)pz);
}
int main()
{
f(100, 200, 300);
return 0;
}
可見根據函數的第一個參數,以及後續參數的類型,就可以根據偏移量計算出後續參數的地址,從而取得後續參數值。
於是可以把上述代碼改寫成可變參數的形式。
void f(int x, ...)
{
char* px = (char*)&x;
char *py = px + sizeof(x);
char *pz = py + sizeof(int);
printf("x=%d, y=%d, z=%d\n", x, *(int*)py, *(int*)pz);
}
int main()
{
f(100, 200, 300);
return 0;
}
雖然寫成了可變參形式,但是函數如何判斷後續實參的個數和類型呢?這就需要在固定參數中攜帶這些信息,如printf(char*, …)使用的格式化字符串方法,通過第一個參數來攜帶後續參數個數以及類型的信息。我們實現一個簡單點的,只能識別%s,%d,%f三種標志。
void f(char* fmt, ...)
{
char* p0 = (char*)&fmt;
char* ap = p0 + sizeof(fmt);
char* p = fmt;
while (*p) {
if (*p == '%' && *(p+1) == 'd') {
printf("參數類型為int,值為 %d\n", *((int*)ap));
ap += sizeof(int);
}
else if (*p == '%' && *(p+1) == 'f') {
printf("參數類型為double,值為 %f\n", *((double*)ap));
ap += sizeof(double);
}
else if (*p == '%' && *(p+1) == 's') {
printf("參數類型為char*,值為 %s\n", *((char**)ap));
ap += sizeof(char*);
}
p++;
}
}
int main()
{
f("%d,%f,%s", 100, 1.23, "hello world");
return 0;
}
輸出:
參數類型為int,值為 100
參數類型為double,值為 1.230000
參數類型為char*,值為 hello world
為簡化分析參數代碼,定義一些宏來簡化,如下。
#define va_list char* /* 可變參數地址 */
#define va_start(ap, x) ap=(char*)&x+sizeof(x) /* 初始化指針指向第一個可變參數 */
#define va_arg(ap, t) (ap+=sizeof(t),*((t*)(ap-sizeof(t)))) /* 取得參數值,同時移動指針指向後續參數 */
#define va_end(ap) ap=0 /* 結束參數處理 */
void f(char* fmt, ...)
{
va_list ap;
va_start(ap, fmt);
char* p = fmt;
while (*p) {
if (*p == '%' && *(p+1) == 'd') {
printf("參數類型為int,值為 %d\n", va_arg(ap, int));
}
else if (*p == '%' && *(p+1) == 'f') {
printf("參數類型為double,值為 %f\n", va_arg(ap, double));
}
else if (*p == '%' && *(p+1) == 's') {
printf("參數類型為char*,值為 %s\n", va_arg(ap, char*));
}
p++;
}
va_end(ap);
}
int main()
{
f("%d,%f,%s,%d", 100, 1.23, "hello world", 200);
return 0;
}
上面的例子中,我們沒有使用任何庫函數就輕松實現了可變參數函數。別高興太早,上述代碼在X86平台的VC++編譯器下可以順利編譯、正確執行。但是在gcc編譯後,運行卻是錯誤的。可見GCC對於可變參數的實參傳遞實現與VC++並不相同。
gcc下編譯運行:
[smstong@cf-19 ~]$ ./a.out
參數類型為int,值為 0
參數類型為double,值為 0.000000
Segmentation fault
可見,上述代碼是不可移植的。為了在使得可變參函數能夠跨平台、跨編譯器正確執行,必須使用C標准頭文件stdarg.h中定義的宏,而不是我們自己定義的。(這些宏的名字和作用與我們自己定義的宏完全相同,這絕不是巧合!)每個不同的C編譯器所附帶的stdarg.h文件中對這些宏的定義都不相同。再次重申一下這幾個宏的使用范式:
va_list ap;
va_start(ap, 固定參數名); /* 根據最後一個固定參數初始化 */
可變參數1類型 x1 = va_arg(ap, 可變參數類型1); /* 根據參數類型,取得第一個可變參數值 */
可變參數2類型 x2 = va_arg(ap, 可變參數類型2); /* 根據參數類型,取得第二個可變參數值 */
...
va_end(ap); /* 結束 */
這次,把我們自己的宏定義去掉,換成#include
#include
#include
void f(char* fmt, ...)
{
va_list ap;
va_start(ap, fmt);
char* p = fmt;
while (*p) {
if (*p == '%' && *(p+1) == 'd') {
printf("參數類型為int,值為 %d\n", va_arg(ap, int));
}
else if (*p == '%' && *(p+1) == 'f') {
printf("參數類型為double,值為 %f\n", va_arg(ap, double));
}
else if (*p == '%' && *(p+1) == 's') {
printf("參數類型為char*,值為 %s\n", va_arg(ap, char*));
}
p++;
}
va_end(ap);
}
int main()
{
f("%d,%f,%s,%d", 100, 1.23, "hello world", 200);
return 0;
}
代碼在VC++和GCC下均可以正確執行了。
也許在有些編譯器環境中,va_end(ap);確實沒有什麼作用,但是在其他編譯器中卻可能涉及到內存的回收,切不可省略。
《C語言程序設計》中提到:
在沒有函數原型的情況下,char與short類型都將被轉換為int類型,float類型將被轉換為double類型。實際上,用...標識的可變參數總是會執行這種類型提升。
引用《C陷阱與缺陷》裡的話:
**va_arg宏的第2個參數不能被指定為char、short或者float類型**。
因為char和short類型的參數會被轉換為int類型,而float類型的參數會被轉換為double類型 ……
例如,這樣寫肯定是不對的:
c = va_arg(ap,char);
因為我們無法傳遞一個char類型參數,如果傳遞了,它將會被自動轉化為int類型。上面的式子應該寫成:
c = va_arg(ap,int);
對於可變參數,編譯器無法進行任何檢查,只能靠調用者的自覺來保證正確。
可變參數必須靠固定參數來定位,所以函數中至少需要提供固定參數,f(固定參數,…)。
當然,也可以提供更多的固定參數,如f(固定參數1,固定參數2,…)。注意的是,當提供2個或以上固定參數時,va_start(ap, x)宏中的x必須是最後一個固定參數的名字(也就是緊鄰可變參數的那個固定參數)。
C++的函數重載特性,允許重復使用相同的名稱來定義函數,只要同名函數的參數(類型或數量)不同。例如,
void f(int x);
void f(int x, double d);
void f(char* s);
雖然源代碼中函數名字相同,其實編譯器處理後生成的是三個具有不同函數名的函數(名字改編name mangling)。雖然在使用上有些類似之處,但這顯然與C的可變參數函數完全不是一個概念。