C缺陷與陷阱(C Traps and Pitfalls)學習筆記

前言

近來學習操做系統這門課,課程的實驗基於linux 0.11,因而從圖書館借來了 C Traps and Pitalls 和 Expert C programming,打算提升一下c語言水平。
先從前一本開始。這本書很薄,即便是英文版也只有140多頁,講的都是c語言中容易犯錯的地方。
注意:這篇筆記並無包括整本書的內容,而只是摘抄了本人須要的知識(加上了一些本身的理解)。如需完整了解,還請自行看書。linux

第一章:詞法陷阱

符號之間的空白(extra space)是被忽略的。

extra space包括空格、製表符和換行
好比你以爲這個函數聲明太長程序員

int* foo(int arg1, const char *arg2, double arg3, struct bar *arg4) 
{
    /*...*/
}

能夠寫成編程

int* foo(   int arg1,
            const char *arg2,
            double arg3,
            struct bar *arg4)
{
    /*...*/
}

符號分析策略

編譯器從左到右分析符號,使獲取的符號儘量地長。
a---b; 等價於 a-- -b; 數組

因此 a = b/*p; 這種寫法極可能不是你想要表達的意思。 /* 會被認爲是註釋符的左邊,因而它後面的內容都成了註釋,實現除法的寫法應該是 a = b / *p;緩存

可是,形如a-----b就不能理解成 (a--)-- (-b) ,而應該是 (a--)-(--b) ,由於 (a--)-- 這樣是不符合語法的。函數


關於整型常量

int a = 90;int b = 090; 是不同的呀。
後者在9的前面多了一個0,編譯器就會覺得090是八進制,因而b的值就是9*8^1 + 0*8^0 = 72d學習


關於char

char就至關於整型,只是它的大小是1字節。相應地,字符'a'就是對應ASCII碼裏的數字。
char*是指針,32位下大小爲4字節。與其它指針無異。
一個用雙引號括起來的字符串(如"hello"),就是一個指向字符串(加上結尾的'\0')首地址的指針縮寫。
因此spa

printf("hello");

相似於操作系統

char* str = "hello";
printf(str);

第二章:語法陷阱

關於調用函數

假若有函數debug

void bar(int arg);

那麼:
bar(); 執行函數
int (*p)(int a) = bar; bar是函數地址,能夠賦值給函數指針(固然類型要相同)

第三章:語義陷阱

關於指針和數組

Only two things can be done to an array: determine its size and obtain a pointer to element 0 of the array. All other array operations are actually done with pointers, even if they are written with what look like subscripts.

若是定義指針來當數組用,須要手動用malloc在運行時堆得到空間;而定義數組,編譯器就自動在棧幀裏分配空間。指針和數組的操做類似,關於數組的操做大部分都是基於指針的。


指針不是數組 Orz...

這裏要指出的是關於char類型數組和指針的兩個不一樣。

假定如今須要一個hello的字符串,能夠這麼寫:

char* str = "hello";
char str[6] = {'h','e','l','l','o','\0'};

前者編譯器會在字符串後面加上結束符 '\0',後者則須要本身注意留出空間存放該符號。

字符串常量(好比上面的 "hello")存放在常量區,在編譯時就肯定好了,在運行的時候不能修改。因此下面的操做是錯誤的。

char* str = "hello";    //指針指向常量區
str[3] = 'a';    //錯誤,修改了常量區的內容

而使用數組則不會出現這樣的問題,由於char數組會將內存常量的內容複製到棧幀中。

char str[6] = "hello";       //將內存常量中的"hello"複製給棧幀中的數組
str[3] = 'a';    //正確,修改的是棧幀中的內容

數組做爲函數參數

不能將整個數組看成參數傳入函數,使用數組名做爲參數會把數組首地址做爲指針傳入函數。因而

int strlen(char s[])
{
    /* stuff */
}

int strlen(char *s)
{
    /* stuff */
}

的效果是同樣的。因此常見的參數 char* argv[]char** argv 也是同樣的。
更準確的說法是,數組做爲參數傳入函數會「退化」成指針。在定義該數組的函數裏使用 sizeof 能夠正確得到數組大小,而將該數組首地址傳入函數後,只用 sizeof 只能得到指針大小。例如

int main()
{
    char str[] = "hello";
    printf("%u", sizeof(str));
    return 0;
}
//輸出結果爲6
int main(int argc, char* argv[])
{
    printf("%u", sizeof(argv[0]));
    return 0;
}
//輸出結果爲4

計算邊界和不對稱邊界

寫for循環的時候,會遇到一個問題:

for (int i = 0; i < 10; i++)
{
    a[i] = 0;
}
for (int i = 1; i <= 10; i++)
{
    a[i] = 0;
}

哪一個寫法好呢?本書給出了後者寫法的理由。

