2013年9月4日 星期三

從三元運算子講講 C/C++ 的運算式表達問題

在電腦科技氾濫的時代,幾乎所有的科技都用上了電腦,周遭所認識的朋友們大概也脫離不
了在自己的計畫案中,需要寫寫程式,但是除了使用程式庫,會呼叫 API 寫寫應用程式外
( 當然牽涉到特殊領域的 API 那還得要另外學習該領域相關知識 譬如說 UI 或 核心同步物件 )
大概很少人會實際認真的想想,真正基本面的功夫掌握得如何,這正是本篇要講的話題
今天就來講講 C/C++ 語言本身一些進階的主題。

構成程式語言最重要的基本元素之一當然就是 運算子 了,運算子就相當於英文裡面的 ABC
運算子將會構成程式語言的表達語句,這種語句就稱為運算式,讀者們都知道,一門語言
都會有一定的念法還有先後次序,這種觀念在程式語言裡面是一樣的想法,就是運算式究竟
要怎麼算,這個就是程式語言很重要基本屬性,優先次序結合方向

下面有一張表格列出了所有的運算子 C/C++ ,可以看到一共有 58 種運算子有 17 類


我自己發明了一套口訣的記法,只大略記憶每個大類之間互相的優先順序

範疇>單元>指向>算術>移位>比較>恆等>位元>邏輯>賦值>throw>逗號( , )

假如讀者對程式語言的語感夠好,其實這樣的優先次序設計是很自然的不太需要背
怎麼講呢,就拿 位元 來說,這一類裡面其實還細分了 AND XOR OR,其實 AND
就很像 10 進算術的 乘法,OR 就很類似 10 進算術的 加法,所以 XOR 就類似有乘有加
介於中間,讀者稍微感覺一下就會覺得先 AND 後 XOR 末 OR 這種設計是合理的選擇

那麼表格中的顏色是甚麼意思呢 ? 恩,這就是一種語感,我把認為類似相關的運算子標上
相同顏色當作同一組,基本上這個表格是要背起來放在腦袋裡面,各位可以注意到這邊
有一個相當重要的特性,只有類型 3 與 15 的運算子結合律是由 右到左 ,這邊就引出了
第一個問題,為什麼只有類型 3 與 15 的運算子結合律是由右到左呢 ?  這是因為這些運算
都具有 Assignment (賦值) 的特性。另外一點常常犯的錯誤就是使用移位運算子,也就是
<<>> ,移位運算子的運算次序其實比加減運算子的次序更低,所以常常寫程式要將高低
位元組合成一個 WORD 的時候常常就會忘記加上小括號改變運算次序

WORD wVal;
wVal = wVal << 8 + bLoVal ;  // 這是錯誤的運算式,少了小括號
wVal =(wVal << 8)+ bLoVal ;  // 這是正確的運算式

以上這個例子算簡單的問題,接下來讓我們看看常混淆的運算式運算規則,例如

y = 10*x--

套用前面學到的表格我們馬上就知道整個運算子的優先次序結合律,那編譯器當然也
一定知道阿,沒錯,編譯器會對這運算式進行掃描,並且用一些結構記錄這條運算式使用
到的運算子與其相關的特性,顯然編譯器知道如下運算子優先次序的事情:

1.  suffix --  ( 後置遞減 運算子 )
2.  *          ( 乘法         運算子 )
3.  =          ( 賦值         運算子 )

這邊就是很詭異的地方,也是大家時常一知半解的地方,稍有經驗的讀者們一定知道答案
透過經常撰寫程式的經驗知道答案就是  10*x 的值傳遞給 y 後,x 在自己減一,這邊問號
就出現了,不對阿,這樣不是就沒有依照運算優先次序表了嗎 ? 怎麼怪怪的呢 ? 其實,優
先次序完全正確,並沒有甚麼不對的地方,這邊第二個重點出現了

運算子的優先次序並不總是等於運算式的運算順序

