註:我個人偏好將 opearor 譯為『運算器』, 一般譯法為『運算子』。有興趣考古的可以參考〈考古–operator 為什麼翻譯為『運算子』〉
補充說明:本文經網友指正後修訂計算順序的細節, 修訂後文章疏漏謬誤處責任在我, 與網友無關, 感謝大家的指導。
C 語言因為是誕生在電腦資源相對昂貴的時代, 因此把程式寫得簡潔、讓程式碼執行得更有效率, 是 C 語言世界中極受推崇的做法, 尤其 C 語言原本是為了開發 UNIX 而設計, 你總不會希望光是作業系統跑起來就佔掉一大堆記憶體, 使用者的應用程式跑不起來吧?
不過這樣的程式風格造就了許多簡潔但很難理解的程式碼, 像是之前C/C++ 的條件判斷一文就是比較簡單的例子, 能不用關係運算器就不用, 你必須自行判斷條件運算式結果是不是 0 才能了解程式的流程。
C 運算器 (operator) 的四要素--優先順序、運算結果、副作用與結合性
撰寫運算式也是另一個你會遇到 C 語言本格派簡潔風格的地方, 遇到這種狀況, 只要掌握運算器的四要素, 就可以弄清楚編譯器是如何解譯運算式了, 這四要素就是:
-
優先順序 (operator precedence):運算式中的運算元左右都有運算器時, 例如
1 + 2 * 3
, 其中的2
到底是要給左邊的+
還是右邊的*
呢?C 語言中除了我們熟悉的先乘除後加減四則運算外, 還有各式各樣的運算, 如果不熟悉優先順序, 結果往往出人意料。 -
運算結果:每種運算都會產生運算結果, 並將運算結果代回運算式中繼續運算, 像是剛剛提到的關係運算, 如果不知道它的運算結果只有 0 或 1, 就無法理解
(a==10)*b
會算出什麼? -
副作用 (side effect):有些運算除了運算結果外, 還具有副作用, 像是
++
, 就會改變運算元的內容, 如果同一個運算元出現在運算式中多次, 就有可能會有問題。 -
結合性 (Associativity):運算元的左右兩邊是同一個或相同優先順序的運算器時, 像是
6 / 3 * 2
, 其中的3
是要給左邊的/
還是右邊的*
呢?
以下先將常見的運算器整理成表格, 稍後會挑幾個運算器當例子, 說明如何依據四要素解譯運算式:
優先權 | 運算器 | 運算結果 | 副作用 | 結合性 |
---|---|---|---|---|
1 | 後置 ++ -- |
運算元自身 | 遞增/減運算元 | 由左至右 |
1 | () |
函式傳回值 | 執行函式造成的副作用 | 由左至右 |
1 | [] |
陣列元素 | 無 | 由左至右 |
1 | . |
結構或 union 的欄位 | 無 | 由左至右 |
1 | -> |
依位址指向結構或 union 的欄位 | 無 | 由左至右 |
1 | (型別){資料清單} |
以字面值建立的複合結構或陣列 | 無 | 由左至右 |
2 | 前置 ++ -- |
遞增/減運算元後的值 | 遞增/減運算元 | 由右至左 |
2 | 單元的 + - |
加正負號後的值 | 無 | 由右至左 |
2 | ! ~ |
邏輯/位元反向值 | 無 | 由右至左 |
2 | * |
依位址指向的位置 | 無 | 由右至左 |
2 | & |
取得位址 | 無 | 由右至左 |
2 | sizeof |
運算元佔的位元組數 | 無 | 由右至左 |
3 | (型別) |
轉型值 | 無 | 由右至左 |
4 | * / % |
兩運算元值相乘、相除、取餘數 | 無 | 由左至右 |
5 | + - |
兩運算元值相加、減 | 無 | 由左至右 |
6 | << >> |
將運算元值左/右移動指定位元數後的結果 | 無 | 由左至右 |
7 | < <= > >= |
比較關係, 成立時 1, 否則 0 | 無 | 由左至右 |
8 | == != |
比較關係, 成立時 1, 否則 0 | 無 | 由左至右 |
9 | & |
位元 AND 運算值 | 無 | 由左至右 |
10 | ^ |
位元 XOR 運算值 | 無 | 由左至右 |
11 | | |
位元 OR 運算值 | 無 | 由左至右 |
12 | && |
邏輯 AND 運算值, 真為 1、僞為 0 | 無 | 由左至右 |
13 | || |
邏輯 OR 運算值, 真為 1、僞為 0 | 無 | 由左至右 |
14 | ?: |
? 前的運算結果為 0 時為 : 右邊運算結果, 否則為 : 左邊運算結果 | 無 | 由右至左 |
15 | = |
右邊運算結果 | 左運算元被更改為 右側運算結果 | 由右至左 |
15 | += -= *= /= %= <<= >>= &= ^= |= |
複合運算的結果 | 左運算元被更改為 複合運算結果 | 由右至左 |
16 | , |
右側運算結果 | 無 | 由左至右 |
++/--
接著我們就以遞增運算器為例, 說明如何透過前述的四要素來解讀運算式, 請看以下範例:
#include <stdio.h>
int a = 10;
int b;
int main(void) {
printf("%d\n", b = a+++3);
printf("b = %d, a = %d\n", b, a);
}
其中 a+++3
因為都沒有空格, 所以 C 編譯器會從左至右解譯, 嘗試找出最長字元 (maximum munch)的運算器, 因此會找出 ++
以及剩下的 +
, 等同於:
b = a++ + 3
下一步就是依據前述表格幫個別運算器標示優先順序:
15 1 5
b = a++ + 3
根據優先順序, 我們可以加上小括號明確分配運算元:
15 1 5
b = ((a++) + 3)
首先最內層的 a++
, 根據前一節的表格, 後置的 ++
運算結果就是運算元本身, 所以等同於:
b = (10 + 3)
接著計算加法, 變成:
b = 13
最後計算 =
, 根據前一節的表格, 它的運算結果就是右邊運算式的運算結果, 所以整個 b=a+++3
的運算結果就是 11, 而因為 =
與 ++
的副作用, b
與 a
的值個別是 13 與 11, 程式列印的結果如下:
13
b = 13, a = 11
運算元的條件
有些運算器因為它的特性使然, 只能接受特定的運算元, 否則無法運算, 請看以下例子:
#include <stdio.h>
int main(){
int b;
int a = 10;
printf("%d\n", b=a++++3);
return 0;
}
我們只是在剛剛的範例多加了一個加號, 但是編譯結果就會出錯:
test.c: In function 'main':
test.c:7:23: error: lvalue required as increment operand
7 | printf("%d\n", b=a++++3);
| ^~
test.c:7:25: error: expected ')' before numeric constant
7 | printf("%d\n", b=a++++3);
| ^
| )
編譯器說在第 3 個 +
這裡需要一個 lvalue
來當遞增運算的運算元, 什麼是 lvalue
稍後會說明, 我們先來解析整個運算式。底下是解譯運算式的結果, 因為有 4 個連續的 +
, 所以找出字數最長的可能運算器就會解譯成是連續 2 個遞增的 ++
:
b = a++++ 3
接著標示優先順序:
15 1 1
b = a++++ 3
等同於以下加上小括號的運算式:
b = ((a++)++) 3
先進行最內層的 a++
運算, 運算結果為 10:
b = (10++) 3
接著要運算 10++
就會出問題, 還記得後置的 ++
有副作用, 會去遞增運算元的內容, 但是這裡 10 並不是像是變數那樣的容器, 無法遞增內容。當編譯器發現這樣的狀況時, 就會引發編譯錯誤。這種可以儲存並變更內容的容器, 就是編譯錯誤訊息中所說的 lvalue
, 你可以把它當成可以放在 =
運算器左側的運算元就是 lvalue
。
另外, 你也可以看到最右側的 3
變成是多餘的文字, 所以編譯器還有發出另一個錯誤訊息, 認為在這個 3
前面就應該要出現 ')' 結束 printf()
。
使用空格明確分割運算器
實際上如果寫出這樣包含一連串 +
的運算式, 應該會被閱讀程式昏頭的人咒罵, 最好是加上必要的空格, 讓運算式的語意明確, 並且符合語法, 例如:
#include <stdio.h>
int main(){
int b;
int a = 10;
printf("%d\n", b=a++ + +3);
return 0;
}
由於加上了空格, 編譯器會依照空格切割, 運算式就被解譯成:
15 1 5 2
b = a++ + +3
也就是:
b = ((a++) + (+3))
也就是:
b = ((10) + (+3))
↓
b = (10 + 3)
↓
b = 13
↓
13
使用小括號明確指定表達運算式
大部分的人應該不會想要把前面的表格背下來, 也不會想每次寫個運算式就要查表, 那最簡單不出錯的方式就是善用小括號, 讓運算式照你指定的順序運算, 像是以下這個例子:
#include <stdio.h>
int main(){
int a = 10, b = 8;
printf("%d\n", a == 10 * b);
return 0;
}
如果原意是想依據 a == 10
的判斷結果來印出 b 或是 0 的話, 結果卻完全不是這麼一回事, 解析一下就知道, 先加上優先順序:
8 4
a == 10 * b
所以運算過程是:
a == (10 * b)
變成判斷 a
和 10 * b
是否相等, 10 與 80 不相等, 運算結果為 0, 因此執行結果為:
0
只要使用小括號改寫程式, 就可以得到我們需要的結果:
#include <stdio.h>
int main(){
int a = 10, b = 8;
printf("%d\n", (a == 10) * b);
return 0;
}
執行結果就會是:
8
喔, 對了, 這個運算式看起來好像很厲害, 實務上也真的有人寫出類似這樣的運算式, 但是實在沒那麼直覺好懂, 我建議至少換成:
#include <stdio.h>
int main(){
int a = 10, b = 8;
printf("%d\n", (a == 10) ? b : 0);
return 0;
}
或是更直白一點, 改成 if...else...
多行一點也沒關係:
#include <stdio.h>
int main(){
int a = 10, b = 8;
if(a == 10)
printf("%d\n", b);
else
printf("0\n");
return 0;
}
利用串接運算器同時設定多個變數的值
你可能看過這樣的程式碼:
#include <stdio.h>
int main(){
int a, b, c;
a = b = c = 10;
printf("%d %d %d\n", a, b, c);
return 0;
}
程式中的 a = b = c = 10;
就是利用 =
運算器的運算結果與結合性同時設定初值給多個變數。由於 =
具有由右至左的結合性, 因此上述運算式等同於:
a = (b = (c = 10))
=
的運算結果就是右邊運算式的運算結果, 所以運算過程為:
a = (b = (c = 10))
↓
a = (b = 10)
↓
a = 10
↓
10
加上 =
的副作用會更改左邊的運算元內容, 所以運算結束後, 就等同於將運算式中的所有變數都設定為相同的值了。
運算式的實際計算順序--順序點 (sequence point)
有一點要特別留意的是透過優先順序與結合性可以解譯運算式的語意, 但是實際的計算順序卻有可能會和你想的不一樣。
舉例來說, C 語言並沒有規範加法運算的兩個運算元哪一邊要先計算, 編譯器的實作不一定是我們直覺想像的左邊先計算。C 語言也沒有規範遞增/遞減運算的副作用要立即生效, 有的編譯器會在計算遞增遞減運算結果後就立刻變更變數值、但有的編譯器卻是在整個運算式計算結束後才變更變數值。
C 語言只有針對副作用規範順序點 (sequence point), 程式中的許多地方會被視為是順序點, 當程式執行到這個地方時, 前一個順序點之後到該處之間的所有副作用都要生效, 在這個順序點之後的任何副作用也不能提前發生。舉例來說, 叫用函式時在計算出所有的參數值後就是一個順序點, 計算參數過程中的副作用都要在這個時間點生效, 以底下這個程式為例:
#include <stdio.h>
int main(){
int b;
int a = 10;
printf("%d\n", b=a++ + a);
return 0;
}
如果你希望的是將 b
設定為 (a + 1) + a
, 並且同時將 a
遞增 1, 那可能就要碰運氣, 看看你用的是哪一種編譯器了。以下是 gcc 12.1 採用 C17 標準與開啟所有警告 (-std=c17 -Wall) 的編譯結果:
<source>: In function 'main':
<source>:7:25: warning: operation on 'a' may be undefined [-Wsequence-point]
7 | printf("%d\n", b = a+++a);
編譯結果會出現警告, 這是因為 b =a++ + a
等同於 b = (a++) + a
, 如同前面所說, +
兩邊的 a++
和 a
誰先計算並沒有一定的規則、a++
的副作用是否要立即生效也沒有規範, 只能確認在進入 printf()
函式前的順序點一定會生效, 因此這個運算式的計算結果就會依照編譯器實作的不同而異, 沒有一定, C 語言將這種沒有標準結果的狀況稱為 "undefined behavior"(未定義的行為, 簡稱 UB), 這就是警告訊息中 "undefined" 的意思。gcc 必須啟用 -Wsequence-point
或是 -Wall
選項才會將順序點造成的 UB 視為警告, 既然是警告, 就還是可以執行, 執行後的結果如下:
21
可見 gcc 編譯器的計算順序是加法的左邊先算, 而且計算遞增運算的結果後就立刻變更了變數內容, 過程如下:
b =a++ + a
↓
b = (a++) + a
↓
b = (10) + 11
↓
b = 21
↓
21
以下則是 MSVC V19 一樣採 C17 標準並開啟所有警告 (/std:c17 /Wall) 的編譯結果:
C:/WinSdk/Include/10.0.18362.0/ucrt\stdio.h(948): warning C4710: 'int printf(const char *const ,...)': function not inlined
C:/WinSdk/Include/10.0.18362.0/ucrt\stdio.h(948): note: see declaration of 'printf'
雖然有警告, 但它並沒有對順序點造成的 UB 提出警告, 而是對 printf
函式的宣告提出警告, 這和我們主題無關, 忽略不看。實際執行結果為:
20
如果進一步觀察編譯器產生的組合語言程式碼, 會發現它的副作用是在運算式計算完畢後才生效, 和 gcc 的順序並不相同。如果你有興趣比較不同編譯器的結果, 可以使用我在Compiler Explorer 網站上建好的範例。
對於會產生 UB 的程式, 雖然可以執行, 但我們不應該依賴特定編譯器的實作方式, 除了在不同平台時可能會因為編譯器實作的差異而得到不同的結果外, 每個人的解讀也可能不同。實務上應該避免 UB, 改成明確語意的寫法, 例如:
#include <stdio.h>
int main(){
int b;
int a = 10;
b = a++;
b += a;
printf("%d\n", b);
return 0;
}
最簡單的原則就是在同一個運算式中, 不要變更、讀取同一個變數的值, 也不要修改同一個變數多次。
有關順序點的細節, 有機會再另外撰寫文章說明。
結語
本文透過幾個代表性的範例, 說明運算式的解譯方式, 主要希望讓大家瞭解只要掌握個別運算器的優先順序、運算結果、副作用與結合性這四大要素, 即使遇到再奇怪的運算式, 都能夠知道它的語意, 不會再看不懂, 頻頻得到令你驚訝的結果了。
Top comments (0)