读书笔记之:C语言深度剖析
第1章关键字
1.register
虽然寄存器的速度非常快,但是使用register修饰符也有些限制的:register变量必须是能被CPU寄存器所接受的类型。
意味着register变量必须是一个单个的值,并且其长度应小于或等于整型的长度。而且register变量可能不存放在内存中,
所以不能用取址运算符“&”来获取register变量的地址。
2.static修饰符
(1)修饰变量
静态局部变量,在函数体里面定义的,就只能在这个函数里用了,同一个文档中的其他函数也用不了。由于被static修饰的变量总是存在内存的静态区,所以即使这个函数运行结束,这个静态变量的值还是不会被销毁,函数下次使用时仍然能用到这个值。
(2)修饰函数
第二个作用:修饰函数。函数前加static使得函数成为静态函数。但此处“static”的含义不是指存储方式,而是指对函数的作用域仅局限于本文件(所以又称内部函数)。使用内部函数的好处是:不同的人编写不同的函数时,不用担心自己定义的函数,是否会与其它文件中的函数同名。
关键字static有着不寻常的历史。起初,在C中引入关键字static是为了表示退出一个块后仍然存在的局部变量。随后,static在C中有了第二种含义:用来表示不能被其它文件访问的全局变量和函数。为了避免引入新的关键字,所以仍使用static关键字来表示这第二种含义。
3.if语句使用注意
先处理正常情况,再处理异常情况。
在编写代码是,要使得正常情况的执行代码清晰,确认那些不常发生的异常情况处理代码不会遮掩正常的执行路径。这样对于代码的可读性和性能都很重要。因为,if
语句总是需要做判断,而正常情况一般比异常情况发生的概率更大(否则就应该把异常正常调过来了),如果把执行概率更大的代码放到后面,也就意味着if语句将进行多次无谓的比较。
另外,非常重要的一点是,把正常情况的处理放在if后面,而不要放在else后面。当然这也符合把正常情况的处理放在前面的要求。
4.千万小心又小心使用void指针类型。
按照ANSI(AmericanNationalStandardsInstitute)标准,不能对void指针进行算法操作,即下列操作都是不合法的:
void*pvoid;
pvoid++;//ANSI:错误
pvoid+=1;//ANSI:错误
ANSI标准之所以这样认定,是因为它坚持:进行算法操作的指针必须是确定知道其指向数据类型大小的。也就是说必须知道内存目的地址的确切值。
例如:
int*pint;
pint++;//ANSI:正确
但是大名鼎鼎的GNU(GNU'sNotUnix的递归缩写)则不这么认定,它指定void*的算法操作与char*一致。因此下列语句在GNU编译器中皆正确:
pvoid++;//GNU:正确
pvoid+=1;//GNU:正确
在实际的程序设计中,为符合ANSI标准,并提高程序的可移植性,我们可以这样编写实现同样功能的代码:
void*pvoid;
(char*)pvoid++;//ANSI:正确;GNU:正确
(char*)pvoid+=1;//ANSI:错误;GNU:正确
GNU和ANSI还有一些区别,总体而言,GNU较ANSI更“开放”,提供了对更多语法的支持。但是我们在真实设计时,还是应该尽可能地符合ANSI标准。
5.const与宏
节省空间,避免不必要的内存分配,同时提高效率
编译器通常不为普通const只读变量分配存储空间,而是将它们保存在符号表中,这使得它成为一个编译期间的值,没有了存储与读内存的操作,使得它的效率也很高。
例如:
#define M 3//宏常量
const int N=5;//此时并未将N放入内存中
......
inti=N;//此时为N分配内存,以后不再分配!
intI=M;//预编译期间进行宏替换,分配内存
intj=N;//没有内存分配
intJ=M;//再进行宏替换,又一次分配内存!
const定义的只读变量从汇编的角度来看,只是给出了对应的内存地址,而不是象#define一样给出的是立即数,所以,const定义的只读变量在程序运行过程中只有一份拷贝(因为它是全局的只读变量,存放在静态区),而#define定义的宏常量在内存中有若干个拷贝。#define宏是在预编译阶段进行替换,而const修饰的只读变量是在编译的时候确定其值。
#define宏没有类型,而const修饰的只读变量具有特定的类型。
6.最易变的关键字----volatile
volatile是易变的、不稳定的意思。很多人根本就没见过这个关键字,不知道它的存在。也有很多程序员知道它的存在,但从来没用过它。我对它有种“杨家有女初长成,养在深闺人未识”的感觉。volatile关键字和const一样是一种类型修饰符,用它修饰的变量表示可以被某些编译器未知的因素更改,比如操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。
先看看下面的例子:
int i=10;
int j=i;//(1)语句
int k=i;//(2)语句
这时候编译器对代码进行优化,因为在(1)(2)两条语句中,i没有被用作左值。这时候编译器认为i的值没有发生改变,所以在(1)语句时从内存中取出i的值赋给j
之后,这个值并没有被丢掉,而是在(2)语句时继续用这个值给k赋值。编译器不会生成出汇编代码重新从内存里取i的值,这样提高了效率。但要注意:(1)(2)语句之间i没有被用作左值才行。
再看另一个例子:
volatile int i=10;
int j=i;//(3)语句
int k=i;//(4)语句
volatile关键字告诉编译器i是随时可能发生变化的,每次使用它的时候必须从内存中取出i的值,因而编译器生成的汇编代码会重新从i的地址处读取数据放在k中。
这样看来,如果i是一个寄存器变量或者表示一个端口数据或者是多个线程的共享数据,就容易出错,所以说volatile可以保证对特殊地址的稳定访问。
但是注意:在VC++6.0中,一般Debug模式没有进行代码优化,所以这个关键字的作用有可能看不出来。你可以同时生成Debug版和Release版的程序做个测试。
留一个问题:const volatile int i=10;这行代码有没有问题?如果没有,那i到底是什么属性?
这个可以同时使用。
7.空结构体是有大小的
structstudent
{
}stu;
sizeof(stu)的值是多少呢?在VisualC++6.0上测试一下。
很遗憾,不是0,而是1。为什么呢?你想想,如果我们把structstudent看成一个模子的话,你能造出一个没有任何容积的模子吗?显然不行。编译器也是如此认为。编译器认为任何一种数据类型都有其大小,用它来定义一个变量能够分配确定大小的空间。既然如此,编译器就理所当然的认为任何一个结构体都是有大小的,哪怕这个结构体为空。那万一结构体真的为空,它的大小为什么值比较合适呢?假设结构体内只有一个char型的数据成员,那其大小为1byte(这里先不考虑内存对齐的情况).也就是说非空结构体类型数据最少需要占一个字节的空间,而空结构体类型数据总不能比最小的非空结构体类型数据所占的空间大吧。这就麻烦了,空结构体的大小既不能为0,也不能大于1,怎么办?定义为0.5个byte?但是内存地址的最小单位是1个byte,0.5个byte怎么处理?解决这个问题的最好办法就是折中,编译器理所当然的认为你构造一个结构体数据类型是用来打包一些数据成员的,而最小的数据成员需要1个byte,编译器为每个结构体类型数据至少预留1个byte的空间。所以,空结构体的大小就定位1个byte。
8. 大端与小端
在x86 系统下,输出的值为多少?
#include <stdio.h>
int main()
{
int a[5]={1,2,3,4,5};
int *ptr1=(int *)(&a+1);
int *ptr2=(int *)((int)a+1);
printf("%x,%x",ptr1[-1],*ptr2);
return 0;
}
5和0x02000000
下图中是数组a在内存中的存放方式。
01 |
0xbfd46624 |
01 |
0xbfd46624 |
00 |
|
00 |
0xbfd46625 |
00 |
|
00 |
|
00 |
|
00 |
|
02 |
0xbfd46628 |
02 |
0xbfd46628 |
00 |
|
00 |
|
00 |
|
00 |
|
00 |
|
00 |
|
03 |
0xbfd4662c |
03 |
0xbfd4662c |
00 |
|
00 |
|
00 |
|
00 |
|
00 |
|
00 |
|
04 |
0xbfd46630 |
04 |
0xbfd46630 |
00 |
|
00 |
|
00 |
|
00 |
|
00 |
|
00 |
|
05 |
0xbfd46634 |
05 |
0xbfd46634 |
00 |
|
00 |
|
00 |
|
00 |
|
00 |
|
00 |
|
00 |
0xbfd46638 |
00 |
0xbfd46638 |
00 |
|
00 |
|
00 |
|
00 |
|
00 |
|
00 |
|
由于x86是小端方式,所以低位内容存放到了低位地址。图中每一种颜色代笔一个int型的内存分布。&a可以获得数组a的地址,也就是这儿的0xbfd46624, 所以&a+1的结果应该是0xbfd46638(即图中最下面红色部分)。对于代码中的ptr1由于其为int型指针,所以ptr[-1]的意思应该是取0xbfd46638地址之前的一个整型,即为a数组中的最后一个值5。而在计算ptr2的时候,(int)a是将整型地址a转换成了一个整型数,这样(int)a+1的结果就是0xbfd46625,然后再将其转化为int型指针,这样利用ptr2获得的数值就是从0xbfd46625开始的一个整型,即为0x02000000
9. #define a int[10]与 typedef int a[10];
留两个问题:
1) ,#define a int[10]//宏定义没有问题 | 2), typedef int a[10];//这是定义一个新类型 |
3) ,#define a int*[10]//宏定义没有问题 | 4), typedef int * a[10];//定义了一个新类型:元素为int*的10个元素的数组 |
5) ,#define *a int[10]//宏定义是错误的 | 6), typedef int (* a)[10];//定义了指向10个元素数组的指针类型 |
7) ,#define *a * int[10] //宏定义不正确 | 8), typedef int * (* a)[10];//数组指针,该数组中的元素是int*,个数为10 A~D不正确, |
请判断这里面哪些定义正确,哪些定义不正确。另外,int[10]和 a[10]到底该怎么用?
宏定义只是进行简单的替换,而typedef是定义了一个类型,所以,可以很容易的判断上面的各项是否正确。 上面表格中相同颜色高亮的几处说明非常的相似,或者效果一样。
10. 花括号
花括号每个人都见过,很简单吧。但曾经有一个学生问过我如下问题:
char a[10] = {“abcde”};
他不理解为什么这个表达式正确。我让他继续改一下这个例子:
char a[10] { = “abcde”};
问他这样行不行。那读者以为呢?为什么?
花括号的作用是什么呢?我们平时写函数,if、while、for、switch 语句等都用到了它,但有时又省略掉了它。简单来说花括号的作用就是打包。你想想以前用花括号是不是为了把一些语句或代码打个包包起来,使之形成一个整体,并与外界绝缘。这样理解的话,上面的问题就不是问题了。
11.再论 a 和&a 之间的区别
int main()
{
char a[5]={'A','B','C','D'};
char (*p3)[5] = &a;
char (*p4)[5] = a;
return 0;
}
int main()
{
char a[5]={'A','B','C','D'};
char (*p3)[3] = &a;
char (*p4)[3] = a;
return 0;
}
int main()
{
char a[5]={'A','B','C','D'};
char (*p3)[10] = &a;
char (*p4)[10] = a;
return 0;
}
int a[5][5];
int (*p)[4];
p = a;
问&p[4][2] - &a[4][2]的值为多少?
12. 用 malloc 函数申请 0 字节内存
另外还有一个问题:用 malloc 函数申请 0 字节内存会返回 NULL 指针吗?
可以测试一下,也可以去查找关于 malloc 函数的说明文档。申请 0 字节内存,函数并不返回 NULL,而是返回一个正常的内存地址。但是你却无法使用这块大小为 0
的内存。这好尺子上的某个刻度,刻度本身并没有长度,只有某两个刻度一起才能量出长度。对于这一点一定要小心,因为这时候 if(NULL != p)语句校验将不起作用。
13. 不使用任何变量编写 strlen 函数
看到这里,也许有人会说,strlen 函数这么简单,有什么好讨论的。是的,我相信你能 熟练应用这个函数,也相信你能轻易的写出这个函数。但是如果我把要求提高一些呢:
不允许调用库函数,也不允许使用任何全局或局部变量编写 int my_strlen (char *strDest); 似乎问题就没有那么简单了吧?这个问题曾经在网络上讨论的比较热烈,我几乎是全程“观战” ,差点也忍不住手痒了。不过因为我的解决办法在我看到帖子时已经有人提出了, 所以作罢。
解决这个问题的办法由好几种,比如嵌套有编语言。因为嵌套汇编一般只在嵌入式底 层开发中用到,所以本书就不打算讨论 C 语言嵌套汇编的知识了。 有兴趣的读者,可以查找相关资料。 也许有的读者想到了用递归函数来解决这个问题。是的,你应该想得到,因为我把这 个问题放在讲解函数递归的时候讨论。
既然已经有了思路, 这个问题就很简单了。
代码如下:
int my_strlen( const char* strDest )
{
assert(NULL != strDest);
if ('\0' == *strDest)
{
return 0;
}
else
{
return (1 + my_strlen(++strDest));
}
}
第一步:用 assert 宏做入口校验。
第二步:确定参数传递过来的地址上的内存存储的是否为'\0'。如果是,表明这是一个 空字符串,或者是字符串的结束标志。
第三步:如果参数传递过来的地址上的内存不为'\0',则说明这个地址上的内存上存储 的是一个字符。既然这个地址上存储了一个字符,那就计数为 1,然后将地址加 1 个 char类型元素的大小,然后再调用函数本身。如此循环,当地址加到字符串的结束标志符'\0'时, 递归停止。
当然,同样是利用递归,还有人写出了更加简洁的代码:
int my_strlen( const char* strDest )
{
return *strDest?1+strlen(strDest+1):0;
}
这里很巧妙的利用了问号表达式, 但是没有做参数入口校验, 同时用*strDest 来代替('\0' == *strDest)也不是很好。所以,这种写法虽然很简洁,但不符合我们前面所讲的编码规范。
可以改写一下:
int my_strlen( const char* strDest )
{
assert(NULL != strDest);
return ('\0' != *strDest)?(1+my_strlen(strDest+1)):0;
}
上面的问题利用函数递归的特性就轻易的搞定了, 也就是说每调用一遍 my_strlen 函数, 其实只判断了一个字节上的内容。但是,如果传入的字符串很长的话,就需要连续多次函数调用,而函数调用的开销比循环来说要大得多,所以,递归的效率很低,递归的深度太大甚 至可能出现错误(比如栈溢出) 。所以,平时写代码,不到万不得已,尽量不要用递归。即便是要用递归,也要注意递归的层次不要太深,防止出现栈溢出的错误;同时递归的停止条 件一定要正确,否则,递归可能没完没了