读书笔记之C Traps and Pitfalls

第一章:Lexical Pitfalls

主要讲了一些词汇层上的内容,比如说‘=’和‘==’是不一样的(这个当然),只不过在编程的时候比较容易写错,同理还有‘&&’和‘&’,‘||’和‘|’。

不过书中提到了一个比较有意思的问题:c编译器是怎样区分token的?,举个例子,a—-b(估计没人会这么写)。这个应该被理解为(a—)-b,因为c编译器的原则是Greedy lexical analysis. 也就是说它采取的是贪婪法则,会识别到最大的有意义的token。

另外本章还提出‘’与“”是不同的,这个显然,但是文中解释还是不错的,‘’中包含的是一个字符,相当于一个integer(整数),而“”相当于一个指向一个省略了名字的数组的指针。因此char s = ‘/’;是错误的,因为前者是char型指针,后者是整形常量。编译的话会得到这样的一个错误:cannot convert from ‘char’ to ‘char ‘。同时指针的内容是变量的地址,也不能是常量

第二章:Syntactic Pitfalls

这一章讲的是语法上可能会遇到的所谓的pitfalls。
2.1) 函数声明的问题:
(void ()()) 0)();这个表达式确实很莫名其妙,话说直到现在我也没有明白它的具体意义何在,

但书中对这个问题的分解还是比较有价值的。

1
2
3
4
float *g();  //函数g()返回float型的指针
float (*h)(); //h是指向函数的指针,函数的返回值为float
(float (*)()); //'指向返回值为float的函数的指针'的类型转换符
(*fp)(); //表示调用fp所指向的函数,当fp为函数指针时

因此

(*)()) 0 ``` 就是将常数0转换成“指向返回值为void的函数的指针”
1
2
如果fp是一个指向返回值为void类型的函数指针,那么fp的声明如下:void (*fp)();
替换掉fp得到:```(*(void (*)()) 0)();

但是利用typedef来解决问题的那个方法还是没看懂。
2.2) 优先级的问题

这个在之前的笔记中有了总结:
(1): 括号,结构体选择符,数组下标这些不算operator的优先级最高
(2): 一目>双目>三目>赋值>逗号
(3): 算术>移位>关系>逻辑
(4): 逻辑运算符所有的优先级都不等,位运算符优先
2.3) 分号的问题

注意不要在不该加的地方加上,比如说在有些if以及return的后面

2.4) switch语句的break问题

c语言中break加不加会产生很神奇的作用,看需求。对于case,因为c语言把case当作true来看待,所以程序会移植向下走,直到遇到break。

2.5) else

else会跟随最近的一个if,所以记得不要在这个问题上犯错误,很容易被格式上的缩进影响到else的配对问题

第三章: Semantic Pitfalls

这一章的内容是讲的语义层上的缺陷,也就是说猿媛们写完程序后得到了与自己预期不一样的结果。我觉得这不能称之为c语言的缺陷,而是猿媛们自己不细心的结果,以及对一些问题没有很清晰的掌握。

3.1) 首先提出的是数组和指针的问题

(1)对于数组其实只可以做两件事,一个就是决定数组的大小,另一个就是获得首元素的指针.

(2)当指针指向数组时,可以对指针进行加减法运算,这样才有意义。

char *p;
p+1;
但是要注意不要加过了界,因为对于很多machine来说,指向的并不是下一个相邻的存储位置。
(3)指针和数组的共性

1
2
3
char *p, a[10];
p=a; // right
p=&a; //wrong

因为 &a 是一个指向数组的指针了,p 只是一个指向char型的指针。

1
*a = 84;//对于数组名,可以当成指针用。

区别下面两个声明:

1
2
int (*ap)[31];//ap是一个指向数组的指针,*ap是一个包含了31个int的数组
int *ap[31]; //数组ap包含了31个指向int的指针,相当于多维数组

3.2) 数组不完全等同于指针

(1) 当函数要调用指针或数组时,要首先分配存储空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
//数组
char r[100];
strcmp(r,s);
//指针
char *r,*malloc;
r= malloc(strlen(s)+strlen(t)+1);
if(!r)
{
exit(1)
}
strcmp(r,s);
/* 一段时间后*/
free(r);

而如果是下面就是错的:

1
2
char *r;
strcmp(r,s);

因为只是定义了,但是r没有指向任何存储空间。

(2)前面笔记中还提到了一点,指针是变量,而数组名不是变量,因此不适用与变量的运算等等

(3)指针的另一个作用是函数声明中

(4)空指针并不是空字符串,空指针在c语言中是一个很特别的存在,可以定义不能引用

