C陷阱與缺陷 要點總結

 

C陷阱與缺陷 要點總結

 

‍詞法

詞法分析中的貪心法

每一個符號應該包含儘可能多的字符,也就是說編譯器把程序分解爲符號的方法是,從左到右一個字符一個字符的讀入,如果該字符可能組成一個符號,那麼再讀入下一個字符,判斷已經讀入的兩個字符組成的字符串是否可能是一個符號的組成部分,如果可能重複上述判斷,直到讀入的字符組成的字符串已不再可能組成一個有意義的符號,這個策略被稱爲“貪心法”

y/*x 實際想表達的是y/(*x) 但是/*會被理解爲一段註釋的開始,這樣的準二義性問題會招致麻煩。

習題

a+++++b

整形常量

如果一個整形常量的第一個字符是0,那麼該常量將被視爲8進制。

字符與字符串

  • 單引號和雙引號有時候會被弄混,編譯器有時候不會報錯,在運行時產生難以預料的錯誤。
  • 單引號引起的字符實際上代表一個整數。
  • 錯誤:printf('\n')會產生錯誤

語法

變量聲明由兩部分組成:類型和一組類似表達式的聲明符。

模擬開機時啓動程序,(*(void(*)())0)()

float ff()表達的含義是ff()求值的結果是一個float,也就是說ff表示一個返回結果爲float的函數

float *ptr表達的含義是*ptr的結果是一個float,也就是說ptr是一個float的指針

float *g(),表達的含義是*g()求值的結果是float,但是g和右邊()結合的優先級高於左邊的*,因此g表示一個返回float指針的函數

float (*h)(), 表達的含義是 (*h)()求值的結果是float,h表示一個返回float的函數的指針

類型的轉換符:把一個變量聲明中的變量名和結尾的分號去掉,再將剩餘部分用一個括號封裝起來。比如前面的(float (*)())

函數指針的調用,將設fp表示一個函數指針,那麼(*fp)()表示調用該函數指針指向的函數,可以簡寫爲fp(),但是*兩邊的()不能去掉,因爲()運算符的優先級高於*,如果省掉括號表示對函數執行結果解引用。

void (*sfp)(int) sfp表示一個函數指針

void (*signal(something))(int) 表達的意思是(*singnal(something))的結果是一個傳入參數爲int,返回結果爲void的函數,那麼signal的表達的意思就是傳入somenthing返回一個函數指針。

正常人的寫法應該是 typedef void (*HANDLER)(int); HANDLER signal(int, HANDLER);

關於運算符的優先級,靠譜的做法還是增加括號,明確的表達。

表達式中的分號,if、while等語句如果後面加了分號則表示獨立的語句。如果struct 定義後面少加了分號,則可能出現下面類似的錯誤:

struct { int xxx; int ccc}

main()

{}表達式的意思可能就變成返回一個結構體。 奇葩的聯想。

switch 中的break也是一個比較容易出錯的地方,如果真的需要省略break,可以在對應的位置增加註釋

case 1:

/*此處沒有break*/

case 2:

break;

default:

break;

if else對括號的使用要一致,避免出現else懸掛的問題,看下面的錯誤例子

if (x==0)

if (y == 0) error();

else {

z = x + y;

f(&z);

}

c語言允許初始化列表出現多餘的逗號,這樣在初始化列表很長的時候,需要分成多行,每行都是以逗號結尾,這種語法上的相似性可以方便代碼編輯工具進行處理。

語義

數組

c語言的數組需要注意兩點,

第一點,c語言只有一維數組,但是數組的成員可以是任何類型,包括數組類型,所以可以模擬出多維數組。

第二點,c語言中對數組只能有兩種操作,第一種指定數組大小,第二種獲取下標爲0的元素的地址。其他的元算,即使是下標的運算也是通過指針元算進行的。

對於一個數組變量a,sizeof(a)表示這個數組的大小,而其他時候a都表示下標爲0元素的地址。*(a+i) 簡記爲a[i]

非數組的指針常見的:

第一,不檢查malloc的返回值

第二,分配的內存沒有及時釋放

第三,申請的內存長度不足。

作爲參數傳遞的數組名會被轉換成指向數組第一個元素的地址。

雖然指針和數組可以轉換,但是如果一個指針變量不代表一個數組,那麼不要把他聲明爲一個數組類型,避免出現誤導。

混淆指針和指針所指向的數據:複製指針並不同時複製指針指向的數據。

空指針不是空字符串,可以把0賦值給一個指針變量,但是不能嘗試訪問該指針變量指向內存存儲的內容,空指針不能解引用。

邊界計算與不對稱邊界

欄杆錯誤或者說差一錯誤,100英尺圍欄,每10英尺需要一個欄杆,總共需要多少欄杆

