【C 陷阱與缺陷】(三)語義陷阱

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

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

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

代碼,練習上傳至:https://github.com/hairrrrr/C-CrashCourse

0. 指針與數組

C 語言中數組與指針這兩個概念之間的聯繫密不可分。

關於數組:
  • C 語言中只有一維數組,而且數組大小必須在編譯期就作爲一個常數確定下來。數組元素可以是任何類型的對象,也可以是另外一個數組。(C99 允許變長數組)
  • 對於一個數組,我們只能夠做兩件事:確定該數組的大小,以及獲得指向該數組下標爲 0 的元素的指針。

任何一個數組下標運算都等同於一個對應的指針運算。

聲明數組

int a[3];

聲明瞭一個擁有 3 個整型元素的數組。

struct{
    int p[4];
    double x;
}b[14];

聲明瞭一個擁有 17 個元素的數組,且每個元素都是一個結構。

int calendar[12][31];

聲明瞭擁有 12 個數組類型的元素,其中每個元素都是擁有 31 個整型元素的數組。因此 sizeof(calendar)的值是 12x31 與 sizeof(int)的乘積。

關於指針

任何指針都是指向某種類型的變量。

int *ip;

表明 ip 是一個指向整型變量的指針。

我們可以將整型變量 i 的地址賦值給指針 ip :

int i;
ip = &i;

如果我們給 *ip 賦值,就可以改變 i 的取值:

*ip = 17;
數組與指針

如果一個指針指向的是數組中的一個元素,那麼我們只要給這個指針加 1,就能夠得到指向該數組中下一個元素的指針。減法同理。

如果兩個指針指向的是同一個數組中的元素,那麼兩個指針相減是有意義的:

int *q = p + i;

我們可以通過 q - p 得到 i 的值。

int a[3];
int* p = a;

數組名被當作指向數組下標爲 0 的元素的地址。

注意,我們沒有寫成:

p = &a;

這樣的寫法在 ANSI C 中是非法的,因爲 &a是一個指向數組的指針,而 p 是指向整型變量的指針,它們了類型並不匹配。

繼續我們的討論,現在 p 指向數組 a 中下標爲 0 的元素,p + 1 指向下標爲 1 的元素,以此類推。如果希望 p 指向下標爲 1 的元素,可以這樣寫:

p = p + 1;

當然,也可以這樣寫:

p++;

*a 是數組 a 中下標爲 0 的元素的引用。同理,*(a + 1)是數組中下標爲 1 的元素的引用,*(a + i)是數組中下標爲 i 的元素的引用,簡寫爲 a[i]

由於 a + ii + a的含義一致,因此a[i]i[a]也具有相同的含義。但我們絕不推薦這種寫法。

二維數組
int calendar[12][31];

請思考,calendar[4]含義是什麼?

calender[4]是 calendar 數組第 5 個元素,是 calendar 數組 12 個擁有着 31 個整型元素的數組之一。sizeof(calendar[4])大小爲 31 與 sizeof(int)的乘積。

p = calendar[4];

這個語句使 p 指向了數組 calendar 下標爲 0 的元素。

如果 calendar 是數組,我們可以:

i = calender[4][7];

上式等價於:

i = *(calender[4] + 7);

等價於:

i = *(*(calender + 4) + 7);

下面我們再看:

p = calender;

這個語句是非法的。因爲 calendar 是一個二維數組,即數組的數組,calendar 是一個指向數組的指針,而 p 是指向整型變量的指針。

我們需要聲明一種指向數組的指針,經過上一章的討論,我們不難得出:

int (*ap)[31];

這個語句的效果是:聲明瞭 *ap 是一個擁有 31 個元素的數組,所以,ap 就是指向這樣的數組的指針。因此,我們可以這樣寫:

int calender[12][31];
int (*monthp)[31];
monthp = calendar;

這樣 monthp 指向 calendar 數組的第一個元素,也就是 calendar 的 12 個擁有 31 個整型變量的數組類型的元素之一。

假定在新的一年開始時,我們需要清空 calendar 數組,用下標的形式可以很容易的做到:

int month;
for(month = 0; month < 12; month++){
    int day;
    for(day = 0; day < 31; day++)
        calendar[month][day] = 0;
}

上面的代碼用指針應該如何表示?

int (*month)[31] = calander;
for(;month < calendar + 12; month++){
    int *day = *month;
    for(; day < *month + 31; day++)
        *day = 0;
}

原書中的代碼爲:

int (*monthp)[31];
for(monthp = calendar; monthp < &calendar[12]; monthp++){
    int *dayp;
    for(dayp = *monthp; dayp < &(*monthp)[31]; dayp++)
        *dayp = 0;
}

