Implementing dprintf() with __VA_ARGS__
在 程式寫作的過程中,通常我們會需要輸出一些訊息,方便我們瞭解程式的運作情形,以便偵錯。因為這些訊息,對程式的真正使用者,也就是我們開發者的客戶,並 沒有意義。是故,通常我們會將程式分成測試版 (debug version 或 debug mode) 或釋出版 (release version 或 release mode),然後只在測試版裡輸出這些訊息,而在釋出版裡完全抑制這些訊息的輸出。
第零版:dprintf_v0.c
這種除錯用的訊息,最直覺簡單的寫法,如下:
#include
int main()
{
int i = 3;
#ifndef NDEBUG
fprintf(stderr, "%s(%d): i == %d\n", __FILE__, __LINE__, i);
#endif
return 0;
}
// OUTPUT:
// dprintf_v0.c(7): i == 3
在 preprocessing 時檢查 NDEBUG
[1] 是否有被定義,如果沒有,表示是為測試版,就將除錯訊息輸出。另外,我們也一併印出,印出訊息的程式碼檔案與所在行號,這對追蹤程式,有著極大的幫助[2]。
第一版:dprintf_v1.c
然而,#ifndef
/#endif
到處散落在程式裡,實在很亂又難看。所以,乾脆利用
,寫成一個 dprintf_v1()
,然後只有在測試版才真的印出東西出來:
#include
#include
void dprintf_v1(const char* file, size_t line, const char* fmt, ...)
{
#ifndef NDEBUG
va_list ap;
fprintf(stderr, "%s(%d): ", file, line);
va_start(ap, fmt);
vfprintf(stderr, fmt, ap);
va_end(ap);
fprintf(stderr, "\n");
fflush(stderr);
#endif
}
int main()
{
int i = 3;
dprintf_v1(__FILE__, __LINE__, "i == %d", i);
return 0;
}
// OUTPUT:
// dprintf_v1.c(20): i == 3
直接在 dprintf_v1()
裡面,利用 NDEBUG
決定是否要輸出訊息。如果是釋出版,dprintf_v1()
就相當於什麼都不做的 function。不過,由於 dprintf_v1()
不一定會與呼叫端,存在於同一個 translation unit[3],因此無法被最佳化,呼叫的 overhead 不可避免。
第二版:dprintf_v2.c
實作 dprintf_v1()
的函式庫,不一定與呼叫 dprintf_v1()
的程式模組,使用相同的編譯模式,同屬於測試版或釋出版。所以,我們最好還是讓呼叫端來決定是否要把訊息印出來,同時,讓呼叫端可以在 compile-time 或 run-time 來決定是否要印出:
#include
#include
// Use parameter enable to choose whether to print the message at run-time.
void dprintf_v2_impl(const char* file, size_t line, int enable, const char* fmt, ...)
{
va_list ap;
if (enable) {
fprintf(stderr, "%s(%d): ", file, line);
va_start(ap, fmt);
vfprintf(stderr, fmt, ap);
va_end(ap);
fprintf(stderr, "\n");
fflush(stderr);
}
}
// Choose whether to print the message at compile-time (preprocessing-time actually)
#ifndef NDEBUG
# define dprintf_v2 dprintf_v2_impl
#else
# define dprintf_v2
#endif
int main()
{
int enable_debug = 1;
int i = 3;
dprintf_v2(__FILE__, __LINE__, enable_debug, "i == %d", i);
return 0;
}
// OUTPUT:
// dprintf_v2.c(29): i == 3
在這個版本裡,我們利用 NDEBUG
來決定,dprintf_v2
是否會被代換成 dprintf_v2_impl
:
- 如果會代換,就由第一個參數,於
dprintf_v2_impl()
裡,在 run-time 決定是否要印出訊息,此時,呼叫dprintf_v2_impl()
的 overhead 已經發生。 - 如果不代換,剩下的括弧雖然會保留,但括弧與括弧內的參數,相當於是一串由 comma operator 隔開的 expressions,整個括弧最後會被 evaluate 成最後一個 expression 的值與型別[4],也就是
i
。相對於整個程式來說,因為沒有任何的 side-effect,這一個括弧,相當於沒有任何意義,理論上會被 optimizer 整個消除。
然而,理論歸理論,我們無法保證這個括弧,會確實地被 optimizer 整個消除。萬一裡面有 side-effect,又沒有被 optimizer 消除,那事情就麻煩了[5]。所以最好在 #define
時,把參數串也包含在內。另外,每次都要寫 __FILE__
和 __LINE__
實在太麻煩了,最好能夠自動得出。
2007-09-26 新增:
若是將 dprintf_v2.c
編譯成釋出版,但 GCC 加上 -Wall
選項,就會跑出這樣的警告訊息:
SHELL> gcc -DNDEBUG -Wall dprintf_v2.c
dprintf_v2.c: In function `main':
dprintf_v2.c:29: warning: left-hand operand of comma expression has no effect
dprintf_v2.c:29: warning: left-hand operand of comma expression has no effect
dprintf_v2.c:29: warning: left-hand operand of comma expression has no effect
dprintf_v2.c:29: warning: left-hand operand of comma expression has no effect
dprintf_v2.c:29: warning: statement with no effect
可見,這個沒有存在意義的括弧與其內容,會被 GCC 偵測出來。
使用 function-like macro 的問題
如果使用有參數的 macro,正式名稱為 function-like macro[6] 的方法來寫的話,會碰到一個問題就是,function-like macro 的參數,是「一對一對應的」,然而因為 dprintf_v2()
的參數個數,為不定個數,所以我們根本不可能窮舉出所有的參數個數,更何況,preprocessor 並沒有所謂的 function overloading 可以用。
另外,像這樣子的直覺想法:
#define dprintf_v2(params) dprintf_v2_impl(params)
也是不可行的。因為在 main()
裡呼叫 dprintf_v2()
時,給的參數有五個之多,而 params
只能代表一個參數,故無法通過編譯。GCC 會產生這樣的錯誤訊息:macro "dprintf_v2" passed 5 arguments, but takes just 1
。
即使,我們在呼叫端,多使用一層括弧,來避開這個問題,也是沒有用的:
#define dprintf_v2(params) dprintf_v2_impl(params)
void foo()
{
// 多一層括弧,讓整串參數變成一個 function-like macro 的參數。
dprintf_v2(("i == %d", i));
}
因為即使 dprintf_v2()
呼叫成功了,在呼叫 dprintf_v2_impl()
時,也會碰到參數個數不符的錯誤。
使用 C99 的 __VA_ARGS__:dprintf_v3.c
在 C99 標準裡,新增了 __VA_ARGS__
這個東西,可以讓 preprocessor 也支援不定長度參數。使用方法是,就好像 C 的不定個數參數的函式一樣,寫 function-like macro 時,在參數列的最後面寫 ...
,然後就可以用 __VA_ARGS__
代表 ...
所傳入的參數。
有了這個功能,我們就可以解決以上的所有問題:
#include
#include
void dprintf_v3_impl(const char* file, size_t line, int enable, const char* fmt, ...)
{
va_list ap;
if (enable) {
fprintf(stderr, "%s (%d): ", file, line);
va_start(ap, fmt);
vfprintf(stderr, fmt, ap);
va_end(ap);
fprintf(stderr, "\n");
fflush(stderr);
}
}
#ifndef NDEBUG
# define dprintf_v3(enable, ...) \
dprintf_v3_impl(__FILE__, __LINE__, enable, __VA_ARGS__)
#else
# define dprintf_v3(enable, ...) // define to nothing in release mode
#endif
int main()
{
int enable = 1;
int i = 3;
dprintf_v3(enable, "i == %d", i);
return 0;
}
// OUTPUT:
// dprintf_v3.c (27): i == 3
在這個例子的 main()
裡,呼叫 dprintf_v3()
所用的參數,enable 會對應到 dprintf_v3_impl()
的第三個參數,然後 "i == %d", i
這兩個參數,會對應到 dprintf_v3_impl()
的第四、第五個參數,也就是 __VA_ARGS__
所在的位置。由於要考慮到呼叫 dprintf_v3()
時,可能只有給 format string 參數,而沒有給要代換掉 format specifiers 的其他參數,因此,...
和 __VA_ARGS__
所對應到的參數,要包含到 dprintf_v3_impl()
的 fmt
。也就是說,不能夠這樣寫:
#define dprintf_v3(enable, fmt, ...) \
dprintf_v3_impl(__FILE__, __LINE__, enable, fmt, __VA_ARGS__)
否則,若只是像下面這樣呼叫,就會產生錯誤,因為 ...
和 __VA_ARGS__
一定要對應到至少一個參數。
int main()
{
int enable = 1;
dprintf_v3(enable, "a simple string");
return 0;
}
如此一來,利用 __VA_ARGS__
,我們既可以藏 __FILE__
與 __LINE__
於 #define
中,呼叫時不必加上這兩個參數,又可以在維持方便好用的呼叫語法的條件下,在釋出版裡利用 preprocessor,從根本上把「印訊息」的所有 overhead 確實消除。真可謂是完美的 dprintf()
。唯一的缺點就是,preprocessor 必須要支援 ...
和 __VA_ARGS__
,否則依據上面一連串的分析,是無法達到這樣完美的境界的。
偵測是否支援 __VA_ARGS__
最後,讓我們加上偵測 __VA_ARGS__
的程式碼片段,以避免老舊 compiler 產生編譯錯誤。
由於 __VA_ARGS__
是 C99 新增的功能,因此我們可以利用 __STDC_VERSION__
這個 predefined macro name,來判斷 compiler 所支援的標準 C 版本。C99 的 __STDC_VERSION__
依規定會是 199901L
,所以只要 __STDC_VERSION__
小於 199901L
,就讓 preprocesser 印出錯誤訊息,要求使用好一點、新一點的 compiler:
#include
#include
void dprintf_impl(const char* file, size_t line, int enable, const char* fmt, ...)
{
va_list ap;
if (enable) {
fprintf(stderr, "%s (%d): ", file, line);
va_start(ap, fmt);
vfprintf(stderr, fmt, ap);
va_end(ap);
fprintf(stderr, "\n");
fflush(stderr);
}
}
#ifndef NDEBUG
# if (__STDC_VERSION__ <>
# error "Please use a newer compiler that support __VA_ARGS__."
# else
# define DPRINTF(enable, ...) \
dprintf_impl(__FILE__, __LINE__, enable, __VA_ARGS__)
# endif
#else
# define DPRINTF(enable, ...) // define to nothing in release mode
#endif
int main()
{
int enable = 1;
int i = 3;
DPRINTF(enable, "i == %d", i);
return 0;
}
// OUTPUT:
// dprintf.c (32): i == 3
由於其實是個 macro,所以還是依照一般的慣例,使用全大寫命名 DPRINTF()
。額外的好處是,既避免了一堆 #ifndef
/#endif
,仍然可以因為大、小寫差異的關係,讓我們可以方便地看出,那一行程式是不會被包含在釋出版裡。
如果使用 GCC 編譯,請加上 -std=c99
參數,因為 GCC 預設只開啟 C89 與其多加的 extension 功能,否則我們將可以確實地看到 Please use a newer compiler that support __VA_ARGS__
的錯誤訊息[7]。至於 VC6 這個爛東西,不支援 __VA_ARGS__
[8],所以我們得另外想點辦法才行,請期待下一篇文章:《Workaround: lacking of __VA_ARGS__ in VC6》。
- 在 UNIX 世界裡,常見使用
NDEBUG
代表「釋出版」;在 Visual C++ 世界裡,常見使用_DEBUG
代表測試版。我們可以在一些大家都會引入的標頭檔裡,將這個歧異消除。 ↩ - 可以利用 Editplus 的 external filter 功能,擷取程式的輸出結果,在輸出視窗裡點兩下,馬上跳到印出訊息的程式碼所在處。 ↩
- 有時候,translation unit 又被稱作 compilation unit。 ↩
- C99 6.5.17.2: The left operand of a comma operator is evaluated as a void expression; there is a sequence point after its evaluation. Then the right operand is evaluated; the result has its type and value. If an attempt is made to modify the result of a comma operator or to access it after the next sequence point, the behavior is undefined. ↩
- 對於
dprintf_v2()
的行為,預期應該要和assert()
一樣,在測試版有效,在釋出版相當於沒有這整個 function call,會被 preprocessor 消除。 ↩ - 見 C99 6.10.3.10。 ↩
- 事實上,
__VA_ARGS__
屬於 GCC extension 之一,故即使編譯時沒有加上-std=c99
參數,只要我們不用__STDC_VERSION__
阻擋,仍然是可以使用最終的dprintf()
版本。 ↩ - 其實這是非戰之罪,VC6 是 1995 年出的,C99 是 1999 年定案的,要求 1995 年的 compiler 支援 1999 年的標準,太過嚴苛。 ↩
看了您的這篇文章後,受益匪淺..寫的真是太詳細了.
我剛好也有相同的應用.這這種方式除錯及trace真是太方便了.
只是我會在多加上 時間差 以及 ThreadID ,並且輸出到 debug view(http://www.microsoft.com/technet/sysinternals/Miscellaneous/DebugView.mspx)
還有我多加了一個 depth 參數, 用來讓除錯時有層次以及計算不同層次中的時間差( 例如 main depth 為 0 , main->funcA() 為 1)
ThreadID 主要是可以適用於 multithread 的狀況下,
時間差主要是可以找出程式效能瓶頸.
使用debug view主要是讓程式在web上依然能夠除錯.
而且我會有一個 method 列出所有 thread 目前所 RUN 到的位置.. 用來檢查是不是有 deadlock
以上這幾點提供您參考.
另外關於您的續篇..《Workaround: lacking of __VA_ARGS__ in VC6》
可不可以先透漏一點技巧給我呢? 關於這方面我找了很久.還是找不到相關資料..
再次謝謝您寫這麼精闢的文章分享
也歡迎加入我的 msn 大家有空聊寥程式心得
abin,
好酒陳甕底,啊,不是,寫文章要花很多時間,這一篇前前後後也花了十天才寫完。第二集技術方面已經沒問題了,但是需要時間鋪陳,化為文字與適於講解的範例,加上最近工作很忙,回到家就累攤了,所以只好請您等等囉。
不過可以先透漏一下解法的關鍵字:(C++ || TLS),後者是 Thread Local Storage 的縮寫,請注意 expression shortcut 的效果。
ps. 這一系列有三集,第三集有方向了,但是詳細解法尚待思量。