左閉右開
若是 x>=20 && x<=40 爲真,那麼x有多少種可能的取值?20?答案應該是 40 - 20 + 1 = 21 。這樣的寫法彷佛挺反直覺,容易出錯,若是寫成左閉右開 x>=20 && x<41 就好多了。相似地,在C語言裏,數組第1個元素下標爲0,因而定義一個10個元素的數組,最大的下標爲9。可能挺反直覺,可是隻要利用好這特色,使代碼簡潔,邏輯簡單。例如:

#define N 128
static char buffer[N];
void bufwrite(char *p, int n)
{   
    int i;
    int temp;
    for (i = 0; i < N && (temp=getchar()) != EOF; i++)
    {
        bufer[i] = temp;
    }
}

那麼退出循環的時候i就必定是緩衝區中元素的個數了。

其實,採用左開右閉寫法習慣,更可能是由於c語言本數對內存的描述是地址加偏移量。好比數組int a[10]不存在a[10]而存在a[0]。咱們主動迎合這種作法能夠避免很多麻煩。不然,想要一個10個元素的數組就要定義int a[11]。這麼作增長了出錯的可能。


求值順序

操做符&& || ?: ,都是從左到右執行。
其中, && 左邊操做數爲假,右邊不會執行; || 左邊操做數爲真,右邊不會執行。能夠利用這個特色簡化代碼,例如

int a[100];
int i = 0;
while (i < 100 && a[i] != 0)
{
    /* stuff */
}
其中,`&&` 保證了數組訪問不會越界。

除了特定的操做符 && || ?: ,,操做符對其操做數的求值順序是不肯定的。常見的

int i = 0;
while (i < n)
{
    y[i] = x[i++];
}

賦值號左邊的i是不肯定的。正確寫法能夠爲:

int i = 0;
while (i < n)
{
    y[i] = x[i];
    i++;
}

整型溢出

兩個unsigned類型相加不會有溢出的問題(簡單捨去進位),一個unsigned和一個int相加也沒有問題(int將會被轉換爲unsigned)。兩個int相加就可能存在溢出問題了。
假定兩個int都爲正數。對於檢查溢出,這樣寫是不對的

if (a + b < 0)

由於在檢查的過程當中已經致使溢出發生。而不一樣機器對溢出的處理是不同的,不可以假定溢出了什麼事都不發生。因此檢查溢出時應該避免溢出發生:

if ((unsigned)a + (unsigned)b > INT_MAX)
//或
if (a > INT_MAX - b)

第四章:連接

c程序是先編譯後連接。具體能夠看CSAPP的第七章,有關於連接的基本介紹

形參實參和返回值

爲了兼容舊版本的c程序,對於函數的聲明,須要忽略參數。以庫函數square爲例,聲明爲double square();,只要在調用函數的時候可以正確地傳入參數,就能夠正常調用函數。
有一點須要注意:對於上面square的聲明,若是傳入函數的參數爲float類型,它在傳入時會被轉化爲double類型,這樣沒什麼不妥;對於參數類型爲short、char的函數,若是聲明時忽略參數,傳入的參數會被轉化爲int,就會產生問題:函數要的是8位的char,而int的長度通常都不是8,進行位運算可能會出現問題。

固然,以上都是爲了兼容纔在聲明時忽略參數。咱們寫程序仍是老老實實把聲明寫完整吧。


檢查全局變量的類型

好比,定義一個全局變量 int a; ,而在另外之外一個c文件錯誤地聲明爲 extern long a; 。顯然,這樣是錯的。編譯器和連接器足夠聰明的話,能發現這個問題,而後給出錯誤。不然,編譯、連接經過,問題潛伏在程序中。程序可能正常運行(int和long都爲32位等緣由),或者出錯(聲明爲double,定義爲int等)。總之,程序員有義務保證聲明和定義一致

定義和聲明不一致致使錯誤很正常啊,爲何要拿一小節來說呢?由於定義全局變量 char str[]; 和聲明 extern char* str; 就是定義和聲明不一致的錯誤,而咱們不易察覺。問題在於:char指針不是char數組啊(Orz二者像歸像,仍是有不一樣啊....)

char a[128];    //a表明這個數組的首地址
char *b = a;    //b是一個指針變量,它的值爲數組a的首地址

a++;    //錯誤
b++;    //正確

體現的是char類型的數組與指針的區別。


頭文件

頭文件瞭解決上面一小節的問題。作法是,將全部外部變量的聲明都寫在頭文件,而後將頭文件include在每一個涉及這些變量的文件中,包括定義該變量的文件。緣由是,在同一文件中聲明和定義一個全局變量,那麼編譯器就能夠檢查聲明和定義是否一致,若是一致,在其它文件中只要include這個頭文件,就可使用該變量了。將函數聲明放入頭文件也是一樣道理。
有一點要說明:函數默認是全局的(除非是 static),因此在其餘文件聲明的時候能夠不加 extern ,但出於閱讀方便,咱們都是加上 extern(標準庫頭文件都有);變量則必定要加(不加 extern 就變成定義了)。

