1200字范文,内容丰富有趣,写作的好帮手!
1200字范文 > 《C语言陷阱与缺陷》第二章【语法陷阱】上

《C语言陷阱与缺陷》第二章【语法陷阱】上

时间:2018-10-23 00:25:03

相关推荐

《C语言陷阱与缺陷》第二章【语法陷阱】上

目录

✊一、理解函数声明

☝️ 二、运算符的优先级问题

✌️ 三、注意作为语句结束标志的分号

✊一、理解函数声明

先来看下面这条语句:

(* (void(*) () ) 0 ) ();

这样的表达式有没有让你感到不寒而栗呢?其实大可不必,因为构造这类表达式其实只有一条简单的规则:按照使用的方法来声明。

任何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是一个指向返回值为浮点类型的函数的指针,因此,

(flaot (*) ())

表示一个“指向返回值为浮点类型的函数的指针”的类型转换符。

有了这些预备知识,我们现在可以分两步来分析表达式 (*(void(*)())0)()。

第一步,假定变量fp是一个函数指针,那么如何调用fp所指向的函数呢?调用方法如下:

(*fp) ();

因为是fp一个函数指针,那么*fp就是该指针所指向的函数,所以(*fp)()就是调用该函数的方式。ANSIC标准允许程序员将上式简写为fp(),但是一定要记住这种写法只是一种简写形式。

在表达式(*fp)()中,*fp两侧的括号非常重要,因为函数运算符()的优先级高于单目运算符*,如果*fp两侧没有括号,那么*fp()实际上与*(fp())的含义完全一致,ANSIC把它作为*((*fp)())的简写形式。7简得

现在,剩下的问题就只是找到一个恰当的表达式来替换fp。我们将在分析的第二步来解决这个问题。如果C编译器能够理解我们大脑中对于类型的认识,那么我们可以这样写:

(*0) ();

上式并不能生效,因为运算符*必须用一个指针来作为操作数。不仅如此,这个指针还应该是一个函数指针,这样经运算符*作用后的结果才能作为函数被调用。因此,在上式中必须对0作类型转换,转换后的类型可以大致描述为“指回返回值为void类型的函数的指针”。

如果fp是一个指向返回值为void类型的函数的指针,那么(*fp)()的值为void,fp的声明如下:

void (*fp) ();

因此,我们可以用下式来调用存储位置为0的子例程

void (*fp) ();(*fp) ();

译注:此处作者假设fp默认初始化为0,这种写法不宜提倡

这种写法的代价是多声明了一个“哑”变量。

我们一旦知道如何声明一个变量,自然也就知道如何对一个常数进行类型转换,将其类型转换为该变量的类型:只需要在变量声明中将变量名去掉即可。因此,将常熟0转型为“指向返回值为void的函数的指针”类型,可以这样写:

(void (*) () ) 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类型,因此我们可以如下声明:

void (*signal(something)(int));

此处的something代表了signal函数的参数类型,我们还需要进一步了解如何声明它们。上面声明可以这样理解:传递适当的参数以调用signal函数,对signal函数返回值(为函数指针类型)解除引用 (dereference),然后传递一个整型参数调用解除引用后所得函数,最后返回值为void类型。因此,signal函数的返回值是一个指向返回值为void类型的函数的指针。

那么,signal函数的参数又是如何呢?signal函数接受两个参数:一个整型的信号编号,以及一个指向用户定义的信号处理函数的指针。我们此前已经定义了指向用户定义的信号处理函数的指针sfp:

void (*sfp)(int);

sfp的类型可以通过将上面声明中的sfp去掉而得到,即void(*)(int)。此外,signal函数的返回值是一个指向调用前的用户定义信号处理函数的指针,这个指针的类型与sfp指针类型一致。因此,我们可以如下声明signal函数:

void (*signal(int, void(*)(int))) (int);

同样地,使用typedef可以简化上面的函数声明:

typedef void (*HANDLER) (int);HANDLER signal(int,HANDLER);

☝️ 二、运算符的优先级问题

假设存在一个已定义的常量FLAG,它是一个整数,且改整数的二进制表示中只有某一位是1,其与各位均为0,亦即该整数是2的某次幂。如果对于整型变量flags,我们需要判断它在常量FLAG为1的那一位上是否同样为1,通常可以这样写:

if (flags & FLAG)...

上式的含义是判断flags按位与FLAG这个表达式的结果是否为0,考虑到可读性,如果对表达式的值是否为0的判断能够显示的加以说明,无疑使得代码自身就起到了注释该段代码意图的作用,其写法如下:

if (flags & FLAG != 0)...

这是一个错误的语句,因为!=运算符的优先级要高于&运算符,所以上式的结果被解释为:

if (flags & (FLAG != 0) )...

这个表达式含义是先判断FLAG的值是不是等于0,再去执行&运算。当FLAG的值等于0时,0不等于0表达式为假跳出,除了FLAG恰好为真的时候,FLAG为其他数时这个表达是都是错误的。

