在C中有一個永恒的問題,它伴隨了你很長時間,然而在這個練習(xí)我打算使用一系列我開發(fā)的宏來解決它。到現(xiàn)在為止你都不知道它們的強大之處,所以你必須使用它們,總有一天你會來找我說,“Zed,這些調(diào)試宏真是太偉大了,我應(yīng)該把我的第一個孩子的出生歸功于你,因為你治好了我十年的心臟病,并且打消了我數(shù)次想要自殺的念頭。真是要謝謝你這樣一個好人,這里有一百萬美元,和Leo Fender設(shè)計的Snakehead Telecaster電吉他的原型?!?/p>
是的,它們的確很強大。
幾乎每個編程語言中,錯誤處理都非常難。有些語言盡可能試圖避免錯誤這個概念,而另一些語言發(fā)明了復(fù)雜了控制結(jié)構(gòu),比如異常來傳遞錯誤狀態(tài)。當然的錯誤大多是因為程序員嘉定錯誤不會發(fā)生,并且這一樂觀的思想影響了他們所用和所創(chuàng)造的語言。
C通過返回錯誤碼或設(shè)置全局的errno
值來解決這些問題,并且你需要檢查這些值。這種機制可以檢查現(xiàn)存的復(fù)雜代碼中,你執(zhí)行的東西是否發(fā)生錯誤。當你編寫更多的C代碼時,你應(yīng)該按照下列模式:
這意味著對于每一個函數(shù)調(diào)用(是的,每個函數(shù))你都可能需要多編寫3~4行代碼來確保它正常功能。這些還不包括清理你到目前創(chuàng)建的所有垃圾。如果你有10個不同的結(jié)構(gòu)體,3個方式。和一個數(shù)據(jù)庫鏈接,當你發(fā)現(xiàn)錯誤時你應(yīng)該寫額外的14行。
之前這并不是個問題,因為發(fā)生錯誤時,C程序會像你以前做的那樣直接退出。你不需要清理任何東西,因為OS會為你自動去做。然而現(xiàn)在很多C程序需要持續(xù)運行數(shù)周、數(shù)月或者數(shù)年,并且需要優(yōu)雅地處理來自于多種資源的錯誤。你并不能僅僅讓你的服務(wù)器在首次運行就退出,你也不能讓你寫的庫使使用它的程序退出。這非常糟糕。
其它語言通過異常來解決這個問題,但是這些問題也會在C中出現(xiàn)(其它語言也一樣)。在C中你只能夠返回一個值,但是異常是基于棧的返回系統(tǒng),可以返回任意值。C語言中,嘗試在棧上模擬異常非常困難,并且其它庫也不會兼容。
我使用的解決方案是,使用一系列“調(diào)試宏”,它們在C中實現(xiàn)了基本的調(diào)試和錯誤處理系統(tǒng)。這個系統(tǒng)非常易于理解,兼容于每個庫,并且使C代碼更加健壯和簡潔。
它通過實現(xiàn)一系列轉(zhuǎn)換來處理錯誤,任何時候發(fā)生了錯誤,你的函數(shù)都會跳到執(zhí)行清理和返回錯誤代碼的“error:”區(qū)域。你可以使用check
宏來檢查錯誤代碼,打印錯誤信息,然后跳到清理區(qū)域。你也可以使用一系列日志函數(shù)來打印出有用的調(diào)試信息。
我現(xiàn)在會向你展示你目前所見過的,最強大且卓越的代碼的全部內(nèi)容。
#ifndef __dbg_h__
#define __dbg_h__
#include <stdio.h>
#include <errno.h>
#include <string.h>
#ifdef NDEBUG
#define debug(M, ...)
#else
#define debug(M, ...) fprintf(stderr, "DEBUG %s:%d: " M "\n", __FILE__, __LINE__, ##__VA_ARGS__)
#endif
#define clean_errno() (errno == 0 ? "None" : strerror(errno))
#define log_err(M, ...) fprintf(stderr, "[ERROR] (%s:%d: errno: %s) " M "\n", __FILE__, __LINE__, clean_errno(), ##__VA_ARGS__)
#define log_warn(M, ...) fprintf(stderr, "[WARN] (%s:%d: errno: %s) " M "\n", __FILE__, __LINE__, clean_errno(), ##__VA_ARGS__)
#define log_info(M, ...) fprintf(stderr, "[INFO] (%s:%d) " M "\n", __FILE__, __LINE__, ##__VA_ARGS__)
#define check(A, M, ...) if(!(A)) { log_err(M, ##__VA_ARGS__); errno=0; goto error; }
#define sentinel(M, ...) { log_err(M, ##__VA_ARGS__); errno=0; goto error; }
#define check_mem(A) check((A), "Out of memory.")
#define check_debug(A, M, ...) if(!(A)) { debug(M, ##__VA_ARGS__); errno=0; goto error; }
#endif
是的,這就是全部代碼了,下面是它每一行所做的事情。
dbg.h:1-2
防止意外包含多次的保護措施,你已經(jīng)在上一個練習(xí)中見過了。
dbg.h:4-6
包含這些宏所需的函數(shù)。
dbg.h:8
#ifdef
的起始,它可以讓你重新編譯程序來移除所有調(diào)試日志信息。
dbg.h:9
如果你定義了NDEBUG
之后編譯,沒有任何調(diào)試信息會輸出。你可以看到#define debug()
被替換為空(右邊沒有任何東西)。
dbg.h:10
上面的#ifdef
所匹配的#else
。
dbg.h:11
用于替代的#define debug
,它將任何使用debug("format", arg1, arg2)
的地方替換成fprintf
對stderr
的調(diào)用。許多程序員并不知道,但是你的確可以創(chuàng)建列斯與printf
的可變參數(shù)宏。許多C編譯器(實際上是C預(yù)處理器)并不支持它,但是gcc可以做到。這里的魔法是使用##__VA_ARGS__
,意思是將剩余的所有額外參數(shù)放到這里。同時也要注意,使用了__FILE__
和__LINE__
來獲取當前fine:line
用于調(diào)試信息。這會非常有幫助。
dbg.h:12
#ifdef
的結(jié)尾。
dbg.h:14
clean_errno
宏用于獲取errno
的安全可讀的版本。中間奇怪的語法是“三元運算符”,你會在后面學(xué)到它。
dbg.h:16-20
log_err
,log_warn
和log_info
宏用于為最終用戶記錄信息。它們類似于debug
但不能被編譯。
dbg.h:22
到目前為止最棒的宏。check
會保證條件A
為真,否則會記錄錯誤M
(帶著log_err
的可變參數(shù)),之后跳到函數(shù)的error:
區(qū)域來執(zhí)行清理。
dbg.h:24
第二個最棒的宏,sentinel
可以放在函數(shù)的任何不應(yīng)該執(zhí)行的地方,它會打印錯誤信息并且跳到error:
標簽。你可以將它放到if-statements
或者switch-statements
的不該被執(zhí)行的分支中,比如default
。
dbg.h:26
簡寫的check_mem
宏,用于確保指針有效,否則會報告“內(nèi)存耗盡”的錯誤。
dbg.h:28
用于替代的check_debug
宏,它仍然會檢查并處理錯誤,尤其是你并不想報告的普遍錯誤。它里面使用了debug
代替log_err
來報告錯誤,所以當你定義了NDEBUG
,它仍然會檢查并且發(fā)生錯誤時跳出,但是不會打印消息了。
下面是一個例子,在一個小的程序中使用了dbg.h
的所有函數(shù)。這實際上并沒有做什么事情,知識想你演示了如何使用每個宏。我們將在接下來的所有程序中使用這些宏,所有要確保理解了如何使用它們。
#include "dbg.h"
#include <stdlib.h>
#include <stdio.h>
void test_debug()
{
// notice you don't need the \n
debug("I have Brown Hair.");
// passing in arguments like printf
debug("I am %d years old.", 37);
}
void test_log_err()
{
log_err("I believe everything is broken.");
log_err("There are %d problems in %s.", 0, "space");
}
void test_log_warn()
{
log_warn("You can safely ignore this.");
log_warn("Maybe consider looking at: %s.", "/etc/passwd");
}
void test_log_info()
{
log_info("Well I did something mundane.");
log_info("It happened %f times today.", 1.3f);
}
int test_check(char *file_name)
{
FILE *input = NULL;
char *block = NULL;
block = malloc(100);
check_mem(block); // should work
input = fopen(file_name,"r");
check(input, "Failed to open %s.", file_name);
free(block);
fclose(input);
return 0;
error:
if(block) free(block);
if(input) fclose(input);
return -1;
}
int test_sentinel(int code)
{
char *temp = malloc(100);
check_mem(temp);
switch(code) {
case 1:
log_info("It worked.");
break;
default:
sentinel("I shouldn't run.");
}
free(temp);
return 0;
error:
if(temp) free(temp);
return -1;
}
int test_check_mem()
{
char *test = NULL;
check_mem(test);
free(test);
return 1;
error:
return -1;
}
int test_check_debug()
{
int i = 0;
check_debug(i != 0, "Oops, I was 0.");
return 0;
error:
return -1;
}
int main(int argc, char *argv[])
{
check(argc == 2, "Need an argument.");
test_debug();
test_log_err();
test_log_warn();
test_log_info();
check(test_check("ex20.c") == 0, "failed with ex20.c");
check(test_check(argv[1]) == -1, "failed with argv");
check(test_sentinel(1) == 0, "test_sentinel failed.");
check(test_sentinel(100) == -1, "test_sentinel failed.");
check(test_check_mem() == -1, "test_check_mem failed.");
check(test_check_debug() == -1, "test_check_debug failed.");
return 0;
error:
return 1;
}
要注意check
是如何使用的,并且當它為false
時會跳到error:
標簽來執(zhí)行清理。這一行讀作“檢查A是否為真,不為真就打印M并跳出”。
當你執(zhí)行這段代碼并且向第一個參數(shù)提供一些東西,你會看到:
$ make ex20
cc -Wall -g -DNDEBUG ex20.c -o ex20
$ ./ex20 test
[ERROR] (ex20.c:16: errno: None) I believe everything is broken.
[ERROR] (ex20.c:17: errno: None) There are 0 problems in space.
[WARN] (ex20.c:22: errno: None) You can safely ignore this.
[WARN] (ex20.c:23: errno: None) Maybe consider looking at: /etc/passwd.
[INFO] (ex20.c:28) Well I did something mundane.
[INFO] (ex20.c:29) It happened 1.300000 times today.
[ERROR] (ex20.c:38: errno: No such file or directory) Failed to open test.
[INFO] (ex20.c:57) It worked.
[ERROR] (ex20.c:60: errno: None) I shouldn't run.
[ERROR] (ex20.c:74: errno: None) Out of memory.
看到check
失敗之后,它是如何打印具體的行號了嗎?這會為接下來的調(diào)試工作節(jié)省時間。同時也觀察errno
被設(shè)置時它如何打印錯誤信息。同樣,這也可以節(jié)省你調(diào)試的時間。
現(xiàn)在我會想你簡單介紹一些預(yù)處理器的工作原理,讓你知道這些宏是如何工作的。我會拆分dbg.h
中阿最復(fù)雜的宏并且讓你運行cpp
來讓你觀察它實際上是如何工作的。
假設(shè)我有一個函數(shù)叫做dosomething()
,執(zhí)行成功是返回0,發(fā)生錯誤時返回-1。每次我調(diào)用dosomething
的時候,我都要檢查錯誤碼,所以我將代碼寫成這樣:
int rc = dosomething();
if(rc != 0) {
fprintf(stderr, "There was an error: %s\n", strerror());
goto error;
}
我想使用預(yù)處理器做的是,將這個if
語句封裝為更可讀并且便于記憶的一行代碼。于是可以使用這個check
來執(zhí)行dbg.h
中的宏所做的事情:
int rc = dosomething();
check(rc == 0, "There was an error.");
這樣更加簡潔,并且恰好解釋了所做的事情:檢查函數(shù)是否正常工作,如果沒有就報告錯誤。我們需要一些特別的預(yù)處理器“技巧”來完成它,這些技巧使預(yù)處理器作為代碼生成工具更加易用。再次看看check
和log_err
宏:
#define log_err(M, ...) fprintf(stderr, "[ERROR] (%s:%d: errno: %s) " M "\n", __FILE__, __LINE__, clean_errno(), ##__VA_ARGS__)
#define check(A, M, ...) if(!(A)) { log_err(M, ##__VA_ARGS__); errno=0; goto error; }
第一個宏,log_err
更簡單一些,只是將它自己替換為fprintf
對stderr
的調(diào)用。這個宏唯一的技巧性部分就是在log_err(M, ...)
的定義中使用...
。它所做的是讓你向宏傳入可變參數(shù),從而傳入fprintf
需要接收的參數(shù)。它們是如何注入fprintf
的呢?觀察末尾的##__VA_ARGS__
,它告訴預(yù)處理器將...
所在位置的參數(shù)注入到fprintf
調(diào)用的相應(yīng)位置。于是你可以像這樣調(diào)用了:
log_err("Age: %d, name: %s", age, name);
age, name
參數(shù)就是...
所定義的部分,這些參數(shù)會被注入到fprintf
中,輸出會變成:
fprintf(stderr, "[ERROR] (%s:%d: errno: %s) Age %d: name %d\n",
__FILE__, __LINE__, clean_errno(), age, name);
看到末尾的age, name
了嗎?這就是...
和##__VA_ARGS__
的工作機制,在調(diào)用其它變參宏(或者函數(shù))的時候它會起作用。觀察check
宏調(diào)用log_err
的方式,它也是用了...
和##__VA_ARGS__
。這就是傳遞整個printf
風(fēng)格的格式字符串給check
的途徑,它之后會傳給log_err
,二者的機制都像printf
一樣。
下一步是學(xué)習(xí)check
如何為錯誤檢查構(gòu)造if
語句,如果我們剖析log_err
的用法,我們會得到:
if(!(A)) { errno=0; goto error; }
它的意思是,如果A
為假,則重置errno
并且調(diào)用error
標簽。check
宏會被上述if
語句·替換,所以如果我們手動擴展check(rc == 0, "There was an error.")
,我們會得到:
if(!(rc == 0)) {
log_err("There was an error.");
errno=0;
goto error;
}
在這兩個宏的展開過程中,你應(yīng)該了解了預(yù)處理器會將宏替換為它的定義的擴展版本,并且遞歸地來執(zhí)行這個步驟,擴展宏定義中的宏。預(yù)處理器是個遞歸的模板系統(tǒng),就像我之前提到的那樣。它的強大來源于使用參數(shù)化的代碼來生成整個代碼塊,這使它成為便利的代碼生成工具。
下面只剩一個問題了:為什么不像die
一樣使用函數(shù)呢?原因是需要在錯誤處理時使用file:line
的數(shù)值和goto
操作。如果你在函數(shù)在內(nèi)部執(zhí)行這些,你不會得到錯誤真正出現(xiàn)位置的行號,并且goto
的實現(xiàn)也相當麻煩。
另一個原因是,如果你編寫原始的if
語句,它看起來就像是你代碼中的其它的if
語句,所以它看起來并不像一個錯誤檢查。通過將if
語句包裝成check
宏,就會使這一錯誤檢查的邏輯更清晰,而不是主控制流的一部分。
最后,C預(yù)處理器提供了條件編譯部分代碼的功能,所以你可以編寫只在構(gòu)建程序的開發(fā)或調(diào)試版本時需要的代碼。你可以看到這在dbg.h
中已經(jīng)用到了,debug
宏的主體部分只被編譯器用到。如果沒有這個功能,你需要多出一個if
語句來檢查是否為“調(diào)試模式”,也浪費了CPU資源來進行沒有必要的檢查。
#define NDEBUG
放在文件頂端來消除所有調(diào)試信息。MakeFile
頂端將-D NDEBUG
添加到CFLAGS
,之后重新編譯來達到同樣效果。file:line
。