[隐藏]

一、简介

简单地说,一个表达式就是操作符和操作数组成的序列。例如,x = y +3

一个对象在一个表达式中的两个序列点之间只能被修改一次。

序列点(Sequence Point)

C标准规定代码中的某些点是序列点,当执行到一个序列点时,在此之前的副作用(Side Effect)必须全部作用完毕,在此之后的副作用必须一个都没发生。

标准中提到的序列点有:

  • 调用一个函数时,在所有准备工作做完之后、函数调用开始之前。比如调用foo(f(), g()) 时,foo 、f() 、g() 这三个表达式哪个先开始求值哪个后开始求值是未定义的(undefined),但是必须都求值完了才能做最后的函数调用,所以f() 和g() 的调用顺序是不确定的,但必定在它们两个全部调用结束之后才开始调用foo。
  • 条件运算符 ? : 的第一个表达式之后、逗号运算符、逻辑与&&、逻辑或||的第一个操作数求值之后都是一个序列点。这就是为什么 ?: 、&&、|| 可以做到短路运算。
  • 一个完整的声明的末尾。这就是为什么我们可以这样声明:int a = 2, b = a; 虽然这是一条语句,但是声明a后是一个序列点,声明a的副作用已经结束,所以可以将它的值赋给b。
  • 一个完整的表达式末尾。完整的表达式包括:一个初始化,语句中的表达式,选择和循环语句中的控制表达式,for语句中的那三个表达式,return语句中的表达式。
  • 库函数即将返回时。这和第一条并不完全相同,因为有些库函数并不是真正的函数,而是宏,比如assert,它的实现往往是一个宏,这条规则确保了这些库函数结束之前所有副操作都已经结束。
  • 在与格式化输入/输出函数中转换说明符相关联的动作之后。
  • 在比较函数即将调用之前和之后。比如qsort中的比较函数。在调用这个函数和把对象当作实参传入之间也是一个序列点。这一条和上一条规则与库函数的内部执行过程有关,通常情况下不必理会。

二、左值(L-value)和右值(R-value)

直观地讲,左值就是可以出现赋值符号左边的东西;右值就是可以出现在赋值符号右边的东西。比如:

a = b + 5;

a是左值,b + 5是右值。

那么它们的位置可以交换吗?

b + 5 = a;

显然不可以。原因在于,所有左值必须有一个可以存储结果的位置,右值只要求它指定了一个值。所以a也可以做右值,因为每一个位置都有一个值;但 b + 5 只表示一个值,因为当计算机计算 b + 5 时,它的结果必然要保存在机器的某个地方,程序员没有办法预先知道是什么地方,也不能确定这个值下次是否还在那个地方,所以它没有指定一个位置。

三、操作符

算术操作符

一元运算符

C语言中的一元算术运算符有 +(正号)- (负号)两个。

运算符 + 其实没有什么作用,它可以用于强调某个数值常量是正的。

运算符 – 可以对某个数进行取负操作。例如

int m = -8, n = -m;

严格来讲,+1、-8都不是字面值常量,而是常量表达式。+、- 被解释成一元运算符而不是数值的一部分。当然,这点细节在实践中并没有什么意义。

二元运算符

二元运算符有:

+0000-0000*0000/0000%

加减运算符+、- 也可以用于指针运算。

使用除法运算符(/)和取余运算符(%)时需要注意的是

  • 用于%的操作数必须是整数,它的结果的符号与左操作数相同。所以 -9 % 7 = -2。
  • 零不能用作 / 和 % 的右操作数。
  • 当两个操作数都是整数时,它的结果向零截取(truncation toward zero),小数部分被舍弃。所以 29 / 5 / 2 的结果2,而-3 / 2 的结果是-1。

但是在C89中,当取余运算的操作数中有负数,结果的符号是不确定的。所以 -9 % 7 的结果可能是 -2 也有可能是 5 。

C89对除法的取整规则也没要求。所以 – 3 / 2 的结果可能是 -1 也有可能是 -2。

自增运算符与自减运算符

自增运算符 ++ 表示操作数加1,自减运算符 — 表示操作数减1,它们都要求操作数是一个左值,也都有两种表示方式:前缀和后缀。

下面的四条语句有什么不同?几乎所有C语言的书都乐此不疲的讨论这个问题。

在这种上下文环境中,它们的效果没有任何区别,都将 i 自增了1。

