這是一位網友的問題,原問題見http://www.ptt.cc/bbs/C_and_CPP/M.1351841869.A.BAF.html。本部落格之前也稍微碰過這個主題,不過之前的重點在於腦筋急轉彎式的模擬 __VA_ARGS__ 功能,實用性是另一回事。

在開始討論各種作法可行性之前,這裡先宣導一項原則:應該要讓正常輸出與非常態輸出分流,更精確的說,給使用者看的輸出應該要和給開發者看的輸出分流。例如使用 stdout 作為正常輸出時,就不應該也用 stdout 作為非常態輸出,而是改用 stderr 或檔案。實戰中非常態輸出的 sink 很多樣,這裡就不多說了。即使不想多花心力去設計非常態輸出機制,使用基本的 stderr 並不會比用 stdout 多費功夫,好處卻是立即可見,可謂有利而無害。

回到原問題上。最常見的 debug code 不外乎吐出一堆程式內部狀態的資訊,以下我用 dprintf 來表示一個用法和 printf 完全相同、但流向不同管道的函數,不一定會和讀者手邊編譯器所提供的 dprintf 相同。原問題所使用的策略,亦即用 macro 製造註解來吃掉 debug code ,到底可不可行呢?

#define dprintf //

dprintf(...whatever...);

依照標準的 C++ preprocessor 轉換流程,處理註解的時間點早於 macro 展開,所以 // 會先被視為對這一行的註解而忽略掉,這相當於把 dprintf 定義成空字串,原本的參數列則會被留下來變成一組用小括號括起來的逗號運算式。很有趣的是,當這組逗號運算式沒有副作用的時候,使出這招的人還會誤以為自己的把戲成功了。有人可能會說,無論如何輸出都被消掉了,那麼也算是達到目的了吧。我的看法是,如果撰寫者嚴格遵守 debug code 不可有副作用的法則,這倒也不失為一個方法,問題在於寫出這樣程式碼的人八成沒意識到這點,而且遇到像 cerr、clog 這種形式的輸出就沒轍了。

還有一些方法試圖利用 ## 連接字串的方式製造 //,不過這些做法只對部分編譯器有效(例如 VC)。事實上標準的 C++ preprocessor 轉換規則已經否定這種做法(一是處理註解的時間點、二是//不是合法的 preprocessing token),所以可以預期拒絕的編譯器應該會比通過的編譯器還要多。總而言之,在標準 C++ 下沒有辦法用 macro 製造註解。這也意味著,不存在既能融入程式碼而且通用於各種陳述形式的方法。所以呢,原問題的發問者歪打正著,反而是網友提供的意見不可行。

接下來討論一些正常處理這類問題的手法。對於函數形式的運算式而言,最直接的方法還是用 macro 包裝。例如:

#ifdef NDEBUG
#   define DFUNC(a, b, c)   ((void) 0)
#   define DPRINTF(...)     ((void) 0)
#else
#   define DFUNC(a, b, c)   dfunc(a, b, c)
#   define DPRINTF(...)     dprintf(__VA_ARGS__)
#endif

比較大的麻煩是舊一點的編譯器可能不支援 __VA_ARGS__。而遇到 stream 式的輸出格式時寫起來怪彆扭的:

#define DOUT(expr) cerr << expr;

DOUT( "message " << 15 );      // shift a string literal??

如果追求較高相容性的話,另一個很常用的方法是將運算式放到不會被執行的路徑上,這類的做法不僅適用於函數,也適用於大部分的運算式。候選的做法可能有:

#define DPRINTF  0 && dprintf               // bad
#define DPRINTF  if (0) dprintf             // bad
#define DPRINTF  if (1) {} else dprintf
#define DPRINTF  (1)? (void) 0 : (void) dprintf

邏輯運算具有短路性質,而且優先順序還算低,看起來似乎適用。主要的問題在於,邏輯運算需要兩個運算元都可以轉成布林值,而目標運算式卻可能具有各種型態,事實上有些 dprintf 實作的回傳型態就是void,因此邏輯運算被淘汰。單獨的 if 若是放在風格不良的巢狀 if-else 當中,很可能會意外和別人的 else 結合,所以放在 else 路徑上才不容易被誤用。我所見過的案例大都採用三元運算子。使用三元運算有一個必要條件,即第二和第三個運算元必須具有相容型別。比較好的做法應該是讓兩者都是 void,因為 void 很難再和其他運算式結合,大大降低被誤用的機會。

由於優先順序非常低,三元運算子不僅能用於函數,連 stream 也照吃不誤。但是如前所述,三元運算子後面兩個運算元必須具有相容型態,遇到 stream 怎麼辦?有一個簡單的修正法,同樣也是藉助運算子優先順序:

namespace debug {
    struct Voidifier {};
    inline void operator&(const Voidifier& unused, std::ostream& ost) {}
}
#ifndef DEBUG_ON
#   define DEBUG_ON     0
#endif
#define DOUT  (!DEBUG_ON)? (void)0 : debug::Voidifier() & std::cerr

DOUT <<  "message" << endl;

還可以進一步做更多有趣的變化,例如在編譯時定義 DEBUG_LEVEL,用以調整輸出的詳細程度:

#define DOUT(level)  (level > DEBUG_LEVEL)? (void)0 : debug::Voidifier() & std::cerr

DOUT(0) <<  "fatal" << endl;
DOUT(3) <<  "warning" << endl;

製作下面這種帶訊息的 assert 也完全不成問題:

inline void operator^(const Voidifier& unused, std::ostream& ost) {
    ost.flush();
    std::abort();
}
#define ASSERT(expr) (!DEBUG_ON || expr)? (void)0 : debug::Voidifier ^ std::cerr \
                    << "** Assertion failed: " #expr ", in " __FILE__  "(" << __LINE__ <<"). "

ASSERT(15 > 51) << "just kidding! \n";

應該有很多人注意到,這樣的做法似乎將原本編譯時期的判斷推移到執行時期,這可能會帶來額外的 overhead。以現代的編譯技術來看,其實這些執行路徑在編譯期即可決定,只要開啟最佳化就不會生成多餘的程式碼。至於 ASSERT 雖然有部分判斷式非得到執行期才有辦法得知,不過判斷 DEBUG_ON 的 || 同樣也是可以在編譯期就確定,因此不會有不必要的判斷。我在 GCC 上做實驗,甚至在 -O0 的狀態下都可以消除這些 overhead。

有時候必須在 debug 做一些更複雜的動作,我看過有人用類似這樣的寫法:

#define DEBUG_CODE(statements) do { statements } while (0)

DEBUG_CODE
(
    long n = 0;
    // ...compute...
    cerr << n << endl;
);

關閉 debug 時可定義成 (void) 0,不過這樣帶來的好處並不明顯比直接用 #ifdef 高就是了。

arrow
arrow
    全站熱搜

    novus 發表在 痞客邦 留言(2) 人氣()