[C 陷阱與缺陷] (五) 庫函數

碼字不易,對你有幫助 點贊/轉發/關注 支持一下作者

微信搜公衆號:不會編程的程序圓

看更多幹貨,獲取第一時間更新

代碼,練習上傳至:

https://github.com/hairrrrr/C-CrashCourse

C語言中沒有定義輸入/輸出語句,任何一個有用的 C 程序(起碼必須接受零個或多個輸入,生成一個或多個輸出)都必須調用庫函數來完成最基本的輸入和輸出操作。ANSI C 標準毫無疑問地意識到了這一點, 因而定義了一個包含大量標準庫函數的集合。從理論上說,任何一個 C 語言實現都應該提供這些標準庫函數。

有關庫函數的使用,我們能給出的最好建議是儘量使用系統頭文件。

一 庫函數

1. 返回整數的 getchar 函數

#include<stdio.h>

main(void){
    char c;
    
    while((c = getchar()) != EOF)
        putchar(c);
}

getchar 函數在一般情況下返回的是標準輸入文件中的下一個字符,當沒有輸入時返回EOF (一個在頭文件stdio.h 中被定義的值,不同於任何一個字符)。這個程序乍一看似乎是把標準輸入複製到標準輸出,實則不然。

原因在於程序中的變量 c 被聲明爲 char 類型,而不是 int 類型。這意味着c無法容下所有可能的字符,特別是,可能無法容下 EOF 。

因此,最終結果存在兩種可能。一種可能是,某些合法的輸入字符在被“截斷”後使得 c 的取值與 EOF 相同;另一種可能是, c 根本不可能取到EOF這個值。對於前一種情況,程序將在文件複製的中途終止;對於後一種情況,程序將陷入一個死循環。

實際上,還有可能存在第三種情況:程序表面上似乎能夠正常工作,但完全是因爲巧合。儘管函數 getchar 的返回結果在賦給 char 類型的變量 c 時會發生“截斷”操作,儘管 while 語句中比較運算的操作數不是函數 getchar 的返回值,而是被“截斷”的值 c,然而令人驚訝地是許多編譯器對上述表達式的實現並不正確。這些編譯器確實對函數 getchar 的返回值作了“截斷”處理,並把低端字節部分賦給了變量c。但是,它們在比較表達式中並不是比較 c 與 EOF,而是比較 getchar 函數的返回值與 EOF ! 編譯器如果採取的是這種做法,上面的例子程序看 上去就能夠“正常”運行了。

2. 更新順序文件

許多系統中的標準輸入/輸出庫都允許程序打開一個文件,同時進行寫入和讀出的操作:

FILE *fp;
fp = open(file, "r+");

上面的例子代碼打開了文件名由變量file 指定的文件,對於存取權限的設定表明程序希望對這個文件進行輸入和輸出操作。

編程者也許認爲,程序一旦執行上述操作完畢,就可以自由地交錯進行讀出和寫入的操作。遺憾的是,事實總難遂人所願,爲了保持與過去不能同時進行讀寫操作的程序的向下兼容性,一個輸入操作不能隨後直接緊跟一個輸出操作,反之亦然。如果要同時進行輸入和輸出操作,必須在其中插入fseek 函數的調用。

下面的程序片段似乎更新了一個順序文件中選定的記錄:.

FILE *fp;

struct record rec;

...

while(fread((char*)&rec), sizeof(rec), 1, fp) == 1 ){
    /* 對 rec 執行某些操縱 */
    if(/* rec 必須被重新寫入 */){
        fseek(fp, -(long)sizeof(rec), 1);
        fwrite( (char*)&rec, sizeof(rec), 1, fp );
    }
}

這段代碼乍看上去毫無問題: &rec 在傳入 fread 和fwrite 函數時被小心翼翼地轉換爲字符指針類型,sizeof(rec) 被轉換爲 長整型(fseek 函數要求第二個參數是 long 類型,因爲 int類型的整數可能無法包含一個文件的大小;sizeof 返回一個unsigned 值,因此首先必須將其轉換爲有符號類型纔有可能將其反號)。但是這段代碼仍然可能運行失敗,而且出錯的方式非常難於察覺。

問題出在:如果一個記錄需要被重新寫入文件,也就是說,fwrite 函數得到執行,對這個文件執行的下一個操作將是循環開始的 fread 函數。因爲在fwrite函數調用與fread函數調用之,間缺少了一個fseek函數調用,所以無法進行上述操作。解決的辦法是把這段代碼改寫爲:

while(fread((char*)&rec), sizeof(rec), 1, fp) == 1 ){
    /* 對 rec 執行某些操縱 */
    if(/* rec 必須被重新寫入 */){
        fseek(fp, -(long)sizeof(rec), 1);
        fwrite( (char*)&rec, sizeof(rec), 1, fp );
        fseek(fp, 0L, 1);
    }
}

第二個fseek函數雖然看上去什麼也沒做,但它改變了文件的狀態,使得文件現在可以正常地進行讀取了。