但从语义上来讲,第一条语句表示的是,将 i 与 1 相加后赋值给 i 。第二条的意思是将 i 加 1 ,结果存储在 i 中,它与第一条语句的不同之处是,后者的 i 只被计算了1次。

这两种表示方法在现在的编译器中已经不会存在效率上的差异,但是后者能让代码更清楚。例如

你能一眼就确定它们两个是相同的吗?

再来看前缀表示和后缀表示。后缀表示的意思是,将 i 的值先存到寄存器中,然后将 i 的内存里的值加1 。前缀是“取i的地址,将这个地址里的值加1,然后把加1后的值放在寄存器”。为了了解这有什么不同,现在来考虑下面的两条语句:

语句1执行后,a 、b 、n的值分别是多少呢? a+++b等效于a+(++b)还是(a++)+b?

这就要知道编译器对多个字符处理的策略。这种策略叫做“贪心法”,即,编译器从左到右一个字符一个字符地读入,如果该字符可能组成一个符号,那么再读入下一个字符,判断已经读入的两个字符组成的字符串是否可能是一个符号的组成部分;如果可能,继续读入下一个字符,重复上述判断,直到读入的字符组成的字符串已不再可能组成一个有意义的符号。

所以语句1应该等效于(a++)+b。

根据上述的后缀表示的含义,可以知道,这条语句的执行顺序是,先把a的值保存在寄存器中,保存a的内存的值加1,a的寄存器中的值与b相加,结果赋值给n,所以a、b、n的值分别是2、1、2。

再来看语句2。先将a的内存的值加1,再将其与b相加,结果赋值给n,所以最后a、b、n的值分别是3、1、4。

知道了这些规则,下面的语句就不难解释了。

语句1相当于

而不是

语句2相当于

语句3相当于

语句4相当于

但是上述的“相当”并不是严格的相同,因为原语句只产生一个序列点,而我们说的与之相当的语句却有三个序列点。

了解了这些,再来看一个经典的问题:

执行这条语句后,n的值是多少呢?

答案是Undefined。因为在这个表达式中有三个副作用都在改变i的值,这些副作用按什么顺序发生不确定,只知道在整个表达式求值结束时一定都发生了。比如现在求第二个++i 的值,这时第一个、第三个++i 发生了没有都不确定,所以第二个++i 的值也不确定。用不同的编译器编译会得到不同的结果。例如,用GCC和VC6.0编译的结果是22,而Turbo C的结果是24。

不止是有多个副作用才会造成未定义的结果,即使是 n = i + i++的结果也是未定义的,因为两个 i 的读写顺序是不确定的。

为了避免不必要的麻烦,不应在两个序列点之间多次改变同一个变量,也不应在读写顺序不确定的情况下对同一变量同时进行读写。在 i = i + 1; 语句中i 的读写顺序是确定的,先读出再写,所以不会出现问题。

移位操作符

我们知道,计算机中的信息都是以二进制存储的。每个字节(byte)由8个二进制位组成,这种二进制数字串被称为这个数据的位模式。比如109的位模式为:

109的二进制表示

C语言中提供的移位操作就是可以向左或向右移动位模式。表达式 x<<k 会生成一个值,其位模式是x的最高的k位被丢弃,右端补k个0。比如 109 << 3 的过程:

109的移位操作

与左移相对应的是右移操作,但它的行为并不像左移那样简单。右移有两种方式:逻辑右移和算术右移。逻辑右移在左端补0,而算术右移在左端补k个最高有效位的值。为右移使用两种方式是非常有意义的。让我们来看下面的例子:

操作 x=109[01101101] x=-109[11101101]
 x<<3  01101000  01101000
 x>>3(逻辑右移)  00001101  00011101
 x>>3(算术右移)  00001101  11111101

可以看到[11101101]在算术右移时,最高位填充的是1,这样就保证负数算术右移后的结果也是负数。

但是C标准并没有规定右移应该使用哪种方式。对于无符号数,右移一定是逻辑的。而对于无符号数,其结果是 implementation-defined 的。但是,在几乎所有机器上,其使用的都是算术右移。这样,对于无符号数或有符号但其值非负的数,x>>k的结果等同于x/2k,但对于负数并不一定如此,比如(-1)/2的结果应该是0,但(-1)>>1的结果却是 -1。