1. 非數組的指針

假定我們兩個這樣的字符串 s 和 t,我們希望將這兩個字符串連接成單個字符串 r :

char* r;
strcpy(r, s);
strcat(r, t);

我們不確定 r 指向何處,而且 r 所指向的地址處不一定有內存空間可供容納字符串。這一次,我們爲 r 分配空間:

char r[100];
strcpy(r, s);
strcat(r, t);

C 語言強制要求我們必須聲明數組大小爲一個常量,因此我們不能保證 r 足夠大。這時,我們可以利用庫函數 malloc :

char *r, *malloc();
r = malloc(strlen(s) + strlen(t));
strcpy(r, s);
strcat(r, t);

這個例子還是錯的,原因有 3 :

  1. malloc 函數可能無法提供請求的內存
  2. 給 r 分配的內存在使用完後應該及時釋放
  3. strlen(s) 的值如果是 n ,那麼字符串 s 的實際長度爲 n + 1,因爲,strlen 會忽略作爲結束標誌的空字符。所以,malloc 時,切記給字符串結尾的空字符留有空間。

修改:

char *r, *malloc();
r = malloc(strlen(s) + strlen(t) + 1);
if(!r){
    complain();
    exit(1);
}
strcpy(r, s);
strcat(r, t);

//一段時間後再使用
free(r);

2. 作爲參數的數組聲明

C 語言中,我們沒有辦法可以將一個數組作爲函數參數直接傳遞。如果我們使用數組名作爲參數,那麼數組名會立刻被轉換爲指向該數組第 1 個元素的指針。例如:

char hello[] = "hello";
printf("%s\n", hello);

printf 函數調用等價於:

printf("%s\n", &hello[0]);

所以,C 語言中會自動的將作爲參數的數組聲明轉換爲相應的指針聲明。也就是像這樣的寫法:

int strlen(char s[]){
    
}

或:

int strlen(char* s){
    
}

C 程序員經常錯誤的假設,在其他情況下也會有這種自動的轉換。後面我們會說到:

extern char* hello;

和下面的語句有着天壤之別:

extern char hello[];

另一個常見的例子就是 main 函數的參數:

int main(int argc, char* argv[]){
    
}

等價於:

int main(int argc, char** argv){
    
}

需要注意的是,前一種寫法強調 argv 是一個指向某數組元素爲字符指針的起始元素的指針。因爲這兩種寫法是等價的,所以可以任選一種最能清晰反應自己意圖的寫法。

3. 避免“舉隅法”

指針的複製並不同時複製指針所指向的數據。

char *p, *q;
p = "xyz";

p 的值並不是字符串 "xyz",而是指向該字符串起始元素的指針。因此,如果我們執行下面的語句:

q = p;

現在 p 和 q 是兩個指向內存中同一地址的指針。如圖:

因此,當我們執行完語句:

q[1] = 'Y';

q 所指向的內存存儲的字符串是"xYz",p 所指向的內存中存儲的當然也是字符串"xYz" 。

注意:ANSI C 中禁止對 string literal (字符串字面量)作出修改。K&R 對這一行爲的說明是:試圖修改字符串常量的行爲是未定義的。

4. 空指針並非空字符串

常數 0 轉換而來的指針不等於任何有效的指針。

#define NULL 0

無論是用 0 還是符號 NULL,效果都是完全相同的。空指針絕不能被解引用

下面的寫法是合法的:

if(p == (char*)0){...}

但是如果寫成這樣:

if(strcmp(p, (char*)0) == 0){...}

就是非法的了。因爲庫函數 strcmp 的實現中會查看它的指針參數所指向的內存中的內容。

如果 p 是一個空指針,即使

printf(p);

printf("%s\n", p);

的行爲也是未定義的。

5.邊界計算與不對稱邊界

如果一個數組有 10 個元素,那麼這個數組下標允許取值範圍是什麼呢?

在 C 語言中,這個數組下標的範圍是 0 ~ 9 。

欄杆錯誤

也稱差一錯誤(off-by-one error)。

解決這種問題的通用原則:

  • 首先考慮最簡單情況下的特例,然後將得到的結果外推。
  • 仔細計算邊界,絕不掉以輕心。
不對稱邊界

解決差一錯誤的一個方法是使用不對稱邊界的思想。

比如,一個字符串中由下標爲 16 到下標爲 37 的字符元素組成的字串,如何表示這個範圍?