程序圓幫你理解

  • &rec爲何要強轉成 char*類型:這就要理解 fread 函數(size_t fread ( void * ptr, size_t size, size_t count, FILE * stream )):fread 函數的參數有四個,簡單的來說就是:從 stream 中讀 count 個 size 大小的元素到 ptr 指向的內存中。而 fread 內部在讀取一個 size 大小的元素時會調用 size 次 fputc 函數,所以我猜測是每次用 fputc 函數讀一個字節然後將該值賦給 ptr 指向的那個地址。既然 fputc 每次只能讀一個,那也應該將 ptr 強轉爲 char* 類型。(但是函數原型是 void* 類型,會發生實參提升,轉成 void*,這又是個問題了)。

  • 其實上面的程序可以簡化爲:

    fread();
    fseek();
    fwrite();
    fread();
    

    我們知道,讀寫之間需要調用一次 fseek,這就是爲什麼要在 fwrite 後調用 fseek 了。

3.緩衝輸出 與內存分配

當一個程序生成輸出時,是否有必要將輸出立即展示給用戶?這個問題的答案根據不同的程序而定。

程序輸出有兩種方式:一種是即時處理方式,另一種是先暫存起來,然後再大塊寫入的方式,前者往往造成較高的系統負擔。因此,C語言實現通常都允許程序員進行實際的寫操作之前控制產生的輸出數據量。

這種控制能力一般是通過庫函數 setbuf 實現的。如果buf是一個大小適當的字符數組,那麼

setbuf(stdout, buf);

語句將通知輸入/輸出庫,所有寫入到 stdout 的輸出都應該使用 buf 作爲輸出緩衝區,直到 buf 緩衝區被填滿或者程序員直接調用 flush (譯註:對於由寫操作打開的文件,調用 fflush 將導致輸出緩衝區的內容被實際地寫入該文件),buf 緩衝區中的內容才實際寫入到stdout 中。緩衝區的大小由系統頭文件<stdio.h>中的 BUFSIZ 定義。

程序圓幫你理解: setbuf 比較老,現在可以用 C99 引入的函數 setvbuf

下面的程序的作用是把標準輸入的內容複製到標準輸出中,演示了setbuf 庫函數最顯而易見的用法:

#include <stdio.h>

main()
	int C;
	
	char buf [BUFSIZ];
	setbuf(stdout, buf) ;
	
	while((c = getchar()) != EOF)
		putchar(c) ;

)

遺憾的是,這個程序是錯誤的,僅僅是因爲一個細微的原因。程序中對庫函數 setbuf 的調用,通知了輸入輸出庫所有字符的標準輸出應該首先緩存在 buf 中。要找到問題出自何處,我們不妨思考一下buf緩衝區最後一次被清空是在什麼時候?答案是在 main 函數結束之後,作爲程序交回控制給操作系統之前 C 運行時庫所必須進行的清理工作的一部分。但是,在此之前 buf 字符數組已經被釋放!

要避免這種類型的錯誤有兩種辦法。第一種辦法是讓緩衝數組成爲靜態數組,即可以直接顯式聲明 buf 爲靜態:

static char buf[BUFSIZ];

也可以把 buf 聲明完全移到 main 函數之外。

第二種辦法是動態分配緩衝區,在程序中並不主動釋放分配的緩衝區(譯註:由於緩衝區是動態分配的,所以 main 函數結束時並不會釋放該緩衝區,這樣 C 運行時庫進行清理工作時就不會發生緩衝區已釋放的情況):

char *malloc() ;

setbuf(stdout, malloc(BUFSIZ));

如果讀者關心一些編程“小技巧”,也許會注意到這裏其實並不需要檢查 malloc 函數調用是否成功。如果 malloc 函數調用失敗,將返回一個 NULL 指針。setbuf 函數的第二個參數取值可以爲 NULL,此時標準輸出不需要進行緩衝。這種情況下,
程序仍然能夠工作,只不過速度較慢而已。

4. 使用errno檢測錯誤

很多庫函數,特別是那些與操作系統有關的,當執行失敗時會通過一個名稱爲 errno 的外部變量,通知程序該函數調用失敗。下面的代碼利用這一 特性進行錯誤處理,似乎再清楚明白不過,然而卻是錯誤的:

/*調用庫函數*/
if (errno)
	/*處理錯誤*/	

出錯原因在於,在庫函數調用沒有失敗的情況下,並沒有強制要求庫函數一定要設置 errno 爲0,這樣errno 的值就可能是前一個執行失敗的庫函數設置的值。

下面的代碼作了更正,似乎能夠工作,很可惜還是錯誤的:

errno = 0;
/*調用庫函數*/
	if (errno)
        /*處理錯誤*/	

庫函數在調用成功時,既沒有強制要求對 errno 清零,但同時也沒有禁止設置 errno。既然庫函數已經調用成功,爲什麼還有可能設置 errno 呢? 要理解這一點,我們不妨假想一下庫函數 fopen 在調用時可能會發生什麼情況。

