校長講堂第九講

  • 2020 年 4 月 10 日
  • 筆記

語義「陷阱」

一個句子可以是精確拼寫的並且沒有語法錯誤,但仍然沒有意義。在這一節中,我們將會看到一些程式的寫法會使得它們看起來是一個意思,但實際上是另一種完全不同的意思。 我們還要討論一些表面上看起來合理但實際上會產生未定義結果的環境。我們這裡討論的東西並不保證能夠在所有的 C 實現中工作。我們暫且忘記這些能夠在一些實現中工作但可能不能在另一些實現中工作的東西,直到以後討論可以執行問題為止。

3.3 C 並不總是轉換實參

下面的程式段由於兩個原因會失敗: double s; s = sqrt(2); printf("%gn", s); 第一個原因是 sqrt()需要一個 double 值作為它的參數,但沒有得到。第二個原因是它返回一個double 值但沒有這樣聲名。改正的方法只有一個: double s, sqrt(); s = sqrt(2.0); printf("%gn", s); C 中有兩個簡單的規則控制著函數參數的轉換:(1)比 int 短的整型被轉換為 int;(2)比 double短的浮點類型被轉換為 double。所有的其它值不被轉換。確保函數參數類型的正確行使程式設計師的責任。 因此,一個程式設計師如果想使用如 sqrt()這樣接受一個 double 類型參數的函數,就必須僅傳遞給它float 或 double 類型的參數。常數 2 是一個 int,因此其類型是錯誤的。 當一個函數的值被用在表達式中時,其值會被自動地轉換為適當的類型。然而,為了完成這個自動轉換,編譯器必須知道該函數實際返回的類型。沒有更進一步聲名的函數被假設返回 int,因此聲名這樣的函數並不是必須的。然而,sqrt()返回 double,因此在成功使用它之前必須要聲名。 實際上,C 實現通常允許一個文件包含 include 語句來包含如 sqrt()這些庫函數的聲名,但是對那 些自己寫函數的程式設計師來說,書寫聲名也是必要的——或者說,對那些書寫非凡的 C 程式的人來說是有必 要的。 這裡有一個更加壯觀的例子: main() { int i; char c; for(i = 0; i < 5; i++) { scanf("%d", &c); printf("%d", i); } printf("n"); } 表面上看,這個程式從標準輸入中讀取五個整數並向標準輸出寫入 0 1 2 3 4。實際上,它並不總是這麼做。譬如在一些編譯器中,它的輸出為 0 0 0 0 0 1 2 3 4。 為什麼?因為 c 的聲名是 char 而不是 int。當你令 scanf()去讀取一個整數時,它需要一個指向一個整數的指針。但這裡它得到的是一個字元的指針。但 scanf()並不知道它沒有得到它所需要的:它將輸入看作是一個指向整數的指針並將一個整數存貯到那裡。由於整數佔用比字元更多的記憶體,這樣做會影響到 c 附近的記憶體。 c 附近確切是什麼是編譯器的事;在這種情況下這有可能是 i 的低位。因此,每當向 c 中讀入一個值,i 就被置零。當程式最後到達文件結尾時,scanf()不再嘗試向 c 中放入新值,i 才可以正常地增長,直到循環結束。

3.4 指針不是數組

C 程式通常將一個字元串轉換為一個以空字元結尾的字元數組。 假設我們有兩個這樣的字元串 s 和 t,並且我們想要將它們連接為一個單獨的字元串 r。我們通常使用庫函數 strcpy()和 strcat()來完成。 下面這種明顯的方法並不會工作: char *r; strcpy(r, s); strcat(r, t); 這是因為 r 沒有被初始化為指向任何地方。儘管 r 可能潛在地表示某一塊記憶體,但這並不存在,直到你分配它。 讓我們再試試,為 r 分配一些記憶體: char r[100]; strcpy(r, s); strcat(r, t); 這隻有在 s 和 t 所指向的字元串不很大的時候才能夠工作。不幸的是,C 要求我們為數組指定的大小是一個常數,因此無法確定 r 是否足夠大。然而,很多 C 實現帶有一個叫做 malloc()的庫函數,它接受一個數字並分配這麼多的記憶體。通常還有一個函數成為 strlen(),可以告訴我們一個字元串中有多少個字元: 因此,我們可以寫: char *r, *malloc(); r = malloc(strlen(s) + strlen(t)); strcpy(r, s); strcat(r, t); 然而這個例子會因為兩個原因而失敗。首先,malloc()可能會耗盡記憶體,而這個事件僅通過靜靜地返回一個空指針來表示。 其次,更重要的是,malloc()並沒有分配足夠的記憶體。一個字元串是以一個空字元結束的。而strlen()函數返回其字元串參數中所包含字元的數量,但不包括結尾的空字元。因此,如果 strlen(s)是 n,則 s 需要 n + 1 個字元來盛放它。因此我們需要為 r 分配額外的一個字元。再加上檢查 malloc()是否成功,我們得到: char *r, *malloc(); r = malloc(strlen(s) + strlen(t) + 1); if(!r) { complain(); exit(1); } strcpy(r, s); strcat(r, t);