3.3) 不对称边界的问题

这里书中洋洋洒洒的写了很多篇幅,介绍了很多不对称边界的好处,其实个人感觉有些没必要。也举了一个例子,写了一大堆的东西,感觉跟本书的精炼有些不匹配。

(1) express a range by the firstelement of the range and the first elements beyond it.

这句话试着翻译了一下,没翻译出味道,所以原文摘录。大意是表示一个区间时最好用半开半闭区间表示,这样比较好计算区间长度,符合不对称边界的思想。

(2) c语言允许将实际过界的不存在的数组元素拿来做赋值或者比较,但是不能引用,即:

1
2
if(bufptr==&buffer[N]){};
//这样是可以的,这样做是为了符合不对称边界的思想,虽然buffer[N]不存在。

但是不能去真正的引用这个值,因为它不存在。是非法的。

不过话说不对称边界在做减法的时候还是很有优势的。对于指针来说,一般都是指向first free character,也就是还没有被引用到的地方。
3.4) 计算顺序问题

这个主要是出现在逻辑运算符那里,只要注意到,当能够判定一个逻辑表达式是否为真假时,计算机就不会继续计算下去了。

1
a&& b; //当a计算为假时,计算机是不会计算b的。

还有一个问题是:

1
a[i++]= b[i]; //wrong

因为无法确定计算机是先计算左边还是右边,还是同时计算,所以做个表达式的结果是不定的。

3.5) 整数溢出的问题

整数溢出的问题只存在于signed类型中。

3.6) main函数的返回值

一般来说我们很少关心main函数的返回值问题。但是很多C系统需要通过main的返回值来确定程序是否运行成功(返回值为0)或者失败。

第四章:连接

这一章主要是讲c语言编译器在连接的时候可能出现的问题

4.1) linker

C语言是分开编译的,然后才把不同的部分连接起来,因此连接器最主要的一个工作就是处理命名冲突问题。而linker对这个问题基本没什么太多的办法,所以采取了一个很简单的方式:不允许相同的命名存在。

4.2) 声明以及定义

有声明,则必须要在某些地方定义。而且声明可以多次,但是定义只能一次。

4.3) 命名冲突与static修饰符

Static修饰符的存在就是为了一定意义上的解决命名冲突而存在的

1
static int a;

表明a只能在这个文件中有效,在其他文件中无效。因此可以把所有用到a的函数都放在这同一个文件中。在其他文件中也可以有外部变量叫做a,与前一个文件中的a不冲突。Static修饰符也可以用于函数,意义是一样的。

4.4) 参数,变量,返回值

(1)函数在使用之前要声明,否则会出现很多问题,一个可能是编译器找不到函数的意义,另一个可能是返回值的类型会出错。

1
2
3
4
5
6
double sqrt(double);
main()
{
doubles;
s= sqrt(2);
}

如果忘记了声明double sqrt(double); 那么编译器默认sqrt(2)会返回int型,而S是double型,所以必然会出错误。另外,由于没有声明,编译器无法把2(int型)转换成double型,因此也是会导致错误的。

4.5) 检查外部类型

对于外部变量,声明和定义的类型必须严格一致,”严格”意味着甚至连指针和数组都不能混着声明与定义

1
2
Char filename[];//definition
Extern char*filename; //declaration

这样子声明和定义是错的,这不是严格一致的。正确的应该是:

1
Extern char filename[]; //declaration

还有可能出现问题的地方是:

在外部声明时,一定要注意返回值的类型要正确,以及参数的类型要正确
4.6) 头文件

头文件的出现就是为了避免上面出现的一些问题。有一个简单的规则:每个外部对象只在一个地方声明。把所有的声明都放到头文件中,当需要用时,直接包含头文件,则可以避免一些错误的产生。

第五章:库函数

这一章节主要讲的是使用库函数时有可能遇到的问题。

5.1) getchar的返回值是int

1
2
3
4
5
6
7
# include<stdio.h>
main()
{
char c;
while((c=getchar())!=EOF)
putchar(c);
}

这个函数的问题是把c定义成char型,是不对的,因为像EOF等字符时不包含在char中的,所以很多字符是无法读入到c中的,并且可能无法终止循环(无法识别EOF)。因此要定义c为int,即:

1
int c;

5.2) 更新顺序文件

这里提到的问题就是:对于文件的写与读是不能同时进行的,如果一定要同时操作输入输出,则需要调用fseek函数。

5.3) 缓冲输出与内存分配

程序需要输出时,并不需要立即输出给用户。程序输出一般有两种方式:一种是即时处理,另一种是先存入缓冲区,然后再大块输出。

