校長講堂第五講

  • 2020 年 4 月 10 日
  • 筆記

句法缺陷

要理解 C 語言程序,僅了解構成它的關鍵字是不夠的。還要理解這些關鍵字是如何構成聲明、表達式、語句和程序的。儘管我們可以很清楚的找到這些關鍵字的定義以及用法,但這些定義有時候是有悖於直覺的。 在這一節中,我們將着眼於一些不明顯句法構造。

2。1 理解函數聲明

我曾經和一些人聊過天,他們那時在書寫在一個小型的微處理器上單機運行的 C 程序。當這台機器的開關打開的時候,硬件會調用地址為 0 處的子序。

為了模仿電源打開的情形,我們要設計一條 C 語句來顯式地調用這個子程序。經過一些思考,我們寫出了下面的語句:

(*(void(*)())0)();

這樣的表達式會令 C 程序員心驚膽戰。但是,並不需要這樣,因為他們可以在一個簡單的規則的幫助下很容易地構造它:以你使用的方式聲明它。

每個 C 變量聲明都具有兩個部分:一個類型和一組對該類型求值的特定表達式。最簡單的表達式就是一個變量:

float f, g;

說明表達式 f 和 g(變量可以近似認為省略表達式)在求值的時候為float類型。由於待求值的時表達式,因此可以自由地使用圓括號:

float ((f));

這表示((f))為 float 類型,因此通過推斷,f 也是一個 float。 同樣的邏輯用在函數和指針類型。例如:

float ff();

表示表達式 ff()是一個 float,因此 ff 是一個返回一個 float 的函數。類似地,

float *pf;

表示*pf 是一個 float 並且因此 pf 是一個指向一個 float 的指針。

這些形式的組合聲明對表達式是一樣的。因此,

float *g(), (*h)();

表示*g()和(*h)()都是 float 表達式。

由於()比*綁定得更緊密,*g()和*(g())一樣,g是一個返回指 float 指針的函數,而 h 是一個指向返回 float 的函數的指針。

當我們知道如何聲明一個給定類型的變量以後,就能夠很容易地寫出一個類型的模型(cast):只要刪除變量名和分號並將所有的東西包圍在一對圓括號中即可。因此,由於

float *g();

聲明 g 是一個返回 float 指針的函數,所以(float *())就是它的模型。

有了這些知識的武裝,我們現在可以準備解決(*(void(*)())0)()了。 我們可以將它分為兩個部分進行分析。首先,假設我們有一個變量 fp,它包含了一個函數指針,並且我們希望調用 fp 所指向的函數。可以這樣寫:

(*fp)();

如果 fp 是一個指向函數的指針,則*fp 就是函數本身,因此(*fp)()是調用它的一種方法。(*fp)中的括號是必須的,否則這個表達式將會被分析為*(fp())。我們現在要找一個適當的表達式來替換 fp。

這個問題就是我們的第二步分析。如果 C 可以讀入並理解類型,我們可以寫:

(*0)();

但這樣並不行,因為*運算符要求必須有一個指針作為他的操作數。另外,這個操作數必須是一個指向函數的指針,以保證*的結果可以被調用。因此,我們需要將 0 轉換為一個可以描述「指向一個返回 void 的函數的指針」的類型。

如果 fp 是一個指向返回 void 的函數的指針,則(*fp)()是一個 void 值,並且它的聲明將會是這樣的:

void (*fp)();

因此,我們需要寫:

void (*fp)(); (*fp)();

來聲明一個啞變量。

一旦我們知道了如何聲明該變量,我們也就知道了如何將一個常數轉換為該類型:只要從變量的聲明中去掉名字即可。因此,我們像下面這樣將 0 轉換為一個「指向返回 void 的函數的指針」:

(void(*)())0

接下來,我們用(void(*)())0 來替換 fp:

(*(void(*)())0)();

結尾處的分號用於將這個表達式轉換為一個語句。

在這裡,我們就解決了這個問題時沒有使用 typedef 聲明。通過使用它,我們可以更清晰地解決這個問題: typedef void (*funcptr)(); (*(funcptr)0)();

2.2 運算符的優先級問題

假設有一個聲明了的常量 FLAG 是一個整數,其二進制表示中的某一位被置位(換句話說,它是 2 的某次冪),並且你希望測試一個整型變量 flags 該位是否被置位。通常的寫法是:

if(flags & FLAG) …

其意義對於很多 C 程序員都是很明確的:if 語句測試括號中的表達式求值的結果是否為 0。出於清晰的目的我們可以將它寫得更明確:

if(flags & FLAG != 0) …

這個語句現在更容易理解了。但它仍然是錯的,因為!=比&綁定得更緊密,因此它被分析為:

if(flags & (FLAG != 0)) …

這(偶爾)是可以的,如 FLAG 是 1 或 0(!)的時候,但對於其他 2 的冪是不行的。 假設你有兩個整型變量,h 和 l,它們的值在 0 和 15(含 0 和 15)之間,並且你希望將 r 設置為 8位值,其低位為 l,高位為 h。一種自然的寫法是:

r = h << 4 + 1;

不幸的是,這是錯誤的。加法比移位綁定得更緊密,因此這個例子等價於:

r = h << (4 + l);

正確的方法有兩種:

r = (h << 4) + l; r = h << 4 | l;

避免這種問題的一個方法是將所有的東西都用括號括起來,但表達式中的括號過度就會難以理解,因此最好還是是記住 C 中的優先級。

不幸的是,這有 15 個,太困難了。然而,通過將它們分組可以變得容易。

綁定得最緊密的運算符並不是真正的運算符:下標、函數調用和結構選擇。這些都與左邊相關聯。

接下來是一元運算符。它們具有真正的運算符中的最高優先級。由於函數調用比一元運算符綁定得更緊密,你必須寫(*p)()來調用 p 指向的函數;*p()表示 p 是一個返回一個指針的函數。轉換是一元運算符,並且和其他一元運算符具有相同的優先級。一元運算符是右結合的,因此*p++表示*(p++),而不是 (*p)++。

在接下來是真正的二元運算符。其中數學運算符具有最高的優先級,然後是移位運算符、關係運算符、 邏輯運算符、賦值運算符,最後是條件運算符。需要記住的兩個重要的東西是: 1. 所有的邏輯運算符具有比所有關係運算符都低的優先級。 2. 一位運算符比關係運算符綁定得更緊密,但又不如數學運算符。

在這些運算符類別中,有一些奇怪的地方。乘法、除法和求余具有相同的優先級,加法和減法具有相同的優先級,以及移位運算符具有相同的優先級。 還有就是六個關係運算符並不具有相同的優先級:==和!=的優先級比其他關係運算符要低。這就允許我們判斷 a 和 b 是否具有與 c 和 d 相同的順序,例如:

a < b == c < d

在邏輯運算符中,沒有任何兩個具有相同的優先級。按位運算符比所有順序運算符綁定得都緊密,每種與運算符都比相應的或運算符綁定得更緊密,並且按位異或(^)運算符介於按位與和按位或之間。

三元運算符的優先級比我們提到過的所有運算符的優先級都低。這可以保證選擇表達式中包含的關係運算符的邏輯組合特性,如:

z = a < b && b < c ? d : e

這個例子還說明了賦值運算符具有比條件運算符更低的優先級是有意義的。另外,所有的複合賦值運算符具有相同的優先級並且是自右至左結合的,因此

a = b = c

b = c; a = b;

是等價的。 具有最低優先級的是逗號運算符。這很容易理解,因為逗號通常在需要表達式而不是語句的時候用來替代分號。 賦值是另一種運算符,通常具有混合的優先級。例如,考慮下面這個用於複製文件的循環: while(c = getc(in) != EOF) putc(c, out); 這個 while 循環中的表達式看起來像是 c 被賦以 getc(in)的值, 接下來判斷是否等於 EOF 以結束循環。 不幸的是,賦值的優先級比任何比較操作都低,因此 c 的值將會是 getc(in)和 EOF 比較的結果,並且會被拋棄。因此,「複製」得到的文件將是一個由值為 1 的位元組流組成的文件。 上面這個例子正確的寫法並不難: while((c = getc(in)) != EOF) putc(c, out);

然而,這種錯誤在很多複雜的表達式中卻很難被發現。例如,隨 UNIX 系統一同發佈的 lint 程序通常帶有下面的錯誤行: if (((t = BTYPE(pt1->aty) == STRTY) || t == UNIONTY) {

這條語句希望給 t 賦一個值,然後看 t 是否與 STRTY 或 UNIONTY 相等。而實際的效果卻大不相同。

C 中的邏輯運算符的優先級具有歷史原因。B語言——C語言 的前輩,具有和 C 中的&和|運算符對應的邏輯運算符。儘管它們的定義是按位的 ,但編譯器在條件判斷上下文中將它們視為和&&和||一樣。當在 C 中將它們分開後,優先級的改變是很危險的。

2.3 注意標誌語句結束的分號

C 中的一個多餘的分號通常會帶來一點點不同:或者是一個空語句,無任何效果;或者編譯器可能提出一個診斷消息,可以方便除去掉它。一個重要的區別是在必須跟有一個語句的 if 和 while 語句中。考慮下面的例子:

if(x[i] > big); big = x[i];

這不會發生編譯錯誤,但這段程序的意義與:

if(x[i] > big) big = x[i];

就大不相同了。第一個程序段等價於:

if(x[i] > big) { } big = x[i];

也就是等價於:

big = x[i];(除非 x、i 或 big 是帶有副作用的宏)。

另一個因分號引起巨大不同的地方是函數定義前面的結構聲明的末尾[譯註:這句話不太好聽,看例子就明白了]。考慮下面的程序片段:

struct foo { int x; } f() { … }

在緊挨着f 的第一個}後面丟失了一個分號。它的效果是聲明了一個函數 f,返回值類型是 struct foo,這個結構成了函數聲明的一部分。如果這裡出現了分號,則 f 將被定義為具有默認的整型返回值。