⚠️注意:&是按位(二进制位)与的意思。

例如:

#include<stdio.h>int main(){int a = 3;//a的二进制序列:00000000000000000000000000000011int b = 5;//b的二进制序列:00000000000000000000000000000101int c = a & b;//c的二进制序列:00000000000000000000000000000001//将c的二进制序列转为十进制就是1printf("%d\n", c);return 0;}

按位与(&)的作用是:二进制位只要有0,按位与出来的结果就是0。

看下面这个例子:

r = hi << 4 + low;

该表达式的本意是将hi的二进制位向左移动4位再加上low的值,但是很遗憾,这样写是错误的。因为加法运算的优先级要比移位运算的优先级高,因此本例实际相当于:

r = hi << (4 + low);

对于这种情况,有两种更正方法:第一种方法是加括号;第二种方法意识到问题出在程序员混淆了算术运算与逻辑运算,于是将原来的加号改为按位逻辑或,但这种方法牵涉到的移位运算与逻辑运算的相对优先级就更加不是那么明显。两种方法如下:

r = (hi << 4) + low;//法1:加括号r = ji << 4 | low;//法2:将原来的加号改为按位逻辑或

用添加括号的方法虽然可以完全避免这类问题,但是表达式中有了太多的括号反而不容易被理解。因此,记住C语言中运算符的优先级是有益的。

⚠️注意:<<是左移操作符,移动的的是二进制序列。

例如:

#include<stdio.h>int main(){int a = 1;//a的二进制序列:00000000000000000000000000000001int b = a << 1;//b的二进制序列:00000000000000000000000000000010//是将a的二进制序列左移后得到的printf("b = %d\n", b);printf("a = %d\n", a);return 0;}

下表是C语言运算符优先级表。

如果把这些运算符恰当分组,并且理解了各组运算符之间的相对优先级,那么这张表其实不难记住。

优先级最高者其实并不是真正意义上的运算符,包括数组下标、函数调用操作符各结构成员选择操作符。它们都是自左向右结合,因此a.b.c的含义是(a.b).c,而不是a.(b.c)

单目运算符的优先级仅次于前述运算符。在所有真正意义上的运算符中,它们的优先级最高。因为函数调用的优先级要高于单目运算符的优先级,所以如果p是一个函数指针,要调用p所指向的函数,必须这样写:(*p)()。如果写成*p(),编译器会解释成*(p())。类型转换也是单目运算符,它的优先级和其他单目运算符的优先级一样。单目运算符是自右向左结合。因此*p++上会被编译器解释成*(p++),即取指针p所指向的对象,然后将p递增1;而不是(*p)++, 即取指针p所指向的对象,然后将该对象递增1。后面的内容还会进一步指出p++的含义有时会出人意料。

优先级比单目运算符要低的,接下来就是双目运算符。在双目运算符中,术运算符的优先级最高,移位运算符次之,关系运算符再次之,接着是逻辑运算符、赋值运算符,最后是条件运算符。

译注:原书如此,条件运算符实际应为三目运算符。

⚠️注意:

我们需要记住的最重要的两点是:

1.任何一个逻辑运算符的优先级低于任何一个关系运算符;

2.移位运算符的优先级比算术运算符要低,但是比关系运算符要高。

属于同一类型的各个运算符之间的相对优先级,理解起来一般没有什么困难。乘法、除法和求余优先级相同,加法、减法的优先级相同,两个移位运算符的先级也相同。1/2*a的含义是(1/2)*a,而不是1/(2*a),这一点也许会让某些人吃惊其实在这方面C语言与Fortran语言、Pascal语言以及其他程序设计语言之间的为表现并无差别。

但是,6个关系运算符的优先级并不相同,这一点或许让人感到有些吃惊。运算符==!=的优先级要低于其他关系运算符的优先级。因此,如果我们要比a与b的相对大小顺序是否和c与d的相对大小顺序一样,就可以这样写:

a < b == c < d

任何两个逻辑运算符都具有不同的优先级。所有的按位运算符优先级要比顺序运算符的优先级高,每个“与”运算符要比相应的“或”运算符优先级高,而按位异或运算符 (^运算符) 的优先级介于按位与运算符和按位或运算符之间。

⚠️注意:按位异或运算符(^运算符)是将二进制位按位异或

例如:

#include<stdio.h>int main(){int a = 3;int b = 5;int c = a ^ b;//写出a和b的二进制序列//a:00000000000000000000000000000011//b:00000000000000000000000000000101//对应的二进制位相同为0,相异为1//c:00000000000000000000000000000110//转化为十进制是6printf("%d\n", c);return 0;}

这些运算符的优先顺序是由于历史原因形成的。B语言是C语言的“祖先”,B语言中的逻辑运算符大致相当于C语言中的&|运算符。虽然这些运算符从定义上而言是按位操作的,但是当它们出现在条件语句的上下文中时,B语言的编译器会将它们作为相当于现在C语言中的&&||运算符来处理。而到了C语言中,这两种不同的用法被区分开来,丛兼容性的角度来考虑,如果对它们优先顺序的改变过大,将是一件危险的事。

