前言

近来学习操作系统这门课,课程的实验基于linux 0.11,于是从图书馆借来了 C Traps and Pitalls 和 Expert C programming,打算提高一下c语言水平。
先从前一本开始。这本书很薄,即使是英文版也只有140多页,讲的都是c语言中容易犯错的地方。
注意:这篇笔记并没有包括整本书的内容,而只是摘抄了本人需要的知识(加上了一些自己的理解)。如需完整了解,还请自行看书。

第一章:词法陷阱

符号之间的空白(extra space)是被忽略的。

extra space包括空格、制表符和换行
比如你觉得这个函数声明太长

int* foo(int arg1, const char *arg2, double arg3, struct bar *arg4) 
{
    /*...*/
}

可以写成

int* foo(   int arg1,
            const char *arg2,
            double arg3,
            struct bar *arg4)
{
    /*...*/
}

符号分析策略

编译器从左到右分析符号,使获取的符号尽可能地长。
a---b; 等价于 a-- -b;

所以 a = b/*p; 这种写法很可能不是你想要表达的意思。 /* 会被认为是注释符的左边,于是它后面的内容都成了注释,实现除法的写法应该是 a = b / *p;

但是,形如a-----b就不能理解成 (a--)-- (-b) ,而应该是 (a--)-(--b) ,因为 (a--)-- 这样是不符合语法的。


关于整型常量

int a = 90;int b = 090; 是不一样的呀。
后者在9的前面多了一个0,编译器就会以为090是八进制,于是b的值就是9*8^1 + 0*8^0 = 72d


关于char

char就相当于整型,只是它的大小是1字节。相应地,字符'a'就是对应ASCII码里的数字。
char*是指针,32位下大小为4字节。与其它指针无异。
一个用双引号括起来的字符串(如"hello"),就是一个指向字符串(加上结尾的'\0')首地址的指针缩写。
所以

printf("hello");

类似于

char* str = "hello";
printf(str);

第二章:语法陷阱

关于调用函数

假如有函数

void bar(int arg);

那么:
bar(); 执行函数
int (*p)(int a) = bar; bar是函数地址,可以赋值给函数指针(当然类型要相同)

第三章:语义陷阱

关于指针和数组

Only two things can be done to an array: determine its size and obtain a pointer to element 0 of the array. All other array operations are actually done with pointers, even if they are written with what look like subscripts.

如果定义指针来当数组用,需要手动用malloc在运行时堆获得空间;而定义数组,编译器就自动在栈帧里分配空间。指针和数组的操作相似,关于数组的操作大部分都是基于指针的。


指针不是数组 Orz...

这里要指出的是关于char类型数组和指针的两个不同。

假定现在需要一个hello的字符串,可以这么写:

char* str = "hello";
char str[6] = {'h','e','l','l','o','\0'};

前者编译器会在字符串后面加上结束符 '\0',后者则需要自己注意留出空间存放该符号。

字符串常量(比如上面的 "hello")存放在常量区,在编译时就确定好了,在运行的时候不能修改。所以下面的操作是错误的。

char* str = "hello";    //指针指向常量区
str[3] = 'a';    //错误,修改了常量区的内容

而使用数组则不会出现这样的问题,因为char数组会将内存常量的内容复制到栈帧中。

char str[6] = "hello";       //将内存常量中的"hello"复制给栈帧中的数组
str[3] = 'a';    //正确,修改的是栈帧中的内容

数组作为函数参数

不能将整个数组当作参数传入函数,使用数组名作为参数会把数组首地址作为指针传入函数。于是

int strlen(char s[])
{
    /* stuff */
}

int strlen(char *s)
{
    /* stuff */
}

的效果是一样的。所以常见的参数 char* argv[]char** argv 也是一样的。
更准确的说法是,数组作为参数传入函数会“退化”成指针。在定义该数组的函数里使用 sizeof 可以正确获得数组大小,而将该数组首地址传入函数后,只用 sizeof 只能获得指针大小。例如

int main()
{
    char str[] = "hello";
    printf("%u", sizeof(str));
    return 0;
}
//输出结果为6
int main(int argc, char* argv[])
{
    printf("%u", sizeof(argv[0]));
    return 0;
}
//输出结果为4

计算边界和不对称边界

写for循环的时候,会遇到一个问题:

for (int i = 0; i < 10; i++)
{
    a[i] = 0;
}
for (int i = 1; i <= 10; i++)
{
    a[i] = 0;
}

哪个写法好呢?本书给出了后者写法的理由。