当移动的位数是负数或者比数据的总位数还大时会怎样呢?C标准对于这种的结果定义是 undefined 的。虽然很多机器对于移动的位数大于总位数采取对总位数取余的方式,即 x<<36 在32位机器上和 x<<4的结果是相同的,但这并不能保证;移动负数位通常也是没有意义的。所以应该避免这种情况。

移位操作只对整数类型的操作数有意义。

位操作符

位操作是对它们的操作数的每个位执行AND、OR或XOR操作。它们对应的操作符是 &、|、^。它们的运算规则可以用下图表示:

位运算

位操作也只能用于整数运算。

赋值操作符

C语言中并不存在赋值语句,赋值就像+那样是操作符。是操作就要产生结果,那么操作运算的结果是什么呢?

n = i 的结果值是n赋值后的值,所以我们可以把几个赋值操作连在一起:

因为赋值运算符是右结合的,所以上述语句等价于:

k = 0 的结果赋值给 j ,然后再将其结果赋值给 i 。

需要注意的是,因为赋值存在类型转换,这种连续赋值可能得到不是我们想要的结果:

首先将1.5转换成整形将1赋给i,又将1转换成浮点型1.0赋值给 f ,这时 f 的值不是我们预期的1.5而是1.0。

赋值操作要求左操作数必须是可更改的左值,但它的结果却不是左值。同样,自增自减运算的结果也不是左值,所以 i++ = n 是错误的语句。

合法的赋值操作必须满足以下条件之一:

  • 左操作数是有限定符或无限定符的算术类型,右操作数是算术类型
  • 左操作数是和右操作数相容的有限定符或无限定符的结构或联合体
  • 左右操作数都指向有限定符或无限定符的相容类型,并且左指针指向的类型必须要有右指针指向类型的全部限定符
  • 一个操作符是指向对象或不完整类型的指针,令一个是指向有限定符或无限定符的void类型的指针
  • 左操作数是一个指针,右操作数是空指针常量(NULL)
  • 左操作数是布尔类型(_Bool)变量,右操作数是指针。

理解这几个条件在编程中是很重要的,考虑下面的程序:

赋值1是违法的。

要明白这个为什么不合法,首先应该知道,被指定为 “char *” 类型的变量是一个指向字符型的指针,它的类型是指针类型,而不是字符类型。它的有const限定符的版本是 “char * const”,而 “const char *”类型不是一个有限定符的类型——它的类型是“指向一个具有const限定符的char类型的指针”,也就是说,const修饰的是指针所指向的类型,而不是指针本身。

所以const char **是没有限定符的,同样,char **也没有限定符。

另一方面,&p的类型和char **类型是相容的。char ** 和const char **都是没有限定符的指针类型,但它们所指向的类型不同,前者指向char * ,后者指向 const char *,所以它们是不相容的。赋值1语句也就是不合法的。

赋值2和赋值3都是合法的。

为什么要做这样的限制呢?

现在考虑,假如没有这个,也就是说这三个赋值操作都是合法的,在赋值1执行之前,它们的内存分布如图所示:

赋值操作限制1

赋值1语句执行后:

赋值操作限制2

赋值2语句执行后:

赋值操作限制3

赋值3语句执行后:

赋值操作限制4

此时c的值已经变成0了,但是它的声明是const 的,所以赋值3不能执行成功。

所以,有了上述的限制,就可以避免这种矛盾。但是你如果使用的GCC来编译这段程序却可以编译通过,只是发出一条警告。这是因为GCC默认使用的是C语言的GNU“方言”(一种C语言的GNU实现),被称为 GNU C。该实现集成了C语言官方ANSI/ISO标准和一些GNU对C语言的扩展。为了应用上述的限制,需要加“-ansi”选项来禁止那些与 ANSI/ISO 标准冲突的 GNU 扩展特性。

另外还有一组复合赋值符:

+=0000-=0000*=0000/=0000%=0000<<=0000>>=0000&=0000^=0000|=

这些操作符都是类似的,这里我们只讨论+=。

a += expression

相当于

a = a + (expression)

但并不完全相同。这不仅仅表现在 += 写的代码更清晰和方便一点。

更重要的是 += 操作符的左操作数只求值一次,而后者会求值两次。所以前者的效率可能会更高一点。由于编译器的优化,效率也可能相同。但像下面这一个例子:

因为编译器没有办法确定两次调用f函数是否相同,所以它没有办法做优化,前者的效率也就一定高于后者。