當 fopen 函數被要求新建一個文件以供程序輸出時,如果已經存在一個同名文件,fopen 函數將先刪除它,然後新建一個文件。 這樣,fopen 函數可能需要調用其他的庫函數,以檢測同名文件是否已經存在。(譯註:假設用於檢測文件的庫函數在文件不存在時,會設置 errno 。那麼,fopen 函數每次新建一個事先並不存在的文件時,即使沒有任何程序錯誤發生,errmo 也仍然可能被設置。)

因此,在調用庫函數時,我們應該首先檢測作爲錯誤指示的返回值,確定程序執行已經失敗。然後,再檢查 errno,來搞清楚出錯原因:

/*調用庫函數*/
if (返回的錯誤值)
	/* 檢查errno */

5. 庫函數 signal

關於 signal 函數使用需要避免的情況:

  • 信號處理函數不應該調用複雜的庫函數(例如:malloc)

    例如,假設malloc函數的執行過程被一個信號中斷。 此時,malloc 函數用來跟蹤可用內存的數據結構很可能只有部分被更新。如果 signal 處理函數再調用 malloc 函數,結果可能是 malloc 函數用到的數據結構完全崩潰,後果不堪設想!

  • 從 siganl 函數中使用 longjup 退出

    基於同樣的原因,從 signal 處理函數中使用 longjmp 退出,通常情況下也是不安全的:因爲信號可能發生在 malloc 或者其他庫函數開始更新某個數據結構,卻又沒有最後完成的過程中。因此,signal 處理函數能夠做的安全的事情,似乎就只有設置一個標誌然後返回,期待以後主程序能夠檢查到這個標誌,發現一個信號已經發生。

  • 算數運算錯誤

    然而,就算這樣做也並不總是安全的。當一個算術運算錯誤(例如溢出或者零作除數)引發一個信號時,某些機器在signal 處理函數返回後還將重新執行失敗的操作。而當這個算術運算重新執行時,我們並沒有一個可移植的辦法來改變操作數。這種情況下,最可能的結果就是馬上又引發一個同樣的信號。因此,對於算術運算錯誤,signal 處理函數的惟一安全、 可移植的操作就是打印一條出錯消息,然後使用 longjmp 或 exit 立即退出程序。

由此,我們得到的結論是:信號非常複雜棘手,而且具有一些從本質上而言不可移植的特性。解決這個問題我們最好採取“守勢”,讓signal處理函數儘可能地簡單,並將它們組織在一起。這樣,當需要適應一個新系統時,我們可以很容易地進行修改。

練習

練習5-1

當一個程序異常終止時,程序輸出的最後幾行常常會去失,原因是什麼?我們能夠採取怎樣的措施來解決這個問題?

一個異常終止的程序可能沒有機會來清空其輸出緩衝區。

解決方案就是在調試時強制不允許對輸出進行緩衝。要做到這一點,不同的系統有不同的做法,這些做法雖然存在細微差別,但大致如下:

setbuf(stdout, (char *)0);

這個語句必須在任何輸出被寫入到 stdout(包括任何對 printf 函數的調用)之前執行。該語句最恰當的位置就是作爲main函數的第一個語句。

練習5-2

下 面程序的作用是把它的輸入複製到輸出:

#include <stdio.h>
main()
	register int c;

	while ((c = getchar()) != EOF)
		putchar(c);
}		

從這個程序中去掉 #include 語句,將導致程序不能通過編譯,因爲這時 EOF 是未定義的。假定我們手工定義了EOF (當然,這是一種不好的做法):

#define EOP -1
main()
{
	register int c;

    while ((c = getchar()) != EOF)
		putchar (c) ;
}

這個程序在許多系統中仍然能夠運行,但是在某些系統運行起來卻慢得多。這是爲什麼?

函數調用需要花費較長的程序執行時間,因此getchar經常被實現爲宏。這個宏在stdio.h頭文件中定義,因此如果一個程序沒有包含 stdio.h 頭文件,編譯器對 getchar 的定義就一無所知。 在這種情況下,編譯器會假定 getchar 是一個返回類型爲整型的函數。

實際上,很多C語言實現在庫文件中都包括有 getchar 函數,原因部分是預防編程者粗心大意,部分是爲了方便那些需要得到 getchar 地址的編程者。因此,程序中忘記包含 stdio.h 頭文件的效果就是,在所有 getchar 宏出現的地方,都getchar 函數調用來替換 getchar 宏。這個程序之所以運行變慢,就是因爲函數調用所導致的開銷增多。同樣的依據也完全適用於putchar 。

參考資料《C 缺陷與陷阱》


以上就是本次的內容,感謝觀看。

如果文章有錯誤歡迎指正和補充,感謝!

最後,如果你還有什麼問題或者想知道到的,可以在評論區告訴我呦,我在後面的文章可以加上。

最後,關注我,看更多幹貨!

我是程序圓,我們下次再見。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章