例如:

B语言中的if (a > b & a > c)...相当于C语言中的if (a > b && a > c)... 。

在本节到现在为止提及的所有运算符中,三条件运算符的优先级最低。这就允许我们在三目条件运算符的条件表达式中包括关系运算符的逻辑组合,例如:

tax_rate = income > 4000 && residency < 5 ? 3.5 : 2.0;

当前语句中的优先级 && 最高,其次是条件运算符,最低是 = 运算符。因此,当前语句的含义是,如果income 大于4000并且residency 小于5,就将3.5赋给tax_rate,否则就将2.0赋给tax_rate。

本例其实还说明了赋值运算符的优先级低于条件运算符的优先级是具有意义的。此外,所有赋值运算符的优先级是一样的,而且它们的结合方式是自右向左,因此,

home_score = visitor_score = 0;

当前语句的含义是,将0赋给visitor_score,再将visitor_score的值赋给home_score

与下面这两条语句所表达的意思是相同的:

visitor_score = 0;home_score = visitor_score;

例如:

a = b = 10; 相当于b = 10;a = b;

在所有的运算符中,逗号运算符的优先级最低。这一点很容易记住,因为在需要一个表达式而不是一条语句时,经常使用逗号运算符来替换作为语句结束标志的分号。逗号运算符在宏定义中特别有用,这一点在后面的内容还会进一步讨论。

在涉及赋值运算符时,经常会引起优先级的混淆。考虑下面这个例子,例子中循环语句的本意是复制一个文件到另一个文件:

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(pt1->aty)的值是否等于STRTYt的取值或者为1或者为0;如果t取值为0,还将进一步与UNIONTY比较。

✌️ 三、注意作为语句结束标志的分号

在C程序中,如果不小心多写了一个分号,可能不会造成什么不良后果:这个分号也许会被视作一个不会产生任何实际效果的空语句;或者编译器会因为这个多余的分号而产生一条警告信息,根据警告信息的提示能够很容易去掉这个分号。一种重要的例外情形是在if或者while语句之后需要紧跟一条语句时,如果此时多了一个分号,那么原来紧跟在if或者while子句之后的语句就是一条单独的语句,与条件判断部分没有了任何关系。考虑下面的这个例子:

if (x[i] > big);big = x[i];

编译器会正常的接受第一行代码中的分号而不会提示任何警告信息,因此编译器对这段程序代码的处理与对下面这段代码的处理就大不相同:

if (x[i] > big)big = x[i];

前面第一个例子(即在 if 之后多加了一个分号的例子)时间上相当于

if (x[i] > big) {}big = x[i];

当然,也就等于(除非 x 、i 或者big 是有副作用的宏)

big = x[i];

如果不是多写了一个分号,而是遗漏了一个分号,同样会招致麻烦,例如:

if (n < 3)returnlogrec.date = x[0];logrec.time = x[1];logrec.code = x[2];

此时的return语句后面遗漏了一个分号,然而这段程序代码仍然会顺利通过编译而不会抱错,只是将语句

logrec.date = x[0];

当做了return语句的操作数。上面这段代码时间相当于:

if (n < 3)return logrec.date = x[0];logrec.time = x[1];logrec.code = x[2];

如果这段代码所在的函数声明其返回值为void,编译器会因为实际返回值的类型与声明返回值的类型不一致而报错。然而,如果一个函数不需要返回值(即返回值为void),我们通常会在函数声明时省略返回值类型,但是此时对编译器而言会隐含地将函数返回值类型视作int类型。如果是这样,上面的错误就不会被编译器检测到。在上面的例子中,当n>=3时,第一个赋值语句会被直接跳过,由此造成的错误可能会是一个潜伏很深、极难发现的程序Bug

当一个声明的结尾紧跟一个函数定义时,有分号与没分号的实际效果相差极为不同?如果声明结尾的分号被省略,编译器可能会把声明的类型视作函数的返回值类型。考虑下面的例子:

strust logrec{int date;int time;int code;}int main(){...}

在第一个}与紧随其后的函数main定义之间,遗漏了一个分号。因此,上面代码段的实际效果是声明函数main的返回值是structlogrec类型。写成下面这样,会看得更清楚:

strust logrec{int date;int time;int code;}int main(){...}

如果分号没有被省略,函数main的返回值类型会缺省定义为int类型。

在函数main中,如果本应返回一个int类型数值,却声明返回一个structlogrec类型的结构,会产生怎样的效果呢?我们把它留作本章结尾的一个练习。虽然刻意地往消极面去联想也许有些“病态”,但对于要考虑到各种意外情形的程序设计来说 (比如航空航天或医疗仪器的控制程序),却是不无裨益的。

本内容不代表本网观点和政治立场,如有侵犯你的权益请联系我们处理。
网友评论
网友评论仅供其表达个人看法,并不表明网站立场。