这两种写法的结果也有可能不同,比如:

前者i只自增一次,但后者可能自增两次,我们没有办法确定到底会发生什么,所以后者的结果是未定义的。

关系操作符

关系操作符用来测试操作数之间的大小关系。C语言提供的关系操作符有:

>0000>=0000<0000<=0000!=0000==

前4个操作符的功能显而易见。!=用来测试“不相等”,==用来测试“相等”。它们通常用在if或for语句中,作为测试表达式。

C语言是用非0值表示真,0表示假的,所以这些操作符产生的结果是一个整型。如果比较的关系为真,那么表达式的值是1,否则是0。但这里需要注意的是,表达式 a < b < c 在C语言中是合法的,但是因为 < 是左结合的,所以这个表达式等价于 (a < b) < c。又因为 a < b 的结果是1或0,所以这个表达式是用1或0和c比较,这通常并不是我们想要的。

但用整型来表示布尔型的规则也给我们带来了好处。我们在比较的时候可以使用一些简写方法:

if (expression != 0) …

if (expression) …

if (expression == 0) …

if (!expression) …

每对写法的功能是相同的。但需要注意的是,== 和 = 是完全不同的,如果你原本打算写:

if (expression == 0) …

不小心写成了:

if (expression = 0) …

那么因为赋值表达式的结果是给左操作数赋值后的值,所以后者的结果一定是0(除非左操作数不是一个左值,那么编译器会报错),这个测试表达式的值也就始终是假了。这里有个小技巧就是,每次在和常量作比较时,都这样写:

if (0 == expression) …

那么在你少写了一个=时

if (0 = expression) …

编译器就会报错。

逻辑操作符

逻辑操作符有 && 和 || 两个,它们看起来和位运算 & 和 | 很像,但却有本质的不同——它们用来测试表达式的真假。和关系操作符一样,它们的表达式的结果也是1(真)或0(假)。

expression1 && expression2

如果 expression1expression2 的值都是真,那么整个表达式的值就是真,否则是假。

expression1 || expression2

如果 expression1expression2 的值至少有一个是真,那么整个表达式的值就是真,否则是假。

它们都有一个有趣的行为,被称为“短路求值”。expression1 总是首先被求值,在 && 中,如果 expression1 的值是假,那么 expression2 就不会被求值,因为只要 expression1 是假,整个表达式的值就一定为假;在 || 中也有类似的行为,只要 expression1 的值是真,就不必对 expression2 求值。作为例子,我们下面用 && 来讨论这点。

我们可以把最有可能是假的表达式放在前面,这样就有很大概率提高效率,当然前提是 expression2expression1 是无关的。比如在链表中,我们经常需要判断指针是否为空然后比较值的大小:

if (node != NULL && node->value > 0)

这时你就不能交换两者的位置:

if (node->value > 0 && node != NULL)

因为一旦指针为空,那么 node->value > 0 的比较将出错,无论如何 node != NULL 都不会执行。

和 == 与 = 操作符一样,&&、|| 经常被误写成 &、|,比 == 更不幸的是,没有一个良好的习惯可以避免这种错误的发生,我们唯一能做是就是小心出错。

条件操作符

条件操作符接受三个操作数:

expression1 ? expression2 : expression3;

首先计算 expression1,如果它的值为真,那么整个表达式的值就是 expression2expression3 不再被执行;如果 expression1 为假,那么整个表达式的值就 expression3 的值,expression2 不会被执行。例如:

它的意思是,如果 n > 0,就执行 b * 2,并把它的值作为整个表达式的值赋给 n;否则,执行 c / 2,并把它的值作为整个表达式的值赋给 n。

可以看出,这和下面的if分支结构是一样的:

但是使用条件操作符的代码中“a=”只写了一次,这和复合赋值操作符是类似的,在某些时候可以减少不少工作量:

不仅如此,使用条件操作符的语句的效率可能比使用分支结构的代码的效率高,这和体系结构有关,这里就不详细讨论了。

sizeof 操作符

sizeof 经常被误认为是函数,但其实它是一个操作符,至少从它的操作数的形式就可以看出,你不可能在调用一个函数的时候不给参数加括号,也不可能把 char、int 等关键字当做函数参数,但这些却可以用于 sizeof。

sizeof 可以获取一个变量或类型的大小,它的用法是:

sizeof (typename)
sizeof (expression)
sizeof expression

