C陷阱与缺陷学习笔记

本书的介绍

作者以自己1985年在Bell实验室时发表的一篇论文为基础,结合自己的工作经验扩展成为这本对C程序员具有珍贵价值的经典著作。写作本书的出发点不是要批判C语言,而是要帮助C程序员绕过编程过程中的陷阱和障碍。
全书分为8章,分别从词法分析、语法语义、连接、库函数、预处理器、可移植性缺陷等几个方面分析了C编程中可能遇到的问题。最后,作者用一章的篇幅给出了若干具有实用价值的建议。
本书适合有一定经验的C程序员阅读学习,即便你是C编程高手,本书也应该成为你的案头必备书籍。

前言

N年读过这本书,当时读的时候囫囵吞枣,加上时间久远,90%的内容都忘记了。昨天在整理书籍的时候翻出来了,这本书短小精悍却不失为经典之作。抽出时间再拜读一遍,顺便做做笔记,记录精华。

第一章,词法“陷阱”

1.1 =不同于==

c语言中”=”是赋值运算符
“==”是关系运算符,用于两个数进行比较

1.2 & 和 | 不同于&& 和 ||

&:按位与,优先级高于&&
|:按位或,优先级高于||
&&:逻辑与
||:逻辑或

1.3 词法分析中的“贪心法”

