C程序设计语言-第2章类型、运算符与表达式
第2章 类型、运算符与表达式
变量和常量是程序处理的两种基本数据对象。声明语句说明变量的名字及类型,也可以指定变量的初值。运算符指定将要进行的操作。表达式则把变量与常量组合起来生成新的值。对象的类型决定该对象可取值的集合以及可以对该对象执行的操作。本章将详细讲述这些内容。
ANSI标准对语言的基本类型与表达式做了许多小的修改与增补。所有整型都包括signed(带符号)和unsigned(无符号)两种形式,且可以表示无符号常量与十六进制字符常量。浮点运算可以以单精度进行,还可以使用更高精度的long double类型运算。字符串常量可以在编译时连接。ANSI C还支持枚举类型,该语言特性经过了长期的发展才形成。对象可以声明为const(常量)类型,表明其值不能修改。该标准还对算术类型之间的自动强制转换规则进行了补充,以适合于更多的数据类型。
2.1 变量名
对变量的命名与符号常量的命名存在一些限制条件,这一点我们在第1章没有说明。名字是由字母和数字组成的序列,但其第一个字符必须为字母。下划线_
被看做是字母,通常用于命名较长的变量名,以提高其可读性。由于库例程的名字通常以下划线开头,因此常量名不能以下划线开头。大写字母与小写字母是有区别的,所以,x与X是两个不同的名字。在传统的C语言用法中,变量名使用小写字母,符号常量名全部使用大写字母。
类似于if、else、int、float等关键字是保留给语言本身使用的,不能把它们用作变量名。所有关键字中的字符都必须小写。
选择的变量名要能够尽量从字面上表达变量的用途,这样做不容易引起混淆。局部变量一般使用较短的变量名(尤其是循环控制变量),外部变量使用较长的名字。
2.2 数据类型及长度
C语言只提供了下列几种基本数据类型:
- char 字符型,占用一个字节,可以存放本地字符集中的一个字符
- int 整型,通常反映了所有机器中整数的最自然长度
- float 单精度浮点型
- double 双精度浮点型
此外,还可以在这些基本数据类型的前面加上一些限定符。short与long两个限定符用于限定整型:
short int sh;
long int counter;
在上述这种类型的声明中,关键字int可以省略。通常很多人也习惯这么做。
short与long两个限定符的引入可以为我们提供满足实际需要的不同长度的整型数。int通常代表特定机器中整数的自然长度。short类型通常为16位,long类型通常为32位,int类型可以为16位或32位。各编译器可以根据硬件特性自主选择合适的类型长度,但要遵循下列限制:short与int类型至少为16位,而long类型至少为32位,并且short类型不得长于int类型,而int类型不得长于long类型。
类型限定符signed与unsigned可用于限制char类型或任何整型。unsigned类型的数总是正值或0,并遵守算术模2^n
定律,其中n是该类型占用的位数。例如,如果char对象占用8位,那么unsigned char类型变量的取值范围为0~255,而signed char类型变量的取值范围则为-128~127(在采用对二的补码的机器上)。不带限定符的char类型对象是否带符号则取决于具体机器,但可打印字符总是正值。
long double类型表示高精度的浮点数。同整型一样,浮点型的长度也取决于具体的实现,float、double与long double类型可以表示相同的长度,也可以表示两种或三种不同的长度。
有关这些类型长度定义的符号常量以及其他与机器和编译器相关的属性可以在标准头文件<limits.h>
与<float.h>
中找到,这些内容将在附录B中讨论。
练习2-1 编写一个程序以确定分别由signed及unsigned限定的char、short、int与long类型变量的取值范围。采用打印标准头文件中的相应值以及直接计算两种方式实现。后一种方法的实现较困难一些,因为要确定各种浮点类型的取值范围。
2.3 常量
类似于 1 2 3 4 的整数常量属于int类型。long类型的常量以字母l或L结尾,如123456789L。如果一个整数太大以至于无法用int类型表示时,也将被当做long类型处理。无符号常量以字母u或U结尾。后缀ul或UL表明是unsigned long类型。
浮点数常量中包含一个小数点(如123.4)或一个指数(如1e-2),也可以两者都有。没有后缀的浮点数常量为double类型。后缀f或F表示float类型,而后缀l或L则表示long double类型。
整型数除了用十进制表示外,还可以用八进制或十六进制表示。带前缀0的整型常量表示它为八进制形式;前缀为0x或0X,则表示它为十六进制形式。例如,十进制数31可以写成八进制形式037,也可以写成十六进制形式0x1f或0X1F。八进制与十六进制的常量也可以使用后缀L表示long类型,使用后缀U表示unsigned类型。例如,0XFUL是一个unsigned long类型(无符号长整型)的常量,其值等于十进制数15。
一个字符常量
是一个整数,书写时将一个字符括在单引号中,如'x'
。字符在机器字符集中的数值就是字符常量的值。例如,在ASCII字符集中,字符'0’的值为48,它与数值0没有关系。如果用字符'0’代替这个与具体字符集有关的值(比如48),那么,程序就无需关心该字符对应的具体值,增加了程序的易读性。字符常量一般用来与其他字符进行比较,但也可以像其他整数一样参与数值运算。
某些字符可以通过转义字符序列(例如,换行符\n
)表示为字符和字符串常量。转义字符序列看起来像两个字符,但只表示一个字符。另外,我们可以用
\ooo
表示任意的字节大小的位模式。其中,ooo代表1~3个八进制数字(0…7)。这种位模式还可以用
\xhh
表示,其中,hh是一个或多个十六进制数字(0…9, a…f, A…F)。因此,我们可以按照下列形式书写语句:
上述语句也可以用十六进制的形式书写为:
ANSI C语言中的全部转义字符序列如下所示:
\a 响铃符 \\ 反斜杠
\b 回退符 \? 问号
\f 换页符 \' 单引号
\n 换行符 \" 双引号
\r 回车符 \ooo 八进制数
\t 横向制表符 \xhh 十六进制数
\v 纵向制表符
字符常量'\0'
表示值为0的字符,也就是空字符(null)。我们通常用'\0'
的形式代替0,以强调某些表达式的字符属性,但其数字值为0。
常量表达式
是仅仅只包含常量的表达式。这种表达式在编译时求值,而不在运行时求值。它可以出现在常量可以出现的任何位置,例如:
或
字符串常量也叫字符串字面值,是用双引号括起来的0个或多个字符组成的字符序列。例如:
|
|
或
|
|
都是字符串。双引号不是字符串的一部分,它只用于限定字符串。字符常量中使用的转义字符序列同样也可以用在字符串中。在字符串中使用\"
表示双引号字符。编译时可以将多个字符串常量连接起来,例如,下列形式:
|
|
等价于
|
|
字符串常量的连接为将较长的字符串分散在若干行中提供了支持。
从技术角度看,字符串常量就是字符数组。字符串的内部表示使用一个空字符'\0'
作为串的结尾,因此,存储字符串的物理存储单元数比括在双引号中的字符数多一个。这种表示方法也说明,C语言对字符串的长度没有限制,但程序必须扫描完整个字符串后才能确定字符串的长度。标准库函数strlen(s)可以返回字符串参数s的长度,但长度不包括末尾的'\0'
。下面是我们设计的strlen函数的一个版本:
标准头文件<string.h>
中声明了strlen和其他字符串函数。
我们应该搞清楚字符常量与仅包含一个字符的字符串之间的区别:'x'
与"X"
是不同的。前者是一个整数,其值是字母x在机器字符集中对应的数值(内部表示值);后者是一个包含一个字符(即字母x)以及一个结束符’\0’的字符数组。
枚举常量是另外一种类型的常量。枚举是一个常量整型值的列表,例如:
|
|
在没有显式说明的情况下,enum类型中第一个枚举名的值为0,第二个为1,依次类推。如果只指定了部分枚举名的值,那么未指定值的枚举名的值将依着最后一个指定值向后递增,参看下面两个例子中的第二个例子:
不同枚举中的名字必须互不相同。同一枚举中不同的名字可以具有相同的值。枚举常量名是全局的,这也是不同枚举中的名字必须互不相同的原因。
枚举为建立常量值与名字之间的关联提供了一种便利的方式。相对于#define
语句来说,它的优势在于常量值可以自动生成。
尽管可以声明enum类型的变量,但编译器不检查这种类型的变量中存储的值是否为该枚举的有效值。例如:
枚举最常见的用法是检查枚举变量的值是否等于该枚举类型声明的某个常量值,虽然枚举变量的取值不限于该枚举声明的值,但这样做就失去了使用枚举的意义。此外,调试程序可以以符号形式打印出枚举变量的值。
2.4 声明
所有变量都必须先声明后使用,尽管某些变量可以通过上下文隐式地声明。一个声明指定一种变量类型,后面所带的变量表可以包含一个或多个该类型的变量。例如:
一个声明语句中的多个变量可以拆开在多个声明语句中声明。上面的两个声明语句也可以等价地写成下列形式:
按照这种形式书写代码需要占用较多的空间,但便于向各声明语句中添加注释,也便于以后修改。
还可以在声明的同时对变量进行初始化。在声明中,如果变量名的后面紧跟一个等号以及一个表达式,该表达式就充当对变量进行初始化的初始化表达式。例如:
如果变量不是自动变量,则只能进行一次初始化操作,从概念上讲,应该是在程序开始执行之前进行,并且初始化表达式必须为常量表达式。每次进入函数或程序块时,显式初始化的自动变量将被初始化一次,其初始化表达式可以是任何表达式。默认情况下,外部变量与静态变量将被初始化为0。未经显式初始化的自动变量的值为未定义值(即无效值)。
任何变量的声明都可以使用const限定符限定。该限定符指定变量的值不能被修改。对数组而言,const限定符指定数组所有元素的值都不能被修改:
const限定符也可配合数组参数使用,它表明函数不能修改数组元素的值:
|
|
如果试图修改const限定符限定的值,其结果取决于具体的实现。
2.5 算术运算符
二元算术运算符包括: +、-、*、/、%(取模运算符)
。整数除法会截取结果中的小数部分。表达式
|
|
的结果是x除以y的余数,当x能被y整除时,其值为0。例如,如果某一年的年份能被4整除但不能被100整除,那么这一年就是闰年,此外,能被400整除的年份也是闰年。因此,可以用下列语句判断闰年:
取模运算符%
不能应用于float或double类型。在有负操作数的情况下,整数除法截取的方向以及取模运算结果的符号取决于具体机器的实现,这和处理上溢或下溢的情况是一样的。
二元运算符+
和-
具有相同的优先级,它们的优先级比运算符 *
、/
和%
的优先级低,而运算符*
、/
和%
的优先级又比一元运算符+
和-
的优先级低。算术运算符采用从左到右的结合规则。
本章末尾的表2-1完整总结了所有运算符的优先级和结合律。
2.6 关系运算符与逻辑运算符
关系运算符包括下列几个运算符:
> >= < <=
它们就有相同的优先级。优先级仅次于它们的是相等性运算符:
== !=
关系运算符的优先级比算术运算符低。因此,表达式 i < lim-1
等价于 i < (lim-1)
。
逻辑运算符&&
与||
有一些较为特殊的属性。由&&
与||
连接的表达式按从左到右的顺序进行求值,并且,在知道结果值为假或真后立即停止计算。绝大多数C语言程序运用了这些属性。例如,下列在功能上与第1章的输入函数getLine中的循环语句等价的循环语句:
在读入一个新字符之前必须先检查数组s中是否还有空间存放这个字符,因此必须首先测试条件i<lim-1
。如果这一测试失败,就没有必要继续读入下一字符。
类似地,如果在调用getchar函数之前就测试c是否为EOF,结果也是不正确的,因此,函数的调用与赋值都必须在对c中的字符进行测试之前进行。
运算符&&
的优先级比||
的优先级高,但两者都比关系运算符和相等性运算符的优先级低。因此,表达式:
i<lim-1 && (c = getchar()) != '\n' && c != EOF
就不需要另外加圆括号了。但是,由于运算符!=的优先级高于赋值运算符的优先级,因此,在表达式:
(c = getchar()) != '\n'
中,就需要使用圆括号,这样才能达到预期的目的:先把函数返回值赋值给c,然后再将c与'\n'
进行比较。
根据定义,在关系表达式或逻辑表达式中,如果关系为真,则表达式的结果值为数值1;如果为假,则结果值为数值0。
逻辑非运算符!的作用是将非0操作数转换为0,将操作数0转换为1。该运算符通常用于下列类似的结构中:
if (!valid)
一般不采用下列形式:
if (valid == 0)
当然,很难评判上述两种形式哪种更好。类似于!valid
的用法读起来更直观一些(“如果不是有效的”),但对于一些更复杂的结构可能会难于理解。
练习2-2 在不使用运算符&&或||的条件下编写一个与上面的for循环语句等价的循环语句。
2.7 类型转换
当一个运算符的几个操作数类型不同时,就需要通过一些规则把它们转换为某种共同的类型。一般来说,自动转换是指把“比较窄的”操作数转换为“比较宽的”操作数,并且不丢失信息的转换,例如,在计算表达式f+i时,将整型变量i的值自动转换为浮点型(这里的变量f为浮点型)。不允许使用无意义的表达式,例如,不允许把float类型的表达式作为下标。针对可能导致信息丢失的表达式,编译器可能会给出警告信息,比如把较长的整型值赋给较短的整型变量,把浮点型值赋值给整型变量,等等,但这些表达式并不非法。
由于char类型就是较小的整型,因此在算术表达式中可以自由使用char类型的变量,这就为实现某些字符转换提供了很大的灵活性。比如,下面的函数atoi就是一例,它将一串数字转换为相应的数值:
我们在第1章讲过,表达式
s[i] - '0'
能够计算出s[i]中存储的字符所对应的数字值,这是因为'0'
、'1'
等在字符集中对应的数值是一个连续的递增序列。
函数lower是将char类型转换为int类型的另一个例子,它将ASCII字符集中的字符映射到对应的小写字母。如果待转换的字符不是大写字母,lower函数将返回字符本身。
上述这个函数是为ASCII字符集设计的。在ASCII字符集中,大写字母与对应的小写字母作为数字值来说具有固定的间隔,并且每个字母表都是连续的————也就是说,在A~Z之间只有字母。但是,后面一点对EBCDIC字符集是不成立的,因此这一函数作用在EBCDIC字符集中就不仅限于转换字母的大小写。
附录B介绍的标准头文件<ctype.h>
定义了一组与字符集无关的测试和转换函数。例如,tolower(c)函数将c转换为小写形式(如果c为大写形式的话),可以使用tolower替代上述lower函数。类似地,测试语句:
|
|
可以用该标准库中的函数
isdigit(c)
替代。在本书的后续内容中,我们将使用<ctype.h>
中定义的函数。
将字符类型转换为整型时,我们需要注意一点。C语言没有指定char类型的变量是无符号变量(unsigned)还是带符号变量(signed)。当把一个char类型的值转换为int类型的值时,其结果有没有可能为负整数?对于不同的机器,其结果也不同,这反映了不同机器结构之间的区别。在某些机器中,如果char类型值的最左一位为1,则转换为负整数(进行“符号扩展”)。而在另一些机器中,把char类型值转换为int类型时,在char类型值的左边添加0,这样导致的转换结果值总是正值。
C语言的定义保证了机器的标准打印字符集中的字符不会是负值,因此,在表达式中这些字符总是正值。但是,存储在字符变量中的位模式在某些机器中可能是负的,而在另一些机器上可能是正的。为了保证程序的可移植性,如果要在char类型的变量中存储非字符数据,最好指定signed或unsigned限定符。
当关系表达式(如i>j
)以及由&&
、||
连接的逻辑表达式的判定结果为真时,表达式的值为1;当判定结果为假时,表达式的值为0。因此,对于赋值语句:
|
|
来说,当c为数字时,d的值为1,否则d的值为0。但是,某些函数(比如isdigit)在结果为真时可能返回任意的非0值。在if、while、for等语句的测试部分中,“真”就意味着“非0”,这二者之间没有区别。
C语言中,很多情况下会进行隐式的算术类型转换。一般来说,如果二元运算符(具有两个操作数的运算符称为二元运算符,比如+
或*
)的两个操作数具有不同的类型,那么在进行运算之前先要把“较低”的类型提升为“较高”的类型。运算的结果为较高的类型。附录A.6节详细地列出了这些转换规则。但是,如果没有unsigned类型的操作数,则只要使用下面这些非正式的规则就可以了:
- 如果其中一个操作数的类型为long double, 则将另一个操作数转换为long double类型;
- 如果其中一个操作数的类型为double,则将另一个操作数转换为double类型;
- 如果其中一个操作数的类型为float,则将另一个操作数转换为float类型;
- 将char与short类型的操作数转换为int类型;
- 如果其中一个操作数的类型为long,则将另一个操作数也转换为long类型。
注意,表达式中float类型的操作数不会自动转换为double类型,这一点与最初的定义有所不同。一般来说,数学函数(如标准头文件<math.h>
中定义的函数)使用双精度类型的变量。使用float类型主要是为了在使用较大的数组时节省存储空间,有时也为了节省机器执行时间(双精度算术运算特别费时)。
当表达式中包含unsigned类型的操作数时,转换规则要复杂一些。主要原因在于,带符号值与无符号值之间的比较运算是与机器有关的,因为它们取决于机器中不同整数类型的大小。例如,假定int类型占16位,long类型占32位,那么,-1L<1U,这是因为unsigned int类型的1U将被提升为signed long类型;但-1L>1UL,这是因为-1L将被提升为unsigned long类型,因而成为一个比较大的正数。
赋值时也要进行类型转换。赋值运算符右边的值需要转换为左边变量的类型,左边变量的类型即赋值表达式结果的类型。
前面提到过,无论是否进行符号扩展,字符型变量都将被转换为整型变量。
当把较长的整数转换为较短的整数或char类型时,超出的高位部分将被丢弃。因此,下列程序段:
执行后,c的值将保持不变。无论是否进行符号扩展,该结论都成立。但是,如果把两个赋值语句的次序颠倒一下,则执行后可能会丢失信息。
如果x是float类型,i是int类型,那么语句x=i与i=x在执行时都要进行类型转换。当把float类型转换为int类型时,小数部分将被截取掉;当把double类型转换为float类型时,是进行四舍五入还是截取取决于具体的实现。
由于函数调用的参数是表达式,所以在把参数传递给函数时也可能进行类型转换。在没有函数原型的情况下,char与short类型都将被转换为int类型,float类型将被转换为double类型。因此,即使调用函数的参数为char或float类型,我们也把函数参数声明为int或double类型。
最后,在任何表达式中都可以使用一个称为强制类型转换的一元运算符强制进行显式类型转换。在下列语句中,表达式将按照上述转换规则被转换为类型名指定的类型:
(类型名)表达式
我们可以这样来理解强制类型转换的准确含义:在上述语句中,表达式首先被赋值给类型名指定的类型的某个变量,然后再用该变量替换上述整条语句。例如,库函数sqrt的参数为double类型,如果处理不当,结果可能会无意义(sqrt在<math.h>
中声明)。因此,如果n是整数,可以使用
sqrt((double) n)
在把n传递给函数sqrt之前先将其转换为double类型。注意,强制类型转换只是生成一个指定类型的n的值,n本身的值并没有改变。强制类型转换运算符与其他一元运算符具有相同的优先级,表2-1对运算符优先级进行了总结。
在通常情况下,参数是通过函数原型声明的。这样,当函数被调用时,声明将对参数进行自动强制转换。例如,对于sqrt的函数原型
double sqrt(double)
;
下列函数调用:
root2 = sqrt(2)
;
不需要使用强制类型转换运算符就可以自动将整数2强制转换为double类型的值2.0。
标准库中包含一个可移植的实现伪随机数发生器的函数rand以及一个初始化种子数的函数srand。前一个函数rand使用了强制类型转换。
练习2-3 编写函数htoi(s),把由十六进制数字组成的字符串(包含可选的前缀0x或0X)转换为与之等价的整型值。字符串中允许包含的数字包括:0~9、a~f以及A~F。
2.8 自增运算符与自减运算符
C语言提供了两个用于变量递增与递减的特殊运算符。自增运算符++
使其操作数递增1,自减运算符--
使其操作数递减1。我们经常使用++
运算符递增变量的值,如下所示:
++
与--
这两个运算符特殊的地方主要表现在:它们既可以用作前缀运算符(用在变量前面,如++n
),也可以用作后缀运算符(用在变量后面,如n++
)。在这两种情况下,其效果都是将变量n的值加1。但是,它们之间有一点不同。表达式++n
先将n的值递增1,然后再使用变量n的值,而表达式n++
则是先使用变量n的值,然后再将n的值递增1。也就是说,对于使用变量n的值的上下文来说,++n
和n++
的效果是不同的。如果n的值为5,那么
x = n++;
执行后的结果是将x的值置为5,而
x = ++n;
将x的值置为6。这两条语句执行完成后,变量n的值都是6。自增与自减运算符只能作用于变量,类似于表达式(i+j)++
是非法的。
在不需要使用任何具体值且仅需要递增变量的情况下,前缀方式和后缀方式的效果相同。例如:
但在某些情况下需要酌情考虑。例如,考虑下面的函数squeeze(s, c),它删除字符串s中出现的所有字符c:
每当出现一个不是c的字符时,该函数把它拷贝到数组中下标为j的位置,随后才将j的值增加1,以准备处理下一个字符。其中的if语句完全等价于下列语句:
我们在第1章中编写的函数getLine是类似结构的另外一个例子。我们可以将该函数中的if语句:
用下面这种更简洁的形式代替:
我们再来看第三个例子。考虑标准函数strcat(s, t)
,它将字符串t连接到字符串s的尾部。函数strcat假定字符串s中有足够的空间保存这两个字符串连接的结果。下面编写的这个函数没有任何返回值(标准库中的该函数返回一个指向新字符串的指针):
在将t中的字符逐个拷贝到s的尾部时,变量i和j使用的都是后缀运算符++,从而保证在循环过程中i与j均指向下一个位置。
练习2-4 重新编写函数squeeze(s1, s2),将字符串s1中任何与字符串s2中字符匹配的字符都删除。
练习2-5 编写函数any(s1, s2),将字符串s2中的任一字符在字符串s1中第一次出现的位置作为结果返回。如果s1中不包含s2中的字符,则返回-1。(标准库函数strpbrk具有同样的功能,但它返回的是指向该位置的指针。)
2.9 按位运算符
C语言提供了6个位操作运算符。这些运算符只能作用于整型操作数,即只能作用于带符号或无符号的char、short、int与long类型:
& 按位与(AND)
| 按位或(OR)
^ 按位异或(XOR)
<< 左移
>> 右移
~ 按位求反(一元运算符)
按位与运算符&经常用于屏蔽某些二进制位,例如:
n = n & 0177;
该语句将n中除7个低二进制位外的其他各位均置为0。
按位或运算符|常用于将某些二进制位置为1,例如:
x = x | SET_ON;
该语句将x中对应于SET_ON
中为1的那些二进制位置为1。
按位异或运算符^
当两个操作数的对应位不相同时将该位设置为1,否则,将该位设置为0。
我们必须将位运算符&
、|
同逻辑运算符&&
、||
区分开来,后者用于从左至右求表达式的真值。例如,如果x的值为1,y的值为2,那么,x&y
的结果为0,而x&&y
的值为1。
移位运算符<<
与>>
分别用于将运算的左操作数左移与右移,移动的位数则由右操作数指定(右操作数的值必须是非负值)。因此,表达式x<<2
将把x的值左移2位,右边空出的2位用0填补,该表达式等价于对左操作数乘以4。在对unsigned类型的无符号值进行右移位时,左边空出的部分将用0填补;当对signed类型的带符号值进行右移时,某些机器将对左边空出的部分用符号位填补(即“算术移位”),而另一些机器则对左边空出的部分用0填补(即“逻辑移位”)。
一元运算符~
用于求整数的二进制反码,即分别将操作数各二进制位上的1变为0,0变为1。例如:
x = x & ~077
将把x的最后6位设置为0。注意,表达式x&~077
与机器字长无关,它比形式为x&0177700
的表达式要好,因为后者假定x是16位的数值。这种可移植的形式并没有增加额外开销,因为~077是常量表达式,可以在编译时求值。
为了进一步说明某些位运算符,我们来看函数getbits(x,p,n),它返回x中从右边数第p位开始向右数n位的字段。这里假定最右边的一位是第0位,n与p都是合理的正值。例如,getbits(x,4,3)返回x中倒数第4、3、2三位的值。
其中,表达式x>>(p+1-n)
将期望获得的字段移位到字的最右端。~0
的所有位都为1,这里使用语句~0<<n
将~0
左移n位,并将最右边的n位用0填补。再使用~
运算对它按位取反,这样就建立了最右边n位全为1的屏蔽码。
练习2-6 编写一个函数setbits(x,p,n,y),该函数返回对x执行下列操作后的结果值:将x中从第p位开始的n个(二进制)位设置为y中最右边n位的值,x的其余各位保持不变。
练习2-7 编写一个函数invert(x,p,n),该函数返回对x执行下列操作后的结果值:将x中从第p位开始的n个(二进制)位求反(即,1变成0,0变成1),x的其余各位保持不变。
练习2-8 编写一个函数rightrot(x, n), 该函数返回将x循环右移(即从最右端移出的位将从最左端移入)n(二进制)位后所得到的值。
2.10 赋值运算符与表达式
在赋值表达式中,如果表达式左边的变量重复出现在表达式的右边,如:
i = i + 2
则可以将这种表达式缩写为下列形式:
i += 2
其中的运算符+=
称为赋值运算符
。
大多数二元运算符(即有左、右两个操作数的运算符,比如+)都有一个相应的赋值运算符op=
,其中,op
可以是下面这些运算符之一:
+ - * / % << >> & ^ |
如果expr1和expr2是表达式,那么
expr1 op= expr2
等价于:
expr1 = (expr1) op (expr2)
它们的区别在于,前一种形式expr1只计算一次。注意,在第二种形式中,expr2两边的圆括号是必不可少的,例如,
x *= y + 1
的含义是:
x = x * (y + 1)
而不是
x = x * y + 1
我们这里举例说明。下面的函数bitcount统计其整型参数的值为1的二进制位的个数。
这里将x声明为无符号类型是为了保证将x右移时,无论该程序在什么机器上运行,左边空出的位都用0(而不是符号位)填补。
除了简洁外,赋值运算符还有一个优点:表示方式与人们的思维习惯比较接近。我们通常会说“把2加到i上”或“把i增加2”,而不会说“取i的值,加上2,再把结果放回到i中”,因此,表达式i+=2比i=i+2更自然。另外,对于复杂的表达式,例如:
yyval[yypv[p3+p4] + yypv[p1+p2]] += 2
赋值运算符使程序代码更易于理解,代码的阅读者不必煞费苦心地去检查两个长表达式是否完全一样,也无须为两者为什么不一样而疑惑不解。并且,赋值运算符还有助于编译器产生高效代码。
从上述例子中我们可以看出,赋值语句具有值,且可以用在表达式中。下面是最常见的一个例子:
while ((c = getchar()) != EOF)
...
其他赋值运算符(如+=
、-=
等)也可以用在表达式中,尽管这种用法比较少见。
在所有的这类表达式中,赋值表达式的类型是它的左操作数的类型,其值是赋值操作完成后的值。
练习2-9 在求对二的补码时,表达式x&=(x-1)
可以删除x中从右边数第一个值为1的二进制位。请解释这样做的道理。用这一方法重写bitcount函数,以加快其执行速度。
2.11 条件表达式
下面这组语句:
用于求a与b中的最大值,并将结果保存到z中。条件表达式
(使用三元运算符“?:”)提供了另外一种方法编写这段程序。在表达式
expr1 ? expr2 : expr3
中,首先计算expr1,如果其值不等于0(为真),则计算expr2的值,并以该值作为条件表达式的值,否则计算expr3的值,并以该值作为条件表达式的值。expr2与expr3中只能有一个表达式被计算。因此,以上语句可以改写为:
z = (a > b) ? a : b; /* z = max(a, b) */
应该注意,条件表达式实际上就是一种表达式,它可以用在其他表达式可以使用的任何地方。如果expr1与expr3的类型不同,结果的类型将由本章前面讨论的转换规则决定。例如,如果f为float类型,n为int类型,那么表达式
(n > 0) ? f : n
是float类型,与n是否为正值无关。
条件表达式中第一个表达式两边的圆括号并不是必须的,这是因为条件运算符 ?: 的优先级非常低,仅高于赋值运算符。但我们还是建议使用圆括号,因为这可以使表达式的条件部分更易于阅读。
采用条件表达式可以编写出很简洁的代码。例如,下面的这个循环语句打印一个数组的n个元素,每行打印10个元素,每列之间用一个空格隔开,每行用一个换行符结束(包括最后一行):
在每10个元素之后以及在第n个元素之后都要打印一个换行符,所有其他元素后都要打印一个空格。编写这样的代码可能需要一些技巧,但比用等价的if-else结构编写的代码要紧凑一些。下面是另一个比较好的例子:
printf("You have %d item%s.\n", n, n==1 ? "" : "s");
练习2-10 重新编写将大写字母转换为小写字母的函数lower,并用条件表达式替代其中的if-else结构。
2.12 运算符优先级与求值次序
表2-1总结了所有运算符的优先级与结合性,其中的一些规则我们还没有讲述。同一行中的各运算符具有相同的优先级,各行间从上往下优先级逐行降低。例如,*、/与%
三者具有相同的优先级,它们的优先级都比二元运算符+
、-
高。运算符()表示函数调用。运算符->
和.
用于访问结构成员,第6章将讨论这两个运算符以及sizeof()
运算符。第5章将讨论运算符*
(通过指针间接访问)与&
(对象地址),第3章将讨论逗号运算符。
表2-1 运算符的优先级与结合性
----------------------------------------+---------------------------------
运算符 结合性| 运算符 结合性
----------------------------------------+---------------------------------
() [] -> . 从左至右| ^ 从左至右
! ~ ++ -- + - * & (type) sizeof 从右至左| | 从左至右
* / % 从左至右| && 从左至右
+ - 从左至右| || 从左至右
<< >> 从左至右| ?: 从右至左
< <= > >= 从左至右| = += -= *= /= %= &= 从右至左
== != 从左至右| ^= |= <<= >>= , 从左至右
& 从左至右|
----------------------------------------+---------------------------------
注:一元运算符+、-、&与*比对应的二元运算符+、-、&与*的优先级高。
注意,位运算符&
、^
与|
的优先级比运算符==
与!=
的低。这意味着,位测试表达式,如
if ((x & MASK) == 0) ...
必须用圆括号括起来才能得到正确结果。
同大多数语言一样,C语言没有指定同一运算符中多个操作数的计算顺序(&&
、||
、?:
和,
运算符除外)。例如,在形如
x = f() + g();
的语句中,f()可以在g()之前计算,也可以在g()之后计算。因此,如果函数f或g改变了另一个函数所使用的变量,那么x的结果可能会依赖于这两个函数的计算顺序。为了保证特定的计算顺序,可以把中间结果保存在临时变量中。
类似地,C语言也没有指定函数各参数的求值顺序。因此,下列语句
printf("%d %d\n", ++n, power(2,n)); /* 错 */
在不同的编译器中可能会产生不同的结果,这取决于n的自增运算在power调用之前还是之后执行。解决的办法是把该语句改写成下列形式:
函数调用、嵌套赋值语句、自增与自减运算符都有可能产生“副作用”————在对表达式求值的同时,修改了某些变量的值。在有副作用影响的表达式中,其执行结果同表达式中的变量被修改的顺序之间存在着微妙的依赖关系。下列语句就是一个典型的令人不愉快的情况:
a[i] = i++;
问题是:数组下标i是引用旧值还是引用新值?对这种情况编译器的解释可能不同,并因此产生不同的结果。C语言标准对大多数这类问题有意
未作具体规定。表达式何时会产生这种副作用(对变量赋值),将由编译器决定,因为最佳的求值顺序同机器结构有很大关系。
在任何一种编程语言中,如果代码的执行结果与求值顺序相关,则都是不好的程序设计风格。很自然,有必要了解哪些问题需要避免,但是,如果不知道这些问题在各种机器上是如何解决的,就最好不要尝试运用某种特殊的实现方式。
2.13 表达式和子表达式
表达式是由一系列运算符(operators)和操作数(operands)组成的序列。如下这些都是合法的表达式:
4
-6
4 + 21
a * (b + c/d) / 20
q=5*2
x=++q % 3
q>3
"hello world"
可以看到一个表达式也可以没有运算符,例如“4”这种形式就是最简单的表达式形式,即最简单的表达式只有一个常量或一个变量名而没有运算符
还可以看到,一些表达式是多个较小的表达式的组合,这些小的表达式被称为子表达式(subexpression)。例如表达式c/d
是表达式a * (b + c/d) / 20
的子表达式,而表达式c和d又是表达式c/d
的子表达式。
表达式的4个作用:
-
计算数值(computation of a value)
-
指明数据对象或者函数(designates an object or a function)
例如程序中有int i;
声明语句,那么表达式i=3
中子表达式i
就指代i
所代表的那个对象(object),即一块连续的内存空间。
而在表达式printf()中printf指代的是标准C库中的printf函数。
- 产生副作用(generate side effects)
副作用就是运行时对数据对象或文件的修改。来看几个例子:
表达式i=50
的副作用是将变量i
的值设置为50
表达式printf("ABC")
的值为3(实际打印的字符数,不包括字符’\0’),副作用就是在标准输出设备上连续打印字符A、B和C。
注意,并不是所有的表达式都有副作用,表达式2+3
的值为5,但是没有任何副作用。
- 以上的组合(combination)
最后来看一个完整的程序:
|
|
需要注意的是,函数调用也是表达式,print()这个表达式中()是操作符,而print是操作符()的操作数。
任何表达式都有值和类型这两个基本属性。
void类型的表达式是一种比较特殊的表达式,简称void表达式。void表达式不能计算出一个具体的值,因此被认为计算的是一个不存在的值,且void表达式的值不能在任何地方以任何方式使用。此外,除非将一个void表达式的值转换为void类型,否则针对void表达式的其他任何显式或隐式转换都是不允许的(可以说void表达式存在的唯一价值是得到它的副作用,而不是它的值。)
我们可以借助gdb提供的print和ptype命令来实际地查看表达式的值和类型两大属性:
(gdb) ptype 1
type = int
(gdb) ptype 1.0
type = double
(gdb) ptype "hello world"
type = char [12]
(gdb) print printf("hello world\n")
hello world
$2 = 12
(gdb) ptype printf("hello world\n")
type = int
(gdb) ptype setbuf(stdout, 0)
type = void
(gdb) print setbuf(stdout, 0)
$3 = void
可以看到在C语言中字符串的类型是字符型数组。setbuf的返回类型为void,我们在使用print打印其值是没有意义的,因此gdb索性给了个void。