邊界的計算兩個通則:

通則一,計算最簡單的特例,在此結果上外推

通則二,仔細計算邊界,絕不掉以輕心

避免出現欄杆錯誤的編程技巧,即不對稱邊界:採用第一個入界點和第一個出界點表示一個範圍,這裏的下界是入界點表示包含在取值範圍之內,上界爲出界點,不包含在取值範圍之內。

另一種考慮不對稱邊界的方式是,把上界作爲某序列中第一個被佔用的元素,把下界作爲第一個被釋放的元素。

對於指針bufptr,是讓它始終指向最後一個已佔用的字符,還是讓它指向第一個未被佔用字符。根據不對稱邊界的原則,選擇後一種更爲合適。

依據不對稱邊界的原則,判斷指針到達緩衝區尾部的方法是if(bufpter == &buf[N]) 而不是 if(bufptr > &buf[N-1])。儘管buf[N]的原色是不存在的,他的地址仍然可以用來比較,但是不能訪問不存在的元素。

求值順序

c語言中只有四個運算符 (‘&&’, ‘||’, ‘?’, ‘,’)存在規定的求值順序,運算符&&和||都是先求左值,如果有需要再求右值,表達式a?b:c也是先求a的值如果有需要再計算b或c,而逗號運算符,先對左側操作求值,然後該值被丟棄,再對右側操作求值。

f(x,y)中x和y的求值順序時未定義的。g((x,y))中x和y的求值順序是先計算x,丟棄之後再計算y,而函數g傳入的參數也是隻有一個。

賦值操作符並不保證任何求值順序,比如下面的例子:

x[i] = y[i++],並不能保證左側先執行。

不要使用位運算符 (&,|,~)來替代邏輯運算符(&&,||,!),有時候運行結果正常只是巧合,意義不同,並且邏輯運算符有求值順序,能夠避免錯誤訪問。

整數溢出,這裏不是類型長度導致的溢出,而是指有符號整型的溢出,兩個有符號整型計算,結果可能會溢出。而溢出的結果是未定義的,各編譯器實現不同,因此不能使用類似下面的方式來檢測溢出if(a+b < 0),而應該把他們都轉換成無符號整型進行比較 if ((unsigned)a + (unsigned)b > INT_MAX)

main函數應該有返回值。

連接

4.1什麼是連接器

連接器不理解C語言,編譯器的責任是把C語言翻譯成連接器能夠理解的形式。連接器的作用是把編譯器或者彙編器生成的若干目標模塊,整合成一個載入模塊或者一個可執行文件的實體。

程序中每個函數或者外部變量沒有聲明爲static,就都是一個外部對象。連接器通常把一個目標模塊看成是一組外部對象組成。

工作過程:連接器的輸入是目標模塊,輸出是載入模塊,連接器讀入目標模塊和庫文件,同時生成載入模塊,對每個目標模塊中的每個外部對象,連接器都要檢查載入模塊,看是否已有同名的外部對象,如果沒有就寫入載入模塊,如果有就進行同名衝突處理。

一個目標模塊中引用了其他目標模塊的外部對象時,生成載入模塊時需要記錄這些引用,直到讀入定義該外部對象的模塊時,修改載入模塊中的標記,標記該外部對象不再是未定義的。

4.2聲明與定義

變量的定義如果出現在所有函數體之外,稱爲外部對象的定義。

外部變量的定義如果沒有指定初始值,那麼多次定義可能能夠編譯通過,但是不應該這樣做,每個外部變量只應該定義一次。

4.3命名衝突與static修飾符

爲了避免可能出現的命名衝突,如果一個函數僅被同一文件中的其他函數調用,我們應該把它聲明爲static。

4.4形參實參返回值

任何一個函數,在被調用的每個文件中,都在第一次被調用之前進行了聲明或者定義就不會出現參數或者返回值的錯誤。

也就是說在某些時候,調用函數之前可以不聲明或者定義該函數,此時返回值和參數有有一些默認的規則。這不是常規做法?

4.5檢查外部類型

保證一個特定的名稱的所有外部定義在每個目標模塊中具有相同的類型,是程序員的責任,編譯器可能檢測不到這種錯誤。

如:extern int n; long n; char filename[] = "/ect/passwd" ; extern char* filename;

4.6頭文件

避免上述問題的方法是,外部對象在一個地方聲明,這個地方應該是一個頭文件中。定義外部變量的地方也應高包含該頭文件。

庫函數

5.1 getchar

返回整形的getchar,看一個例子c=getchar,如果c被定義爲char類型,結果是c無法容納下所有字符,包括EOF,有時候編譯器在這類情況下會把返回結果截斷。

5.2

文件操作,爲了保持與過去不能同時進行讀寫操作的程序的向下兼容性,一個輸入操作,不能隨後緊跟着一個輸出操作,如果需要同時進行輸入和輸出操作,需要在其中插入fseek函數

5.3緩衝輸出與內存分配

程序輸出的兩種方式:第一種叫做及時處理,另外一種方式是先暫存起來,然後大塊寫入。前者旺旺造成較高的系統負擔。通過調用庫函數setbuf(stdout, buf)來實現。在使用setbuf時,需要注意buf變量的生命週期,在指針執行打印之前buf不能被釋放 。

5.4使用errno檢測錯誤

在調用庫函數時,應該先檢查返回值,在確認函數執行失敗的情況下,再查看errno查看錯誤原因。這麼做的原因是在執行成功情況下errno也可能被上次庫函數設置,而沒有清零。

5.5 signal

信號處理函數非常複雜棘手,而且具有一些從本質上而言的不可移植性,signal處理函數應該儘可能的簡單,並把他們組織在一起。事件處理函數必須是不對全局變量產生影響的可重入的函數。

常用的做法是打印錯誤信息,然後調用longjmp或者exit退出程序。

‍預處理器

宏只對程序文本起作用。宏提供了一種對組成程序字符進行變換的方式,而並不是作用於程序中的對象。

6.1注意宏定義中的空格

6.2宏不是函數,所以我們需要:

宏定義中的每個參數都用括號括起來

宏定義整個表達式也應該用括號括起來

宏中的參數可能被求值多次,所以要確保宏的參數沒有副作用,不如不能傳遞類似(a++)這類的參數

宏可能產生非常龐大的表達式

6.3宏不是語句,看下面的例子

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

if (x>0 && y > 0)

assert(x>y)

else

assert(y>x)

最終展開之後else錯誤的宏定義中的if結合了。

6.4宏不是類型定義

#define T1 struct foo *

T1 a,b;

展開表達式,b並不是我們想要的類型。

因此我們應該使用typedef來定義類型。

‍可移植性缺陷

7.1 c標準的變更

7.2 標示符名稱的限制

7.3整數的大小

7.4字符是有符號整數還是無符號整數

字符轉換爲較大的數時,如果字符的最高位是1,那麼轉換成無符號數還是有符號數?

可以聲明爲unsigned char類型,這樣編譯器在轉換時,只是把多餘的位補0

注意,不能使用(unsigned)c的方式獲得無符號整數,因爲這個操作會先把c轉換爲int,而這個結果可能是非預期的。

7.5移位運算

向右移位時多出來的空位是由0來填充,還是由符號位副本來填充?

無符號數進行移位,空位用0來填充,有符號數移位,可以用0也可以用符號位來填充,所以,如果關注右移空出的位,應該要轉換爲無符號數。

移位計數(移動的位數)的取值範圍是?

如果被移位對象的長度爲n,那麼移位計數的取值範圍應該大於等於0,而嚴格小於n,加上如此限制可以更高效的在硬件執行移位運算。

移位運算比除法運算快

右移運算和/有時候結果是不同的(-1) >> 1不等於0,而-1/2等於0

7.6 內存爲置0

null指針不指向任何對象,因爲除非是用於賦值或者比較運算,出於其他任何目的使用null指針都是非法的。

7.7 除法運算髮生的截斷

7.8 隨機數的大小,有的系統大小爲0到2^31 -1 有的大小爲0到2^15 -1

7.9 大小寫轉換 toupper tolowerr有的系統實現的是函數,_toupper _tolower是通過宏實現的,宏的使用需要對傳入的參數慎重。

7.10 realloc的實現

7.11

通過n+‘0'的這種方式獲取n對應的字符是不可以移植的,可移植的方法是“0123456789”[n]

基於2的補碼的計算所能表示的負數的範圍要大於所能表示整數的範圍,一個long類型整數有k位和一個符號位,該long類型整數能表示-2^k卻不能表示2^k,所以一個有符號的long不能通過添加負號來轉換爲整數,可能會溢出。負值轉換爲正值時把-n賦值給unsigned long類型,而不是賦值給long類型

當除法運算中有一個操作數爲負數時,他的表現與具體實現有關。n爲負數時,n%10可能是一個正數。

建議

直截了當表明意圖,主要是說在操作符優先級方面,使用括號表達明確意思。

考慮最簡單的特例,輸入數據爲空或者僅有一個元素,考慮程序設計或者驗證程序。

使用不對稱邊界

潛伏在深處的bug,避免使用生僻的語言特性,這樣可以方便移植,也可以避免編譯器bug。

防禦性編程 (防範式編程 代碼大全2 第8章)

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