原文是这么说的:如果编译器输入流截止某个字符之前都已经被分解为一个个符号,那么下一个符号将包括从该字符之后可能组成一个符号的最长字符串。
例子:==被编译器解析为一个比较符号,而= =被编译器解释为两个赋值符号
a=b/*p;本意是a=b/(*p),但*被编译器解释为注释的开始符号,要么写成a=b/ *p或者a=b/(*p)

1.4 整型常量

在C/C++中,表示8进制整数需要在最前面加0,如0122
在表示十进制的地方,不要用0进行文本格式的对齐。

1.5 字符与字符串

单引号中的字符被编译器视为一个整数,不管单引号中有几个字符,比如’a’和’abcd’都被视为整数。

unsigned int value1 = 'tag1';   
unsigned int value2 = 'cd';   
char value3 = 'abcd';           

在vs2013编译上述代码后各个变量结果如下:

value1= 't'<<24 | 'a'<<16 | 'g'<<8 | '1'=0x74616731
 value2= 'c'<<8 | 'd'=0x00006364
 value3='d'=0x64

可以用多个字符给value3赋值,但value3仅保留最后一个字符的值。
对于value1和value2,赋值时最多传入四个字符,可以少于4个字符,否则编译器会报错。

第2章 语法“陷阱”

2.1 理解函数声明

这一节主要讲的是函数指针的声明、定义和使用。
void (*pFunc)();
typedef void (*pFunc)(); 相当于定义了一个数据类型pFunc,这个数据类型是函数指针类型,这个类型是void (*)();
pFunc f;
使用f的方式如下:
(*f)(); ANSI C标准允许进行简写,简化为 f();
注意,上面的调用方式不能写成下面的形式:
*f(); 由于括号的优先级最高,所以这种方式实际变成*(f());
*f; 只计算出一个整数值,并未进行函数调用;
f; 只计算出一个整数值,并未进行函数调用;

2.2 运算符的优先级问题

在编程中要特别注意运算符的优先级。
运算符的优先级可以利用口诀进行记忆,即“单算移关与,异或逻条赋”
■“单”表示单目运算符:逻辑非(!),按位取反(~),自增(++),自减(–),取地址(&),取值(*);
■“算”表示算术运算符:乘、除和求余(*,/,%)级别高于加减(+,-);
■“移”表示按位左移(<<)和位右移(>>);
■“关”表示关系运算符:大小关系(>,>=,<,<=)级别高于相等不相等关系(==,!=);
■“与”表示按位与(&);
■“异”表示按位异或(^);
■“或”表示按位或(|);
■“逻”表示逻辑运算符:逻辑与(&&)级别高于逻辑或(||);
■“条”表示条件运算符(? :);
■“赋”表示赋值运算符(=,+=,-=,*=,/=,%=,>>=,<<=,&=,^=, |=,!=);
◆逗号运算符(,) 级别最低,口诀中没有表述
◆(),[],->这些其实不算运算符,级别最高

注意:单目运算符和条件运算符、赋值运算符采用自右至左的结合方式,
其余运算符采用自左至右的结合方式。
比如:
*p++解释为*(p++)而不是(*p)++,这是因为++运算符采用自右至左的结合方式

2.3 注意作为语句结束标志的分号

在大多数情况下,多写一个分号编译器并不会报警,并且程序能正常运行;漏写分号编译时会出错。
但是,这可能导致严重的bug。
比如:

多分号的情况

if(x==y);
   a=b;

这样a=b;不会受if语句的影响。

缺少分号的情况

if(x==y)
   return
x=5;

如果return后面没有分号,返回值将从void变成整数1,大多数情况下会被编译器检测到,如果函数的返回值恰好是整型,这将导致非常严重的bug。

struct ab{
int x;
int y;
}
main()
{}

struct结构体定义结束时缺少分号,main函数的返回值就变成了结构体类型。

2.4 switch语句

switch中的case语句结束时不要忘记break语句,确实不需要break语句的时候应明确标注,方便代码维护。
笔者初学C语言时曾经在这个问题上栽过跟头,一段代码运行结果与预期不符,调试三天最后发现问题在于漏写了break;

2.5 函数调用

2.6 “悬挂”else引发的问题

else与最近的if进行匹配。
例如:
if(x==y)
if(a==b)
printf(“a==b”);
else
printf(“x!=y”);

if(x==y)
{
  if(a==b)
printf("a==b");
}
else
 printf("x!=y");

执行结果将截然不同。

第3章 语义“陷阱”

3.1 指针与数组

  • C语言中只有一维数组,多维数组实际上也是用一维数组表示。
  • 利用数组下标进行运算等同于对应的指针运算。a[i]与i[a]都能通过编译并且正常运行。p=&a[0]与p=a是一样的。
  • 除了a被用作运算符sizeof这一情形,在其它所有的情形中数组名都代表指向数组a中下标为0的元素的指针。

3.2 非数组的指针

char *r,*s,*t;
r=malloc(strlen(s)+strlen(t));
strcpy(r,s);
strcat(r,t);

这段代码有三处错误:
1. malloc之后未检查返回值是否为NULL
2. 注意sizeof与strlen的区别,strlen计算字符串所包括的字符数目,不包含结尾的’\0’字符,所以分配内存时应该是strlen(s)+1;
3. 分配完的内存应及时释放,避免发生内存泄漏

3.3 作为参数的数组声明

C语言中会自动将形参中作为参数的数组声明转化为对应的指针声明。

3.4 避免“举隅法”

char *p,*q;
p="xyz";
q=p;

在这个例子中,p和q指向了同一块内存,复制指针并不同时复制指针指向的数据。

3.5 空指针并非空字符串

编程时应注意空指针和空字符串的区别。
比如:

char *p,*q;
p=NULL;
q=malloc(10);
\*q=0;

在这个例子中,p就是空指针,q不是空指针,q指向了空字符串。
可以使用下面的代码判断是否是空字符串:

char *p;
if(p!=NULL && *p)
{}

3.6 边界计算与不对称边界

c语言中数组的下标是从0开始的。
在循环中利用操作数组元素非常容易出现越界问题,需要特别注意。

3.7 求值顺序

在C语言中,定义了||、&&、?:、, 的求值顺序。

C语言规定,必须首先对左侧操作数求值,然后根据需要对右侧操作数求值;
根据需要利用了短路性质,即:
A&&B, 当A为false时,不去计算B的值而直接返回false;当A为true时,计算B的值。
A || B,当A为true时, 不去计算B的值而直接返回true;当A为false时,计算B的值。
比如:

if(count!=0 && (a/count)>2)
{}

先计算左侧的表达式判断count是否等于0,count不为0才计算a/count。在这个例子中,求值顺序至关重要,否则会引发除0异常。
在比如一个例子:

char *p;
if(p!=NULL && *p)
{}

首先判断p是否是空指针,然后判断是否是空字符串。

对于?:这个条件运算符,比如:a?b:c

操作数a先被求值,根据a的值再求操作数b或者操作数c的值,只计算其中一个值。

逗号运算符,首先对左侧操作数求值,然后该值被”丢弃”,在对右侧操作数求值。

C语言中其他所有运算符对其操作数求值的顺序是未定义的。特别指出,赋值运算符并不保证任何求值顺序。
例如:

i=0;
while(i<n)
{
    y[i++]=x[i];
}

在这个例子中,左侧操作数中的i++有可能比x[i]先执行,也有可能比x[i]后执行。

3.8 运算符&&、|| 和 !

注意逻辑与和位与、逻辑或和位或的区别。
逻辑运算有短路性质,而位运算则没有此性质。

3.9 整数溢出

在进行算术运算时,应考虑整数溢出的影响。如果溢出对结果有影响,就应该进行判断。
判断的方法是:

//把a,b两个数均视为无符号数
if((unsigned int)a+(unsigned int)b>INT_MAX)
{}

limits.h头文件中有相关的宏定义:

#define INT_MIN     (-2147483647 - 1) /* minimum (signed) int value */
#define INT_MAX       2147483647    /* maximum (signed) int value */

