?

淺析C語言運算符及表達式的教學誤區

2019-04-08 00:46熊志斌
現代計算機 2019年6期
關鍵詞:逗號表達式副作用

熊志斌

(海南熱帶海洋學院藝術與創意學院,三亞572022)

0 引言

C語言是一門優秀的程序設計語言,以功能強大、語法靈活、易移植等特點而著稱。自上世紀70年代以來,C語言一直是最受歡迎的編程語言之一,廣泛用于系統應用開發。我國高校計算機專業把C語言作為編程入門課程,許多工科專業也把C語言作為必修課或選修課,因此提高C語言教學質量,具有重要的意義。筆者發現在一些教材、教改論文中把一些錯誤表達式當成正確的代碼來分析、講解,如下面類似的錯誤表達式,在許多教材、教改論文分析、討論,也出現在等級考試、企業招聘試題中:

其實,這些表達式都是錯誤的,會導致未定義行為(Undefined Behavior),C標準對未定義行為的解釋是,使用不可移植的或錯誤的程序結構,以及使用錯誤數據的行為,而標準對這種行為沒有強制性的要求[1-2]。C標準定義了很多未定義行為,表達式導致的未定義行為只是其中之一。C標準不要求編譯器負責診斷這種未定義行為,因此,一些未定義行為在編譯時能通過,程序也可以正確執行。正是在某些平臺能編譯執行這些錯誤代碼,才使得它們被當成正確的代碼被分析、被討論、被考試。但并不能保證這些代碼在不同編譯器中都能通過,也不能保證在不同平臺都可以正確執行。

把這些錯誤的表達式當成正確的代碼分析、講授,即不能使學生掌握正確的C語法知識,也不利于培養學生的優良編程風格。本文試圖從C標準出發,正本溯源,去蕪存菁,通過介紹序列點和副作用,澄清“表達式求值按運算符優先級順序從左到右計算,同級運算符按結合性方向計算”的模糊認識,剖析導致未定義行為的表達式的錯誤根源,以供C語言教學時參考。

1 相關的重要概念

與運算符及表達式相關的重要概念,除教材上介紹的運算符優先級和結合性外,C標準定義的副作用(Side Effects)和序列點(Sequence Point)也是不可或缺的兩個概念。教材出于內容簡單明了,易于學習的考慮,盡量回避復雜的概念,因此教材在介紹運算符及表達式相關知識時,基本回避了副作用和序列點,國內外的經典教材都是如此[3-4]。筆者在教學中發現,在講授運算符及表達式時,引入上述兩個概念是大有裨益的,能幫助學生正確地理解運算符及表達式的知識。

1.1 副作用

C標準對副作用的定義是,訪問易變(Volatile)型變量、修改變量、修改文件、以及調用執行前述操作的函數都是副作用[2]。副作用可以簡單理解成,作為表達式求值過程中的副產品,某些變量的值發生了修改[5]。例如表達式:

表達式(1)是一個算術表達式,在計算表達式a+b的值時,變量b的值也會發生修改,修改變量的值就是副作用。表達式(2)是一個賦值表達式,表達式的值為5,變量a的值被修改成5,也是發生副作用。副作用并非是不受歡迎的副產品,而是修改變量值的一個手段。

1.2 序列點

序列點是程序執行中的一個點,在這個點之前,前面的表達式的求值和副作用已經完成,而后面表達式的求值和副作用還沒有發生[2]。C標準定義以下序列點[2]:

(1)運算符&&;運算符||;逗號運算符,;條件運算符?:的第一個子表達式求值結束后;

(2)函數調用運算符()中對所有實參數完成求值之后;

(3)每個完整表達式結束時。完整表達式包括變量初始化表達式,表達式語句的表達式,return語句的表達式,if或switch語句中的控制表達式,while或do語句的控制表達式,for語句的所有三個表達式;

(4)標準庫函數返回之前,標準輸入輸出函數格式化轉換說明符關聯動作之后,標準查找函數和排序函數在調用比較函數之前和之后及參數傳遞之后.

由序列點的定義可知,與運算符&&;或運算符||;逗號運算符,;條件運算符?:等4個運算符的左操作數屬于前一個序列點,右操作數屬于后一個序列點,因此,這4個運算符的左操作數的求值要先于右操作數完成,本文第2節詳細分析這4個運算符。每個表達式語句后存在一個序列點,體現在程序里就是語句后的分號“;”是一個序列點。

2 深入理解操作數的求值順序

2.1 求值順序的不確定性

C標準規定,在兩個序列點之間,運算符的子表達式(操作數)的求值順序和副作用的發生順序,屬于未規定行為[2]。

