《C语言程序设计:现代方法(第2版)》第4章 表达式
第4章 表达式
计算器不能让我们学会算术,只会使我们忘记算术。
C语言的一个特点就是它更多地强调表达式而不是语句,表达式是表示如何计算值的公式。最简单的表达式是变量和常量。变量表示程序运行时需要计算的值,常量表示不变的值,更加复杂的表达式把运算符用于操作数(操作数自身就是表达式)。在表达式a+(b*c)
中,运算符+用于操作数a
和(b*c)
中,而这两者自身又都是表达式。
运算符是构建表达式的基本工具,C语言拥有异常丰富的运算符。首先,C提供了基本运算符,这类运算符在大多数编程语言中都有。
- 算术运算符,包括加、减、乘和除。
- 关系运算符进行诸如“i比0大”这样的比较运算。
- 逻辑运算符实现诸如“i比0大并且i比10小”这样的关系运算。
但是C语言不只包括这些运算符,还提供了许多其他运算符。事实上,运算符非常多,我们需要在本书的前20章中逐步进行介绍。虽然掌握如此众多的运算符可能是一件非常烦琐的事,但这对于成为C语言专家是特别重要的。
本章将涵盖一些C语言中最基础的运算符:算术运算符(4.1节)、赋值运算符(4.2节)和自增及自减运算符(4.3节)。4.1节除了讨论算术运算符外,还解释了运算符的优先级和结合性,这两个特性对含有多个运算符的表达式而言非常重要。4.4节描述C语言表达式的亲值方法。最后,4.5节介绍表达式语句,即一种允许把任何表达式都当作语句来使用的特性。
4.1 算术运算符
算术运算符是包括C语言在内的许多编程语言中都广泛应用的一种运算符,这类运算符可以执行加法、减法、乘法和除法。表示4-1展示了C语言的算术运算符。
加法类运算符和乘法类运算符都属于二元运算符,因为它们需要两个操作数。一元运算符只需要一个操作数。
i = +1; /* + used as a unary operator */
j = -i; /* - used as a unary operator */
一元运算符+什么都不做;实际上,经典C中甚至不存在这种运算符。它主要用于强调某数值常量是正的。
二元运算符或许看上去很熟悉,只有求余运算符%可能例外,i%j的值是i除以j后的余数。例如10%3的值是1,而12%4的值是0。
除%运算符以外,表4-1中的二元运算符既允许操作数是整数也允许操作数是浮点数,两者混合也是可以的。当把int型操作数和float型操作数混合在一起时,运算结果是float型的。因此9+2.5f的值为11.5,而6.7f/2的值为3.35。
运算符/和运算符%需要特别注意以下几点。
- 运算符/可能产生意外的结果。当两个操作数都是整数时,运算符/会丢掉分数部分来“截取”结果。因此,1 / 2的结果是0而不是0.5。
- 运算符%要求操作数是整数。如果两个操作数中有一个不是整数,程序将无法编译通过。
- 把零用作/或%的右操作数会导致未定义的行为(4.4节)。
- 当运算符/和运算符%用于负操作数时,其结果难以确定。根据C89标准,如果两个操作数中有一个为负数,那么除法的结果既可以向上取整也可以向下取整。(例如,-9/7的结果既可以是-1也可以是-2。)在C89中,如果i或者j是负数,i&j的符号与具体实现有关。(例如,-9%7的结果是-1),i%j的值的符号与i的相同(因此-9%7的值是-2)。
“由实现定义”的行为
术语由实现定义(implementation-defined)出现频率很高,因此值得花些时间讨论一下。C标准故意对C语言的部分内容未加指定,并认为其细节可以由“实现”来具体定义。所谓实现是指程序在特定的平台上编译、链接和执行所需要的软件。因此,根据实现的不同,程序的行为可能会稍有差异。C89中运算符/和运算符%对负操作数的行为就是一个由实现定义行为的例子。
留下语言的一部分内容未加指定看起来可能有点奇怪,甚至很危险,但这正反映了C语言的基本理念。C语言的目标之一是高效,这常常意味着要与硬件行为相匹配。-9除以7时,有些CPU产生的结果是-1,有些为-2。C89标准简单地反映了这一现实。
最好避免编写依赖于由实现定义的行为的程序。如果不可能做到,起码要仔细查阅手册——C标准要求在文档中说明由实现定义的行为。
运算符的优先级和结合性
当表达式包含多个运算符时,其含义可能不是一目了然的。例如,表达式i+j*k
是“i加上j,然后结果再乘以k”还是j乘以k,然后加上i“呢?解决这个问题的一种方法就是添加圆括号,写为(i+j)*k
或者i+(j*k)
。作为通用规则,C语言允许在所有表达式中用圆括号进行分组。
可是,如果不使用圆括号结果会如何呢?编译器是把表达式i+j*k
解释为(i+j)*k
还是i+(j*k)
?和其他许多语言一样,C语言采用运算符优先级(operator precedence)
规则来解决这种隐含的二义性问题。算术运算符的相对优先级如下:
最高优先级:+ -
(一元运算符)
* / %
最低优先级:+ -
(二元运算符)
当两个或更多个运算符出现在同一个表达式中时,可以通过按运算符优先级从高到低的次序重复给予表达式添加圆括号来确定编译器解释表达式的方法。下面的例子说明了这种结果:
i + j * k
等价于 i + ( j * k )
-i * -j
等价于( -i ) * ( -j )
+i + j / k
等价于( +i ) + ( j / k )
当表达式包含两个或更多个相同优先级的运算符时,仅有运算符优先级规则是不够用的。这种情况下,运算符的结合性(associativity)
开始发挥作用。如果运算符是从左向右结合的,那么称这种运算符是左结合的(left associative)
。二元算术运算符(既*、/、%、+和-
)都是左结合的,所以
i - j - k
等价于 (i - j) - k
i * j / k
等价于(i * j)/k
如果运算符是从右向左结合的,那么称这种运算符是右结合的(right associative)
。一元运算符(+ 和 - )都是右结合的,所以
- + i
等价于 -(+i)
在许多语言(特别是C语言)中,优先级和结合性规则都是非常重要的。然而,C语言的运算符太多了(差不多50种),很少有程序员愿意记住这么多优先级和结合性规则。程序员在有疑问时会参考运算符表(附录A),或者加上足够多的圆括号。
程序:计算通用产品代码的校验位
美国和加拿大的货物生产商都会在超市销售的每件商品上放置一个条形码。这种被称为通用产品代码(Universal Product Code,UPC)
的条形码可以识别生产商和产品。每个条形码表示一个12位的数,通常这个数会打印在条形码下面。例如,下的条形码来自Stouffer's法式面包腊肠比萨的包装:
数字0 13800 15173 5出现在条形码的下方。第1个数字表示商品的种类(大部分商品用0或7表示,2表示需要称量的商品,3表示药品或与健康相关的商品,而5表示赠品)。第一组5位数字用来标识生产商(13800是雀巢美国的冰冻食品公司的代码)。第二组5位数字用来标识产品(包括包装尺寸)。最后一位数字是”校验位“,它唯一的目的是用来帮助识别前面数字中的错误。如果条形码扫描出现错误,那么前11位数字可能会和最后一个数字不匹配,超市扫描机将拒绝整个条形码。
下面是一种计算校验位的方法:首先把第1位、第3位、第5位、第7位、第9位和第11位数字相加;然后把第2位、第4位、第6位、第8位和第10位数字相加;接着把第一次加法的结果乘以3,再和第二次加法的结果相加;随后再把上述结果减去1;相减后的结果除以10取余数;最后用9减去上一步骤中得到的余数。
还用Stouffer's的例子,我们 由0+3+0+1+1+3得到第一个和8,由1+8+0+5+7得到第二个和21。把第一个和乘以3后再加上第二个和得到45,减1得到44。把这个值除以10取余数为4。再用9减去余数4,结果为5。下面还有两个通用产品代码,试着手工算出各自的校验位(不要去厨房找答案):
Jif牌奶油花生黄油(18盎司): 0 51500 24128 ?
Ocean Spray牌蔓越桔果酱(8盎司): 0 31200 01005 ?
答案在本页最下面。
下面编写一个程序来计算任意通用产品代码的校验位。要求用户录入通用产品代码的前11位数字,然后程序显示出相应的校验位。为了避免混淆,要求用户分3部分录入数字:左边的第一个数字、第一组5位数字以及第二组5位数字。程序会话的形式如下所示:
Enter the first (single) digit:0
Enter first group of five digits:13800
Enter second group of five digits:15173
Check digit: 5
程序不是按一个五位数
来读取每组5位数字的,而是将它们读作5个一位数。把数看成一个个独立的数字进行读取更为方便,而且也无需担心由于五位数过大而无法存储到int型变量中。(某些编译器限定int型变量的最大值是32 767。)为了读取单个数字,我们使用带有%1d转换说明的scanf函数,其中%1d匹配只有一位的整数。
/**
* Computes a Universal Product Code check digit
*/
#include <stdio.h>
int main() {
int d, i1, i2, i3, i4, i5, j1, j2, j3, j4, j5,
first_sum, second_sum, total;
printf("Enter the first (single) digit:");
scanf("%1d", &d);
printf("Enter first group of five digits:");
scanf("%1d%1d%1d%1d%1d", &i1, &i2, &i3, &i4, &i5);
printf("Enter second group of five digits:");
scanf("%1d%1d%1d%1d%1d", &j1, &j2, &j3, &j4, &j5);
first_sum = d + i2 + i4 + j1 + j3 + j5;
second_sum = i1 + i3 + i5 + j2 + j4;
total = 3 * first_sum + second_sum;
printf("Check digit: %d\n", 9 - ((total - 1) % 10));
return 0;
}
注意,表达式9 - ((total - 1) % 10)
可以写成9 - (total - 1) % 10
,但是额外的圆括号可使其更容易理解。
4.2 赋值运算符
求出表达式的值以后常常需要将其存储到变量中,以便将来使用。C语言的 =(简单赋值(simple sassignment)
)运算符可以用于此目的。为了更新已经存储在变量中的值,C语言还提供了一种复合赋值(compound assignment)
运算符。
4.2.1 简单赋值
表达式v=e的赋值效果是求出表达式e的值,并把此值复制给v。如下面的例子所示,e可以是常量、变量或更为复杂的表达式:
i = 5; /* i is now 5 */
j = i; /* j is now 5 */
k = 10 * i + j; /* k is now 55 */
如果v和e的类型不同,那么赋值运算符发生时会把e的值转化为v的类型:
int i;
float f;
i = 72.99f; /* i is now 72 */
f = 136; /* f is now 136.0 */
类型转换的问题(7.4节)以后讨论。
在许多编程语言中,赋值是语句
;然而,在C语言中,赋值就像+那样是运算符
。换句话说,赋值操作参数结果,这就如同两个数相加产生结果一样。赋值表达式v=e的值就是赋值运算后v的值。因此,表达式i = 72.99f
的值是72(不是72.99)。
副作用
通常我们不希望运算符修改它们的操作数,数学中的运算符就是如此。表达式 i + j不会改变i或j的值,只是计算出i加j的结果。
大多数C语言运算符不会改变操作数的值,但是也有一些改变,由于这类运算符所做的不再仅仅是计算出值,所以称它们有副作用(side effect)。简单赋值运算符是已知的第一个有副作用的运算符,它改变了运算符的左操作数。表达式i = 0求值产生的结果为0,并(作为副作用)把0赋值给i。
副作用
既然赋值是运算符,那么多个赋值可以串联在一起:
i = j = k =0;
运算符=是右结合的,所以上述赋值表达式等价于
i = (j = (k = 0 ));
作用是先把0赋值给k,再赋值给j,最后再赋值给i。
注意由于存在类型转换,串在一起的赋值运算的最终结果可能不是预期的结果:
int i;
float f;
f = i = 33.3f;
首先把数值33赋值给变量i,然后把33.0(而不是预期的33.3)赋值给变量f。
通常情况下,可以使用v类型值的地方都可以进行形如v=e的赋值。在下面的例子中,表达式j = i把i的值复制给j,然后j的新值加上1,得到k的新值:
i = 1;
k = 1 + (j = i);
printf("%d %d %d\n", i, j, k); /* prints “1 1 2” */
但是,按照上述这种形式使用赋值运算符通常不是一个好主意。其一,“嵌入式赋值”不便于程序的阅读;其二,在4.4节我们将会看到,这样做也会使隐含错误的根源。
4.2.2 左值
大多数C语言运算符允许它们的操作数是变量、常量或者包含其他运算符的表达式。然而,赋值运算符要求它的左操作数必须是左值(lvalue)。左值表示存储在计算机内存中的对象,而不是常量或计算的结果。变量是左值,而诸如10或2 * i
这样的表达式则不是左值。目前为止,变量是已知的唯一左值;在后面的章节中,我们将介绍其他类型的左值。
既然赋值运算符要求左操作数是左值,那么在赋值表达式的左侧放置任何其他类型的表达式都是不合法的:
12 = i; /*** WRONG ***/
i + j =0; /*** WRONG ***/
-i =j; /*** WRONG ***/
编译器会检测出这种错误,并给出诸如”invalid lvalue in assignment”这样的错误消息。
4.2.3 复合赋值
利用变量的原有值计算出新值并重新赋值给这个变量在C语言程序中是非常普遍的。例如,下面这条语句就是把变量i的值加上2后再赋值给它自己:
i = i + 2;
C语言的复合赋值运算符允许缩短这个语句以及类似的语句。使用+=运算符,可以将上面的表达式简写为:
i += 2; /* same as i = i + 2; */
+=运算符把右操作数的值加到左侧的变量中去。
还有另外9中复合赋值运算符,包括
-= *= /= %=
(其他复合赋值运算符(20.1节)将在后面的章节中介绍。)所有复合赋值运算符的工作原理大体相同。
- v += e 表示v加上e,然后将结果存储到v中。
- v -= e表示v减去e,然后将结果存储到v中。
- v *= e表示v乘以e,然后将结果存储到v中。
- v /= e表示v除以e,然后将结果存储到v中。
- v %= e表示v除以e取余数,然后将求余的结果存储到v中。
注意,这里没有说v += e "等价于" v = v + e。一个问题是运算符的优先级:表达式i *= j + k
和表达式i = i * j + k
是不一样的。在极少数情况下,由于v自身的副作用,v += e 也不等同于 v = v + e。类似的说明也使用与其他复合赋值运算符。
在使用赋值运算符时,注意不要交换组成运算符的两个字符的位置。交换字符位置产生的表达式也许可以被编译器接收,但不会有预期的意义。例如,原打算写表达式i += j 但却写成了 i =+ j,程序也能够通过编译。但是,最后一个表达式i =+ j等价于表达式i = ( +j),只是简单地把j的值赋给i。
复合赋值运算符有着和=运算符一样的特性。特别是,它们都是右结合的,所以语句
i += j += k;
意味着
i += (j += k);
4.3 自增运算符和自减运算符
最常用于变量的两种运算是”自增“(加1)和”自减“(减1)。当然,也可以通过下列方式完成这类操作:
i = i + 1;
j = j - 1;
复合赋值运算符可以将上述这些语句缩短一些:
i += 1;
j -= 1;
而C语言允许用++ (自增)和---(自减)运算符将这些语句缩的更短些。
乍一看,简化的原因仅仅是使用了自增和自减运算符:++表示操作数加1,而--表示操作数减1。但是,这是一种舞蹈,实际上自增和自减运算符的使用是很复杂的。复杂的原因之一就是,++和---运算符既可以作为前缀(prefix)运算符(如++i和--i)使用也可以作为后缀(postfix)运算符(如i++和i--)使用。程序的正确性可能和选取适合的运算符形式紧密相关。
复杂的另一个原因是,和 赋值运算符一样,++和--也有副作用:它们会改变操作数的值。计算表达式++i(“前缀自增“)的结果是i+1,而副作用的效果是自增i:
i = 1;
printf("i is %d\n",++i); /* prints "i is 2" */
printf("i is %d\n",i); /* prints "i is 2" */
计算表达是i++(“后缀自增”)的结果是i,但是会引发i随后进行自增:
i = 1;
printf("i is %d\n",i++); /* prints "i is 1" */
printf("i is %d\n",i); /* prints "i is 2" */
第一个printf函数显示了i自增前的原始值,第二个printf函数显示了i变化后的新值。正如这些例子说明的那样,++i意味着“立即自增i”,而i++则意味着“现在先用i的原始值,稍后再自增i”。这个“稍后”有多久呢?C语言标准没有给出精确的时间,但是可以放心地假设i将在下一条语句执行前进行自增。
--运算符具有相似的特性:
i = 1;
printf("i is %d\n", --i); /* prints "i is 0" */
printf("i is %d\n", i); /* prints "i is 0" */
i = 1;
printf("i is %d\n", i--); /* prints "i is 1" */
printf("i is %d\n", i); /* prints "i is 0" */
在同一个表达式中多次使用++或--运算符,结果往往会很难理解。思考下列语句:
i = 1;
j = 2;
k = ++i + j++;
在上述语句执行后,i、j和k的值分别是多少呢?由于i是在值被使用前进行自增,而j是在值被使用后进行自增,所以最后一个语句等价于:
i = i + 1;
k = i + j;
j = j + 1;
所以最终i、j和k的值分别是2、3和4。如果执行语句
i = 1;
j = 2;
k = i++ + j++;
i、j和k的值将分别是2、3和3。
需要记住的是,后缀++和后缀--比一元的正号、负号优先级高,而且都是左结合的。前缀++和前缀--与一元的正号、负号优先级相同,而且都是右结合的。
4.4 表达式求值
表4-2总结了到目前为止讲到的运算符。(附录A有一个类似的展示全部运算符的表格。)表4-2的第一列显示了每种运算符相对于表中其他运算符的优先级(最高优先级为1,最低优先级为5),最后一列显示了每种运算符的结合性。
表4-2(或者附录A中的运算符汇总表)用途很广泛。先看其中的一种用途。假设我们读某人的程序时遇到类似这样的复杂表达式:
a = b += c++ - d + --e / -f;
现在表达式中发现了前缀--运算符和一元负号运算符(优先级都为2):
a = b += (c++) - d + (--e) / (-f);
注意,另外一个负号的左侧紧挨着一个操作数,所以这个运算符一定是减法运算符,而不是一元负号运算符。
接下来,注意到运算符/(优先级为3):
a = b += (c++) - d + ((--e) / (-f));
这个表达式包含两个优先级为4的运算符:减号和加号。当两个具有相同优先级的运算符和同一个操作数相邻时,需要注意结合性。在此例中,-运算符和+运算符都和d毗邻,所以应用结合性规则。-运算符和+运算符都是自左向右结合,所以圆括号先括减号,然后再括加号:
a = b += (((c++) - d) + ((--e) / (-f)));
最后剩下运算符=和运算符+=。这两个运算符都和b相连,所以必须考虑结合性。赋值运算符从右向左。所以括号先加在表达式+=周围,然后加在表达式=周围:
(a = (b += (((c++) - d) + ((--e) / (-f)))));
现在这个表达式完全加上了括号。
子表达式的求值顺序
有了运算符的优先级和结合性规则我们可以将任何C语言表达式划分成子表达式;如果表达式是完全括号化的,那么这些规则还可以唯一确定添加圆括号的方式。与之相矛盾的是,这些规则并不总是允许我们确定表达式的值,表达式的值可能依赖于表达式的求值顺序。
C语言没有定义子表达式的求值顺序(除了含有逻辑与运算符及逻辑或运算符(5.1节)、条件运算符(5.2节)以及逗号运算符(6.3节)的子表达式)。因此,在表达式(a + b) * (c - d)
中无法确定子表达式(a + b)
是否在子表达式(c - d)
之前求值。
不管子表达式的计算顺序如何,大多数表达式都有相同的值。但是,当子表达式改变了某个操作数的值时,产生的值就可能不一致了。思考下面这个例子:
a = 5;
c = (b = a + 2) - (a = 1);
第二条语句的执行结果是未定义的,C标准没有作规定。对大多数编译器而言,c的值是6或者2。如果先计算子表达式(b = a + 2)
,那么b的值为7而c的值为6。但是,如果先计算子表达式(a = 1)
,那么b的值为3而c的值为2。
在表达式中,既在某处访问变量的值又在别处修改它的值是不可取的。表达式(b = a + 2) - (a = 1)
既访问了a的值(为了计算a + 2),又(通过赋值为1)修改了a的值。有些编译器在遇到这样的表达式时会产生一条类似“operation on 'a' may be undefined” 的警告消息。
为了避免出现此类问题,一个好主意就是:不在子表达式中使用赋值运算符,而是采用一串分离的赋值表达式。例如,上述语句可以改写成如下形式:
a = 5;
b = a + 2;
a = 1;
c = b - a;
在执行完这些语句后,c的值将始终是6。
除了赋值运算符,仅有自增和自减运算符可以改变操作数。使用这些运算符时,要注意表达式不要依赖特定的计算顺序。在下面的例子中,j有两个可能的值:
i = 2;
j = i * i++;
很自然地就会认定j赋值为4。但是,该语句的执行效果是未定义的,j也可能赋值为6。这种情况是:(1)取出第二个操作数(i的原始值),然后i自增;(2)取出第一个操作数(i的新值);(3)i的原始值和新值相乘,结果为6。“取出”变量意味着从内存中获取它的值。变量的后续变化不会影响已取出的值,因为已取出的值通常存储在CPU中称为寄存器(18.2节)的一个特殊位置。
未定义的行为
根据C标准,类似c = (b = a + 2) - (a = 1);
和j = i * i++;
这样的语句都会导致“未定义的行为”(undefined behavior),这跟4.1节中讲的由实现定义的行为是不同的。当程序中出现为定义的行为时,后果是不可预料的。不同的编译器给出编译结果可能是不同的,但这还不是唯一可能发生的事情:首先程序可能无法通过编译,就算通过了编译也可能无法运行,就算可以运行也有可能崩溃、不稳定或者产生无意义的结果。换句话说,应该像躲避瘟疫一样避免未定义的行为。
4.5 表达式语句
C语言有一条不同寻常的规则,那就是任何表达式都可以用作语句。换句话说,不能表达式是什么类型,计算什么结果,我们都可以通过在后面添加分号的方式将其转换成语句。例如,可以把表达式++i转换成语句
++i;
执行这条语句时,i先进行自增,然后把新产生的i值取出(与放在表达式中的效果一样)。但是,由于++i不是更长的表达式的一部分,所以它的值会被丢弃,执行下一条语句。(当然,对i的改变是持久的。)
因为会丢掉++i的值,所以除非表达式有副作用,否则将表达式用作语句并没有意义。一起来看看下面的3个例子。在第一个例子中,i存储了1,然后取出i的新值但是未使用:
i = 1;
在第二个例子中,取出i的值但没有使用,随后i进行自减:
i--;
在第三个例子中,计算出表达式i * j - 1;
的值丢弃掉:
i * j - 1;
因为i和j没有变化,所以这条语句没有任何作用。
键盘上的误操作很容易造成“什么也不做”的表达式语句。例如,本想输入
i = j;
但是却错误地输入
i + j;
(因为=和+两个字符通常在键盘的同一个键上,所以这种错误发生的频率可能会超出想象。)某些编译器可能会检查出无意义的表达式语句,会显示类似“statement with no effect”的警告。