浅析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语言中循环语句
怎样确定一次函数表达式