3.10 为函数main提供返回值

main函数如果未声明返回值,默认返回整数值。这个返回值用来告知操作系统执行结果,返回0表示成功,返回非0表示失败。
大部分情况下这样做没有问题。但如果系统关注这个执行结果,就必须明确返回一个有意义的值。

第4章 连接

  • 4.1 什么是连接器
  • 4.2 声明与定义
  • 4.3 命名冲突与static修饰符
  • 4.4 形参、实参与返回值
  • 4.5 检查外部类型
  • 4.6 头文件

第四章主要是讲多个文件链接造成的命名冲突问题。
为了解决这个问题,可以使用static修饰符修饰函数和全局变量,被static修饰的函数和全局变量的作用域仅限于定义它的源文件内,在其它源文件内是无法访问的。
如果需要在多个源文件内共享同一个全局变量,最好的做法是在头文件中声明extern int a;然后在一个源文件中定义int a;
另外,在多个源文件中使用全局变量时,应确保变量类型相同。
考虑下面的程序,在一个头文件中包含定义:
char filename[]=”\etc\passwd”;
而在另外一个文件中包含声明:
extern char* filename;
虽然在很多场合,filename在使用时退化为指针,这样编码也能通过编译,但会存在问题。
例如:在ConsoleApplication1.cpp中输入下面代码

#include "stdafx.h"
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
char filename[] = "abcdefgh";
unsigned int myfunc();
int _tmain(int argc, _TCHAR* argv[])
{
    unsigned int i;
    for (i = 0; i < strlen(filename); i++)
    {
        printf("%c", i[filename]);
    }
    myfunc();
    system("pause");
    return 0;
}

在source.cpp输入下面代码:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

extern char *filename;
unsigned int myfunc()
{
    unsigned int i = 0;
    char b = *filename;
    return b;
}

使用vs2013进行编译然后运行,会出现内存指针越界。
char b = *filename;这一行对应的汇编代码如下:
001314B5 mov eax,dword ptr ds:[00138000h]
001314BA mov cl,byte ptr [eax]
001314BC mov byte ptr [b],cl
可以看出,编译器将filename识别为char **类型。
如果在source.cpp声明为extern char filename[];就不会出现上述问题。

第5章 库函数

5.1 返回整数的getchar函数

getchar()函数的返回值是一个整数类型,而不是字符类型。
因为getchar函数除了返回终端输入的字符外,在遇到Ctrl+D(Linux下)即文件结束符EOF时,getchar ()的返回EOF,这个EOF在函数库里一般定义为-1。

char c;
while((c=getchar())!=EOF)
{}

这样写很可能会出现问题,把getchar返回值赋值给c会发生截断。

5.2 更新顺序文件

在C语言中调用fread和fwrite函数交替对文件进行操作时,应该在两个函数调用之间调用一次fseek。

