导读
任何一种程序设计语言,总存在一些语言特性。而《C陷阱与缺陷》此书,就是对C语言一些程序员常犯错误的特性的总结。在作者看来,程序设计错误实际上反映的是程序与程序员对该程序的“心智模式”两者的相异之处。其中,心智模式被解释为人们深植心中,对于周遭世界如何运作的看法和行为。那么,本书是按照什么逻辑顺序来整理的呢?即是,反映了将一个可视源程序文件经过预处理、编译、汇编、链接等编译步骤生成一个可加载模块或可执行文件过程的逻辑顺序。
广义上的编译过程正如图1所示,包括预处理、编译、汇编及链接。其中,
* 预处理主要负责头文件展开、宏替换、条件编译、删除注释等工作;
* 编译主要负责将预处理生成的文件经过词法分析、语法分析、语义分析、生成中间代码及优化后生成目标汇编文件;
* 汇编主要将汇编代码转换为机器可执行指令的过程。
* 链接主要把由编译器或汇编器生成的若干个目标模块,整合成一个被称为载入模块或可执行文件的实体。
而详细编译步骤如图2所示,包含词法分析、语法分析、语义分析生成中间代码等。其中,
* 词法分析是由词法分析器(或称为扫描器)来处理,主要是将源代码程序的字符序列分割成一系列的记号,该记号一般可以分为:关键字、标识符、字面量(包含数字、字符串等)和特殊符号(如加号、等号等)。与此同时,将标识符存放到符号表,将数字、字符串存放到文字表,以备后面的步骤使用。简单来说,词法分析器负责将源代码程序分解为一个一个符号。而分解的方法或处理策略被称为“贪心法”。
* 语法分析是由语法分析器对由词法分析器产生的记号进行语法分析,从而产生语法树。而语法树是以表达式为节点的树。
* 语义分析是由语义分析器对由语法分析器产生的语法树作进一步判断语句是否真正有意义。语义有静态语义和动态语义之分,编译器所能分析的语义是静态语义,而动态语义是只有在运行期才能确定的语义。静态语义通常包括声明和类型的匹配,类型的转换。经过语义分析后,整个语法树的表达式都被标识了类型,如果有些类型需要做隐式转换,语义分析程序会在语法树中插入相应的转换节点。
1 词法缺陷
词法缺陷针对编译步骤中的词法分析,主要围绕“贪心法”的概念及其引起的问题来展开,并伴随讲解一些容易混淆的运算符,如&和&&,|和||,=和==。
* 贪心法
贪心法是编译器将程序分解成符号时,从左到右一个字符一个字符地读入,如果该字符可能组成一个符号,那么再读入下一个字符,判断已经读入的两个字符组成的字符串是否可能是一个符号的组成部分;如果可能,继续读入下一个字符,重复上述判断,直到读入的字符组成的字符串已不再可能组成一个有意义的符号。若将该方法归纳为一条简单的规则,则是:每一个符号应该包含尽可能多的字符。 |
表达式a---b与表达式a-- -b含义相同,而与a- --b不同。
表达式a-----b与表达式(a--)-- -b含义相同,而与(a--) - (--b)不同。
表达式n-->0与表达式(n--)>0含义相同。
除了字符串与字符常量,符号的中间不能嵌有空白(空格符、制表符和换行符)。如,==是单个符号,= = 则是两个符号。 |
表达式y = x/*p /*p指向除数*/;
编译器将x之后的/*p看成注释/*的开始,而不是除号/与*p。(贪心法)
表达式y = x / *p /*p指向除数*/ ;
编译器读到/时,发现其后面是空白,则将/当成一个单独的符号,贪心法在此时(遇到空白)终止。
* 运算符
=和==
赋值运算符= 与关系运算符== 容易引起的错误是多写或少写。
<span style="font-size:14px;">if (x = y)
break;
/*此处是将y的值赋给了x,然后检查该值是否为0.若是出于此目的,则应该写为下面形式,使得意图更一目了然*/
if ((x = y) != 0)
break;</span>
<span style="font-size:14px;">if (x == 5) break;/*此处是判断x与5是否相等。若是出于此目的,则建议写为下面形式,以避免漏掉一个=而浑然不知*/if (5 == x) break;</span>
同理,在编写&和&&,|和||时,也要注意避免多写或少写一个&或|
附: 在关系表达式(关系运算符有>、<、==、!=等)与逻辑表达式(逻辑运算符有如&&、||、!等)中,如果关系为真,则表达式的结果数值为1;如果为假,则结果值为数值0. if (表达式1) statement1; /*表达式1的数值为0,则statement1不执行;表达式1的数值为非0,则statement1执行*/ while (表达式2) statement2; /*表达式2的数值为0,则statement2不执行;表达式2的数值为非0,则statement2执行*/ |
* 字符与字符串
用单引号引起的一个字符实际上代表一个整数,整数值对应于该字符在编译器采用的字符集(如ASCII字符集)中的序列值。如,对于采用ASCII字符集的编译器而言,'a'的含义与十进制数97严格一致。
用双引号引起的字符串,代表的却是一个指向无名数组起始字符的指针,该数组被双引号之间的字符以及一个额外的二进制值为零的字符'\0'初始化。
附: char amessage[] = "now is the time"; /*定义一个数组,"now is the time"不是字符串,而是初始化列表{'n','o','w',...}*/ char *pmessage = "now is the time"; /*定义一个指针,"now is the time"是字符串常量*/ 其中,pmessage是一个指针,其初值指向一个字符串常量,而字符串常量是在静态区中的。 |
2 语法缺陷
语法缺陷针对编译步骤中的语法分析,主要围绕函数声明、运算符优先级及语句结束标志——分号来展开,并伴随讲解容易引发错误的switch语句,垂悬else等。
* 函数声明
函数声明的作用:
<span style="font-size:14px;"><span style="font-size:12px;">函数的定义就是函数体的实现。函数体就是一个代码块,它在函数被调用时执行。函数定义的语法如下:
类型 函数名(形式参数)
代码块
示例:
void function_name(void)
{
//语句
return ;
}
当编译器遇到一个函数调用时,它产生代码传递参数并调用这个函数,且接收该函数的返回值。但编译器如何知道该函数期望接受的是什么类型和多少数量的参数呢?又如何该函数返回值的类型呢?
1> 不提供调用函数的特定信息
编译器认定该函数调用时参数的类型和数量都是正确的,同时,假定函数将返回一个整型值。(若函数返回值并非整型,则这种隐式假定就会导致错误)
2> 提供调用函数的特定信息
显然,提供调用函数的特定信息更为安全,我们可以通过两种方法来实现。
2.1>调用前定义
同一源文件中,函数在被调用前定义,编译器就会记住它的参数数量、参数类型以及函数的返回值类型。接着,在调用该函数时,编译器就可以检查该函数,确保它们是正确的。
2.2>使用函数原型
函数原型总结了函数定义的起始部分的声明,向编译器提供有关该函数应该如何调用的完整信息。使用方法有二。
第一,在同一源文件中,函数被调用前使用函数原型,之后,函数定义可以在函数被调用之后的位置。
第二,把原型置于一个单独的头文件,当其他源文件需要这个函数原型的时,就包含该头文件。
示例:
头文件func.h中,进行函数原型的声明
void function_name(void); /*函数声明,然后在别的文件定义和调用*/</span>
</span>
程序中的每个函数都位于内存中的某个位置,而函数名被使用时总是由编译器把它转换为指向该位置的函数指针常量。那如何声明一个函数指针变量呢?
<span style="font-size:14px;">声明一个函数指针变量,再为其赋值
void (*func_ptr)(void);
func_ptr = function_name;
声明一个函数指针变量同时初始化
void (*func_ptr)(void) = function_name;</span>
我们知道,任何C变量的声明都由两部分组成:类型及一组类似表达式的声明符。函数指针声明语句void (*func_ptr)(void);中,func_ptr是一个指向返回值类型为void的无参数的函数的指针变量。而变量func_ptr就是最简单的声明符。一旦知道了如何声明一个给定类型的变量,那么该类型的类型转换符就很容易得到:只需要把声明中的变量名和声明末尾的分号去掉,再将剩余部分用一个括号把整个“封装”起来即可。故而,void (*func_ptr)(void);中,func_ptr是变量名,那(void (*)(void))或(void (*)())则表示一个“指向返回值类型为void的无参数的函数的指针”的类型转换符。
除了将一个变量声明为函数指针类型外,我们也可以把一个绝对地址转为函数指针。
例如,当计算机启动时,硬件将调用地址为0位置的函数。假设,该函数无参数无返回类型。从上述可知,无参数无返回值的函数指针类型为(void (*)()),则我们可以构造指向绝对地址为0的该种类型的函数指针,并调用0位置的函数:
首先将绝对地址0转换为该类型的函数指针,即是 (void (*)())0。
接着调用该函数指针指向的函数。现在(void (*)())0是函数指针,所以(*(void (*)())0)是该指针所指向的函数。因该函数无参数,所以再调用时只需在后面加上括号()即可,即语句(*(void (*)())0)();就可以显示调用绝对地址在0位置的函数。(附:因为函数名在编译时编译器会将其转换为函数指针,所以该语句可以简写为((void (*)())0)();即少了一个*)
同理,如果该函数在绝对地址为0x123AB的位置,则该语句为(*(void (*)())0x123AB)();
其实,可以通过关键字typedef来定义新的类型名称。
如,给类型(void (*)())定义一个名称func_ptr_type,则为:
typedef void (*func_ptr_type)();
那原先的语句(*(void (*)())0x123AB)();可改为(*(func_ptr_type)0x123AB)();
附: 我们知道,声明一个整型变量、浮点型变量的语句为: int a; float b; 即是类型加变量名。当我们知道函数指针类型为(void (*)())时,容易犯迷糊,写出如下声明语句: (void (*)()) func_ptr; /*错误的*/ 该声明语句是错误的,避免该错误可遵循一条简单的规则:按照使用的方式来声明。 (void (*func_ptr)()); /*正确的*/ 该声明语句就是在讲函数声明的作用时举的例子。而根据ANSI标准,一个没有参数的函数的原型则应该写为: (void (*func_ptr)(void)); |
* 运算符优先级
要记住优先级表,重点记住以下两点:
1> 任何一个逻辑运算符的优先级低于任何一个关系运算符。
2> 移位运算符的优先级比算术运算符要低,但是比关系运算符要高。
优先级、结合性与求值顺序:
两个相邻操作符的执行顺序由它们的优先级决定。如果它们的优先级相同,它们的执行顺序由它们的结合性决定。除此之外,编译器可以*决定使用任何顺序对表达式进行求值,只要它不违背逗号、&&、||和?:操作符所施加的限制。
简单说,优先级决定表达式中各种不同的运算符起作用的优先次序,而结合性则在相邻的运算符的具有同等优先级时,决定表达式的结合方向。
C语言指定了求值顺序的运算符:
1> 逻辑运算符&&与||
&&的左操作数先求值,如果为真,再对右操作数进行求值。如果左操作数求值为假,则右操作数便不再进行求值。——短路求值
||的左操作数先求值,如果为假,再对右操作数进行求值。如果左操作数求值为真,则右操作数便不再进行求值。——短路求值
2> 三目运算符?:
三目运算符接受三个操作数,语法如下:
expression1 ? expression2 : expression3
求值顺序为:首先计算expression1,如果它的值为真(非零值),那整个表达式的值就是expression2的值,expression3不会进行求值;如果expression1的值是假(零值),那整个表达式的值就是expression3的值,expression2不会进行求值。
3> 逗号运算符
逗号操作符将两个或多个表达式分隔开来,语法如下:
expression1, expression2, ...., expressionN
求值顺序是这些表达式自左向右逐个进行求值,整个逗号表达式的值就是最后那个表达式的值。
C语言没有指定求值顺序的情况:
1> C语言没有指定同一运算符中多个操作数的计算顺序(&&、||、?:、,除外)。如x = f() + g();则f()可以先于g()计算,也可以后于g()计算。
2> C语言也没用指定函数各参数的求值顺序。如,printf("%d, %d\n", ++n,power(2,n));/*编译器决定n先自增还是先power调用*/
* 垂悬else
C语言有这样的规则,else始终与同一对括号内最近的未匹配的if结合。
<span style="font-size:14px;">if (x == 0)
if (y == 0) error();
else
{
z = x +y;
f(&z);
}
根据上述规格,上面语句通过缩进,可调整为如下形式:
if ( x == 0)
{
if (y == 0)
error();
else
{
z = x + y;
f(&z);
}
}
</span>
<span style="font-size:14px;">C语言允许初始化列表中出现多余的逗号,如int days[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,}这种特性的作用是什么呢?《C陷阱与缺陷》中的解释是初始化列表的每一行在语法上都是以逗号结尾的,这样自动化的程序设计工具(如代码编辑器)才能更方便地处理很大的初始化列表。《C专家编程》中的解释是这是从最早的C语法中继承下来的东西,不管存在与否都没有什么意义。</span>
3 语义缺陷
程序的语句,即使看上去拼写与语法都是正确的,但语句的意义却可能与预期的相去甚远。语义缺陷针对编译步骤中的语义分析,考察了若干种可能引起上述歧义的程序书写方式。主要围绕指针与数组来展开。
* 一维数组
char ch_array[5];
数组名并非表示整个数组,而是一个指针常量,其值是数组第一个元素的地址。当数组名在表达式中使用时,编译器才会为它产生一个指针常量。但在两种情况下,数组名并不用指针常量表示——作为sizeof的操作数和作为&的操作数。
<span style="font-size:14px;">char ch_array[5]; char (*ptr_array)[5];
1>sizeof(ch_array) 结果:返回整个数组的长度
2>&ch_array 结果:取一个数组名地址,其所产生的是一个指向数组的指针,类型为char (*)[5]</span>
char ch_array[5];与char *ch_ptr;
声明一个数组时,编译器将根据声明所指定的元素数量为数组保留内存空间,然后再创建数组名,它的值是一个常量,指向这段空间的起始位置。即,ch_array的值与&ch_array[0]相等。
声明一个指针变量时,编译器只为指针本身保留内存空间,但指针并未被初始化为指向任何现有的内存空间。
因为ch_array是指针常量,所以表达式ch_array++编译不通过。但表达式ch_ptr++却能编译通过。当ch_ptr = ch_array;时,我们可以通过对ch_ptr做地址算术运算,进而获取ch_array数组中的各个元素值。
数组的操作,乍看上去是以数组下标进行运算的,实际上都是通过指针进行的。如,计算数组元素ch_array[i]的值时,C语言实际上先将其转换为*(ch_array+i)的形式,然后再进行求值,因此在程序中这两种形式是等价的。
一维数组int a[n]; |
|
名称 |
意义 |
a |
数组名,是指针常量,指向第一个元素的地址。其值与&a[0]等同 |
&a |
数组名取地址,虽然&a的值与a一样,都是首元素的地址,但此处&a的地址是整个数组的地址,类型为int (*)[n]。与a的区别在指针运算上就能体现出来: a+1是第2个元素的地址,等同于&a[1],这里的1是一个数组元素内存单元 &a+1是首地址+sizeof(a),这里的1是一整个数组内存大小 |
*a |
数组第一个元素的值。相当于*(&a[0]) |
a[0] |
数组第一个元素的值。 |
&a[0] |
数组第一个元素的地址。 |
* 作为函数参数的一维数组
一维数组名作为函数参数,当把数组名传递给函数时,实际上传递的是该数组第一个元素的地址。也就是,数组名会立刻被转换为指向该数组第一个元素的指针。故而:
<span style="font-size:14px;">int strlen(char s[])
{
/*具体内容*/
}
与下面的写法完全相同:
int strlen(char *s)
{
/*具体内容*/
}</span>
C程序员经常错误地假设,在其他情形下也会有这种自动地转换。如:在一个文件中包含定义:char filename[] = "/etc/passwd";而在另一个文件中包含声明:extern char *filename;
尽管在一个语句中引用filename的值将得到指向该数组起始元素的指针,但是filename的类型是“字符数组”,而不是“字符指针”。在第二个声明中,filename被确定为一个指针。这两个对filename的声明使用存储空间的方式是不同的。故而,应该改变filename的声明或定义中的一个,使其与另一个类型匹配。因此,有如下改法:
<span style="font-size:14px;">char filename[] = "/etc/passwd"; /*文件1*/
extern char filename[]; /*文件2*/
或者:
char *filename = "/etc/passwd"; /*文件1*/
extern char *filename; /*文件2*/</span>
* 二维数组
int array[3][6];
二维数组可看成是一维数组的一维数组。如,将array看成拥有3个数组类型的元素,其中每个元素都是一个拥有6个整型元素的数组。它在内存中的存储形式如下图所示:
实现方框表示第1维的3个元素,虚线用于划分第2维的6个元素。在C中,多维数组的元素存储顺序按照最右边的下标率先变化的原则,称为行主序。
多维数组名与一维数组名的唯一区别是多维数组第1维的元素实际上是另一个数组。int array[3][6];中,数组名array也是一个指针常量,其值是一个指向它第1元素的指针,所以array是一个指向一个包含6个整型元素的数组的指针。在使用array名称时会将其转化为一个指向数组的指针常量。故而,下面的语句是正确的。
<span style="font-size:14px;"><span style="font-family:SimSun;font-size:12px;">int array[3][6];
int (*array_ptr)[6];
array_ptr = array;</span></span>
二维数组int a[m][n]; |
|
名称 |
意义 |
a |
数组名,是指针常量,指向第一个含有n个整型元素的数组的地址,与&a[0]等同,a+1与&a[0]+1相等。a是一个指向整型数组的指针,类型为int (*)[] |
a[0] |
第一个包含n个整型元素的子数组,即*(a+0)。如同一维数组的数组名,故而a[0]+1是该子数组第二个元素的地址 |
a[0][0] |
第一个元素的值 |
*a |
*a是第一个包含n个整型元素的子数组,与a[0]等价。 |
**a |
*a是子数组,所以**a则为子数组的第一个元素。与a[0][0]等价,为第一个含有n个整数的子数组里的第一个元素值 |
&a |
数组名取地址,虽然&a的值与a一样,都是首元素的地址,但此处&a的地址是整个二维数组的地址,类型为int (*)[m][n]。 &a+1的值为:首地址+m*n*sizeof(int)个字节单元 |
&a[0] |
第一个包含n个整型元素的整个子数组的地址 &a[0]+1的值为:首地址+n*sizeof(int)个字节单元 |
&a[0][0] |
第一个元素的地址,&a[0][0]+1的值为&a[0][1] |
<span style="font-size:14px;">清空int array[3][6]数组
int i = 0;
for (i = 0; i < 3; i++)
{
int j = 0;
for (j = 0; j < 6; j++)
array[i][j] = 0;
}
或者
int (*array_ptr)[6];
for (array_ptr = array; array_ptr < &array[3];array_ptr++)
{
int *temp;
for (temp = *array_ptr; temp < &(*array_ptr)[6]; temp++)
*temp = 0;
}</span>
* 作为函数参数的二维数组
作为函数参数的多维数组名的传递方式和一维数组名相同——实际传递的是个指向数组第1个元素的指针。但是,两者之间的区别在于,多维数组的每个元素本身是另一个数组,编译器需要知道它的维数,以便函数形参的下标表达式进行求值。
<span style="font-size:14px;"><span style="font-family:SimSun;font-size:12px;">/*作为函数参数的二维数组形式如下*/
int func(int array[3][6]);
int func(int array[][6]);
int func(int (*array)[6]);</span>
</span>
<span style="font-size:14px;"><span style="font-size:12px;"><span style="font-family:SimSun;">/*示例1 int array[m][n]*/
int func(int array[][6], int m, int n)
{
int i = 0;
for (i = 0; i < m; i++)
{
for (j = 0; j < n; j++)
printf("%d ", array[i][j]);
printf("\n");
}
return 0;
}
int main()
{
int array[3][6] =
{
{1,2,3,4,5,6},
{11,12,13,14,15,16},
{21,22,23,24,25,26},
};
func(array, 3, 6);
return 0;
}</span>
</span></span>
<span style="font-size:14px;">/*示例2 int array[m][n],通过二维数组元素求值公式*(array+i*n_j),给函数func传递第一个子数组*array */int func(int *array, int m, int n){ int i = 0; for (i = 0; i < m; i++) { for (j = 0; j < n; j++) printf("%d ", *(array+i*n+j));/*用于内存空间连续的情况*/ printf("\n"); } return 0;}int main(){ int array[3][6] = { {1,2,3,4,5,6}, {11,12,13,14,15,16}, {21,22,23,24,25,26}, }; func(*array, 3, 6); return 0;}</span>
<span style="font-size:14px;">/*示例3 二维数组的内存空间在堆上动态分配*/int func(int **array, int m, int n){ int i = 0; for (i = 0; i < m; i++) { for (j = 0; j < n; j++) printf("%d ", *(*(array+i)+j));/*动态分配的各个array[i]的内存空间并不连续,故而不能用*(array+i*n+j) */ printf("\n"); } return 0;}int main(){ int i = 0, j = 0, m = 3, n = 6;// <del>int array[3][6] =</del> // <del>{</del>// <del>{1,2,3,4,5,6},</del>// <del>{11,12,13,14,15,16},</del>// <del>{21,22,23,24,25,26},</del>// <del>};</del> int **array = NULL; array = (int **)malloc(m*sizeof(int *)); if (NULL == array) {return 0;} for (i = 0; i < m; i++) { array[i] = (int *)malloc(n * sizeof(int)); /*异常处理...*/ } func(array, m, n); return 0;}</span>
<span style="font-size:14px;">/*示例4 错误的例子。因为int array[3][6]中,array是int (*)[6]类型的与int **不同 */
int func(int **array, int m, int n)
{
int i = 0;
for (i = 0; i < m; i++)
{
for (j = 0; j < n; j++)
printf("%d ", *(*array+i*n+j));
printf("\n");
}
return 0;
}
int main()
{
int array[3][6] =
{
{1,2,3,4,5,6},
{11,12,13,14,15,16},
{21,22,23,24,25,26},
};
func(array, 3, 6);/* 将int (*)[6]类型转为int **类型是错误的 */
return 0;
}</span>
指向数组的指针与指向指针的指针内存分布图如下:
指针数组与指向指针的指针内存分布图如下:
<span style="font-size:14px;">int main(int argc, char *argv[])
{
/*具体内容*/
}
与下面的写法完全等价
int main(int argc, char **argv)
{
/*具体内容*/
}</span>
<span style="font-size:14px;">#include <stdio.h>
int main(int argc, char *argv[])
{
int i;
for (i = 0; i < argc; i++)
printf("%s%s", argv[i], (i < argc -1) ? " " : "");
printf("\n");
return 0;
}
/*argv是一个指向指针数组的指针,可通过指针来处理命令行*/
#include <stdio.h>
int main(int argc, char *argv[])
{
while (--argc > 0)
printf("%s%s", *++argv, (argc > 1) ? " " : "");
printf("\n");
return 0;
}</span>
C语言中的指针常量 |
|
名称 |
意义 |
函数名 |
函数名被使用时,它的值是指向函数起始位置的指针常量 |
|
int func(void); |
数组名 |
当数组名用于表达式时,它的值是指针常量。 |
|
int a[3]; int b[3][4]; |
字符串常量 |
当字符串常量出现于表达式时,它的值是个指针常量。如同数组,可进行指针运算、间接访问和下标引用。 |
|
指针运算:“abc”+1的值为“指针加上1”的值即b |
|
间接访问:*”abc”的值为a |
|
下标引用:“abc”[2]的值为c |
* 空指针NULL
<span style="font-size:14px;"><span style="font-family:SimSun;font-size:12px;">NULL在kernel-header/include/linux中的stddef.h中定义int *p = NULL;
#undef NULL
#if defined(__cplusplus) /*__cplusplus是C++编译器的内置宏定义*/
#define NULL 0
#else
#define NULL ((void *)0) /*与所有的数据对象指针兼容,但与函数指针不兼容*/
#endif</span></span>
int **p = NULL; /*此处宏NULL应为 0*/
int (*ary_ptr)[6] = NULL; /*此处宏NULL应为 0*/
int (*fun_ptr)() = NULL; /*此处宏NULL应为 0*/
* 边界计算与不对称边界
<span style="font-size:14px;"><span style="font-family:SimSun;font-size:12px;">int i, a[10];
for (i = 0; i<=10;i++)
{
a[i] = 0;
}</span></span>
本来应是i<10,却写成i<=10,以致实际上并不存在的a[10]被设置为0,也就是内存中在数组a之后的一个字的内存被设置为0。如果编译器按照内存地址递减的方式给变量分配内存,那么内存中数组a之后的一个字实际上是分配给了整型变量i。此时,本来循环计数器i的值为10,循环体内并不存在的a[10]设置为0,实际上却是将计数器i的值设置为0,这就陷入了一个死循环。
降低这类错误的编程技巧是:用第一个入界点和第一个出界点来表示一个数值的方位。如上述示例中,不应该说整数i的范围满足边界条件i>=0且i<=9,而是说整数i满足边界条件i>=0且i<10.这里下界是“入界点”,即包括取值范围之中;上界是“出界点”,即不包括在取值范围之中。——即不对称边界。
<span style="font-size:14px;">所以a[10]清零应该这样写:
int i, a[10];
for (i = 0; i < 10;i++)
{
a[i] = 0;
}
而不是这样写:
int i, a[10];
for (i = 0; i <= 9;i++)
{
a[i] = 0;
}</span>
<span style="font-size:14px;">范例#define N 1024static char buffer[N];static char *bufptr = buffer;/**参数p:指向将要写入缓冲区的第1个字符*参数n:将要写入缓冲区的字符数*/void bufwrite(char *p, int n){ while (n-- >-0) { if (bufptr == &buffer[N])/*不对称边界原则,从而代替if (bufptr > &buffer[N-1])*/ flushbuffer();/*把缓冲区中的内容写出*/ *bufptr++ = *p++; }}</span>ANSI C标准允许:数组中实际不存在的“溢界”元素的地址位于数组所占内存之后,这个地址可以用于进行赋值和比较。
4 链接
一个C程序可能由多个文件组成,而编译器一般每次只能处理一个文件,所以编译完之后可能存在很多目标模块,且在编译时也不能检测出那些需要一次了解了解多个源程序文件才能察觉的错误。这时就需要链接器把这些不同的目标模块合并成一个整体。
链接器一般是与编译器分离的,它不可能了解C语言的诸多细节。那么,链接器是如何做到把若干个C源程序合并成一个整体呢?尽管链接器并不理解C语言,然而它却能够理解机器语言和内存布局。编译器的责任是把C源程序“翻译”成对链接器有意义的形式,这样链接器就能够“读懂”C源程序了。
典型的链接器把由编译器或汇编器生成的若干个目标模块,整合成一个被称为载入模块或可执行文件的实体,该实体能够被操作系统直接执行。其中,模型目标模块是直接作为输入提供给链接器的;而另外一些目标模块则是根据链接过程的需要,从包括类似printf函数的库文件中取得的。
链接器通常把目标模块看成是由一组外部对象组成的。每个外部对象代表着机器内存中的某个部分,并通过一个外部名称来识别。链接器的输入是一组目标模块和库文件。链接器的输出是一个载入模块。链接器读入目标模块和库文件,同时生成载入模块。对每个目标模块中的每个外部对象,链接器都要检查载入模块,看是否已有同名的外部对象。如果没有,链接器就将该外部对象添加到载入模块中;如果有,链接器就要开始处理命名冲突。
* 定义与声明
定义与声明最重要的区别是:定义创建了对象并为对象分配了内存,声明没有分配内存。
定义:int i; /*创建了对象i,并为其分配了内存*/
声明:
1> extern int i; /*告诉编译器,该对象已在别的地方定义了*/
2> void func(int i, char c); /*告诉编译器,声明了一个函数原型*/
附:
1)声明可以多次出现。
2)声明可以出现在函数内部。
3)定义int i;与声明extern int i;这两个语句可以是在同一个源文件中,也可以位于程序的不同源文件之中。
* 检查外部类型
在第3章语义缺陷中,提到char filename[] = "/etc/passwd";的问题。其中分析了原因并给出了如下的修改方法:
<span style="font-size:14px;">char filename[] = "/etc/passwd"; /*文件1*/
extern char filename[]; /*文件2*/
或者:
char *filename = "/etc/passwd"; /*文件1*/
extern char *filename; /*文件2*/</span>
其实,对这类问题,有一个简单的避免规则:每个外部对象只在一个地方声明。
这个声明的地方一般就在一个头文件中,需要用到该外部对象的所有模块都应该包括这个头文件。
定义该外部对象的模块也应该包括这个头文件。
<span style="font-size:14px;"><span style="font-family:SimSun;font-size:12px;">头文件file.h中的声明:
extern char filename[];
源文件file.c中的定义:
#include "file.h"
char filename[] = "/etc/passwd";
展开来就是:
extern char filename[];
char filename[] = "/etc/passwd";
file.c文件中,由于包含了头文件file.h。这样,filename定义的类型自动与声明的类型符合。</span></span>
5 库函数
C语言中没有定义输入/输出语句,任何一个C程序要完成基本的输入/输出操作都必须调用库函数。库函数的使用,作者所给的建议是尽量使用系统头文件。
* 返回整数的getchar函数
<span style="font-size:14px;"><span style="font-family:SimSun;font-size:12px;">函数原型:int getchar(void);
示例:
#include <stdio.h>
int main()
{
char c; /* c为char类型而不是int类型,则可能无法容下EOF */
while ((c = getchar()) != EOF) /* getchar函数在一般情况下返回的是标准输入文件中的下一个字符,当没有输入时返回EOF(值为-1) */
putchar(c);
return 0;
}</span></span>
* 缓冲输出
程序输出有两种方式:一种是即时处理方式,另一种是先暂存起来,然后大块写入的方式,前者往往造成较高的系统负担。因此,C语言实现通常都允许程序员进行实际的写操作之前控制产生的输出数据量。
这种控制一般是通过库函数setbuf实现的。char buf[BUFSIZ];则setbuf(stdout, buf);语句通知输入/输出库,所有写入到stdout的输出都应该使用buf作为输出缓冲区,直到buf缓冲区被填满或者程序员直接调用fflush,buf缓冲区中的内容才实际写入到stdout中。(对于由写操作打开的文件,调用fflush将导致输出缓冲区的内容被实际地写入该文件)
函数setbuf原型:void setbuf(FILE *str, char *buf);
<span style="font-size:14px;"><span style="font-family:SimSun;font-size:12px;">/*示例1: 错误的*/
#include <stdio.h>
int main()
{
int c;
char buf[BUFSIZ];
setbuf(stdout, buf);
while ((c = getchar()) != EOF)
putchar(c);
return 0;
}</span></span>
错误分析:buf缓冲区最后一次被清空是在main函数结束之后,作为程序交回控制给操作系统之前C运行时库所必须进行的清理工作的一部分。但是,在此之前buf字符数组已经被释放。
避免方式有两种:一是让缓冲区数组称为静态数组,二是动态分配缓冲区
<span style="font-size:14px;"><span style="font-family:SimSun;font-size:12px;">/*示例2: 静态缓冲区数组*/
#include <stdio.h>
int main()
{
int c;
static char buf[BUFSIZ];
setbuf(stdout, buf);
while ((c = getchar()) != EOF)
putchar(c);
return 0;
}</span></span>
<span style="font-size:14px;"><span style="font-family:SimSun;font-size:12px;">/*示例3: 动态分配缓冲区*/
#include <stdio.h>
int main()
{
int c;
setbuf(stdout, (char *)malloc(BUFSIZ));
while ((c = getchar()) != EOF)
putchar(c);
return 0;
}</span></span>
* 练习
1> 当一个程序异常终止时,程序输出的最后几行常常会丢失,原因是什么?我们能够采取怎样的措施来解决这个问题?
答:
一个异常终止的程序可能没有机会来清空其输出缓冲区。因此,该程序生成的输出可能位于内存中的某个位置,却永远不会被写出了。在某些系统上,这些无法被写出的数据可能长达好几页。
对于试图调试这类程序的编程者来说,这种丢失输出的情况经常会误导他们,因为它会造成这样一种印象,程序发生失败的时刻比实际上运行失败的真正时刻要早的多。解决方案就是在调试时强制不允许对输出进行缓冲。要做到这一点,不同的系统有不同的做法,这些做法虽然存在细微差别,但大致如下:
setbuf(stdout, (char *)0);
这个语句必须在任何输出被写入到stdout(包括对printf函数的调用)之前执行。该语句最恰当的位置就是作为main函数的第一个语句。
2>
<span style="font-size:14px;">#define EOF -1
int main()
{
register int c;
while ((c = getchar()) != EOF)
putchar(c);
return 0;
}<span style="font-family:SimSun;font-size:12px;">
</span></span>
这个程序在许多系统中仍然能够运行,但是在某些系统运行起来却慢得多。这是为什么?
答:
函数调用需要花费较长的程序执行时间,因此getchar经常被实现为宏。这个宏在stdio.h中定义,因此如果一个程序没有包含stdio.h文件,编译器对getchar的定义就一无所知。在这种情况下,编译器会假定getchar是一个返回类型为整型的函数。
实际上,很多C语言的实现在库文件中都包括了getchar函数,原因不分手预防编程者粗心大意,部分是为了方便那些需要得到getchar地址的编程者,都用getchar函数调用来替换getchar宏。这个程序之所以运行变慢,就是因为函数调用所导致的开销增多。同样的依据也完全适用于putchar。
6 预处理
在严格意义上的编译过程开始之前,C语言预处理器首先对程序代码作了必要的转换处理。因此,我们运行的程序实际上并不是我们所写的程序。
宏只是对程序的文本起作用。宏提供了一种对组成C程序的字符进行变换的方式,而并不作用于程序中的对象。
* 不能忽视宏定义中的空格
#define f (x) ((x)-1) /*定义了宏f,因为f与(x)之间有个空格,所以f代表的是(x) ((x)-1)*/
#define f(x) ((x)-1) /*定义了宏f(x)*/
宏定义中不能出现空格,但在宏调用则没有此限制。如定义宏#define f(x) ((x)-1)之后,f(3)与f (3)求值后都等于2。
* 宏并不是函数
宏从表面上看其行为与函数非常类似,但其实不是函数。故而,
1> 在宏定义时,最好把每个参数都用括号括起来。
2> 在宏调用时,确保参数没有副作用。
* 宏并不是语句
有时,为了让宏的行为与语句类似,需要在宏调用时在后面添加分号。但不恰当的宏定义,也许会带来意想不到的错误。
错误的宏定义:
#defien assert(e) if(!e) assert_error(__FILE__, __LINE__)
宏调用:
if (x > 0 && y > 0)
assert(x > y);
else
assert(y > x);
宏调用展开:
if (x > 0 && y > 0)
if(!(x > y)) assert_error(__FILE__, __LINE__);
else
if(!(y > x)) assert_error(__FILE__, __LINE__);
结果导致else垂悬问题。
正确的宏定义:
#define assert(e) (void)((e) || assert_error(__FILE__, __LINE__)))
* 宏并不是类型定义
宏定义:#define T struct foo *
宏调用:T a,b;
宏展开:struct foo *a, b;
结果:a被定义为一个指向结构的指针,而b却被定义为一个结构。
类型定义:typedef T struct foo *
类型使用:T a,b;
结果:a和b都被定义为一个指向结构的指针。
7 可移植缺陷
C语言在许多不同的系统平台上都有实现,各个实现之间有着或多或少的细微差别,以至于没有两个实现是完全相同的。C程序员如果细微自己写的程序在另一个编程环境也能够工作,他就必须掌握许多这类细小的差别。 本章讨论几个最常见的错误来源,重点放在语言的属性上,而不是函数库的属性上。 * 标识符名称的限制 有些C实现把一个标识符中出现的所有字符都作为有效字符处理,而另一些C实现却会自动截断一个长标识符的尾部。而ANSI C标准所能保证的只是,C实现必须能够区别出前6个字符不同的外部名称。因而,为了保证出现的可移植性,谨慎地选择外部标识符的名称是重要的。比方说,两个函数的名称分别为print_fields与print_float,这样的命名方式就不恰当。 * 整数的大小 C语言提供了3中不同长度的整数:short、int、long,并对不同类型整数的相对长度作了一些规定: 1> 3种类型的整数其长度是非递减的。即,short容纳的值肯定能够被int容纳,int容纳的值也肯定能够被long容纳。 2> 一个普通(int 类型)整数足够大以容纳任何数组下标。 3> 字符长度由硬件特性决定。 ANSI 标准要求long类型整数的长度至少应该是32位,而short类型和int类型整数的长度至少应该是16位。* 移位运算符 使用以为运算符经常遇到两个问题: 1> 在向右移位时,空出的位是由0填充,还是由符号位的副本填充? 2> 移位技术(即移位操作的位数)允许的取值范围是什么? 答: 1> 如果被移位的对象是无符号数,那么空出的位将被0填充。如果被移位的对象是有符号位,那么C语言实现既可以用0填充,也可以用无符号位的副本填充空出的位。 2> 如果被移位的对象长度是n位,那么移位计数必须大于或等于0,而严格小于n。