要理解一个C程序,仅仅理解组成该程序的符号是不够的。程序员还必须理解这些符号是如何组合成声明、表达式、语句和程序的。虽然这些组合方式的定 义都很完备,几乎无懈可击,但有时这些定义与人们的直觉相悖,或者容易引起混淆。本章将讨论一些用法和意义与我们想当然的认识不一致的语法结构。
当计算机启动时,硬件将调用首地址为0位置的子例程。为了模拟开机启动时的情形,我们必须设计出一个C语句,以显式调用该子 例程。经过一段时间的思考,我们最后得到的语句如下:
(*(void(*) ())0) ();
像这样的表达式恐怕会令每个C程序员的内心都“不寒而栗”。然而,他们大可不必对此望而生畏,因为构造这类表达式其实只有一条简单的规则:按照使用的方式来声明。
任何C变量的声明都由两部分组成:类型以及一组类似表达式的声明符 (declarator)。声明符从表面上看与表达式有些类似,对它求值应该返回一个声明中给定类型的结果。最简单的声明符就是单个变量,如:
float f,g;
这个声明的含义是:当对其求值时,表达式f和g的类型为浮点数类型(float)。因为声明符与表达式的相似,所以我们也可以在声明符中任意使用括号:
float ((f));
这个声明的含义是:当对其求值时,((f))的类型为浮点类型,由此可以推知, f也是浮点类型。
同样的逻辑也适用于函数和指针类型的声明,例如:
float ff();
这个声明的含义是:表达式ff()求值结果是一个浮点数,也就是说,ff是一个返回值为浮点类型的函数。类似地,
float *pf;
这个声明的含义是*pf是一个浮点数,也就是说,pf是一个指向浮点数的指针。
以上这些形式在声明中还可以组合起来,就像在表达式中进行组合一样。因 此,
float *g(),(*h)();
表示*g()与(h)()是浮点表达式。因为()结合优先级高于, g()也就是(g());g是一个函数,该函数的返回值类型为指向浮点数的指针。同理,可以得出h是一个函数指针,h所指向函数的返回值为浮点类型。
一旦我们知道了如何声明一个给定类型的变量,那么该类型的类型转换符就很容易得到了:只需要把声明中的变量名和声明末尾的分号去掉,再将剩余的部分用一个括号整个“封装”起来即可。例如,因为下面的声明:
float (*h)();
表示h是一个指向返回值为浮点类型的函数的指针,因此,
(float (*)())
表示一个“指向返回值为浮点类型的函数的指针”的类型转换符。
拥有了这些预备知识,我们现在可以分两步来分析表达式
(*(void(*)())0)
第一步,假定变量fp是一个函数指针,那么如何调用fp所指向的函数呢? 调用方法如下:
(*fp)();
因为fp是一个函数指针,那么*fp就是该指针所指向的函数,所以(*fp)()就是调用该函数的方式。ANSI C标准允许程序员将上式简写为fp(),但是一定要记住这种写法只是一种简写形式。
在表达式(fp)()中,fp两侧的括号非常重要,因为函数运算符()的优先级高于单目运算符。如果两侧没有括号,那么fp()实际上与*(fp())的含义完全一致,ANSI C把它作为*((fp())的简写形式。
现在,剩下的问题就只是找到一个恰当的表达式来替换fp。我们将在分析的 第二步来解决这个问题。如果C编译器能够理解我们大脑中对于类型的认识,那 么我们可以这样写:
(*0)();
上式并不能生效,因为运算符必须要一个指针来做操作数。而且,这个指针还应该是一个函数指针,这样经运算符作用后的结果才能作为函数被调用。因此,在上式中必须对0作类型转换,转换后的类型可以大致描述为:“指向返回值为void类型的函数的指针”。
如果fp是一个指向返回值为void类型的函数的指针,那么(*fp)()的值为void, fp的声明如下:
void (*fp)();
因此,我们可以用下式来完成调用存储位置为0的子例程:
void (*fp)();
(*fp)();
这种写法的代价是多声明了一个“哑"变量。
但是,我们一旦知道如何声明一个变量,也就自然知道如何对一个常数进行 类型转换,将其转型为该变量的类型:只需要在变量声明中将变量名去掉即可。
因此,将常数0转型为“指向返回值为void的函数的指针”类型,可以这样写:
(void (*)0)0
因此,我们可以用(void (*)())0来替换fp,从而得到:
(*(void (*)())0)();
末尾的分号使得表达式成为一个语句。
在我当初解决这个问题的时候,C语言中还没有typedef声明。尽管不用 typedef来解决这个问题对剖析本例的细节而言是一个很好的方式,但无疑使用 typedef能够使表述更加清晰:
typedef void (*funcptr)();
(* (funcptr)0)();
这个棘手的例子并不是孤立的,还有一些C程序员经常遇到的问题,实际上和这个例子是同一个类型的。例如,考虑signal库函数,在包括该函数的C编译器实现中,signal函数接受两个参数:一个是代表需要“被捕获”的特定signal 的整数值;另一个是指向用户提供的函数的指针,该函数用于处理“捕获到”的 特定signal,返回值类型为void。
一般情况下,程序员并不主动声明signal函数,而是直接使用系统头文件 signal.h中的声明。那么,在头文件signal.h中,signal函数是如何声明的呢?
首先,让我们从用户定义的信号处理函数开始考虑,这无疑是最容易解决的。 该函数可以定义如下:
void sigfunc(int n){
/*特定信号处理部分*/
}
函数sigfunc的参数是一个代表特定信号的整数值,此处我们暂时忽略它。
上面假设的函数体定义了 sigfunc函数,因而sigfunc函数的声明可以如下:
void sigfunc(int ):
现在假定我们希望声明一个指向sigfunc函数的指针变量,不妨命名为sfp因为sfp指向sigfunc函数,则sfp就代表了sigfunc函数,因此sfp可以被调用。
又假定sig是一个整数,则(*sfp)(sig)的值为void类型,因此我们可以如下声明sfp:
void (*sfp)(int);
因为signal函数的返回值类型与sfp的返回类型一样.上式也就声明了 signal函数,我们可以如下声明signal函数:
void (*signal (soznet?iing) ) (int);
此处的something代表了 signal函数的参数类型,我们还需要进一步了解如 何声明它们。上面声明可以这样理解;传递适当的参数以调用signal函数,对signal 函数返回值(为函数指针类型)解除引用(dereference),然后传递一个整型参数 调用解除引用后所得函数,最后返回值为void类型。因此,signal函数的返回值 是一个指向返回值为void类型的函数的指针。
那么,signal函数的参数又是如何呢? signal函数接受两个参数:一个整型的 信号编号,以及一个指向用户定义的信号处理函数的指针。我们此前已经定义了 指向用户定义的信号处理函数的指针sfp:
void (*sfp) (int);
Sfp的类型可以通过将上面的声明中的sm去掉而得到,即Void (*)(int)。此 外,signal函数的返回值是一个指向调用前的用户定义信号处理函数的指针,这 个指针的类型与sfj)指针类型一致。因此,我们可以如下声明signal函数:
void (signal(int r void(*) (int})) (int);
同样地,使用typedef可以简化上面的函数声明:
typedef void ("HANDLER)(int);
HANDLER signal(intr HANDLER);
假设存在一个已定义的常量FLAG, FLAG是一个整数,且该整数值的二进制表示中只有某一位是1,其余各位均为0,亦即该整数是2的某次幂。如果对于整型变量flags,我们需要判断它在常量FLAG为1的那一位上是否同样也为1,
通常可以这样写:
if (flags & FLAG) ...
上式的含义对大多数C程序员来说是显而易见的:if语句判断括号内表达式的值是否为0。考虑到可读性,如果对表达式的值是否为0的判断能够显式地加以说明,无疑使得代码自身就起到了注释该段代码意图的作用。其写法如下,
if (flags & FLAG != 0)...
这个语句现在虽然更好懂了,但却是一个错误的语句。因为!=运算符的优先级要高于&运算符,所以上式实际上被解释为:
if (flags & (FLAG != 0))...
因此,除了 FLAG恰好为1的情形,FLAG为其他数时这个式子都是错误的。
又假设hi和low是两个整数,它们的值介于0到15之间,如果r是一个8 位整数,且r的低4位与low各位上的数一致,而r的高4位与hi各位上的数一 致。很自然会想到要这样写:
r = hi<<4 十 low;
但是很不幸,这样写是错误的。加法运算的优先级要比移位运算的优先级高, 因此本例实际上相当于:
r = hi<< (4 + low);
对于这种情况,有两种更正方法:第一种方法是加括号;第二种方法意识到 问题出在程序员混淆了算术运算与逻辑运算,但这种方法牵涉到的移位运算与逻辑运算的相对优先级就更加不是那么明显。两种方法如下:
r =(hi<<4) + low; //法 1:加括号
r = hi<<4 | low; //法2:将原来的加号改为按位逻辑或
用添加括号的方法虽然可以完全避免这类问题,但是表达式中有了太多的括 号反而不容易理解。因此,记住C语言中运算符的优先级是有益的。
遗憾的是,运算符优先级有15个之多,因此记住它们并不是一件容易的事。
C语言运算符优先级表(由上至下,优先级依次递减)
运算符 | 结合性 |
---|---|
() [] -> | 自左向右 |
! ~ ++ – - (type)* & sizeof | 自右相左 |
* / % | 自左向右 |
+ - | 自左向右 |
<< >> | 自左向右 |
< <= > => | 自左向右 |
== != | 自左向右 |
& | 自左向右 |
^ | 自左向右 |
| | 自左向右 |
&& | 自左向右 |
|| | 自左向右 |
?: | 自右相左 |
assignments | 自右相左 |
, | 自左向右 |
如果把这些运算符恰当分组,并且理解了各组运算符之间的相对优先级,那 么这张表其实不难记住。
优先级最高者其实并不是真正意义上的运算符,包括:数组下标、函数调用 操作符各结构成员选择操作符。它们都是自左于右结合,因此a.b.c的含义是 (a.b).c,而不是 a.(b.c)
单目运算符的优先级仅次于前述运算符。在所有的真正意义上的运算符中, 它们的优先级最高。因为函数调用的优先级要高于单目运算符的优先级,所以如果p是一个函数指针,要调用p所指向的函数,必须这样写:(p)()。如果写成p(), 编译器会解释成*(p())。类型转换也是单目运算符,它的优先级和其他单目运算符的优先级一样。单目运算符是自右至左结合,因此p++会被编译器解释成(p++), 即取指针p所指向的对象,然后将p递增1;而不是(*p)++,即取指针p所指向 的对象,然后将该对象递增1。本书3.7节还进一步指出了 p++的含义有时会出人意料。
优先级比单目运算符要低的,接下来就是双目运算符。在双目运算符中,算术运算符的优先级最高,移位运算符次之,关系运算符再次之,接着是逻辑运算 符,赋值运算符,最后是条件运算符。
我们需要记住的最重要的两点是:
1 .任何一个逻辑运算符的优先级低于任何一个关系运算符。
2.移位运算符的优先级比算术运算符要低,但是比关系运算符要高。
属于同一类型的各个运算符之间的相对优先级,理解起来一般没有什么困难。 乘法、除法和求余优先级相同,加法、减法的优先级相同,两个移位运算符的优先级也相同。1/2*a的含义是(1/2)a,而不是1/(2a),这一点也许会让某些人吃惊,其实在这方面C语言与Fortran语言、Pascal语言以及其他程序设计语言之间的行为表现并无差别。
但是,6个关系运算符的优先级并不相同,这一点或许让人感到有些吃惊。 运算符==和!=的优先级要低于其他关系运算符的优先级。因此,如果我们要比较a与b的相对大小顺序是否和c与d的相对大小顺序一样,就可以这样写:
a < b == c < d
任何两个逻辑运算符都具有不同的优先级=所有的按位运算符优先级要比顺 序运算符的优先级高,每个“与”运算符要比相应的“或”运算符优先级高,而 按位异或运算符(^运算符)的优先级介于按位与运算符和按位或运算符之间。
这些运算符的优先顺序是由于历史原因形成的。B语言是C语言的“祖先”, B语言中的逻辑运算符大致相当于C语言中的&和|运算符。虽然这些运算符从 定义上而言是按位操作的,但是当它们出现在条件语句的上下文中时,B语言的 编译器会将它们作为相当于现在C语言中的&&和||运算符处理。而到了C语言 中,这两种不同的用法被区分开来,从兼容性的角度来考虑,如果对它们优先顺 序的改变过大将是一件危险的事。
在本节到现在为止提及的所有运算符中,三目条件运算符优先级最低。这就 允许我们在三目条件运算符的条件表达式中包括关系运算符的逻辑组合,例如:
tax_rate = income>40000 && residency<5 ? 3.5: 2.0;
本例其实还揭示了:赋值运算符的优先级低于条件运算符的优先级是有意义
的。此外,所有的赋值运算符的优先級是一样的,而且它们的结合方式是从右到 左,因此,
home_score =visitor_score = 0;
与下面两条语句所表达的意思是相同的:
visitor_score = 0;
home_score = visitor_score;
在所有的运算符中,逗号运算符的优先缓最低。这一点很容易记住,因为逗 号运算符常用于在需要一个表达式而不是一条语句的情形下替换作为语句结束标 志的分号。逗号运算符在宏定义中特别有用。
在涉及到赋值运算符时,经常会引起优先级的混淆。考虑下面的这个例子, 例子中循环语句的本意是复制一个文件到另一个文件:
while (c=getc(in) != EOF)
putc(c,out);
在while语句的表达式中,c似乎是首先被赋予函数getc(in)的返回值,然后 与EOF比较是否到达文件结尾以便决定是否终止循环。然而,由于赋值运算符的 优先级要低于任何一个比较运算符,因此c的值实际上是函数getc(in)的返回值与 EOF比较的结果。此处函数getc(in)的返回值只是一个临时变量,在与EOF比较 后就被“丢弃” 了。因此,最后得到的文件“副本”中只包括了一组二进制值为 1的字节流。
上例实际应该写成:
while ((c=getc(in)} != EOF)
putc(c,out);
如果表达式再复杂一点,这类错误就很难被察觉。例如,本书第4章章首提 及的lint程序的一个版本,在发布时包括了下面一行错误代码:
if( (t = BTYPE(pt1->aty)= = STRTY)|| t ==UNIONTY) {
这行代码本意是首先赋值给t,然后判断t是否等于STRTY或者UNIONTY。 实际的结果却大相径庭:根据BTYPE(ptl->aty)的值是否等于STRTY, t的取值或
者为1或者为0;如果t取值为0,还将进一步与UNIONTY比较。