5.3 缓冲输出与内存分配

int main()
{
    char buf[512];
    setbuf(stdout,buf);
    while((c=getchar())!=EOF)
        putchar(c);
}

程序调用库函数setbuf,通知输入/输出的数据首先缓存在buf中,main函数返回时,buf被释放,控制权交给操作系统之前C运行时库要进行清理工作,会引用已经被释放的buf。

5.4 使用errno检测错误

使用errno这个变量检测错误时先确定程序已经执行失败。
比如调用fopen函数打开一个文件,即使文件打开成功,errno这个变量的值也会被设置。

5.5 库函数signal

第6章 预处理器

  • 6.1 不能忽视宏定义中的空格
  • 6.2 宏并不是函数
  • 6.3 宏并不是语句
  • 6.4 宏并不是类型定义

本章的主要是宏定义方面的内容:
C语言中的宏的本质是编译之前的文本替换。因此大多数宏定义都用括号括起来,防止在文本替换之后由于运算符优先级问题使得运算顺序与预期不一致。
尽量避免在宏定义中使用++和–操作符,或者在宏定义中改变指针的值,

\#define toupper(c) ((c)>='a' && (c)<='z'? (c)+'A'-'a':(c))
toupper(*p++);

结果将使人大吃一惊。

第7章 可移植性缺陷

7.1 应对C语言标准变更

7.2 标识符名称的限制

标识符名称的第一个字符不能是数字(即第一个字符必须是下划线、大写或小写字母)。 ANSI 允许外部标识符名称包含 6 个有效字符,内部(一个函数中)标识符名称包含 31 个有效字符。外部标识符是链接过程中所涉及的标识符,其中包括文件间共享的函数名和全局变量名。
内部名指的是仅出现于定义该标识符的文件中的那些标识符。
以下摘自MSDN:
尽管 ANSI 允许外部标识符名称包含 6 个有效字符,内部标识符(在一个函数内)名称包含 31 个有效字符,但 Microsoft C 编译器允许内部或外部标识符名称包含 247 个字符。如果您不担心 ANSI 兼容性,则可使用 /H(限制外部名称的长度)选项将此默认值修改为更小或更大的数字。外部标识符(在全局范围内或使用存储类 extern 进行声明)可能要受其他命名限制的约束,因为这些标识符必须由其他软件(如链接器)处理。

7.3 整数的大小

在不同的编译器上,整数的长度并不完全相同,可能是2个字节,也可能是4个字节,甚至可能是8个字节,移植代码的时候要注意。

7.4 字符是有符号整数还是无符号整数

把一个char型变量转化为int变量,编译器会自动进行符号位扩展。尤其当字符变量的最高位为1时,要特别注意这个问题。
unsigned int a = 0;
int b = 0;
int d=0;
char c = 0x80;
a = c; //这种写法,c将首先被转换为int类型,由于存在符号位扩展,a=0xffffff80
b = c; //这种写法,c将首先被转换为int类型,由于存在符号位扩展,b=0xffffff80
d = (unsigned char)c;//c是无符号类型,所以不会进行符号位扩展,d=0x00000080

7.5 移位运算符

汇编语言中有逻辑移位shl、shr和算术移位sar、sal,其中shl和sal的执行结果是一样的,但sar将操作数视为有符号数,结果受符号位影响,shr将操作数视为无符号数。
c语言中只有左移<<和右移>>,
当操作数是有符号数,>>用sar实现;当操作数是无符号数,>>用shr实现。

在C语言中,移位位数必须在大于-1小于33,否则编译器会报错。
有符号数中的 负数 向右移位运算并不等同于除以2的某次幂。

7.6 内存位置0

这个位置有些系统中可读,有些系统(windows)中不可读。

  • 7.7 除法运算时发生的截断
  • 7.8 随机数的大小
  • 7.9 大小写转换
  • 7.10 首先释放,然后重新分配
  • 7.11 可移植性问题的一个例子

第8章 建议与答案

  • 8.1 建议
  • 8.2 答案

你可能感兴趣的:(C陷阱与缺陷学习笔记)