前言
近来学习操作系统这门课,课程的实验基于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 char
或 signed char
是由编译器决定的,大部分编译器默认为 signed char
,EOF
一般定义为-1。假定int为32位。
如果char为
unsigned char
。当getchar返回EOF(0xffffffff)
,那么变量c被赋值为0xff
。与EOF(int)比较,c需要扩展为0x000000ff
(无符号扩展用零填充),两者不相等,循环将不会停止。如果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专家编程作为系统了解。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。