左闭右开
如果 x>=20 && x<=40 为真,那么x有多少种可能的取值?20?答案应该是 40 - 20 + 1 = 21 。这样的写法似乎挺反直觉,容易出错,如果写成左闭右开 x>=20 && x<41 就好多了。类似地,在C语言里,数组第1个元素下标为0,于是定义一个10个元素的数组,最大的下标为9。可能挺反直觉,但是只要利用好这特点,使代码简洁,逻辑简单。例如:

#define N 128
static char buffer[N];
void bufwrite(char *p, int n)
{   
    int i;
    int temp;
    for (i = 0; i < N && (temp=getchar()) != EOF; i++)
    {
        bufer[i] = temp;
    }
}

那么退出循环的时候i就一定是缓冲区中元素的个数了。

其实,采用左开右闭写法习惯,更多是因为c语言本数对内存的描述是地址加偏移量。比如数组int a[10]不存在a[10]而存在a[0]。我们主动迎合这种做法可以避免不少麻烦。否则,想要一个10个元素的数组就要定义int a[11]。这么做增加了出错的可能。


求值顺序

操作符&& || ?: ,都是从左到右执行。
其中, && 左边操作数为假,右边不会执行; || 左边操作数为真,右边不会执行。可以利用这个特点简化代码,例如

int a[100];
int i = 0;
while (i < 100 && a[i] != 0)
{
    /* stuff */
}
其中,`&&` 保证了数组访问不会越界。

除了特定的操作符 && || ?: ,,操作符对其操作数的求值顺序是不确定的。常见的

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

赋值号左边的i是不确定的。正确写法可以为:

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

整型溢出

两个unsigned类型相加不会有溢出的问题(简单舍去进位),一个unsigned和一个int相加也没有问题(int将会被转换为unsigned)。两个int相加就可能存在溢出问题了。
假定两个int都为正数。对于检查溢出,这样写是不对的

if (a + b < 0)

因为在检查的过程中已经导致溢出发生。而不同机器对溢出的处理是不一样的,不能够假定溢出了什么事都不发生。所以检查溢出时应该避免溢出发生:

if ((unsigned)a + (unsigned)b > INT_MAX)
//或
if (a > INT_MAX - b)

第四章:链接

c程序是先编译后链接。具体可以看CSAPP的第七章,有关于链接的基本介绍

形参实参和返回值

为了兼容旧版本的c程序,对于函数的声明,需要忽略参数。以库函数square为例,声明为double square();,只要在调用函数的时候能够正确地传入参数,就可以正常调用函数。
有一点需要注意:对于上面square的声明,如果传入函数的参数为float类型,它在传入时会被转化为double类型,这样没什么不妥;对于参数类型为short、char的函数,如果声明时忽略参数,传入的参数会被转化为int,就会产生问题:函数要的是8位的char,而int的长度一般都不是8,进行位运算可能会出现问题。

当然,以上都是为了兼容才在声明时忽略参数。我们写程序还是老老实实把声明写完整吧。


检查全局变量的类型

比如,定义一个全局变量 int a; ,而在另外以外一个c文件错误地声明为 extern long a; 。显然,这样是错的。编译器和链接器足够聪明的话,能发现这个问题,然后给出错误。否则,编译、链接通过,问题潜伏在程序中。程序可能正常运行(int和long都为32位等原因),或者出错(声明为double,定义为int等)。总之,程序员有义务保证声明和定义一致

定义和声明不一致导致错误很正常啊,为什么要拿一小节来讲呢?因为定义全局变量 char str[]; 和声明 extern char* str; 就是定义和声明不一致的错误,而我们不易察觉。问题在于:char指针不是char数组啊(Orz两者像归像,还是有不同啊....)

char a[128];    //a代表这个数组的首地址
char *b = a;    //b是一个指针变量,它的值为数组a的首地址

a++;    //错误
b++;    //正确

体现的是char类型的数组与指针的区别。


头文件

头文件了解决上面一小节的问题。做法是,将所有外部变量的声明都写在头文件,然后将头文件include在每个涉及这些变量的文件中,包括定义该变量的文件。原因是,在同一文件中声明和定义一个全局变量,那么编译器就可以检查声明和定义是否一致,如果一致,在其它文件中只要include这个头文件,就可以使用该变量了。将函数声明放入头文件也是同样道理。
有一点要说明:函数默认是全局的(除非是 static),所以在其他文件声明的时候可以不加 extern ,但出于阅读方便,我们都是加上 extern(标准库头文件都有);变量则一定要加(不加 extern 就变成定义了)。

