《C和指针》、 《C专家编程》、 《C陷阱和缺陷》并称c语言三本经典著作,笔者在许多年前囫囵吞枣读完了这三本经典,然后把这三本书束之高阁。时至今日,大部分内容都已忘记,前些时间偶然翻出来,重读这些经典,顺便做做笔记,记录其精华。
《C和指针》通过对指针的基础知识和高级特性的探讨,帮助程序员把指针的强大功能融入到自己的程序中去。
本章讲的是C语言的基础知识,目的是使读者对C语言有一个整体的初步的认识。
这一章给读者一些忠告,包括:
第二章仍然讲C语言的基础知识,主要知识点包括:
程序的编译和链接,编译是将源文件翻译成目标文件,包括对define和include等预处理器的替换;链接是将目标文件和所需要的库文件生成可执行文件;
C中定义了一些字母前加”\”来表示常见的那些不能显示的ASCII字符,如\0,\t,\n等,就称为转义字符,因为后面的字符,都不是它本来的ASCII字符意思了。
C语言中转义字符有两种表示方式,一种是在字符前加”\”,还有一种是三字母词。大多数程序猿只了解第一种,对第二种并不熟悉。
三字母词就是几个字符的序列,合起来表示另一个字符,比如
三字母词 | 含义 |
---|---|
??( | [ |
??< | { |
??= | # |
??) | ] |
??> | } |
??/ | \ |
??! | |
??’ | ^ |
??- | ~ |
“\”转义
部分转义字符定义:
转义字符 | 含义 |
---|---|
\a | 警告字符,可能会奏响铃声或者产生一些其它可见字符 |
\b | 退格 |
\f | 进纸 |
\n | 换行 |
\r | 回车 |
\t | 水平制表符 |
\v | 垂直制表符 |
\ddd | 表示1-3个八进制数字,给定的八进制数转义为对应的ASCII字符 |
\xddd | 表示1-3个十六进制数字,给定的十六进制数转义为对应的ASCII字符,这个值大小可能超出范围 |
注:
1,\v垂直制表和\f换页符对屏幕没有任何影响,但会影响打印机执行响应操作。
2,\n其实应该叫回车换行。换行只是换一行,不改变光标的横坐标;回车只是回到行首,不改变光标的纵坐标。
3,\t 光标向前移动四格或八格。
4,\b是退格键,往前移动光标,不删除字符,但该字符可能会被后续字符覆盖。
5,这些都是从电传打字机沿用下来的。
C语言是一种自由形式的语言,语法较为宽松,但良好的程序风格和文档将使代码更容易阅读和维护。
第3章仍然讲C语言的基础知识,主要讲数据类型的定义。
C语言的数据类型包括基本类型、聚合类型和指针类型,基本类型主要包括int,char,float,double,聚合类型包括结构体、数组、枚举和联合,具体类型见下图:
其中有些细节需要注意:
ANSI标准规定:长整型至少应该和整型一样长,整型至少应该和短整型一样长。标准同时规定长整型的长度至少为32位,短整型至少为16位,对于整型的长度并没有明确规定。究竟整型数的长度是32位还是16位或者是64位,取决于编译器。当然,编译器可以把这三种类型的长度都设置为32位。
设计char类型的初衷是为了容纳字符类型,但其本质是8位的整型数据。至于缺省的char类型是signed还是unsigned,由编译器决定。为确保可移植性,char类型变量应该位于signed和unsigned的交集,即ascii字符集中。
int *a;
int* a;
这两种定义的结果是一样的,都是将a定义为一个int类型的指针。
但是,如果要定义三个指针,正确的方式是:
int *a,*b,*c;而不是int* a,b,c;
常量使用const修饰,常量在运行过程中不可修改,初始化一个常量由两种方法,
一种是在定义的时候进行初始化;
另一种是在函数调用的时候,声明为const类型的形参将被初始化为实参
int * const p;这种定义,p是常量,指针的值不可改变,但指针指向的内容可以改变;
int const * a;这种定义,*p是常量,指针的值可改变,但指针指向的内容不可改变;
这两种定义的含义不同
一个标识符的作用域有四种类型:
- 文件作用域:
- 代码块作用域:
- 函数作用域:goto语句后的标识符
- 原型作用域:函数声明中形参名字
static关键字有两种用法,
修饰全局变量和函数时,改变其链接属性,文件外部不可访问;
修饰局部变量时,改变其存储类型,由栈空间存储变成全局空间存储;
在C++中,static可以将类的成员变量变成静态成员变量。
进行算术运算(加、减、乘、除、取余以及符号运算)时,不同类型数招必须转换成同 一类型的数据才能运算,算术转换原则为:
整型提升:对于所有比int小的类型,包括char, signed char, unsigned char, short, unsigned short,首先会提升为int类型。
在进行运算时,以表达式中最长类型为主,将其他类型位据均转换成该类型,如:
(1)若运算数中有double型或float型,则其他类型数据均转换成double类型进行运算。
(2)若运算数中最长的类型为long型.则其他类型数均转换成long型数。
(3)若运算数中最长类型为int型,则char型也转换成int型进行运算。算术转换是在运算过程中自动完成的。
(4)若运算数中最长类型包含signed与unsigned int,signed会转换为unsigned。
(5)若运算数中最长类型包含signed long int 与unsigned long int,signed会转换为unsigned。
(6)若运算数中最长类型包含float 与double,float会转换为double。
(7)若运算数中最长类型包含double与long double,double 会转换为long double。
(8)C语言中算术运算总是以缺省整型的精度进行运算,表达式中的字符类型和短整型在使用前进行整型提升,比如
char a,b,c;
a=b+c;
b和c被提升为整型,然后执行加法运算,运算结果被截断,赋值给c
进行赋值操作时,赋值运算符右边的数据类型必须转换成赋值号左边的类型,若右边的数据类型的长度大于左边,则要进行截断或舍入操作。
注意:在进行自动类型转换的时候,如果原来的数是无符号数,那么在扩展的时候,高位填充的是0;如果是有符号数,那么高位填充的时符号位。扩展时与转换后的数的数据类型无关。
例子:
unsigned char c=0xff;
int a;
unsigned int b;
a=c;
b=c;
运行结果:a=0xff,b=0xff
char c=0xff;
int a;
unsigned int b;
a=c;
b=c;
运行结果:a=0xffffffff,b=0xffffffff
第4章仍然讲C语言的基础知识,主要讲语句。
printf函数其实是有返回值的,msdn是这么说的:
Return Values
Each of these functions returns the number of characters printed, or a negative value if an error occurs.
返回值是打印字符的个数。
其实sprintf函数也有返回值,返回值是写入buffer中的字节数。
for(expression1,expression2,expression3)
statement;
expression1:初始化部分,在循环体执行前执行一次
expression2:条件部分,在循环体每次执行前执行
expression3:调整部分,在循环体执行完毕、条件部分每次执行前执行
for语句中可以使用break语句和continue语句。
每个case标签必须具有一个唯一的常量值或常量表达式,常量表达式可以在编译期间进行计算。
switch语句中可以使用continue语句,但没有任何效果。
再次强调,case语句后不要漏写break语句。
ANSI标准说明无符号数的移位都是逻辑移位,有符号数的移位究竟是逻辑移位还是算术移位由编译器决定。
算术移位将保留操作数的符号位。
赋值运算符从右向左的结合顺序,x=y=a+3;会被编译器翻译成两条语句,y=a+3;x=y;这样写是合法的。但是赋值运算符的计算顺序ANSI标准并未规定,编译器可以先计算运算符左侧表达式的值,也可以先计算运算符右侧表达式的值。因此,*p++=*p++;这样的语句使用不同的编译器可能得到不同的结果。
逗号可以把多条语句合成一条,比如
while(x
这样写是合法的,但这并不是好的写法。
数组的下标引用和间接访问表达式是等价的,a[下标]=*(a+下标);
C语言并不具备显示的布尔累心个,所以用整数代替布尔类型:0是假,任何非0皆为真,”1”这个值并不比其它非零值更真。
左值和右值
左值:能够出现在赋值运算符左侧的东西;
右值:能够出现在赋值运算符右侧的东西;
左值可以成为右值,右值缺并不一定能成为左值。
变量可以成为左值,包含下标引用和间接访问的表达式可以成为左值。
指针是C语言的精华之所在,具有强大的灵活性,也是最容易出问题的地方。对于初学者而言,指针难以理解和掌握。即使对于有经验的程序猿,用好指针也不是一件容易的事。对指针的掌握程度已经程序检验一个程序猿的试金石。
指针的本质是一个变量,这个变量中保存的值被解释为内存地址。这个内存地址可以指向不同的数据类型,比如指向char类型,int类型或者float类型甚至指向指针类型。
对于固定架构的cpu来说,指针变量的长度是固定的,在x86计算机上,指针变量的长度是4。这也是指针强大的灵活性的基础,也是指针强制类型转换的基础。
对于cpu而言,在内存地址中存储的数据都是0、1这样的二进制位,至于长度是1个字节、2个字节或者4个字节,完全由程序开发者决定。比如:
int *a=100;
char *b=(char*)a;//a和c指向同一个内存地址,在解引用时*a访问的是4个字节,*c访问的是一个字节
int c;
char d;
c=*a; //对应汇编语言 mov c,DWORD PTR [a]
d=*b; //对应汇编语言 movsb b,DWORD PTR [b]
指针可以进行算术运算。
指针可以加上一个整数,加上一个整数之后,指针的值会移动适当长度,比如:
int *a,*b;
b=a;
a+=1;//此时b-a=sizeof(int)
两个指向相同数据类型的指针可以进行减法运算。程序猿应当确保这两个指针指向同一数组或者由malloc申请的内存中,否则结果没有实际意义。
指针在使用前应进行初始化并判断是否为空指针。解引用空指针在大部分操作系统中会造成程序崩溃,如果程序不崩溃,将导致一个潜在的bug。
声明一个指针并不会自动分配相应的内存。
再次强调,*p++等同于*(p++)。
函数定义即函数体,函数体在代码块中实现。函数可以有返回值,也可以没有返回值,返回值也可以被丢弃。
函数声明提供给编译器使用,提供了参数个数以及参数类型这些信息,编译期间检查函数调用传入的参数个数及每个参数的类型与函数声明是否严格匹配,不一致则报错。
函数声明的作用域与变量的作用域一样,可以是文件作用域,也可以是代码块作用域。为了防止函数声明的多个拷贝出现不一致的情况,通常把函数原型的声明放在一个头文件中,使用include函数包含这个头文件,如果修改函数声明的话,修改一处即可。这样可以提高代码的可维护性和编程效率。
函数调用时参数传递有两种方式,
传值调用:被调用函数将获得实参的一份拷贝,对参数的读写操作都是对拷贝的操作,因此,形参不能改变实参。查看反汇编代码,可以看到参数传递过程用栈实现,用push语句将实参入栈,这样实参和形参保存在不同的内存地址。
传址调用:顾名思义,传入的是变量的地址,用变量的地址对变量进行解引用,就可以修改实参的值。如果传入的参数是一个数据名,进行数组名的拷贝,按道理讲数组名应该代表整个数组,但实际上数组名代表的是数据第一个元素的地址,与指针等价,因此传递数组参数时并不会进行数据拷贝,退化为传址调用。
递归函数:在函数内部直接或者间接调用了自身的函数。
在理解了递归函数之后,阅读递归函数最容易的方法不是纠缠于其执行过程,而是相信递归函数会顺利完成任务。
教科书中常用两个例子介绍递归函数,分别是计算阶乘和斐波那契序列,用递归函数实现的代码如下:
unsigned int factorial(unsigned int n)
{
if (n==0 || n==1)
{
return 1;
}
return n*factorial(n-1);
}
unsigned int fibonacci(unsigned int n)
{
if (n==0)
{
return 0;
}
else if (n==1)
{
return 1;
}
return fibonacci(n-1)+fibonacci(n-2);
}
在计算阶乘时,使用递归并未有任何优越之处;使用递归计算斐波那契数列则造成大量的重复计算,效率低下。
《C和指针》列举了一个非常有意思的例子,把一个整数值转化为字符串打印出来
思路是把这个整数值除以10,把余数加上0x30,依次得到从个位到最高位的字符,但这样打印出来就会倒序显示,使用递归可以解决这个问题,为了简单起见,假设输入的数据是无符号数
void myitoa(unsigned int n)
{
unsigned int quotient;
quotient=n/10;
if (quotient)
{
myitoa(quotient);
}
printf("%d",n%10);
}
尾部递归:如果递归函数体内执行的最后一条语句是调用自身,这个函数就被称为尾部递归。
尾部递归都可以转化为用循环实现,效率通常更高一些。因为大部分编译器都通过运行时栈实现递归函数,与循环实现的方法相比,递归调用会占用大量的栈空间。
计算阶乘和斐波那契序列的两个递归函数就是尾部递归,用循环实现的代码如下:
unsigned int factorial(unsigned int n)
{
unsigned int result=1;
if (n==0 || n==1)
{
return 1;
}
while(n)
{
result*=n--;
}
return result;
}
unsigned int fibonacci(unsigned int n)
{
unsigned int result=0;
unsigned int prev,prevprev,i;
if (n==0)
{
return 0;
}
else if (n==1)
{
return 1;
}
prev=1;
prevprev=0;
i=2;
while(i++<=n)
{
result=prev+prevprev;
prevprev=prev;
prev=result;
}
return result;
}
数组名代表什么?合乎逻辑的答案是代表整个数组,事实并非如此,在C语言中,数组名代表的是一个指针常量,也就是第一个元素的地址。
对于数组而言,除优先级不同,下标引用和间接引用是完全一致的,但间接引用在某些情况下效率更高。
字符串是非常重要的数据类型,在软件开发,绝大部分软件都要涉及对字符串的处理,并且字符串处理的代码通常占整个工程代码量的20%以上。对新手而言,字符串处理过程非常容易出现溢出的问题,或者存在溢出的漏洞。
C语言中并无显示的字符串数据类型,因为字符串以字符常量的形式存储于字符数组中,换言之,字符串就是字符数组。
字符串以’\0’结尾,因此字符串内部不能出现’\0’,’\0’本身不是字符串的一部分。
strlen函数返回一个字符串的长度,字符串长度不包括最后结尾的’\0’。这个返回类型是size_t类型,size_t被定义为无符号整数。
注意,下面两行代码的功能并不相同:
if(strlen(x)>=strlen(y))
if(strlen(x)-strlen(y)>=0)
第一行代码的执行结果与预期相同;但第二行代码中,由于两个无符号数相减的结果仍然是无符号数,无符号数是不会小于0的,所以if语句的结果永远为真。
长度不受限制,是指这些函数通过寻找字符串结尾的NUL字符来判断字符串的长度,这类函数很容易造成溢出。
这类函数有strcpy,strcat,strcmp。
其中strcpy和strcat可能出现溢出,这两个函数返回第一个参数的拷贝,返回值常被忽略。strcmp是安全的。
这类函数有strncpy,strncat,strncmp。
strchr strrchr strstr
strpbrk :查找字符集中任意字符在字符串中第一次出现的位置
strspn:查找字符串开始位置与指定字符集匹配的字符个数,到第一个不匹配的字符为止
strcspn :与strspn相反
strtok:字符串分割函数,会修改源字符串,不可重入
toupper() tolower()
isdigit() isxdigit() iscntrl() isspace() islower() isupper() isalpha() isalnum() ispunct() isgraph() isprint()
memcpy:源地址和目的地如果重合,结果是未定义的
memmove:功能与memcpy类似,源地址和目的地址可以重合,内部实现方式是分配一块临时空间,把源数据拷贝到临时空间,再从临时空间拷贝到目的地址
memset
memcmp
C语言提供了基本数据类型,又称标量。
C语言还提供了聚合数据类型,又称向量,需要程序猿自己定义。聚合数据类型可以同时存储超过一个的基本类型,包括数组和结构。
数组是相同类型的元素的集合,因此数组可以使用下标进行进行访问。
结构可以是不同类型的数据的集合,这些类型被称为成员,成员的类型不同,其长度也不同,因此结构体中成员不能用下标进行访问。
结构体的声明方式:stuct tag{member_list} vari_list;
结构的自引用:结构体不能包含一个类型为结构体自身的成员,编译器不允许这样做。如果允许这样,将造成一个无限嵌套。但结构体可以包含一个指向结构体类型的指针,因为指针的长度是固定的。
不完整声明:用于两个相互依赖的结构体的声明。
如
struct B;
struct A{
struct B;
int a;
};
struct B{
struct A;
char c;
};
结构体的成员在分配时,受内存对齐的影响。
许多实际的计算机系统对基本类型数据在内存中存放的位置有限制,它们会要求这些数据的起始地址的值是某个数k的倍数,这就是所谓的内存对齐,而这个k则被称为该数据类型的对齐模数(alignment modulus)。这种强制的要求一来简化了处理器与内存之间传输系统的设计,二来可以提升读取数据的速度。
大部分的参考资料都是如是说的:
1、平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
2、性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。
比如这么一种处理器,它每次读写内存的时候都从某个8倍数的地址开始,一次读出或写入8个字节的数据,假如软件能保证double类型的数据都从8倍数地址开始,那么读或写一个double类型数据就只需要一次内存操作。否则,我们就可能需要两次内存操作才能完成这个动作,因为数据或许恰好横跨在两个符合对齐要求的8字节内存块上。
结构体对齐包括两个方面的含义:
1)结构体总长度;
2)结构体内各数据成员的内存对齐,即该数据成员相对结构体的起始位置;
结构体数据对齐,是指结构体内的各个数据对齐。在结构体中的第一个成员的首地址等于整个结构体的变量的首地址,而后的成员的地址随着它声明的顺序和实际占用的字节数递增。为了总的结构体大小对齐,会在结构体中插入一些没有实际意思的字符来填充(padding)结构体。
在结构体中,成员数据对齐满足以下规则:
结构体中的第一个成员的首地址也即是结构体变量的首地址。
结构体中的每一个成员的首地址相对于结构体的首地址的偏移量(offset)是该成员数据类型大小的整数倍。
结构体的总大小是对齐模数(对齐模数等于#pragma pack(n)所指定的n与结构体中最大数据类型的成员大小的最小值)的整数倍。
结构体变量可以使用传值调用的方式,但这样将消耗大量的栈空间,最好使用传址调用的方式。
两个结构体变量之间可以使用赋值符号,编译器将根据结构体的大小进行内存拷贝。其汇编语言伪代码如下:
mov ecx,sizeof(struct)
mov esi,struct_A
mov edi,struct_B
rep movs dword ptr[edi],dword ptr[esi]
在C语言中,两个结构体变量之间不能用关系运算符进行比较。
联合的所有成员引用的是内存中相同的位置,只是按照程序猿的意图,解释为不同的数据类型。比如4个字节,可以解释为int类型,也可以解释为4个字节。
位域是指信息在存储时,并不需要占用一个完整的字节, 而只需占几个或一个二进制位。例如在存放一个开关量时,只有0和1 两种状态, 用一位二进位即可。目前较少使用。
如果声明一个数组,数组的大小在编译时就必须指定,运行时在栈上分配内存,比如,sub esp,0x10;
如果一块内存空间的长度在运行时才能确定,就需要动态内存分配。
C语言中动态内存分配的函数有malloc和calloc,二者的区别是calloc分配内存之后用0进行初始化,调用这两个函数之后应检查其返回值是否为NULL。分配后的内存需要调用free函数进行释放,否则会造成内存泄漏。当进程终止时,该进程未被释放的内存都将归还给内存池。即使这样,一个只申请内存不释放内存的程序仍然是危险的,存在内存耗尽的可能。
realloc函数更改已经配置好的内存空间,
如果将分配的内存减少,realloc仅仅是改变索引的信息。
如果是将分配的内存扩大,则有以下情况:
1)如果当前内存段后面有需要的内存空间,则直接扩展这段内存空间,realloc()将返回原指针。
2)如果当前内存段后面的空闲字节不够,那么就使用堆中的第一个能够满足这一要求的内存块,将目前的数据复制到新的位置,并将原来的数据块释放掉,返回新的内存块位置。
3)如果申请失败,将返回NULL,此时,原来的指针仍然有效。
使用realloc函数时,要注意保存原始内存的地址,并调用free函数释放原来的内存,realloc并不会自动释放这块内存。
使用结构体和指针可以创建强大而灵活的数据结构–链表,链表包括单链表、双向链表和循环链表。这一章主要讲有序单链表和有序双链表的插入操作。
函数指针:函数名在使用时总是被编译器转换为函数指针。可以利用函数指针组成转移表。
命令行参数:main函数有两个命令行参数,argv和argc,argv表示参数个数,argc是字符串数组;
字符串常量:当字符串常量出现在表达式中时,它的值是个指向字符数组的指针常量,可以使用下标访问和解引用。”xyz”[0]和*”xyz”都表示字符’x’;”xyz”+1是个指针,指向字符’y’;
编译C程序的第一个步骤是预处理器的处理,本质是在编译源代码之前进行一些文本替换。
ANSI C中的预定义符号包括:
预定义符号 | 含义 |
---|---|
_FILE_ | 正在进行编译的文件名 |
_LINE_ | 文件当前的行号 |
_DATE_ | 文件被编译时的日期 |
_TIME_ | 文件被编译时的时间 |
_STDC_ | 如果编译器支持ANSI,其值为1 |
不同的编译器还会定义一些其它的预定义符号,比如VC中定义的WINVER,DEBUG
宏定义仅进行文本替换,替换之后可能存在运算符优先级与预期不一致的问题。
宏参数和宏定义可以包含其它的宏定义,但不能进行递归。
宏定义与函数相比,在运行可以减少堆栈开销,减少调用时间。宏定义与类型无关,不会进行类型检验。
宏定义具有副作用。
使用#undef可以移除一个宏定义。
头文件可以进行嵌套包含,标准要求至少支持8层嵌套。
perror():打印错误信息
exit():终止当前程序的执行
函数名称 | 功能 |
---|---|
perror() | 打印错误信息 |
exit() | 终止当前程序的执行 |
tmpfile() | 创建临时文件 |
tmpnam() | 创建临时文件名 |
remove | 删除文件 |
rename | 对文件进行重命名 |
标准库中getchar()、putchar()、getc()、putc()的这四个函数都是宏定义,
#define getchar() getc(stdin)
#define getc(_stream) fgetc(_stream)
fgetc的返回值为int类型,所以getchar()的返回值也为int类型。
C语言提供了强大的函数库,包括:
本章主要讲述经典数据类型,栈,队列和二叉树的内容。