它的值是一个无符号整数,表示操作数所需要的字节数。比如 sizeof (char) 的值是1,但 sizeof (int)通常是4,但在不同机器可能有不同的值。如果i是整型变量并且 sizeof (int) = 4,那么 sizeof (i) 和 sizeof (1) 和 sizeof (i + 1)的值都是4。

虽然在计算表达式的大小的时候,括号可以省略,但极其不建议这么做,它可能引起不必要的困惑或错误。比如:

你可能在仔细分析能够发现,这里只有一个乘号,*p是sizeof的第二个操作数。

但是你能清楚的知道下面的语句的语义吗?

它的意思是int占用的字节数乘以p,还是把*p转换成int型,然后进行sizeof操作?如果前面还有一句:

你可能会觉得上面那条语句的意思是后者,但真相是,那条语句是错误的。还记得前面我们说过的编译器的贪心策略吗?编译器在发现sizeof (int)是合法词语但sizeof (int) * 不是后会将sizeof (int)解释成一个词语而把 * 解释成乘号,一个无符号整型乘以一个指针当然是错误的。

可以看出,其实 sizeof 远没有看起来这么简单,比如,我们知道 char型的大小都是 1,那么 sizeof (‘a’) 的值是几呢?

你测试之后会发现,它的值并不是 1,而是和 int 一样的大小,这是因为一个字符常量是被当作 int 来运算的。但是 char a;sizeof (a) 的结果却是 1。

另外,在32位机上

看起来有些出乎意料,其实很好理解。首先p是一个指针,所以sizeof (p)是一个指针的大小;*p 是一个字符,所以 sizeof (*p)的值是1;数组a占了3个字节(字符ab和一个表示字符串结束的’\0′),sizeof 可以确定数组的大小,所以sizeof (a) 的值是3;最后一个和第3个类似,得到的是字符串占用的字节数。

其他操作符

为了完整性,这里顺便提一下在其他地方已经或将会见到的操作符。

!操作符对操作数执行逻辑反操作。如果操作数为真,那么取反的结果是0;否则为1。

&是取地址符,取得操作数的地址。*是间接引用操作符,获得某个地址上所存的值,与指针一起使用。例如,下面的语句将变量a的地址赋给了指针变量b,又取得地址b上存的值赋给了c,因为地址b就是变量a的地址,所以c就等于了a。

,逗号操作符将多个表达式分开,它是优先级最低的操作符:

expression1, expression2, … , expressionN

这些表达式从左到右的被执行,整个逗号表达式的值就是最后 expressionN 的值。例如:

i的值实际上等于 c * 3,但 a++ 也是被执行了的。

[]是下标引用操作符。实际上,下标引用操作符除了在优先级上和间接引用操作符不同外,它们是完全等价的:

array[index]

*(array + (index))

()有多种作用:

  • 函数定义和声明时,包围形参列表
  • 调用一个函数
  • 改变表达式的运算顺序
  • 用于类型转换
  • 定义带参数的宏
  • 包围 sizeof 操作符的操作数
  • 用作聚组操作符

在下面这条语句中同时使用了它的多个作用:

它调用了一个名字为 f 的函数,这个函数接收3个参数,没错,( a++, b / (c + 2)) 是一个参数,把 d 转换成 int 型后作为 f 的第三个参数。下面我们还会详细讨论类型转换。

. 和 -> 用于访问结构的某个成员。

四、操作符的优先级和求值顺序

一个表达式可能会用到多个操作符,比如:

虽然这样的语句很奇怪也极少见,但它确实是合法的。

要弄清楚它的含义并不容易。表达式的求值顺序是由多个因素决定的:操作符的优先级和结合性,以及操作符是否影响求值的顺序。两个相邻的操作符哪个先执行哪个后执行取决于它们的优先级;如果优先级相同,则由结合性控制。结合性意味着一串操作符是从左向右执行还是从右向左执行。有些操作符还能控制求值的顺序,保证在某个子表达式求值前另一个子表达式的求值已完成。

下表列出了所有操作符以及它们的属性。

操作符的优先级
优先级 操作符 描述 用法示例 结果类型 结合性 是否影响求值顺序
1 () 

()

[]

.

->

聚组 

函数调用

下标引用

访问结构成员

访问结构指针成员

(exp) 

f(exp1, …, expN)

exp[index]

exp.mem_name

exp->mem_name

与exp相同 

右值

左值

左值