3.5 避免提喻法

提喻法(Synecdoche, sin-ECK-duh-key)是一種文學手法,有點類似於明喻或暗喻,在牛津英文詞典中解釋如下:「a more comprehensive term is used for a less comprehensive or vice versa;as whole for part or part for whole, genus for species or species for genus, etc.(將全面的單位用作不全面的單位,或反之;如整體對局部或局部對整體、一般對特殊或特殊對一般,等等。)」 這可以精確地描述 C 中通常將指針誤以為是其指向的數據的錯誤。正將常會在字元串中發生。例如: char *p, *q; p = "xyz"; 儘管認為 p 的值是 xyz 有時是有用的,但這並不是真的,理解這一點非常重要。p 的值是指向一個有四個字元的數組中第 0 個元素的指針,這四個字元是'x'、'y'、'z'和''。因此,如果我們現在執行: q = p; p 和 q 會指向同一塊記憶體。記憶體中的字元沒有因為賦值而被複制。這種情況看起來是這樣的:要記住的是,複製一個指針並不能複製它所指向的東西。因此,如果之後我們執行: q[1] = 'Y'; q 所指向的記憶體包含字元串 xYz。p 也是,因為 p 和 q 指向相同的記憶體。

3.6 空指針不是空字元串

將一個整數轉換為一個指針的結果是實現相關的(implementation-dependent),除了一個例外。這個例外是常數 0,它可以保證被轉換為一個與其它任何有效指針都不相等的指針。這個值通常類似這樣定義: #define NULL 0 但其效果是相同的。要記住的一個重要的事情是,當用 0 作為指針時它決不能被解除引用。換句話說,當你將 0 賦給一個指針變數後,你就不能訪問它所指向的記憶體。不能這樣寫: if(p == (char *)0) … 也不能這樣寫: if(strcmp(p, (char *)0) == 0) … 因為 strcmp()總是通過其參數來查看記憶體地址的。 如果 p 是一個空指針,這樣寫也是無效的: printf(p); 或 printf("%s", p);

3.7 整數溢出

C 語言關於整數操作的上溢或下溢定義得非常明確。 只要有一次操作數是無符號的,結果就是無符號的,並且以 2n為模,其中 n 為字長。如果兩個操作 數都是帶符號的,則結果是未定義的。 例如,假設 a 和 b 是兩個非負整型變數,你希望測試 a + b 是否溢出。一個明顯的辦法是這樣的: if(a + b < 0) complain(); 通常,這是不會工作的。 一旦 a + b 發生了溢出,對於結果的任何賭注都是沒有意義的。例如,在某些機器上,一個加法運算會將一個內部暫存器設置為四種狀態:正、負、零或溢出。 在這樣的機器上,編譯器有權將上面的例子實現為首先將 a 和 b 加在一起,然後檢查內部暫存器狀態是否為負。如果該運算溢出,內部暫存器將處於溢出狀態,這個測試會失敗。 使這個特殊的測試能夠成功的一個正確的方法是依賴於無符號算術的良好定義,既要在有符號和無符 號之間進行轉換: if((int)((unsigned)a + (unsigned)b) < 0) complain();

3.8 移位運算符

兩個原因會令使用移位運算符的人感到煩惱: 1. 在右移運算中,空出的位是用 0 填充還是用符號位填充? 2. 移位的數量允許使用哪些數? 第一個問題的答案很簡單,但有時是實現相關的。如果要進行移位的操作數是無符號的,會移入 0。如果操作數是帶符號的, 則實現有權決定是移入 0 還是移入符號位。如果在一個右移操作中你很關心空位,那麼用 unsigned 來聲明變數。這樣你就有權假設空位被設置為 0。 第二個問題的答案同樣簡單:如果待移位的數長度為 n,則移位的數量必須大於等於 0 並且嚴格地小於 n。因此,在一次單獨的操作中不可能將所有的位從變數中移出。 例如,如果一個 int 是 32 位,且 n 是一個 int,寫 n << 31 和 n << 0 是合法的,但 n << 32 和 n << -1 是不合法的。 注意,即使實現將符號為移入空位,對一個帶符號整數的右移運算和除以 2 的某次冪也不是等價的。 為了證明這一點,考慮(-1) >> 1 的值,這是不可能為 0 的。[譯註:(-1) / 2 的結果是 0。]