6. 可變參數

到目前為止我們只見過一個帶有可變參數的函數printf

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

以後還會見到更多這樣的函數。現在我們實現一個簡單的myprintf函數:

例 24.9. 用可變參數實現簡單的printf函數

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

void myprintf(const char *format, ...)
{
     va_list ap;
     char c;

     va_start(ap, format);
     while (c = *format++) {
	  switch(c) {
	  case 'c': {
	       /* char is promoted to int when passed through '...' */
	       char ch = va_arg(ap, int);
	       putchar(ch);
	       break;
	  }
	  case 's': {
	       char *p = va_arg(ap, char *);
	       fputs(p, stdout);
	       break;
	  }
	  default:
	       putchar(c);
	  }
     }
     va_end(ap);
}

int main(void)
{
     myprintf("c\ts\n", '1', "hello");
     return 0;
}

要處理可變參數,需要用C到標準庫的va_list類型和va_startva_argva_end宏,這些定義在stdarg.h標頭檔中。這些宏是如何取出可變參數的呢?我們首先對照反彙編分析在調用myprintf函數時這些參數的內存佈局。

     myprintf("c\ts\n", '1', "hello");
 80484c5:	c7 44 24 08 b0 85 04 	movl   $0x80485b0,0x8(%esp)
 80484cc:	08 
 80484cd:	c7 44 24 04 31 00 00 	movl   $0x31,0x4(%esp)
 80484d4:	00 
 80484d5:	c7 04 24 b6 85 04 08 	movl   $0x80485b6,(%esp)
 80484dc:	e8 43 ff ff ff       	call   8048424 <myprintf>

圖 24.6. myprintf函數的參數佈局

myprintf函數的參數佈局

這些參數是從右向左依次壓棧的,所以第一個參數靠近棧頂,第三個參數靠近棧底。這些參數在內存中是連續存放的,每個參數都對齊到4位元組邊界。第一個和第三個參數都是指針類型,各占4個位元組,雖然第二個參數隻占一個位元組,但為了使第三個參數對齊到4位元組邊界,所以第二個參數也占4個位元組。現在給出一個stdarg.h的簡單實現,這個實現出自[Standard C Library]

例 24.10. stdarg.h的一種實現

/* stdarg.h standard header */
#ifndef _STDARG
#define _STDARG

/* type definitions */
typedef char *va_list;
/* macros */
#define va_arg(ap, T) \
	(* (T *)(((ap) += _Bnd(T, 3U)) - _Bnd(T, 3U)))
#define va_end(ap) (void)0
#define va_start(ap, A) \
	(void)((ap) = (char *)&(A) + _Bnd(A, 3U))
#define _Bnd(X, bnd) (sizeof (X) + (bnd) & ~(bnd))
#endif

這個標頭檔中的內部宏定義_Bnd(X, bnd)將類型或變數X的長度對齊到bnd+1位元組的整數倍,例如_Bnd(char, 3U)的值是4,_Bnd(int, 3U)也是4。

myprintf中定義的va_list ap;其實是一個指針,va_start(ap, format)使ap指向format參數的下一個參數,也就是指向上圖中esp+4的位置。然後va_arg(ap, int)把第二個參數的值按int型取出來,同時使ap指向第三個參數,也就是指向上圖中esp+8的位置。然後va_arg(ap, char *)把第三個參數的值按char *型取出來,同時使ap指向更高的地址。va_end(ap)在我們的簡單實現中不起任何作用,在有些實現中可能會把ap改寫成無效值,C標準要求在函數返回前調用va_end

如果把myprintf中的char ch = va_arg(ap, int);改成char ch = va_arg(ap, char);,用我們這個stdarg.h的簡單實現是沒有問題的。但如果改用libc提供的stdarg.h,在編譯時會報錯:

$ gcc main.c
main.c: In function ‘myprintf’:
main.c:33: warning: ‘char’ is promoted to ‘int’ when passed through ‘...’
main.c:33: note: (so you should pass ‘int’ not ‘char’ to ‘va_arg’)
main.c:33: note: if this code is reached, the program will abort
$ ./a.out
Illegal instruction

因此要求char型的可變參數必須按int型來取,這是為了與C標準一致,我們在第 3.1 節 “Integer Promotion”講過Default Argument Promotion規則,傳遞char型的可變參數時要提升為int型。

myprintf的例子可以理解printf的實現原理,printf函數根據第一個參數(格式化字元串)來確定後面有幾個參數,分別是什麼類型。保證參數的類型、個數與格式化字元串的描述相匹配是調用者的責任,實現者只管按格式化字元串的描述從棧上取數據,如果調用者傳遞的參數類型或個數不正確,實現者是沒有辦法避免錯誤的。

還有一種方法可以確定可變參數的個數,就是在參數列表的末尾傳一個Sentinel,例如NULLexecl(3)就採用這種方法確定參數的個數。下面實現一個printlist函數,可以打印若干個傳入的字元串。

例 24.11. 根據Sentinel判斷可變參數的個數

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

void printlist(int begin, ...)
{
     va_list ap;
     char *p;

     va_start(ap, begin);
     p = va_arg(ap, char *);

     while (p != NULL) {
	  fputs(p, stdout);
	  putchar('\n');
	  p = va_arg(ap, char*);
     }
     va_end(ap);
}

int main(void)
{
     printlist(0, "hello", "world", "foo", "bar", NULL);
     return 0;
}

printlist的第一個參數begin的值並沒有用到,但是C語言規定至少要定義一個有名字的參數,因為va_start宏要用到參數列表中最後一個有名字的參數,從它的地址開始找可變參數的位置。實現者應該在文檔中說明參數列表必須以NULL結尾,如果調用者不遵守這個約定,實現者是沒有辦法避免錯誤的。

習題

1、實現一個功能更完整的printf,能夠識別%,能夠處理%d%f對應的整數參數。在實現中不許調用printf(3)這個Man Page中描述的任何函數。