左值

N/A 

L-R

L-R

L-R

L-R

否 

2 ++ 

!

~

+

-

++

*

&

sizeof

(typename)

后缀自增 

后缀自减

逻辑反

按位反

单目,表示正值

单目,表示负值

前缀自增

前缀自减

间接引用

取地址

取其长度

类型转换

exp++ 

exp–

!exp

~exp

+exp

-exp

++exp

–exp

*exp

&exp

sizeof exp|sizeof(typename)

(typename)exp

右值 

右值

右值

右值

右值

右值

右值

右值

右值

右值

右值

右值

L-R 

L-R

R-L

R-L

R-L

R-L

R-L

R-L

R-L

R-L

R-L

R-L

否 

3

/

%

乘法除法 

整数取余

exp1 * exp2 

exp1 / exp2

exp1 % exp2

右值 

右值

右值

L-R 

L-R

L-R

否 

4

-

加法 

减法

exp1 + exp2 

exp1 – exp2

右值 

右值

L-R 

L-R

否 

5 <<>> 左移位 

右移位

exp1 << exp2 

exp1 >> exp2

右值 

右值

L-R 

L-R

否 

6

<=

>

>=

小于 

小于等于

大于

大于等于

exp1 < exp2 

exp1 <= exp2

exp1 > exp2

exp1 >= exp2

右值 

右值

右值

右值

L-R 

L-R

L-R

L-R

否 

7 == 

!=

等于 

不等于

exp1 == exp2 

exp1 != exp2

右值 

右值

L-R 

L-R

否 

8 & 位与 exp1 & exp2 右值 L-R
9 ^ 位异或 exp1 ^ exp2 右值 L-R
10 | 位或 exp1 | exp2 右值 L-R
11 && 逻辑与 exp1 && exp2 右值 L-R
12 || 逻辑或 exp1 || exp2 右值 L-R
13 ?: 条件操作符 exp1 ? exp2 : exp3 右值 N/A
14

+=

-=

*=

/=

%=

>>=

<<=

&=

^=

|=

赋值操作符 exp1 op exp2 右值 R-L
15 , 逗号 exp1, exp2 右值 L-R

可以看到,()的优先级最高,这也是我们可以使用()改变运算顺序的原因。所以,之前的那个表达式等价于:

其实有些操作符的优先级已经被吐槽很多了,但C标准并没有要修改优先级规则的意思,因为一旦改变了有些操作符的优先级,大量的现有代码就会出问题。

之所以说有些优先级是错误的,是因为它们的优先级和人们的第一感觉不一样。下面总结了这些可能被误会的操作符。

问题 示例表达式 可能误以为的语义 实际语义
.和->的优先级高于* *p.a 访问p所指对象的成员a 

(*p).a

把p的成员a当作指针并进行解引用 

*(p.a)

[]高于* int *p[] p是指向int数组的指针 

int (*p)[]

p是元素是int指针的数组 

int *(p[])

()高于* void *fp() fp是个函数指针,所指函数返回void 

void (*fp)();

fp是个函数,返回void型指针 

void *(fp())

==和!=高于位操作符 a & b == 0 (a & b) == 0 a & (b == 0)
==和!=高于赋值符 c = getchar() != EOF (c = getchar()) != EOF c = (getchar() != EOF)
算术运算高于移位运算 a << 1 + b (a << 1) + b a << (1 + b)
,优先级最低 i = 1, 2 i = (1, 2) (i = 1), 2

即便如此,也没必要刻意去记忆这些操作符的优先级,在你不确定的时候总是加上()是万无一失的方法,这样也可以让代码更加可读。

有了优先级和结合性规则,也不能保证每个子表达式的计算都是可预测的,特别是有函数调用子表达式时:

虽然可以确定 g() 和 h() 执行乘法运算,但不能确定是先调用g()还是h(),也不能确定f()的调用在它们相乘之前还是之后,所以这三个函数的调用顺序是完全不可预测的,特别是当结果依赖它们的调用顺序时,就可能出现问题。

五、类型转换

计算机在执行运算时,通常要求操作数是相同的类型。比如,计算机可以执行两个32位整数的加法,也可以执行两个16位整数的加法,但没办法执行执行一个16位和一个32位整数的加法。

但在写C程序时,很多时候并没有这种限制。比如你不会觉得这句的代码有什么问题:

这是因为C编译器自动将不同的类型转换成了相同的类型,这种行为称为“隐式类型转换(implicit conversion)”。

像这样,许多操作数类型为算术类型的双目运算符会引发类型转换。这种转换采用下面的“寻常算术转换”模式:

  • 如果其中一个操作数是long double型,另一个操作数也被转换成long double
  • 否则,如果一个操作数是double,则另一个也被转换成double
  • 否则,如果一个操作数是float,则另一个也被转换成float
  • 否则,也就是说两个操作数都是整数类型,如果操作数有相同的类型,就没有必要进行转换
  • 否则,如果两个操作数都是无符号的或都是有符号的,那么较小的类型转换成较大的类型。这里所说的“较小的类型”实际上是指整型转换等级,简单来说,long long int > long int > int > short int > char > _Bool,各个类型的无符号形式的等级等于有符号形式的类型。
  • 否则,如果一个操作数是无符号类型并且它的转换等级大于等于另一个,那么这个有符号类型被转换成无符号类型操作数的类型
  • 否则,如果一个操作数是有符号类型,并且这个类型能表示那个无符号操作数类型的所有值,那么这个无符号操作数被转换成有符号操作数的类型
  • 否则,两个操作数都转换成与有符号操作数类型相对应的无符号类型。

其实,在需要int或unsigned int 的表达式中,布尔型、字符型也可以出现,这其中的转换过程被称为“整型提升(integer promotions)”。简单的说,就是如果一个表达式中含有_Bool、char、或整型位段(bit-field),包括它们的有符号或无符号类型,以及枚举类型等转换等级小于或等于int的类型,如果 int 型可以表示原有类型的值,那么这些类型都转换成int型来运算,否则转换成unsigned int。如果原有类型的等级大于int,比如long int 、long long int以及它们的无符号类型,那么这些值都不变。这种整型提升采用值保留的原则,就是当几个操作数混合使用时,结果类型可能是有符号的,也可能是无符号的,这取决于操作数的类型的相对大小。与之相对应的是早期 K&R C采用的无符号保留原则,就是当一个无符号类型与int或更小的整型混合使用时,结果类型是无符号的。

除了隐式类型转换,C语言也提供了“显示类型转换(explicit conversion)”。它的用法是:

(typename) expression

typename 就是想要转换成的类型名。()在这里被称为类型转换操作符(cast operators),它有很高的优先级,所以它只会作用于表达式的第一个操作数,如果需要转换整个表达式,就需要将整个表达式括起来。这样的转换也要注意一些规则:

  • 如果要转换成的目标类型是_Bool,那么只有在操作数是0的时候,其结果才是0;否则,结果是1
  • 否则,如果操作数是整数类型,目标类型也是除了_Bool的整数类型,那么如果目标类型可以表示操作数,那么结果值不变;否则,如果目标类型是无符号的,那么结果值就是操作数重复加上或减去目标类型所能表示的最大值直到这个值能被目标类型表示。比如 (unsigned char)-5 的结果是251,(unsigned char)260 的值是4;否则,如果目标类型是有符号的,并且目标类型不能表示操作数,那么结果值是依实现定义的
  • 否则,如果操作数是浮点类型,目标类型是除了_Bool的整数类型,那么把浮点数的小数部分丢弃后的值当作结果值,如果这个丢弃小数部分的值不能在目标类型中表示,那么结果是未定义的
  • 否则,如果操作数可以在目标类型中表示,结果值不变;否则,结果值是向上或向下(由实现定义)与操作数最近似的可以用目标类型表示的值,如果操作数超过了目标类型能够表示的值的范围,那么结果是未定义的

类型转换也有一些微妙的现象。比如假设c是一个字符变量,现在希望得到与c等价的无符号整数,可能使用 (unsigned)c,但这可能是错误的,因为在字符转换为无符号整数时,c首先会被整型提升为int型,如果原来的c是一个有符号的,并且最高位是1,那么按照值保留的原则,编译器在将char扩展成int时,会同时复制符号位1,将这个int型再转换成无符号类型时,其结果就并非和预期的一样。正确的方法是使用语句 (unsigned char)c,因为 unsigned char 型在转换为无符号整数时无需先转换成int型整数,而是直接进行转换的。

另外,我们提到,早起K&R C采用无符号保留的原则,而ANSI C使用值保留的原则,所以 -1 < (unsigned char) 1 在采用不同原则的编译器上会有不同的结果。