C標準對未規定行為的定義是,標準提供了兩種及以上的可能性,但在具體實現中并未強制選擇哪種可能性[2]。C標準定義了很多未規定行為,對未規定行為采取何種可能的方式實現,取決于編譯器。未規定行為和未定義行為有本質區別的,未定義行為是一種嚴重的程序錯誤,而未規定行為是C標準有意為之,目的是給編譯器提供優化空間,以便提高C程序的效率。根據序列點的定義可知,除與運算符&&;或運算符||;逗號運算符,;條件運算符?:之外,其他雙目運算符,左右操作數的求值順序是不確定的,發生副作用的順序也是不確定的;在函數調用運算符()構成的表達式里,函數名和各實參的求值順序和副作用發生的順序也是不確定的。

2.2 存在序列點的表達式

與運算符&&;或運算符||;逗號運算符,;條件運算符?:;函數調用運算符()等5個運算符和操作數構成的表達式中存在序列點,者決定了它們的運算性質與其他運算符不同。

(1)邏輯與運算符及表達式

邏輯與運算符&&和操作數構成邏輯與表達式,一般形式為:

表達式1&&表達式2

邏輯與表達式的求值規則是:表達式1在&&左側,屬于前一個序列點,因此首先計算表達式1的值,如果表達式1的值為0,則提前終止表達式2的計算(邏輯與的短路計算性質),只有當表達式1的值為1時,才開始計算表達式2的值。例如:

(a+1)&&(--b>1)

當a的值為-1,b的值為2時,雖然表達式中自減運算符的優先級最高,但(a+1)在&&左側,屬于前一個序列點,因此首先計算(a+1)的值為0,可以判斷整個邏輯表達式的值為0,表達式--b>1的值來不及計算就被終止了,變量b的值保持不變。

(2)邏輯或運算符及表達式

邏輯或運算符||和操作數構成邏輯或表達式,一般形式為:

表達式1||表達式2

邏輯或表達式的求值規則是:表達式1在||左側,屬于前一個序列點,因此首先計算表達式1的值,如果表達式1的值為1,則提前終止表達式2的計算(邏輯或的短路計算性質),只有當表達式1的值為0時,才開始計算表達式2的值。例如:

(a>b)||(++b>1)

當變量a為2,變量b為1時,首先求a>b的值為1,則整個邏輯表達式的值為1,表達式++b>1來不及計算就被終止,變量b的值保持不變。

(3)條件運算符及表達式

條件運算符?:需要3個操作數構成條件表達式,一般構成形式:

表達式1?表達式2:表達式3

條件表達式的求值規則是:表達式1位于?號前,屬于前一個序列點,首先計算表達式1的值,若表達式1的值為真,則條件表達式的值取表達式2的值,表達式3不被執行;否則,條件表達式的值取表達式3的值,而表達式2不被執行。表達式2和表達式3只能執行其中的一個。例如:

i>j?2*k:++m

當i為2,j為1,k為3,m為4時,雖然乘法運算符和自增運算符的優先級都高于大于運算符,但表達式i>j屬于前一個序列點,因此首先求得表達式i>j的值為1,再執行表達式2*k,不執行表達式++m。所以條件表達式的值為6,m的值保持不變。

(4)逗號運算符及表達式

逗號作運算符需要兩個操作數構成逗號表達式,一般形式為:

表達式1,表達式2

逗號表達式求值規則是:表達式1位于逗號運算符之前,屬于前一個序列點,先求解表達式1,再求解表達式2,表達式2的值是整個逗號表達式的值。例如:

i=25+5,i*6

雖然表達式中乘法運算符*的優先級最高,但i=25+5位于逗號運算符之前,屬于前一個序列點,因此先計算賦值表達式i=25+5,得到i的值為30,然后計算i*6,得180,整個逗號表達式的值為180。

(5)函數調用運算符()

函數調用運算符的一般形式為:f(參數1,參數2,參數3)。對于函數調用運算符有兩點需要注意:一是參數之間的逗號,不是逗號運算符,是分隔符;二是函數名、各參數的求值順序是未規定行為,取決于編譯器的實現。有些參考書籍上說函數的各參數的求值順序是從左到右求值,這是不正確的,C標準本身沒有規定實參的求值順序。

2.3 序列點的好處

認真分析表達式的構成,充分利用表達式中序列點前表達式先計算的特性,可以寫出簡潔高效,風格優雅的C程序代碼。如程序需要從鍵盤讀取數字,直到用戶輸入0時為止,則可以寫成如下風格的代碼:

寫邏輯與表達式時,把最基本的條件放在第一個表達式,首先被執行,如果值為假,后面就不用計算。如防止除0運算:

a!=0&&b/a>5

這樣就可以避免當a值為0時,導致除0的異常。再如防止數組越界

i0