一般是利用setbuf函数来处理这一个问题

1
setbuf(stdout,buf);

下面是一个缓冲区的例子,但是不幸的是,它是错的

1
2
3
4
5
6
7
8
9
# include<stdio.h>
main()
{
int c;
char buf(BUFSIZE);
setbuf(stdout,buf);
while((c=getchar())!=EOF)
putchar(c);
}

C语言利用缓冲区输出时,一般是要么缓冲区满了然后输出,要么是缓冲区没有满,但是调用了fflush函数刷新使得缓冲区的内容输出。

上面的函数buf最后一次被清空是在main函数结束后,而此时由于buf是自动变量,所以存储空间已经被释放了,所以是无法输出的。因此应该将buf[BUFSIZE]定义为

1
static char buf(BUFSIZE);

另一种方法是

1
2
char *malloc();
setbuf(stdout,malloc(BUFSIZE));

并且不要把它free掉。但是个人感觉这样做并不好,因为不free掉空间,可能会导致后面的程序逐渐的没有了内存空间。

当然书中提出的缺点是:如果malloc不到足够的空间的话,则malloc(BUFSIZE)会返回NULL,这就导致了没有缓冲区,也就是程序会“所得即输出“,这样程序会比较慢。

5.4) errno

有些跟操作系统有关的库函数,当程序失败时,会返回一个errno的外部变量,通知程序函数调用失败。但是问题是在库函数调用成功时,并没有要求errno设置为0,也没有禁止设置error,所以对errno的应用要小心,下面的程序就是一个错误

1
2
if(errno)
/*do sth*/;

同理下面的程序也是错的,

1
2
3
errno=0
if(errno)
/*do sth*/;

这两个错误程序分别对应上面提到的两点。

因此在调用库函数时,英爱先检测错误的返回值,然后在检测errno,找出错误原因

1
2
if(error return)
examine errno;

5.5)signal函数

Signal是捕获一步事件的一种方式。文中提到做好不要用这个函数处理库函数。但是也没有给signal函数的例子,所以对此也是不明所以。

第六章:预处理器

预处理器存在的意义有两点:一是可以批量的替换,而是可以利用宏替换替代函数以节省系统开销。

6.1) 宏定义中的空格问题

在宏定义的时候不要乱写空格,而且宏定义是不能加号的,因为宏定义不是一个语句

1
#define f (x) ((x)-1)

这样子得到的其实是f替换的是(x) ((x)-1)

而在应用宏替换的时候,空格则不是什么问题。

6.2) 宏不是函数

在这里主要是要注意2个方面,一个是在宏定义的时候要加上足够的括号,每一个参数都要加上括号。如:

1
#define max(a,b) ((a)>(b)?(a):-(b))

这个主要是防范abs(a-b,c)这样的式子。

另一个问题是i++的问题,因为宏替换时,很有可能会使i自增两次,这也是要很小心的地方,如:

1
2
3
4
biggest = x[0];
i=1;
while(i<n)
biggest = max(biggest,x[i++]);

在这个式子中,宏替换的话会使i自增两次。

在这个问题中,如果max是函数的话,亦或者把i++;单独拿出作为单独语句的话,就不会出错了。

下面的宏:

1
#define putc(x,p) (--(p) ->_cnt>=0?(*(p)->_ptr++=(x)):_flsbuf(x,p))

这是一个输出宏替换,x是待输出字符,p是指向file的指针。这个宏中,虽然x计算两次,但由于是在‘:’两侧,所以相当于计算了一次(对于有些编译器可能会对x计算两次,所以这是个隐患)。而p则是计算了两次,但是由于p是指针,没有自增自减(因为‘->’的优先级高于‘—’),所以也没有问题。但是这么写还是有隐患存在的。

而下面的宏替换则会有更大的问题

1
#define toupper(c) ((c)>=’a’ && (c)<=’z’? (c)+(‘A’-‘a’) : (c))

这个宏遇到了toupper(*p++)就麻烦了。

还有最后的一个问题是多层宏嵌套:

1
max(a,max(b,max(c,d)))

这个系统开销是很恐怖的,因为宏会先把所有的都代入成表达式后,才计算的,这个还不如函数来的快。

6.3) 宏不是语句

1
#define assert(e) if (!e) assert_error(__FILE__,__LINE__)

当下面的程序调用宏的时候就会出问题

1
2
3
4
if(x>0&&y>0)
assert(x>y);
else
assert(y>x);

看着是没问题,但是当宏替换后,else就变成了替换了的if的else,而不是if(x>0&&y>0)中的if,这是由于缩进,所以不太好看出来。即:

1
2
3
4
if(x>0&&y>0)
if(!(x>y)) assert_error(__FILE__,__LINE__);
else
if (!(y>x)) assert_error(__FILE__,__LINE__);

即使加了大括号,即

1
#define assert(e) {if (!e) assert_error(__FILE__,__LINE__);}

也是有问题的,因为替换后会在{}后面加上’;’, 这在语法上是错误的。

书中给出了一个正确的答案:

1
2
#define assert(e) ((void) ((e)||assert_error(__FILE__,__LINE__)))
// 如果e为真,则根本不会计算assert_error(__FILE__,__LINE__)。

6.4) 宏不是type definitions

宏和typedef是有本质不同的,最好不要用宏来做type definitions

1
2
typedef struct foo *T2;
#define T1 struct foo *

这两个都能实现type definitions,但是:

1
2
T1 a, b; // 这个相当于#define struct foo *a, b; 也就是说a是指针,b是结构
T2 c, d; //

因此这两者是不同的。

总结,宏替换能带来很大的优势,但是如果不小心,也会是程序出错,而且错误比较难以发现。

第七章: 可移植性问题

可移植性的问题之所以会出现是因为有很多的c编译器实现,而不同的编译器实现有着个自的特点,在有些细节上的实现有差别。同时由于本书写作过程中,ANSI C还没有定型以及普及,所以可移植性的问题会比较大。现在由于ANSI C的普及,在可移植性问题上,应该好了很多了。

7.1) coping with change

这话说的,不coping with change也不行啊,用哲学上的话说:事物是发展变化的,人如果不跟着change,也不行啊!

7.2) 标示符的名字

这里主要是不同的implementation对于标示符名字的长度的支持是不一样,有的识别标示符的所有的字符,有的只是别前面若干个。对于ANSI C

(1) 对于外部变量,只保证识别前六个字符,所以printf_field与printf_float就会被认为是一样的了

(2) 不区分大小写,所以STATE和state是一样的。

7.3) 整数的大小

C语言提供三种整形,short,int,long。有以下约束

(1) 这三种类型的长度是非递减的

(2) int要足够大以便能够容下任何数组下标

(3) 字符character的长度由硬件决定

7.4) 字符应该是unsigned or signed

当我们要把一个char转换成int时,这个问题才会成为问题。因为我们无法确定应该对char做signed还是unsigned处理呢?对于unsigned char则是直接在多余的位置补充0,而对于unsigned char则要复制符号位。

所以我们最好声明为unsigned char。

7.5)移位运算符

移位一般可以用来代替乘除法,因为移位的速度很快。可是移位也会有一些可移植性的问题,主要是’向右移位时,空出来的位由0填充还是由符号位填充’。如果是无符号数的话,就用0;而如果是有符号数的话,就不一定了,是0还是符号位,这个由不同的implementation决定。如果很在意右移位的空位填充值得话,最好定义为unsigned类型。

7.6) memory location 0

内存位置0到底是什么。

null指针不指向任何对象。因此除了赋值或比较运算,其他任何的使用null都是非法的。而对于非法读写NuLL的的结果,不同的implementation的处理结果是不同的。如果想检测机器的响应的话,可用以下的代码:

1
2
3
4
5
6
7
#include<stdio>
main()
{
char *p;
p=NULL;
printf(“Location 0 contains %d\n”,*p);
}

有的implementation禁止读,有的可以读不可以写,有的既可以读也可以写,对于这种,如果写了,有可能修改操作系统的内容,会造成灾难。

7.7) 除法的截取问题

1
2
q=a/b;
r=a%b;

对于上面的两个算式的结果以及四个值a,b,r,q的值得约束,有下面三条:

(1) 最重要的是 q*b+r==a;

(2) 改变a的正负号,q的正负号也要改变,但是绝对值不应该改变

(3) 当b>0时,希望r>=0且r<b.

很不幸,这三点不可能同时成立,所以不同的implementation采用了(1)(2)或(1)(3)。因此这个除法截取是有移植上的问题的。

大部分implementation采用的是(1)(2)

7.8) 随机数的大小

不同的implementation的随机数大小不一样,有的是2^15-1,有的是2^31-1.

7.9) 大小写转换

这个问题在可移植性上的问题是,对于ASCII字符集,大小写字符的间隔是一致的,而对于别的字符集,间隔未必一致。所以前面章节定义的大小写转换宏就未必有效了。

7.10) 释放与内存分配

C语言的内存分配函数主要有malloc,realloc,free三个。