原因就是因為運算子本身還有第三種屬性,一般都不會寫在次序表中,這種是該程式語言
本身的文法所管轄,就是說,運算子本身還有所謂的 運算子行為,當編譯器遇到
優先次序第一高的 suffix -- 的時候,編譯器會先去查閱該運算子有沒有特殊的行為需要處理
根據 C/C++ 文法告訴我們,suffix -- 的行為是必須整個運算式運算完畢後,自己才進行運算
所以 suffix -- 優先次序在這個例子中還是最高,編譯器根據該運算子行為就知道,必須先
計算其餘剩下的運算子,所以編譯器先把 10*x 先做計算,並且將值傳入 y,編譯器處理完
畢後,才對 x 進行減一的動作。當然沒有特殊的運算子行為時,大部分根據優先次序
結合律,即可對運算式進行正確的運算。假如能夠理解 優先次序並不等於運算順序,就可以
看看下面更容易混淆的混合三元運算子的運算式。

算式1:      val = a < b ? a++ : a = b

要研究上面這條運算式,就要先了解一點,原來 C 和 C++ 的文法其實並不全然相同
那所謂的文法是指甚麼呢 ? 文法指的就是 程式語言分解文法 ( Factored Language Grammar )
這個玩意相當重要,編譯器設計師就是依照著程式語言的分解文法來解讀一條運算式怎麼
進行全盤的完整運算,光只有運算子次序表是不夠完整表達運算式,因為語言敘述的部分
就不能用次序表來表達,對於 ?: 運算子的 FLG 規則,C 與 C++ 並不一樣

下面就列出一部分要討論的 C 文法定義 ( 完整的自己去看 K&R C 那本 A.13 Grammar )

(銜接 assignment-expression 上面的文法 請查閱 A.13 Grammar)
assignment-expression :
    conditional-expression
    unary-expression assignment-operator assignment-expression

assignment-operator : one of
    =  *=  /=  %=  +=  -=  <<=  >>=  &=  ^=  |=

conditional-expression :
    logical-OR-expression
    logical-OR-expression ? expression : conditional-expression
(銜接 conditional-expression 下面的文法 請查閱 A.13 Grammar)

差異就是在 C 版本的定義如上使用 conditional-expression
而 C++ 版本文法類似,但是卻使用 assignment-expression

注 : C++ 文法的部分可以查閱 EBNF Syntax: C++ (ISO/IEC 14882:1998(E))

C 版本:    logical-OR-expression ? expression : conditional-expression

C++ 版本:logical-OR-expression ? expression : assignment-expression

雖然它們的 FLG 並不一樣,但是運算子的優先次序還是得要根據次序表,先列出來看看
編譯器可能知道哪些資訊:

1. suffix ++
2. <
3. ?: = =          ( 有 兩個賦值運算子 與 一個 ?: )

後置單元運算子的特性剛剛解釋過了,編譯器知道它是最高優先次序,查詢了它的運算子
行為後知道這個運算子有整個運算式計算後,才對自己加一的特性,接著編譯器知道優先
次序第二高的是小於運算子,因此編譯器等一下知道要產生類似條件判斷的組合語言指令
來實現小於運算子的動作,前兩個都沒甚麼問題,問題就是最後第三個次序,這個牽涉到
存在有三元運算子整個運算式裡頭,三元運算子本身有隱含 賦值分支 兩種特性,要能
正確的解讀運算式 1,這個就是文法的用途,因為編譯器是利用 遞迴下降分析法 來解析
程式語言的語法,像運算式 1 的語法,編譯器就會根據文法不斷的遞迴分析直到遇見終端
符號,我們套用文法,把自己當編譯器分析看看這樣不同的定義會導致甚麼差異呢 ?

C 語言 :

logical-OR-expression 的文法我沒有列出,反正它的文法定義最終不斷遞迴下降最終會分析到
relational-expression,而 當碰到 a  b 這兩個變數就是終端了,不需要在遞迴下去了,因此

a < b 是屬於 logical-OR-expression 。
a++   我就不分析了 它就是  expression  沒甚麼好討論。

logical-OR-expression ? expression : conditional-expression
根據文法經過遞迴代換變成
logical-OR-expression ? expression logical-OR-expression

根據文法 C 語言遞迴代換變成了 conditional-expression 變成 logical-OR-expression
但是 a 本身已經就變數了,也就是說剛剛好就是終端,所以 logical-OR-expression
就是這個變數 a 本身了,所以整個運算順序用括號表達就可以看的很清楚了

val = ( ( a < b ) ? ( a++ ) : ( a ) ) = b  此運算式不會通過編譯,會產生錯誤 fail

剩下的就是交給次序表決定,因為 = 的結合律由右往左,所以整個運算 = b
那邊會先被處理,上面的運算式在展開過程中會隱含 a++ = b,但是呢 ~~~
這邊就出問題了,這個是非法的運算式,編譯器就會產生錯誤訊息,會產生
甚麼樣的錯誤讀者自己寫個小程式測試看看就知道了。

所以對於三元運算子,想要將它當一個快速的 if else 使用,當 a < b 不成立時,想把
b 的值賦予給 a 顯然對 C 語言來說這樣的語法不對,編譯器處理的方法跟人想的
運算方式並不一樣,所以沒把握的話就乾脆通通都用 if else 就不會有這種問題了
另外一種方法就是對 a = d 加上小括號改成 ( a = d )。

C++ 語言 : 
那 C++ 的部分就不多說廢話,反正文法給了,把自己當編譯器,手動遞迴換看看

logical-OR-expression ? expression : assignment-expression
代換成
logical-OR-expression ? expression : unary-expression assignment-operator assignment-expression
在代換成
logical-OR-expression ? expression : unary-expression = assignment-expression

所以在跟 運算式 1 的型式比較一下,對於 C++ 的部分

a < b 是屬於 logical-OR-expression 。
a++   它就是  expression  沒甚麼好討論。
assignment-operator 編譯器替換為 =

第三個就比較有趣了,因為變數 a 與 b 已經是終端,所以就沒有必要再繼續展開
unary-expression ( 想知道文法定義請自行查閱 ),所以最終的結果使得
編譯器知道三元運算子後面有一個賦值運算式,整個結果用括號表達就是

val = ( ( a < b ) ? ( a++ ) : ( a = b ) )  此運算式可通過編譯 ok

這樣的動作就是我們想要的啦,用 if-else 話語來寫虛擬碼就是

if ( a < b ){
    tmp = a++;
}else{
    a = b;
    tmp = a;
}

val = tmp ;

所以上面的第一個分支內運算式的 suffix ++ 還是遵守次序表,只是它自身的行為會導致
編譯器得讓 tmp = a 先運算,最後才把 a 自己加一,類似前面講的 y = 10*x-- 的行為,
三元運算子的動作編譯器會產生一個看不見的中間變數,用虛擬碼寫大概就像上面這樣。

最後就讓我們看實際編譯器 CL 產生的組合語言是甚麼樣子

    mov eax, DWORD PTR _a$[ebp]
    cmp eax, DWORD PTR _b$[ebp]
    jge SHORT $LN3@main             ; if a >=b goto $LN3@main

    mov ecx, DWORD PTR _a$[ebp
    mov DWORD PTR tv66[ebp], ecx    ; tv66 = a;

    mov edx, DWORD PTR _a$[ebp]
    add edx, 1
    mov DWORD PTR _a$[ebp], edx     ; a++;
    jmp SHORT $LN4@main             ; goto $LN4@main

$LN3@main:
    mov eax, DWORD PTR _b$[ebp]
    mov DWORD PTR _a$[ebp], eax     ; a = b

    mov ecx, DWORD PTR _a$[ebp]
    mov DWORD PTR tv66[ebp], ecx    ; tv66 = b

$LN4@main:
    mov edx, DWORD PTR tv66[ebp]
    mov DWORD PTR _val$[ebp], edx  ; val = tv66

可以看到跟上面虛擬碼的邏輯是等價的型式,但是使用互補的邏輯來表達,這種方法在組合
語言中很常見,你想到寫 < 的時候,組合語言講的是 假如  >= 則跳躍,因為沒有跳躍就表示
< 的情況發生了。

總結來講,雖然運算次序並不見得一定就能推論整個運算式最終的運算流程,但是一支完
整個程式語言的全部文法相當複雜,假如連最基本的運算次序表都不能熟練於心中,那麼
連最基本寫程式判斷運算式運算流程的能力都會沒有,編譯器不需要知道次序表,因為編譯
器直接知道完整的文法,程式語言的文法直接就隱含了運算次序,編譯器一路根據文法規則
遞迴下降分析就可以完成對整個語法的分析,人類不太可能去記憶完整的程式語言文法,在
加上常常也許要閱讀別人寫的程式,將運算次序表記於心中絕對有助於提升閱讀程式的能力
跟對程式語言的語感,基本上只要非上面介紹的這些情況,運算次序表所決定出來的運算次
序大部分都正確。

沒有留言:

張貼留言