探究:若是不使用頭文件會怎樣?能夠直接引用外部函數和變量嗎?
若是沒有聲明就直接引用外部變量和函數,那麼編譯器就假定函數的返回值爲int,變量爲int類型,而後連接器把引用連接起來。若是碰巧引用的變量的確是int型,那麼程序正常,不然類型對不上發生錯誤。
在沒有外部聲明的狀況下,函數內部不能直接引用其餘文件的全局變量。


第五章:庫函數

getchar返回int

先看一段代碼

#include <stdio.h>
int main()
{
    char c;
    while ((c = getchar()) != EOF)
    {
        putchar(c);    
    }
    return 0;
}

代碼的功能就是將輸入流的內容轉到輸出流中,直到輸入流爲空。如今來講說代碼存在的問題。首先要說明,char爲 unsigned charsigned char 是由編譯器決定的,大部分編譯器默認爲 signed charEOF 通常定義爲-1。假定int爲32位。

  1. 若是char爲 unsigned char 。當getchar返回 EOF(0xffffffff) ,那麼變量c被賦值爲 0xff。與EOF(int)比較,c須要擴展爲0x000000ff(無符號擴展用零填充),二者不相等,循環將不會中止。

  2. 若是char是 signed char 。當getchar讀到字符 0xff,因而返回 0x000000ff,c被賦值爲 0xff 。與EOF(int)比較,c須要擴展爲 0xffffffff(有符號擴展用符號位填充),二者相等,循環提早結束。

出於以上,c也應該改成int,使代碼正常工做。
注意:某些編譯器直接拿getchar的返回值與EOF比較。這樣雖然不能正確表達代碼的意思,但能使程序正常工做。


緩存輸出和內存分配

手動爲文件分配緩衝區,可使用setbuf函數,在緩衝區滿時輸出。

#include <stdio.h>

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

這段代碼出錯在於,緩衝輸出在main結束的時候,這時buf數組已經不存在。
解決辦法是爲數組buf加上關鍵字 static,成爲靜態變量(但不建議在函數內部定義靜態函數);或者使用malloc函數,如

setbuf(stdout, malloc(BUFSIZE));

這樣,main結束時緩衝區仍然存在。可是要時刻留意malloc以後要不要free!


使用errno來檢測錯誤

沒有規定:若是庫函數運行正常,要將errno設置爲0。因而如下寫法錯誤:

call library function
if (errno != 0)
    complain

那麼在使用函數前就把errno設置爲0呢?

errno = 0;
call library function
if (errno != 0)
    complain

不行!由於庫函數內部可能會調用其它庫函數。好比調用fopen,函數會調用其它庫函數去檢查某個文件是否存在,若是文件不存在,則errno會被設置,而後建立新文件,返回指針。這時候fopen是正常工做的,可是errno卻不爲0。
那errno該怎麼用?最好的辦法應該是結合返回值使用了。應該在函數返回錯誤信息後,再檢查errno

call library routine
    if (error return)
        examine errno

signal函數

原則是signal處理函數儘量簡單。最好是輸出相關信息後就用exit退出程序。緣由是,信號接收可能出如今任什麼時候候(malloc時接收到信號,信號處理又調用malloc);並且信號處理完後不一樣機器有不一樣操做(某些機器在某些信號處理後重復失敗的操做,如除數爲零)。

第六章:預處理

切記宏只是簡單地複製粘貼!

宏不是函數

如下面的定義爲例

#define MAX(a,b)  ((a)>(b)?(a):(b))

不加括號必然引發悲劇,不用多說。
其次,謹慎使用 ++ -- 之類的運算, MAX(a,a++) 也會產生奇怪的結果。
再次,謹慎嵌套,如 MAX(a, MAX(b, MAX(c, d)) ,展開後表達式很長,debug時會很痛苦,並且會產生沒必要要的重複運算。


宏不是語句

好比,謹慎在宏用 if。假如assert是這樣定義的:

#define assert(e) if(!e) assert_error(__FILE__,__LINE__)

那麼

if (a > 0 && b > 0)
    assert(x > y);
else
    assert(x < y);

展開就變成

if (a > 0 && b > 0)
{
    if (!(x > y)) 
        assert_error(__FILE__,__LINE__);
    else if (!(x < y))
        assert_error(__FILE__,__LINE__);
}

這顯然不是咱們要的結果。可能會想到將assert用花括號圍起來。但這樣就會在if和else之間出現語法錯誤。
實際上,assert是這樣定義的:

#define assert(e) ((void)((e)||_assert_error(__FILE__,__LINE__))

它是一個值而不是語句。

後記

閱讀完這本書後,對這些陷阱能夠總結爲:只作正確的事,不要作一些感受應該正確的事。有些陷阱是歷史緣由,有些是奇怪的緣故,還有些是邏輯的問題。總之,謹慎!同時對c的認識又加深了:c是貼近硬件的語言。還加上嵌入彙編的功能,難怪寫操做系統要用c;還有,這本書提升了我查文檔的能力!這本書對數組和指針的解釋比較分散,建議閱讀c專家編程做爲系統瞭解。

相關文章
相關標籤/搜索