寫邏輯或表達式時,可以讓某些運算首先執行,增加程序的效率。如閏年的滿足條件是:能被4整除而不能被100整除,或者能被400整除??捎眠壿嫳磉_式來表示:

表達式(3)的執行效率比表達式(4)執行效率高。

3 表達式的求值過程

在計算表達式的值時,先根據運算符的優先級和結合性地解析表達式,無論多復雜的一個表達式,經過優先級和結合性對表達式進行解析后,最終都可以解析成某個基本運算符表達式結構(如屬于賦值表達式、條件表達式)。判斷依據就是表達式中最外層執行的運算符。解析過程中,從左到右利用運算符的優先級構成子表達式,同級運算符用結合性構成子表達式。

表達式(5)等價于(x>y)?++y:((++y>2)?y:100),所以表達式(5)是一個條件表達式。

解析成基本運算符表達式結構后,考察基本運算符是否構成序列點,按序列點的前后順序計算子表達式。C語言中只有與運算符&&;或運算符||;逗號運算符,;條件運算符?:;函數調用運算符()等5個基本運算符構成的表達式中存在序列點,因此這些表達式求值,總是先執行序列點之前的子表達式,然后執行序列點之后的子表達式。其他基本運算符構成的表達式不存在序列點,子表達式求值順序和副作用發生順序則是不確定的。

表達式(5)中,條件運算符雖然是右結合性,但并不是先計算子表達式((++y>2)?y:100),而是先計算屬于前一個序列點的子表達式(x>y)。當x值為1,y值為1時,條件表達式的值為100;當x值為2,y值為1時,表達式的值為2。

4 導致未定義行為的表達式

C標準定義了很多未定義行為,對于表達式而言,標準規定,兩個序列點之間,一個變量被多次修改,或被修改一次同時發生了不是為了存儲而讀取變量的操作,會導致未定義行為[2]。

(1)變量被修改多次

上面兩個表達式的變量都發生多次修改,因此是未定義行為。變量修改的時間點是不確定的,這種不確定導致表達式結果不確定。

(2)變量被修改一次同時發生非存儲性的讀取操作

如表達式:

x++*y+x/2

表達式中變量x被修改了一次,同時變量x被讀取參與子表達式x/2的求值,因此是未定義行為。x++自增運算符的副作用在何時發生,是不確定的,這種不確定影響子表達式x/2的求值不確定,導致表達式結果不確定。

又如表達式:

printf("%d %d",a++,a+5)

在函數實參求值完成時是一個序列點,但在此序列點前,實參的求值的順序是不確定的,第二個參數a++和第三個參數a+5求值順序不一樣,傳入的實參是不一樣的,顯示的結果不確定,表達式導致未定義行為。

又如表達式:

a[i]=i++

變量i發生一次修改,同時i被讀取參與a[i]求值,因此是未定義行為。對于賦值運算符兩端的操作數,是先計算a[i]還是先計算i++,是不確定的,當先計算i++時,如果i++的副作用在計算a[i]之前生效,則變成給a[i+1]賦值。

本質上說,表達式導致未定義行為是由于副作用使得子表達式之間存在計算順序上的依賴關系,而副作用的發生順序和子表達式的計算順序是不確定的,導致表達式的結果也不確定。因此,對導致未定義行為的表達式,只需增加臨時變量和語句來破解這種計算順序上的依賴關系就行了。如:

a[i]=i++;

可以寫成兩條語句

a[i]=i;i++;

5 結語

通過介紹C標準中的序列點和副作用兩個概念,以及兩個序列點之間子表達式求值順序和副作用發生順序的不確定性的規定后,學生就不會產生“表達式求值是按運算符優先級從左到右計算,同優先級運算符按結合性方向計算”的似是而非的認識,也很容易判斷出表達式是否會導致未定義行為,避免模仿別人寫出錯誤的表達式。其實,在C89標準中就有副作用和序列點的概念介紹,但國內外教材基本在回避這兩個概念,國內教材甚至以訛傳訛,把錯誤的表達式當正確的代碼分析講授。這說明在C語言教學活動中,教師應該勤查權威的C標準,深入理解C之精髓,提高甄別錯誤、駕馭教材的能力,去蕪存菁,向學生傳授正確的知識。

猜你喜歡
逗號表達式副作用
徐長風:核苷酸類似物的副作用
既有建筑結構鑒定表達式各分項系數的確定分析
逗號
靈活選用二次函數表達式
逗號里的奧秘
藥物副作用,到底怎么解?
安眠藥可以這樣吃
自傲的逗號
議C語言中循環語句
怎樣確定一次函數表達式
91香蕉高清国产线观看免费-97夜夜澡人人爽人人喊a-99久久久无码国产精品9-国产亚洲日韩欧美综合