2007年9月26日星期三

Implementing dprintf() with __VA_ARGS__

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》。


  1. 在 UNIX 世界裡,常見使用 NDEBUG 代表「釋出版」;在 Visual C++ 世界裡,常見使用 _DEBUG 代表測試版。我們可以在一些大家都會引入的標頭檔裡,將這個歧異消除。
  2. 可以利用 Editplus 的 external filter 功能,擷取程式的輸出結果,在輸出視窗裡點兩下,馬上跳到印出訊息的程式碼所在處。
  3. 有時候,translation unit 又被稱作 compilation unit。
  4. 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.
  5. 對於 dprintf_v2() 的行為,預期應該要和 assert() 一樣,在測試版有效,在釋出版相當於沒有這整個 function call,會被 preprocessor 消除。
  6. 見 C99 6.10.3.10。
  7. 事實上,__VA_ARGS__ 屬於 GCC extension 之一,故即使編譯時沒有加上 -std=c99 參數,只要我們不用 __STDC_VERSION__ 阻擋,仍然是可以使用最終的 dprintf() 版本。
  8. 其實這是非戰之罪,VC6 是 1995 年出的,C99 是 1999 年定案的,要求 1995 年的 compiler 支援 1999 年的標準,太過嚴苛。

2 Comments

  1. abin
    Posted September 21, 2007 at 8:48 am | Permalink

    看了您的這篇文章後,受益匪淺..寫的真是太詳細了.
    我剛好也有相同的應用.這這種方式除錯及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 大家有空聊寥程式心得

  2. Posted September 21, 2007 at 10:08 am | Permalink

    abin,

    好酒陳甕底,啊,不是,寫文章要花很多時間,這一篇前前後後也花了十天才寫完。第二集技術方面已經沒問題了,但是需要時間鋪陳,化為文字與適於講解的範例,加上最近工作很忙,回到家就累攤了,所以只好請您等等囉。

    不過可以先透漏一下解法的關鍵字:(C++ || TLS),後者是 Thread Local Storage 的縮寫,請注意 expression shortcut 的效果。

    ps. 這一系列有三集,第三集有方向了,但是詳細解法尚待思量。

没有评论: