C語言三劍客之《C陷阱與缺陷》一書精華提煉
1、C陷阱與缺陷概述
C語言像一把雕刻刀,鋒利,并且在技師手中非常有用。和任何鋒利的工具一樣,C會(huì)傷到那些不能掌握它的人。本文介紹C語言傷害粗心的人的方法,以及如何避免傷害。
第一部分研究了當(dāng)程序被劃分為記號(hào)時(shí)會(huì)發(fā)生的問題。第二部分繼續(xù)研究了當(dāng)程序的記號(hào)被編譯器組合為聲明、表達(dá)式和語句時(shí)會(huì)出現(xiàn)的問題。第三部分研究了由多個(gè)部分組成、分別編譯并綁定到一起的C程序。第四部分處理了概念上的誤解:當(dāng)一個(gè)程序具體執(zhí)行時(shí)會(huì)發(fā)生的事情。第五部分研究了我們的程序和它們所使用的常用庫(kù)之間的關(guān)系。在第六部分中,我們注意到了我們所寫的程序也許并不是我們所運(yùn)行的程序;預(yù)處理器將首先運(yùn)行。最后,第七部分討論了可移植性問題:一個(gè)能在一個(gè)實(shí)現(xiàn)中運(yùn)行的程序無法在另一個(gè)實(shí)現(xiàn)中運(yùn)行的原因。
詞法分析器(lexical analyzer):檢查組成程序的字符序列,并將它們劃分為記號(hào)(token)一個(gè)記號(hào)是一個(gè)由一個(gè)或多個(gè)字符構(gòu)成的序列,它在語言被編譯時(shí)具有一個(gè)(相關(guān)的)統(tǒng)一的意義。
C程序被兩次劃分為記號(hào),首先是預(yù)處理器讀取程序,它必須對(duì)程序進(jìn)行記號(hào)劃分以發(fā)現(xiàn)標(biāo)識(shí)宏的標(biāo)識(shí)符。通過對(duì)每個(gè)宏進(jìn)行求值來替換宏調(diào)用,最后,經(jīng)過宏替換的程序又被匯集成字符流送給編譯器。編譯器再第二次將這個(gè)流劃分為記號(hào)。
1.1 =不是==
C語言則是用=表示賦值而用==表示比較。這是因?yàn)橘x值的頻率要高于比較,因此為其分配更短的符號(hào)。C還將賦值視為一個(gè)運(yùn)算符,因此可以很容易地寫出多重賦值(如a = b = c),并且可以將賦值嵌入到一個(gè)大的表達(dá)式中。
1.2 & 和| 不是 && 和 ||
1.3 多字符記號(hào)
C語言參考手冊(cè)說明了如何決定:“如果輸入流到一個(gè)給定的字符串為止已經(jīng)被識(shí)別為記號(hào),則應(yīng)該包含下一個(gè)字符以組成能夠構(gòu)成記號(hào)的最長(zhǎng)的字符串” “最長(zhǎng)子串原則”
1.4 例外
組合賦值運(yùn)算符如+=實(shí)際上是兩個(gè)記號(hào)。因此,a + /* strange */ = 1
和 a += 1
是一個(gè)意思??雌饋硐褚粋€(gè)單獨(dú)的記號(hào)而實(shí)際上是多個(gè)記號(hào)的只有這一個(gè)特例。特別地,p - > a
是不合法的。它和 p -> a
不是同義詞。另一方面,有些老式編譯器還是將=+視為一個(gè)單獨(dú)的記號(hào)并且和+=是同義詞。
1.5 字符串和字符
包圍在單引號(hào)中的一個(gè)字符只是編寫整數(shù)的另一種方法。這個(gè)整數(shù)是給定的字符在實(shí)現(xiàn)的對(duì)照序列中的一個(gè)對(duì)應(yīng)的值。而一個(gè)包圍在雙引號(hào)中的字符串,只是編寫一個(gè)有雙引號(hào)之間的字符和一個(gè)附加的二進(jìn)制值為零的字符所初始化的一個(gè)無名數(shù)組的指針的一種簡(jiǎn)短方法。
使用一個(gè)指針來代替一個(gè)整數(shù)通常會(huì)得到一個(gè)警告消息(反之亦然),使用雙引號(hào)來代替單引號(hào)也會(huì)得到一個(gè)警告消息(反之亦然)。但對(duì)于不檢查參數(shù)類型的編譯器卻除外。
由于一個(gè)整數(shù)通常足夠大,以至于能夠放下多個(gè)字符,一些C編譯器允許在一個(gè)字符常量中存放多個(gè)字符。這意味著用'yes'代替"yes"將不會(huì)被發(fā)現(xiàn)。后者意味著“分別包含y、e、s和一個(gè)空字符的四個(gè)連續(xù)存儲(chǔ)器區(qū)域中的第一個(gè)的地址”,而前者意味著“在一些實(shí)現(xiàn)定義的樣式中表示由字符y、e、s聯(lián)合構(gòu)成的一個(gè)整數(shù)”。這兩者之間的任何一致性都純屬巧合。
2、句法缺陷
理解這些記號(hào)是如何構(gòu)成聲明、表達(dá)式、語句和程序的。
2.1 理解聲明
每個(gè)C變量聲明都具有兩個(gè)部分:一個(gè)類型和一組具有特定格式的、期望用來對(duì)該類型求值的表達(dá)式。 float *g(), (*h)();
表示*g()和(h)()都是float表達(dá)式。由于()比綁定得更緊密,g()和(g())表示同樣的東西:g是一個(gè)返回指float指針的函數(shù),而h是一個(gè)指向返回float的函數(shù)的指針。
當(dāng)我們知道如何聲明一個(gè)給定類型的變量以后,就能夠很容易地寫出一個(gè)類型的模型(cast):只要?jiǎng)h除變量名和分號(hào)并將所有的東西包圍在一對(duì)圓括號(hào)中即可。
float *g();
聲明g是一個(gè)返回float指針的函數(shù),所以(float *())
就是它的模型。
(*(void(*)())0)();
硬件會(huì)調(diào)用地址為0處的子程序(*0)();
但這樣并不行,因?yàn)?em style="box-sizing: border-box;">運(yùn)算符要求必須有一個(gè)指針作為它的操作數(shù)。另外,這個(gè)操作數(shù)必須是一個(gè)指向函數(shù)的指針,以保證的結(jié)果可以被調(diào)用。需要將0轉(zhuǎn)換為一個(gè)可以描述“指向一個(gè)返回void的函數(shù)的指針”的類型。(Void(*)())0
在這里,我們解決這個(gè)問題時(shí)沒有使用typedef聲明。通過使用它,我們可以更清晰地解決這個(gè)問題:
typedef void (*funcptr)();// typedef funcptr void (*)();
指向返回void的函數(shù)的指針
(*(funcptr)0)();
//調(diào)用地址為0處的子程序
2.2 運(yùn)算符并不總是具有你所想象的優(yōu)先級(jí)
綁定得最緊密的運(yùn)算符并不是真正的運(yùn)算符:下標(biāo)、函數(shù)調(diào)用和結(jié)構(gòu)選擇。這些都與左邊相關(guān)聯(lián)。
接下來是一元運(yùn)算符。它們具有真正的運(yùn)算符中的最高優(yōu)先級(jí)。由于函數(shù)調(diào)用比一元運(yùn)算符綁定得更緊密,你必須寫(*p)()
來調(diào)用p指向的函數(shù);*p()
表示p是一個(gè)返回一個(gè)指針的函數(shù)。轉(zhuǎn)換是一元運(yùn)算符,并且和其他一元運(yùn)算符具有相同的優(yōu)先級(jí)。一元運(yùn)算符是右結(jié)合的,因此*p++
表示*(p++)
,而不是(*p)++
。在接下來是真正的二元運(yùn)算符。其中數(shù)學(xué)運(yùn)算符具有最高的優(yōu)先級(jí),然后是移位運(yùn)算符、關(guān)系運(yùn)算符、邏輯運(yùn)算符、賦值運(yùn)算符,最后是條件運(yùn)算符。需要記住的兩個(gè)重要的東西是:
-
1.所有的邏輯運(yùn)算符具有比所有關(guān)系運(yùn)算符都低的優(yōu)先級(jí)。 -
2.移位運(yùn)算符比關(guān)系運(yùn)算符綁定得更緊密,但又不如數(shù)學(xué)運(yùn)算符。
乘法、除法和求余具有相同的優(yōu)先級(jí),加法和減法具有相同的優(yōu)先級(jí),以及移位運(yùn)算符具有相同的優(yōu)先級(jí)。還有就是六個(gè)關(guān)系運(yùn)算符并不具有相同的優(yōu)先級(jí):==和!=的優(yōu)先級(jí)比其他關(guān)系運(yùn)算符要低。在邏輯運(yùn)算符中,沒有任何兩個(gè)具有相同的優(yōu)先級(jí)。按位運(yùn)算符比所有順序運(yùn)算符綁定得都緊密,每種與運(yùn)算符都比相應(yīng)的或運(yùn)算符綁定得更緊密,并且按位異或(^)運(yùn)算符介于按位與和按位或之間。三元運(yùn)算符的優(yōu)先級(jí)比我們提到過的所有運(yùn)算符的優(yōu)先級(jí)都低。這個(gè)例子還說明了賦值運(yùn)算符具有比條件運(yùn)算符更低的優(yōu)先級(jí)是有意義的。另外,所有的復(fù)合賦值運(yùn)算符具有相同的優(yōu)先級(jí)并且是自右至左結(jié)合的具有最低優(yōu)先級(jí)的是逗號(hào)運(yùn)算符。賦值是另一種運(yùn)算符,通常具有混合的優(yōu)先級(jí)。
2.3 看看這些分號(hào)!
或者是一個(gè)空語句,無任何效果;或者編譯器可能提出一個(gè)診斷消息,可以方便除去掉它。一個(gè)重要的區(qū)別是在必須跟有一個(gè)語句的if和while語句中。另一個(gè)因分號(hào)引起巨大不同的地方是函數(shù)定義前面的結(jié)構(gòu)聲明的末尾,考慮下面的程序片段:
struct foo {
int x;
}
f() {
...
}
在緊挨著f的第一個(gè)}后面丟失了一個(gè)分號(hào)。它的效果是聲明了一個(gè)函數(shù)f,返回值類型是struct foo,這個(gè)結(jié)構(gòu)成了函數(shù)聲明的一部分。如果這里出現(xiàn)了分號(hào),則f將被定義為具有默認(rèn)的整型返回值[5]。
2.4 switch語句
C中的case標(biāo)簽是真正的標(biāo)簽:控制流程可以無限制地進(jìn)入到一個(gè)case標(biāo)簽中??纯戳硪环N形式,假設(shè)C程序段看起來更像Pascal:
switch(color) {
case 1: printf ("red");
case 2: printf ("yellow");
case 3: printf ("blue");
}
并且假設(shè)color的值是2。則該程序?qū)⒋蛴ellowblue,因?yàn)榭刂谱匀坏剞D(zhuǎn)入到下一個(gè)printf()的調(diào)用。這既是C語言switch語句的優(yōu)點(diǎn)又是它的弱點(diǎn)。說它是弱點(diǎn),是因?yàn)楹苋菀淄浺粋€(gè)break語句,從而導(dǎo)致程序出現(xiàn)隱晦的異常行為。說它是優(yōu)點(diǎn),是因?yàn)橥ㄟ^故意去掉break語句,可以很容易實(shí)現(xiàn)其他方法難以實(shí)現(xiàn)的控制結(jié)構(gòu)。尤其是在一個(gè)大型的switch語句中,我們經(jīng)常發(fā)現(xiàn)對(duì)一個(gè)case的處理可以簡(jiǎn)化其他一些特殊的處理。
2.5 函數(shù)調(diào)用
和其他程序設(shè)計(jì)語言不同,C要求一個(gè)函數(shù)調(diào)用必須有一個(gè)參數(shù)列表,但可以沒有參數(shù)。因此,如果f是一個(gè)函數(shù),f();
就是對(duì)該函數(shù)進(jìn)行調(diào)用的語句,而f;
什么也不做。它會(huì)作為函數(shù)地址被求值,但不會(huì)調(diào)用它[6]。
2.6 懸掛else問題
一個(gè)else總是與其最近的if相關(guān)聯(lián)。
3 連接
一個(gè)C程序可能有很多部分組成,它們被分別編譯,并由一個(gè)通常稱為連接器、連接編輯器或加載器的程序綁定到一起。由于編譯器一次通常只能看到一個(gè)文件,因此它無法檢測(cè)到需要程序的多個(gè)源文件的內(nèi)容才能發(fā)現(xiàn)的錯(cuò)誤。
3.1 你必須自己檢查外部類型
假設(shè)你有一個(gè)C程序,被劃分為兩個(gè)文件。其中一個(gè)包含如下聲明:
int n;
而另一個(gè)包含如下聲明:
long n;
這不是一個(gè)有效的C程序,因?yàn)橐恍┩獠棵Q在兩個(gè)文件中被聲明為不同的類型。然而,很多實(shí)現(xiàn)檢測(cè)不到這個(gè)錯(cuò)誤,因?yàn)榫幾g器在編譯其中一個(gè)文件時(shí)并不知道另一個(gè)文件的內(nèi)容。因此,檢查類型的工作只能由連接器(或一些工具程序如lint)來完成;如果操作系統(tǒng)的連接器不能識(shí)別數(shù)據(jù)類型,C編譯器也沒法過多地強(qiáng)制它。那么,這個(gè)程序運(yùn)行時(shí)實(shí)際會(huì)發(fā)生什么?這有很多可能性:
-
1.實(shí)現(xiàn)足夠聰明,能夠檢測(cè)到類型沖突。則我們會(huì)得到一個(gè)診斷消息,說明n在兩個(gè)文件中具有不同的類型。
-
2.你所使用的實(shí)現(xiàn)將int和long視為相同的類型。典型的情況是機(jī)器可以自然地進(jìn)行32位運(yùn)算。在這種情況下你的程序或許能夠工作,好像你兩次都將變量聲明為long(或int)。但這種程序的工作純屬偶然。
-
3.n的兩個(gè)實(shí)例需要不同的存儲(chǔ),它們以某種方式共享存儲(chǔ)區(qū),即對(duì)其中一個(gè)的賦值對(duì)另一個(gè)也有效。這可能發(fā)生,例如,編譯器可以將int安排在long的低位。不論這是基于系統(tǒng)的還是基于機(jī)器的,這種程序的運(yùn)行同樣是偶然。
-
4.n的兩個(gè)實(shí)例以另一種方式共享存儲(chǔ)區(qū),即對(duì)其中一個(gè)賦值的效果是對(duì)另一個(gè)賦以不同的值。在這種情況下,程序可能失敗。
這種情況發(fā)生的另一個(gè)例子出奇地頻繁。程序的某一個(gè)文件包含下面的聲明: char filename[] = "etc/passwd";
而另一個(gè)文件包含這樣的聲明: char *filename;
盡管在某些環(huán)境中數(shù)組和指針的行為非常相似,但它們是不同的。在第一個(gè)聲明中,filename是一個(gè)字符數(shù)組的名字。盡管使用數(shù)組的名字可以產(chǎn)生數(shù)組第一個(gè)元素的指針,但這個(gè)指針只有在需要的時(shí)候才產(chǎn)生并且不會(huì)持續(xù)。在第二個(gè)聲明中,filename是一個(gè)指針的名字。這個(gè)指針可以指向程序員讓它指向的任何地方。如果程序員沒有給它賦一個(gè)值,它將具有一個(gè)默認(rèn)的0值(NULL)([譯注]實(shí)際上,在C中一個(gè)為初始化的指針通常具有一個(gè)隨機(jī)的值,這是很危險(xiǎn)的!)。
這兩個(gè)聲明以不同的方式使用存儲(chǔ)區(qū),它們不可能共存。
避免這種類型沖突的一個(gè)方法是使用像lint這樣的工具(如果可以的話)。為了在一個(gè)程序的不同編譯單元之間檢查類型沖突,一些程序需要一次看到其所有部分。典型的編譯器無法完成,但lint可以。
避免該問題的另一種方法是將外部聲明放到包含文件中。這時(shí),一個(gè)外部對(duì)象的類型僅出現(xiàn)一次[7]。
4 語義缺陷
4.1 表達(dá)式求值順序
一些C運(yùn)算符以一種已知的、特定的順序?qū)ζ洳僮鲾?shù)進(jìn)行求值。但另一些不能。例如,考慮下面的表達(dá)式: a < b && c < d
C語言定義規(guī)定a < b
首先被求值。如果a確實(shí)小于b,c < d
必須緊接著被求值以計(jì)算整個(gè)表達(dá)式的值。但如果a大于或等于b,則c < d
根本不會(huì)被求值。要對(duì)a < b
求值,編譯器對(duì)a和b的求值就會(huì)有一個(gè)先后。但在一些機(jī)器上,它們也許是并行進(jìn)行的。
C中只有四個(gè)運(yùn)算符&&、||、?:和,指定了求值順序。&&和||最先對(duì)左邊的操作數(shù)進(jìn)行求值,而右邊的操作數(shù)只有在需要的時(shí)候才進(jìn)行求值。而?:運(yùn)算符中的三個(gè)操作數(shù):a、b和c,最先對(duì)a進(jìn)行求值,之后僅對(duì)b或c中的一個(gè)進(jìn)行求值,這取決于a的值。,運(yùn)算符首先對(duì)左邊的操作數(shù)進(jìn)行求值,然后拋棄它的值,對(duì)右邊的操作數(shù)進(jìn)行求值[8]。
C中所有其它的運(yùn)算符對(duì)操作數(shù)的求值順序都是未定義的。事實(shí)上,賦值運(yùn)算符不對(duì)求值順序做出任何保證。出于這個(gè)原因,下面這種將數(shù)組x中的前n個(gè)元素復(fù)制到數(shù)組y中的方法是不可行的:
i = 0;
while(i < n)
y[i] = x[i++];
其中的問題是y[i]的地址并不保證在i增長(zhǎng)之前被求值。在某些實(shí)現(xiàn)中,這是可能的;但在另一些實(shí)現(xiàn)中卻不可能。另一種情況出于同樣的原因會(huì)失?。?/p>
i = 0;
while(i < n)
y[i++] = x[i];
而下面的代碼是可以工作的:
i = 0;
while(i < n) {
y[i] = x[i];
i++;
}
當(dāng)然,這可以簡(jiǎn)寫為:
for(i = 0; i < n; i++)
y[i] = x[i];
4.2 &&、||和!運(yùn)算符
4.3 下標(biāo)從零開始
在很多語言中,具有n個(gè)元素的數(shù)組其元素的號(hào)碼和它的下標(biāo)是從1到n嚴(yán)格對(duì)應(yīng)的。但在C中不是這樣。具有n個(gè)元素的C數(shù)組中沒有下標(biāo)為n的元素,其中的元素的下標(biāo)是從0到n - 1。因此從其它語言轉(zhuǎn)到C語言的程序員應(yīng)該特別小心地使用數(shù)組:
int i, a[10];
for(i = 1; i <= 10; i++)
a[i] = 0;
4.4 C并不總是轉(zhuǎn)換實(shí)參
下面的程序段由于兩個(gè)原因會(huì)失?。?/p>
double s;
s = sqrt(2);
printf("%g\n", s);
第一個(gè)原因是sqrt()
需要一個(gè)double值作為它的參數(shù),但沒有得到。第二個(gè)原因是它返回一個(gè)double值但沒有這樣聲明。改正的方法只有一個(gè):
double s, sqrt();
s = sqrt(2.0);
printf("%g\n", s);
C中有兩個(gè)簡(jiǎn)單的規(guī)則控制著函數(shù)參數(shù)的轉(zhuǎn)換:
-
(1)比int短的整型被轉(zhuǎn)換為int; -
(2)比double短的浮點(diǎn)類型被轉(zhuǎn)換為double。所有的其它值不被轉(zhuǎn)換。確保函數(shù)參數(shù)類型的正確性是程序員的責(zé)任。
因此,一個(gè)程序員如果想使用如sqrt()這樣接受一個(gè)double類型參數(shù)的函數(shù),就必須僅傳遞給它float或double類型的參數(shù)。常數(shù)2是一個(gè)int,因此其類型是錯(cuò)誤的。
當(dāng)一個(gè)函數(shù)的值被用在表達(dá)式中時(shí),其值會(huì)被自動(dòng)地轉(zhuǎn)換為適當(dāng)?shù)念愋?。然而,為了完成這個(gè)自動(dòng)轉(zhuǎn)換,編譯器必須知道該函數(shù)實(shí)際返回的類型。沒有更進(jìn)一步聲名的函數(shù)被假設(shè)返回int,因此聲名這樣的函數(shù)并不是必須的。然而,sqrt()返回double,因此在成功使用它之前必須要聲明。
這里有一個(gè)更加壯觀的例子:
main() {
int i;
char c;
for(i = 0; i < 5; i++) {
scanf("%d", &c);
printf("%d", i);
}
printf("\n");
}
表面上看,這個(gè)程序從標(biāo)準(zhǔn)輸入中讀取五個(gè)整數(shù)并向標(biāo)準(zhǔn)輸出寫入0 1 2 3 4。實(shí)際上,它并不總是這么做。譬如在一些編譯器中,它的輸出為0 0 0 0 0 1 2 3 4。
為什么?因?yàn)閏的聲明是char而不是int。當(dāng)你令scanf()
去讀取一個(gè)整數(shù)時(shí),它需要一個(gè)指向一個(gè)整數(shù)的指針。但這里它得到的是一個(gè)字符的指針。但scanf()
并不知道它沒有得到它所需要的:它將輸入看作是一個(gè)指向整數(shù)的指針并將一個(gè)整數(shù)存貯到那里。由于整數(shù)占用比字符更多的內(nèi)存,這樣做會(huì)影響到c附近的內(nèi)存。
附近確切是什么是編譯器的事;在這種情況下這有可能是i的低位。因此,每當(dāng)向c中讀入一個(gè)值,i就被置零。當(dāng)程序最后到達(dá)文件結(jié)尾時(shí),scanf()
不再嘗試向c中放入新值,i才可以正常地增長(zhǎng),直到循環(huán)結(jié)束。
往期精彩
C語言的數(shù)組為什么要從0開始編號(hào)
C語言數(shù)組結(jié)合位運(yùn)算實(shí)戰(zhàn)-位移與查表
分批讀取文件中數(shù)據(jù)的程序流程及其C代碼實(shí)現(xiàn)
ESP8266透?jìng)鳎豪肧TM32f103zet6發(fā)送數(shù)據(jù)到HTTP服務(wù)器
覺得本次分享的文章對(duì)您有幫助,隨手點(diǎn)[在看]
并轉(zhuǎn)發(fā)分享,也是對(duì)我的支持。
免責(zé)聲明:本文內(nèi)容由21ic獲得授權(quán)后發(fā)布,版權(quán)歸原作者所有,本平臺(tái)僅提供信息存儲(chǔ)服務(wù)。文章僅代表作者個(gè)人觀點(diǎn),不代表本平臺(tái)立場(chǎng),如有問題,請(qǐng)聯(lián)系我們,謝謝!