我們採用不對稱邊界:x >= 16 && x <38而不是採用x >= 16 && x <= 37。這樣,這個字串的長度明顯就是 38 - 16,也就是 22 。

用 for 循環遍歷一個大小爲 10 的數組:

for(i = 0; i < 10; i++){
    
}

而非:

for(i = 0; i <= 9; i++){
    
}

6. 求值順序

C 語言中只有 4 個運算符(&&||?:,)存在規定的求值順序。

  • 運算符 && 和 || 首先對左操作數求值,只有在需要時纔對右操作數求值。
  • 運算符 ?: 有 3 個操作數:在 a ? b : c中,首先對 a 求值,根據 a 的值再對操作數 b 或 操作數 c 求值。
  • 逗號運算符從左向右一次求值。(求值然後丟棄再繼續求值。)

運算符 && 和 || 對於保證檢查操作按照正確的順序執行至關重要。例如在語句

if(y != 0 && x / y > tolerance)
    complain();

中,就必須保證僅當 y 非 0 時纔對 x / y 求值。

下面這種從數組 x 中複製前 n 個元素到數組 y 中的做法是不正確的:

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

問題出在哪裏呢?上面的代碼假設 y[i]的地址在 i 的自增操作指向前被求值,這一點並沒有任何保證。

同樣的道理,下面的代碼也是錯誤的:

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

應該使用這一種寫法:

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

或:

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

7. 運算符 && 和 || 與 運算符 & 和 |

按位運算 &,|,^ ,~ 對操作數的處理方式是將其視爲一個二進制的位序列,分別對其每一位進行操作。

邏輯運算 &&,||,! 對操作數的處理方式是將其視爲要麼是“真” 要麼是“假”。通常將 0 視爲 假,非 0 視爲 真。它們的結果只可能是 1 或 0 。

需要注意的是邏輯運算中的 && 和 || 是有求值順序的。

考慮下面的代碼段,其作用是在表中查詢一個特定的元素:

i = 0;
while(i < tabsize && tab[i] != x)
    i++;

假定我們無意中用 & 替換了 &&:

i = 0;
while(i < tabsize & tab[i] != x)
    i++;

這個循環也可能正常工作,但這僅僅是因爲兩個僥倖的原因:

  1. while 循環中的表達式 & 兩側都是比較運算,其結果只會是 1 或 0 。因此 x && y 和 x & y 會具有相同的結果。然而,如果兩個比較運算中的任意一個使用除 1 之外的非 0 的數表示“真”,那麼這個循環就不能正常個工作了。
  2. 對於數組結尾後的下一個元素(實際上是不存在的),只要程序不去修改該元素的值,而僅僅讀取它的值,一般情況下是不會有什麼危害的。運算符 && 和 & 不同,& 要求 兩側的操作數都必須被求值。因此,在後一個代碼中,最後一次循環當 i 等於 tabsize 時,儘管 tab[i] 並不存在,程序依然會查看 tab[i] 的值。

8. 整數溢出

C 語言中存在兩類整數算術運算,有符號運算與無符號運算。在無符號運算中,沒有所謂“溢出”一說:所有無符號運算都是以 2 的 n 次方爲模,這裏 n 是結果中的位數。

如果算數運算符中的一個操作數是無符號整數一個是有符號整數,有符號整數會被轉換爲無符號整數。“溢出”同樣不會發生。

但是當兩個操作數都爲有符號整數時,溢出就可能發生,而且“溢出”的結果是未定義的。

例如,假定 a 和 b 爲連個非負整形變量,我們要檢查 a + b 是否會“溢出”,一種想當然的方式:

if(a + b < 0)
    complain();

這並不能正常運行。當 a + b 確實發生“溢出”時,所有關於結果如何的假設都是不可靠的。例如,有的計算機上,加法運算將設置內部寄存器爲四種狀態之一:正,負,零和溢出。在這種機器上,上面 if 語句的檢測就會失效。

一種正確的方式爲將 a 和 b 強轉爲無符號整數:

if((unsigned)a + (unsigned)b > INT_MAX)
    complain();

此處的 INT_MAX 是一個已定義常量,代表可能的最大整數值。ANSI C 標準在<limits.h>中定義了 INT_MAX 。

不需要用到無符號整數運算的另一種可行的辦法是:

if(a > INT_MAX - b)
    complain();

9. 爲 main 函數提供返回值

已在 【C 必知必會】系列詳細講解過。不再贅述。

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

推薦大家一個學習 C 的 Github 項目

倉庫不斷更新,還能免費獲取C語言必讀經典電子書

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

↑↑↑ 在 README 末尾有電子書免費下載的方式


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

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

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

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

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

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