探究:如果不使用头文件会怎样?可以直接引用外部函数和变量吗?
如果没有声明就直接引用外部变量和函数,那么编译器就假定函数的返回值为int,变量为int类型,然后链接器把引用链接起来。如果碰巧引用的变量的确是int型,那么程序正常,否则类型对不上发生错误。
在没有外部声明的情况下,函数内部不能直接引用其他文件的全局变量。


第五章:库函数

getchar返回int

先看一段代码

#include <stdio.h>
int main()
{
    char c;
    while ((c = getchar()) != EOF)
    {
        putchar(c);    
    }
    return 0;
}

代码的功能就是将输入流的内容转到输出流中,直到输入流为空。现在来说说代码存在的问题。首先要说明,char为 unsigned charsigned char 是由编译器决定的,大部分编译器默认为 signed charEOF 一般定义为-1。假定int为32位。

  1. 如果char为 unsigned char 。当getchar返回 EOF(0xffffffff) ,那么变量c被赋值为 0xff。与EOF(int)比较,c需要扩展为0x000000ff(无符号扩展用零填充),两者不相等,循环将不会停止。

  2. 如果char是 signed char 。当getchar读到字符 0xff,于是返回 0x000000ff,c被赋值为 0xff 。与EOF(int)比较,c需要扩展为 0xffffffff(有符号扩展用符号位填充),两者相等,循环提前结束。

出于以上,c也应该改为int,使代码正常工作。
注意:某些编译器直接拿getchar的返回值与EOF比较。这样虽然不能正确表达代码的意思,但能使程序正常工作。


缓存输出和内存分配

手动为文件分配缓冲区,可以使用setbuf函数,在缓冲区满时输出。

#include <stdio.h>

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

这段代码出错在于,缓冲输出在main结束的时候,这时buf数组已经不存在。
解决办法是为数组buf加上关键字 static,成为静态变量(但不建议在函数内部定义静态函数);或者使用malloc函数,如

setbuf(stdout, malloc(BUFSIZE));

这样,main结束时缓冲区仍然存在。但是要时刻留意malloc之后要不要free!


使用errno来检测错误

没有规定:如果库函数运行正常,要将errno设置为0。于是以下写法错误:

call library function
if (errno != 0)
    complain

那么在使用函数前就把errno设置为0呢?

errno = 0;
call library function
if (errno != 0)
    complain

不行!因为库函数内部可能会调用其它库函数。比如调用fopen,函数会调用其它库函数去检查某个文件是否存在,如果文件不存在,则errno会被设置,然后创建新文件,返回指针。这时候fopen是正常工作的,但是errno却不为0。
那errno该怎么用?最好的办法应该是结合返回值使用了。应该在函数返回错误信息后,再检查errno

call library routine
    if (error return)
        examine errno

signal函数

原则是signal处理函数尽可能简单。最好是输出相关信息后就用exit退出程序。原因是,信号接收可能出现在任何时候(malloc时接收到信号,信号处理又调用malloc);而且信号处理完后不同机器有不同操作(某些机器在某些信号处理后重复失败的操作,如除数为零)。

第六章:预处理

切记宏只是简单地复制粘贴!

宏不是函数

以下面的定义为例

#define MAX(a,b)  ((a)>(b)?(a):(b))

不加括号必然引起悲剧,不用多说。
其次,谨慎使用 ++ -- 之类的运算, MAX(a,a++) 也会产生奇怪的结果。
再次,谨慎嵌套,如 MAX(a, MAX(b, MAX(c, d)) ,展开后表达式很长,debug时会很痛苦,而且会产生不必要的重复运算。


宏不是语句

比如,谨慎在宏用 if。假如assert是这样定义的:

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

那么

if (a > 0 && b > 0)
    assert(x > y);
else
    assert(x < y);

展开就变成

if (a > 0 && b > 0)
{
    if (!(x > y)) 
        assert_error(__FILE__,__LINE__);
    else if (!(x < y))
        assert_error(__FILE__,__LINE__);
}

这显然不是我们要的结果。可能会想到将assert用花括号围起来。但这样就会在if和else之间出现语法错误。
实际上,assert是这样定义的:

#define assert(e) ((void)((e)||_assert_error(__FILE__,__LINE__))

它是一个值而不是语句。

后记

阅读完这本书后,对这些陷阱可以总结为:只做正确的事,不要做一些感觉应该正确的事。有些陷阱是历史原因,有些是奇怪的缘故,还有些是逻辑的问题。总之,谨慎!
同时对c的认识又加深了:c是贴近硬件的语言。还加上嵌入汇编的功能,难怪写操作系统要用c;还有,这本书提高了我查文档的能力!
这本书对数组和指针的解释比较分散,建议阅读c专家编程作为系统了解。


shanyin
129 声望3 粉丝

写代码