数据
基本数据类型
整型包括字符、短整型、整型和长整型,它们都分为有符号(singed)和无符号(皿sied)两种版本。长整型至少应该和整型一样长,而整型至少应该和短整型一样长。
字符在本质上是小整型值。缺省的char要么是signed char
,要么是unsigned char
,这取决于编译器,只有当程序所使用的char型变量的值位于signed char
和unsigned char
的交集中,这个程序才是可移植的。
字符串常量:书写方式是"Hello"
、"\aWarning!\a"
、"Line1\nLine2"
,
链接属性
一共有三种,external、internal、none,none被当作单独的个体,该标识符的多个声明被当作独立不同的实体。属于internal链接属性的标识符在同一个源文件内的所有声明中都指向同一个实体。属于external属性的标识符不管位于几个源文件都表示同一个实体。关键字external
和static
用于在声明中修改标识符的链接属性,external可以访问在其他任何位置定义的这个实体。在C中,static主要定义全局静态变量、定义局部静态变量、定义静态函数。
- 定义全局静态变量:在全局变量前面加上关键字static,该全局变量变成了全局静态变量。全局静态变量有以下特点。
- 在全局区分配内存。
- 如果没有初始化,其默认值为0.
- 该变量在本文件内从定义开始到文件结束可见。
- 定义局部静态变量:在局部变量前面加上关键字static,其特点如下:
- 该变量在全局数据区分配内存。
- 它始终驻留在全局数据区,直到程序运行结束。
- 其作用域为局部作用域,当定义它的函数或语句块结束时,其作用域随之结束。
- 定义静态函数:在函数返回类型前加上static关键字,函数即被定义为静态函数,其特点如下:
- 静态函数只能在本源文件中使用
- 在文件作用域中声明的inline函数默认为static类型
总结:用static定义的全局和局部静态变量的区别是,全局的静态变量的作用域和可见域都是从文件的定义开始到整个文件结束;而局部的静态变量可见域是从文件的定义开始到整个文件结束,作用域是从该语句块的定义开始到该语句块结束。
extern的用法:
- 声明一个全局(外部)变量。当用extern声明一个全局变量的时候,首先应明确一点:extern的作用范围是整个工程,也就是说当我们在.h文件中写了
extern int a;
链接的时候链接器会去其他的.c文件中找有没有int a
的定义,如果没有,链接报错;当extern int a;
写在.c文件中时,链接器会在这个.c文件该声明语句之后找有没有int a
的定义,然后去其他的.cpp文件中找,如果都找不到,链接报错。值得注意的一点:当extern语句出现在头文件中时,不要将声明和定义在一条语句中给出,也就是不要在头文件中写类似于这样的语句:extern int a = 1;
,这种写法,在gcc编译时会给出一个警告:warning: 'a' initialized and declared 'extern'
, - 所有一般(提倡)的做法是:只在头文件中通过extern给出全局变量的声明(即external int a; 而不要写成external int a = 1;),并在源文件中给出定义(并且只能定义一次)
extern “C” { /*用C实现的内容(通常写在另外的.c文件中)*/ }
。C++完全兼容C,当extern与“C”连用时,作用是告诉编译器用C的编译规则去解析extern “C”后面的内容。最常见的差别就是C++支持函数重载,而标准C是不支持的。如果不指明extern “C”
,C++编译器会根据自己的规则在编译函数时为函数名加上特定的后缀以区别不同的重载版本,而如果是按C的标准来编译的话,则不需要。
static和external定义的全局变量区别:
- static修饰全局变量时,声明和定义是同时给出的;而extern一般是定义和声明分开,且定义只能一次
- static的全局作用域只是自身编译单元(即一个.c文件以及这个.c文件所包含的.h文件);而extern的全局作用域是整个工程(一个工程可以包含很多个.h和.c文件)。即区别就在于“全局”的范围是整个工程,还是自身编译单元。
存储类型
变量的存储类型(storagecs)是指存储变量值的内存类型。变量的存储类型决定变量何时创建、何时销毁以及它的值将保持多久。有三个地方可以用于存储变量:普通内存、运行时堆栈、硬件寄存器。凡是在任何代码块之外声明的变量总是存储于静态内存中,也就是不属于堆栈的内存,这类变量称为静态(static)变量。静态变量在程序运行之前创建,在程序的整个执行期间始终存在。它始终保持原先的值,除非给它赋一个不同的值或者程序结束。
在代码块内部声明的变量的缺省存储类型是自动(automatic),也就是说它存储于堆栈中,称为自动(auto)变量。在程序执行到声明自动变量的代码块时,自动变量才被创建,当程序的执行流离开该代码块时,这些自动变量便自行销毁。如果该代码块被数次执行,这些自动变量每次都将重新创建。对于在代码块内部声明的变量,如果给它加上关键字static
,可以使它的存储类型从自动变为静态。具有静态存储类型的变量在整个程序执行过程中一直存在,而不仅仅在声明它的代码块的执行时存在。函数的形式参数不能声明为静态,因为实参总是在堆栈中传递给函数,用于支持递归。最后,关键字register
可以用于自动变量的声明,提示它们应该存储于机器的硬件寄存器而不是内存中,这类变量称为寄存器变量。通常,寄存器变量比存储于内存的变量访问起来效率更高。
static关键字
当它用于函数定义时,或用于代码块之外的变量声明时,static关键字用于修改标识符的链接属性,从external到internal,但标识符的存储类型和作用域不受影响。当它用于代码块内部的变量声明时,static关键字用于修改变量的存储类型,从自动变量修改为静态变量,但变量的链接属性和作用域不受影响。
总结
具有external链接属性的实体在其他语言的术语中成为全局实体,所有源文件中的所有函数均可以访问它。只要变量并非声明于代码块或函数定义内部,它在缺省情况下的链接属性即为external。如果一个变量声明于代码块内部,在它前面添加extern关键字将使它所引用的是全局变量而非局部变量。
具有extemal链接属性的实体总是具有静态存储类型。全局变量在程序开始执行前创建,并在程序整个执行过程中始终存在。从属于函数的局部变量在函数开始执行时创建,在函数执行完毕后销毁,但用于执行函数的机器指令在程序的生命期内一直存在。局部变量由函数内部使用,不能被其他函数通过名字引用。它在缺省情况下的存储类型为自动,这是基于两个原因:其一,当这些变量需要时才为它们分配存储,这样可以减少内存的总需求量。其二,在堆栈上为它们分配存储可以有效地实现递归。如果你觉得让变量的值在函数的多次调用中始终保持原先的值非常重要的话,你可以修改它的存储类型,把它从自动变量改为静态变量。
操作符和表达式
操作符
移位操作简单地把一个值的位向左或向右移动。在左移位中,值最左边的几位被丢弃掉,右边多出来的几个空位则由0补齐。算术左移和逻辑左移是一样的。右移位时,一种是逻辑移位,左边移入的位用0填充,另一种是算术移位,左边移入的位由原先的符号位决定,保证原数的正负形式不变。
无符号值的所有移位操作都是逻辑移位,对于有符号值,采用逻辑移位还是算术移位取决于编译器。
第一个把指定的位设置为1:value = value | 1 << bit_number
,第二个把指定的位清0:value = value & ~ (1 << bit_number)
。
前缀和后缀形式的增值操作符都是复制一份变量值的拷贝,用于递增表达式的值正是这份拷贝,前缀操作符在进行复制之前增加变量的值,后缀操作符在进行复制之后才增加变量的值。这些操作符的结果是变量值的拷贝。
逻辑操作符(&&和||)具有短路性质,如果表达式的值根据左操作数即可决定,它就不再对右操作数进行求值。
布尔值
零是假,任何非零值皆为真。
左值和右值
左值就是能够出现在赋值符号左边的东西,右值就是能够出现在赋值符号右边的东西。如a = b + 25
,a是个左值,因为它表示了一个可以存储结果值的地点,b + 25是个右值,因为它指定了一个值。
表达式求值
隐式类型转换
C的整形算术运算至少以缺省整型类型的精度来进行,为了达到这个精度,表达式中的字符型和短整型操作数在使用之前需要被转换成普通整型。如果某个操作符的各个操作数属于不同类型,那么一个操作数转换为另一个操作数的类型。
操作符的属性
两个相邻的操作符哪个先执行取决于它们的优先级,如果优先级相同,则执行顺序由结合性决定。每个操作符的所有属性都在优先级表中。
警告
有操作符的右移位操作是不可移植的。移位操作的位数不可以是个负值。连续赋值中各个变量的长度需要一致。
指针
内存和地址
尽管一个字包含了4个字节,但是它仍然只有一个地址,至于它的地址是最左边的字节的位置还是最右边的字节的位置,取决于机器。另一个需要注意的是边界对齐,整型值存储的起始位置只能是某些特定的字节,通常是2或4的倍数。硬件仍然通过地址访问内存位置。
值和类型
不能简单地通过检查一个值的位来判断它的类型,必须观察这个值的使用方式。比如01100111011011000110111101100010
这个值,可能被解释成多种:
间接访问操作符
通过指针访问所指向的地址的过程称为间接访问或解引用指针,这个用于执行间接访问的操作符是*
。
未初始化和非法的指针
1 | int *a; |
这个声明创建了一个名叫a的指针变量,后边那条赋值语句把12存储在a所指的内存位置,但是不知道a具体指向的位置,声明一个指向int的指针也不会创建用于存储整型值的空间。在UNIX中,这个错误被称为段违例(segmentation violation),它提示程序试图访问一个并未分配给程序的内存位置。
指针常量
NULL表示指针未指向任何东西。
*100 = 25
是错误的,间接访问操作只能作用于指针类型表达式,如果确实想把25存于位置100,需要使用强制类型转换*(int*)100 = 25
。
指针表达式
1 | char ch = 'a'; |
如ch
表达式,当它作为右值使用时,表达式的值为'a'
,当这个表达式作为左值使用时,它是这个内存的地址而不是该地址所包含的值。
作为右值,这个表达式的值是变量ch的地址。
*的优先级高于+,所以首先执行间接访问操作,可以得到它的值,取这个值的一份拷贝并把它与1相加,最终结果是’b’,
使用后缀++操作符产生的结果不同,它的右值和左值分别是变量ch的值和ch的内存位置,也就是cp原先所指。间接访问操作符和后缀++的组合令人费解,这里涉及三个步骤:
- ++操作符产生cp的一份拷贝
- ++操作符增加cp的值
- 在cp的拷贝上执行间接访问
当一个指针和一个整数量执行算术运算时,整数在执行加法运算前始终会根据指针所指向类型的大小进行调整,“调整”就是把整数值和“合适的大小”相乘。如果两个指针所指向的不是同一个数组的元素,那么他们之间相减的结果是未定义的,如果是,则结果为两个指针之间的距离。
对指针执行关系运算也是有限制的,用关系操作符对两个指针值进行比较是可能的,不过前提是他们指向同一个数组的元素。下边的循环使数组以相反的次序清除,让vp指向数组最后那个元素后边的内存位置,但在对它进行间接访问之前先执行自减操作,当vp指向数组第一个元素时,循环便告终止,不过这发生在第一个数组元素被清除之后。
1 | for(vp = &value[N_VALUE]; vp > &value[0];) |
如果对其简化,现在vp指向数组最后一个元素,它的自减操作放在for的调整部分执行,在第一个元素被清除之后,vp的值还将减去1,而接下去的一次比较是用于结束循环的,比较表达式vp >= &value[0]
的值未定义,因为vp移动到了数组边界之外。
1 | for(vp = &value[N_VALUE-1]; vp >= &value[0]; vp --) |
函数
函数的参数
C函数的所有参数均以传值调用方式进行传递,这意味着函数将获得参数值的一份拷贝。如果被传递的参数是一个数组名,函数将访问调用程序的数组元素,数组并不会被复制。这个行为被称为传址调用。数组名的值实际上是一个指针,传递给函数的就是这个指针的一份拷贝。下标引用实际上是间接访问的另一种形式,它可以对指针执行间接访问操作,访问指针指向的内存位置。只要记住两个规则:
- 传递给函数的标量参数是传值调用的。
- 传递给函数的数组参数在行为上就像它们是通过传址调用的那样。
数组
指针的效率
- 当你根据某个固定数目的增量在一个数组中移动时,使用指针变量将比使用下标产生效率更高的代码。当这个增量是1并且机器具有地址自动增量模型时,这点表现得更为突出。
- 声明为寄存器变量的指针通常比位于静态内存和堆栈中的指针效率更高(具体提高的幅度取决于你所使用的机器)。
- 如果你可以通过测试一些己经初始化并经过调整的内容来判断循环是否应该终止,那么你就不需要使用一个单独的计数器。
- 那些必须在运行时求值的表达式较之诸如
&array[SIZE]
或array+SIZE
这样的常量表达式往往代价更高。
初始化
静态和自动初始化
数组初始化的方式取决于它们的存储类型。存储于静态内存的数组只初始化一次,也就是在程序开始执行之前。程序并不需要执行指令把这些值放到合适的位置,这由链接器完成的,它用包含可执行程序的文件中合适的值对数组元素进行初始化。如果数组未被初始化,数组元素的初始值将会自动设置为零。当这个文件载入到内存中准备执行时,初始化后的数组值和程序指令一样也被载入到内存中。自动变量在缺省情况下是未初始化的。如果自动变量的声明中给出了初始值,则每次执行流执行到这里时都会初始化。
如果初始化不完整,如int vector[5] = {1, 2, 3}
,则之后的元素都会被初始化为0。如果声明中没有给出长度,编译器就把数组的长度设置为刚好容纳所有的初始值的长度。
char message1[] = "hello"
和char *message2 = "hello"
具有不同的含义,前者初始化一个字符数组的元素,后者则是一个真正的字符串常量。
多维数组
C中,多维数组的元素存储按照最右边的下标率先变化的原则,称为行主序。作为函数参数的多维数组的实际传递的是个指向数组第一个元素的指针,但是编译器需要知道维数。如void func(int matrix[][10])
,
字符串、字符和字节
不受限制的字符串函数
常用的字符串函数都是“不受限制”的,只是通过寻找字符串参数结尾的NULL字节来判断长度。必须保证字符串不会溢出。如strcmp
,strcpy
,strcat
。标准库还包含了一类函数,接收一个显式的长度参数用于限定进行复制或比较的字符数,如strncmp
,strncpy
,strncat
。
和strcpy
一样,strncpy
把源字符串的字符复制到目标数组。然而,它总是正好向dst写入len个字符。如果strlen(src0)
的值小于len,dst数组就用额外的NUL字节填充到len长度。如果strlen(src)
的值大于或等于len,那么只有len个字符被复制到dst中。注意!它的结果将不会以NUL字节结尾。
字符串查找基础
在字符串中查找字符最简单的方法是char *strchr(char const *str, int ch)
或char *strrchr(char const *str, int ch)
,在str中查找ch第一次出现的位置。strrchr
返回最后一次出现的位置。
strpbrk
查找任何一组字符第一次在字符串中出现的位置,char *strpbrk(char const *str, char const *group)
,返回一个指向str中第一个匹配group中任何一个字符的字符位置。strstr
查找s1在整个s2中第一次出现的位置。
高级字符串查找
strspn
和strcspn
用于在字符串的起始位置对字符计数,计算字符串str中连续有几个字符都属于字符串accept,原型为size_t strspn(const char *str, const char * accept);
。
- 【函数说明】strspn() 从参数 str 字符串的开头计算连续的字符,而这些字符都完全是 accept 所指字符串中的字符。简单的说,若 strspn() 返回的数值为n,则代表字符串 str 开头连续有 n 个字符都是属于字符串 accept 内的字符。
【返回值】返回字符串 str 开头连续包含字符串 accept 内的字符数目。所以,如果 str 所包含的字符都属于 accept,那么返回 str 的长度;如果 str 的第一个字符不属于 accept,那么返回 0。
注意:检索的字符是区分大小写的。
- 提示:函数 strcspn() 的含义与 strspn() 相反,可以对比学习。
1 |
|
执行结果:str 前 3 个字符都属于 accept
C语言strcspn()
函数:计算字符串str中连续有几个字符都不属于字符串accept,头文件:#inclued<string.h>
。strcspn() 用来计算字符串 str 中连续有几个字符都不属于字符串 accept,其原型为:int strcspn(char *str, char *accept);
- 【参数说明】str、accept为要进行查找的两个字符串。strcspn() 从字符串 str 的开头计算连续的字符,而这些字符都完全不在字符串 accept 中。简单地说,若 strcspn() 返回的数值为 n,则代表字符串 str 开头连续有 n 个字符都不含字符串 accept 中的字符。
- 【返回值】返回字符串 str 开头连续不含字符串 accept 内的字符数目。
- 注意:如果 str 中的字符都没有在 accept 中出现,那么将返回 atr 的长度;检索的字符是区分大小写的。
- 提示:函数 strspn() 的含义与 strcspn() 相反,可以对比学习。
【示例】返回s1、s2包含的相同字符串的位置。
1 |
|
strtok
从字符串中隔离各个单独的称为标记的部分,并丢弃分隔符。char * strtok(char *s, const char *delim);
strtok()用来将字符串分割成一个个片段。参数s 指向欲分割的字符串,参数delim 则为分割字符串,当strtok()在参数s 的字符串中发现到参数delim 的分割字符时则会将该字符改为\0 字符。在第一次调用时,strtok()必需给予参数s 字符串,往后的调用则将参数s 设置成NULL。每次调用成功则返回下一个分割后的字符串指针。
返回值:返回下一个分割后的字符串指针,如果已无从分割则返回NULL。
1 |
|
字符操作
以下函数位于ctype.h
中。
转换函数用于把大写字符转化为小写,tolower
和toupper
。
内存操作
非字符串数据内部包含0值时,无法用字符串函数来处理。不过可以使用另一组相关的函数,他们的操作与字符串函数类似。
void *memcpy(void *dst, void const *src, size_t length)
从src的起始位置复制length个字节到dst的内存起始位置。void *memmove(void *dst, void const *src, size_t length)
和memcpy的行为差不多,不过它的源和目标操作数可以重叠。void *memcmp(void const *a, void const *b, size_t length)
对两端内存的内容进行比较,这些值按照无符号字符逐字节比较。void *memchr(void const *a, int ch, size_t length)
从a的起始位置开始查找字符ch第一次出现的位置,并返回一个指向该位置的指针。void *memset(void *a, int ch, size_t length)
把从a开始的length个字节都设置为字符值ch。
结构和联合
结构的存储分配
考虑这个结构
1 | struct ALIGN { |
如果某机器的整型值长度为4个字节,并且它的起始存储位置必须被4整除,那么这个结构在内存中将如下:
所有结构起始存储位置必须是结构中边界要求最严格的数据类型所要求的。成员a必须存储于一个能被4整除的地址。下一个成员是整型值,所以必须跳过3个字节到达合适的边界。可以在声明中对结构的成员列表重新排列,让那些对边界要求最严格的成员首先出现。
sizeof
操作符能够得出一个结构的整体长度,包括因边界对齐而跳过的那些字节。如果你必须确定结构某个成员的实际位置,应该考虑边界对齐因素,可以使用offsetof
宏(定义于stddef.h)。offsetof(type,member)
,type就是结构的类型,member就是你需要的那个成员名。表达式的结果是一个size_t值,表示这个指定成员开始存储的位置距离结构开始存储的位置偏移几个字节。例如,对前面那个声明而言offsetof(struct ALIGN, b)
的返回值是4。
位段
位段的成员是一个或多个位的字段,让这些不同长度的字段其实存在于一个或多个整型变量中。位段成员必须声明为int
,signed int
,unsigned int
三种,其次,在成员名后边是一个冒号和一整数,整数指定为该位段所占用的位的数目。
注重可移植性的程序应该避免使用位段。由于下面这些与实现有关的依赖性,位段在不同的系统中可能有不同的结果。
- int位段被当作有符号数还是无符号数。
- 位段中位的最大数目。许多编译器把位段成员的长度限制在一个整型值的长度之内,所以一个能够运行于32位整数的机器上的位段声明可能在16位整数的机器上无法运行。
- 位段中的成员在内存中是从左向右分配的还是从右向左分配的。
- 当一个声明指定了两个位段,第2个位段比较大,无法容纳于第1个位段剩余的位时,编译器有可能把第2个位段放在内存的下一个字,也可能直接放在第1个位段后面,从而在两个内存位置的边界上形成重叠。
1 | struct CHAR { |
位段能够利用存储ch和font所剩余的位来增加size的位数,这样避免了声名一个32位的整数来存储size位段。它也可以很方便的访问一个整型值的部分内容。假定磁盘控制器其中一个寄存器是如下定义的:
前五个位段每个都占1位,其余几个位段长些,在一个从右向左分配位段的机器上,下面这个声明允许方便地对寄存器的不同位段进行访问:
动态内存分配
malloc和free
C函数库提供了两个函数,malloc
和free
,分别用于执行动态内存分配和释放。这些函数维护一个可用内存池。malloc从内存池中提取一块合适的内存,并向该程序返回一个指向这块内存的指针。当一块以前分配的内存不再使用时,程序调用free函数把它归还给内存池供以后之需。void* malloc(size_t size)
的参数就是需要分配的内存字节(字符)数。如果内存池中的可用内存可以满足这个需求,malloc就返回一个指向被分配的内存块起始位置的指针。maloc所分配的是一块连续的内存。如果内存池的可用内存无法满足你的请求,malloc函数向操作系统请求,要求得到更多的内存,并在这块新内存上执行分配任务。如果操作系统无法向malloc提供更多的内存,maloc就返回一个NULL指针。因此,对每个从malloc返回的指针都进行检查,确保它并非NULL是非常重要的。
void free(void *pointer)
的参数必须要么是NULL,要么是一个先前从malloc、calloc或realloc(稍后描述)返回的值。向free传递一个NULL参数不会产生任何效果。
对于要求边界对齐的机器,malloc所返回的内存的起始位置将始终能够满足对边界对齐要求最严格的类型的要求。
calloc和realloc
另外还有两个内存分配函数,calloc和realloco它们的原型如下所示:
1 | void* calloc(size_t num_elements, size_t element_size); |
calloc也用于分配内存,在返回指向内存的指针之前把它初始化为0。realloc用于修改一个原先已经分配的内存块的大小,如果它用于扩大一个内润康,那么这块内存原先的内容依然保留,新添加的内存块在原先内存块后边,如果原先内存块无法改变大小,realloc会分配另一块正确大小的内存。
动态内存分配最常见的错误就是忘记检查所请求的内存是否成功分配。动态内存分配的第二大错误来源是操作内存时超出了分配内存的边界。例如,如果你得到一个25个整型的数组,进行下标引用作时如果下标值小于0或大于24将引起两种类型的问题。
- 第1种问题显而易见:被访问的内存可能保存了其他变量的值。对它进行修改将破坏那个变量,修改那个变量将破坏你存储在那里的值。这种类型的bug非常难以发现。
- 第2种问题不是那么明显。在malloc和free的有些实现中,它们以链表的形式维护可用的内存池。对分配的内存之外的区域进行访问可能破坏这个链表,这有可能产生异常,从而终止程序。
动态分配的内存不再需要时,它应该被释放,分配内存但在使用完毕后不释放将引起内存泄漏。
预处理器
预定义符号
预处理器定义了一些符号:
#define
#define
的正式描述为#define name stuff
,每当有符号name出现在这条指令之后时,预处理器就会把它替换为stuff。如果定义中的stuff很长,可以加上\
:
1 |
#define
机制包括了一个规定,允许把参数替换到文本中,这种方法叫做宏,所有用于对数值表达式进行求值的宏定义都应该加上括号,避免使用宏时参数中的操作符或邻近的操作符之间的相互作用。
** 识别结果 1**
在程序中扩展#define
定义符号和宏时,需要涉及儿个步骤。
- 在调用宏时,首先对参数进行检查,看看是否包含了任何由
#define
定义的符号。如果是,它们首先被替换。 - 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被它们的值所替代。
- 最后,再次对结果文本进行扫描,看看它是否包含了任何由
#define
定义的符号。如果是,就重复上述处理过程。
这样,宏参数和#define
定义可以包含其他#define
定义的符号。但是,宏不可以出现递归。当预处理器搜索#define
定义的符号时,字符串常量的内容并不进行检查。你如果想把宏参数插入到字符串常量中,可以使用两种技巧。
- 首先,邻近字符串自动连接的特性使我们很容易把一个字符串分成几段,每段实际上都是一个宏参数。
- 使用预处理器把一个宏参数转换为一个字符串,
#argument
这种结构会被预处理器翻译为argument
:
1 |
|
##
结构把位于两边的符号连接成一个符号,允许宏定义从分离的文本片段创建标识符。
许多C编译器允许在命令行中定义符号,用于启动编译过程,在UNIX编译器中,-D
可以完成,如-Dname
和-Dname=stuff
。
条件编译
条件编译可以选择代码的一部分是被正常编译还是完全忽略。用于支持条件编译的基本结构是#if
指令和与其匹配的#endif
指令。下面显示了它最简单的语法形式。
1 |
|
其中,constant-expression(常量表达式)由预处理器进行求值。如果它的值是非零值(真),那么statements部分就被正常编译,否则预处理器就安静地删除它们。所谓常量表达式,就是说它或者是字面值常量,或者是一个由#define
定义的符号。如果变量在执行期之前无法获得它们的值,那么它们如果出现在常量表达式中就是非法的,因为它们的值在编译时是不可预测的。
#include
指令用于实现文件包含。它具有两种形式。
- 如果文件名位于一对尖括号中,编译器将在由编译器定义的标准位置查找这个文件。这种形式通常用于包含函数库头文件时。
- 另一种形式,文件名出现在一对双引号内。不同的编译器可以用不同的方式处理这种形式。
- 但是,如果用于处理本地头文件的任何特殊处理方法无法找到这个头文件,那么编译器接下来就使用标准查找过程来寻找它。
#error
指令在编译时产生一条错误信息,信息中包含的是你所选择的文本。#line
指令允许你告诉编译器下一行输入的行号,如果它加上了可选内容,它还将告诉编译器输入源文件的名字。因编译器而异的#progma
指令允许编译器提供不标准的处理过程,比如向一个函数插入内联的汇编代码。
输入输出函数
错误报告
perror函数可以报告错误。原型是void perror(char const * msg)
,如果msg不是NULL并且指向一个非空的字符串,perror会打印出这个字符串,并打印当前错误代码的信息。
另一个有用的函数是exit,它用于终止一个程序的执行。它的原型定义于stdlib.h
,如下所示:void exit(int status)
,status参数返回给操作系统,用于提示程序是否正常完成。这个值和main函数返回的整型状态值相同。预定义符号EXIT_SUCCESS
和EXIT_FAILURE
分别提示程序的终止是成功还是失败。这个函数没有返回值。当exit函数结束时,程序己经消失,所以它无处可返。
标准IO函数库
K&R C最早的编译器的函数库在支持输入和输出方面功能甚弱。其结果是,程序员如果需要使用比函数库所提供的I/O更为复杂的功能时,他不得不自己实现。
有了标准I/O函数之后,这种情况得到了极大的改观。标准IO函数库具有一组IO函数,实现了在原先的IO库基础上许多程序员自行添加实现的额外功能。这个函数库对现存的函数进行了扩展,例如为printf创建了不同的版本,可以用于各种不同的场合。
头文件stdio.h
包含了与ANSI函数库的I/O部分有关的声明。ANSI进一步对IO的概念进行了抽象。就C程序而言,所有的1/0操作只是简单地从程序移进或移出字节的事情。因此,毫不惊奇的是,这种字节流便被称为流(stream)。
绝大多数流是完全缓冲的(fully buffered),这意味着“读取”和“写入”实际上是从一块被称为缓冲区的内存区域来回复制数据。从内存中来回复制数据是非常快速的。用于输出流的缓冲区只有当它写满时才会被刷新(flush,物理写入)到设备或文件中。一次性把写满的缓冲区写入和逐片把程序产生的输出分别写入相比效率更高。类似,输入缓冲区当它为空时通过从设备或文件读取下一块较大的输入,重新填充缓冲区。
如果程序失攸,缓冲输出可能不会被实际写入,这就可能使程序员得到关于错误出现位置的不正确结论。这个问题的解决方法就是在每个用于调试的printf函数之后立即调用fflush,如下所示:printf("something or other"); fflush(stdout)
流IO总览
标准库函数使我们在C程序中执行与文件相关的IO任务非常方便。
- 程序为必须同时处于活动状态的每个文件声明一个指针变量,其类型为
FILE*
。这个指针指向这个FILE结构,当它处于活动状态时由流使用。 - 流通过调用fopen函数打开。为了打开一个流,你必须指定需要访问的文件或设备以及它们的访问方式(例如,读、写或者既读又写)。fopen和操作系统验证文件或设备确实存在并初始化FILE结构。
- 然后,根据需要对该文件进行读取或写入。
- 最后,调用fclose函数关闭流。关闭一个流可以防止与它相关联的文件被再次访问,保证任何存储于缓冲区的数据被正确地写到文件中,并且释放FILE结构使它可以用于另外的文件。
I/O函数以三种基本的形式处理数据:单个字符、文本行和二进制数据。对于每种形式,都有一组特定的函数对它们进行处理。
这些函数的区别在于获得输入的来源或输出写入的地方不同。这些变种用于执行下面的任务:
- 只用于stdin或stdout
- 随作为参数的流使用。
- 使用内存中的字符串而不是流。
打开流
fopen
函数打开一个特定的文件,并把一个流和这个文件相关联。它的原型下所示:FILE *fopen(char ccnst *name, char const *mode);
。两个参数都是字符串。name是你希望打开的文件或设备的名字。创建文件名的规则在不同的系统中可能各不相同,所以fopen把文件名作为一个字符串而不是作为路径名、驱动器字母、文件扩展名等各准备一个参数。mode(模式)参数提示流是用于只读、只写还是既读又写,以及它是文本流还是二进制流。下面的表格列出了一些常用的模式。
mode以r、w或a开头,分别表示打开的流用于读取、写入还是添加。如果一个文件打开是用于读取的,那么它必须是原先已经存在的。但是,如果一个文件打开是用于写入的,如果它原先己经存在,那么它原来的内容就会被删除。如果它原先不存在,那么就创建一个新文件。如果一个打开用于添加的文件原先并不存在,那么它将被创建。如果它原先己经存在,它原先的内容并不会被删除。
如果fopen函数执行成功,它返回一个指向FILE结构的指针,该结构代表这个新创建的流。如果函数执行失败,它就返回一个NULL指针,errno会提示问题的性质。
流使用函数fclose
关闭的,int fclose(FILE* f)
,fclose在文件关闭之前刷新缓冲区,如果它执行成功则返回0,否则返回EOF。
字符IO
字符输入是由getchar
函数家族执行的,它们的原型如下所示。
1 | int fgetc(FILE *stream); |
需要操作的流作为参数传递给getc和fgetc,但getchar始终从标准输入读取。每个函数从流中读取下一个字符,并把它作为函数的返回值返回。如果流中不存在更多的字符,函数就返回常量值EOF。返回int型值的真正原因是为了允许报告文件的末尾(EOF)。如果返回值是char型,那么在256个字符中必须有一个被指定用于表示EOF。如果这个字符出现在文件内部,那么这个字符以后的内容将不会被读取,因为它被解释为EOF标志。
EOF被定义为一个整型,它的值在任何可能出现的字符范围之外。这种解决方法允许我们使用这些函数来读取二进制文件。
为了把单个字符写入到流中,你可以使用putchar函数家族。它们的原型如下:
1 | int fputc(int character, FILE *stream); |
第1个参数是要被打印的字符。在打印之前,函数把这个整型参数裁剪为一个无符号字符型值,所以putchar('abc')
仅仅打印一个字符。
fgetc
和fputc
都是真正的函数,但getc
、putc
、getchar
和putchar
都是通过#define
指令定义的宏。之所以提供两种类型的方法,是为了允许你根据程序的长度和执行速度哪个更重要选择正确的方法。
未格式化的行IO
未格式化的IO(unformatted line IO)简单读取或写入字符串,而格式化的IO则执行数字和其他变量的内部和外部表示形式之间的转换。gets和puts函数家族是用于操作字符串而不是单个字符。这个特征使它们在那些处理一行行文本输入的程序中非常有用。这些函数的原型如下所示。
1 | char *fgets(char *buffer, int buffer_size, FILE *stream); |
fgets从指定的stream读取字符并把它们复制到buffer中。当它读取一个换行符并存储到缓冲区之后就不再读取。如果缓冲区内存储的字符数达到buffer_size-1个时它也停止读取。在这种情况下,并不会出现数据丢失的情况,因为下一次调用fgets将从流的下一个字符开始读取。在任何一种情况
下,一个NULL字节将被添加到缓冲区所存储数据的末尾,使它成为一个字符串。如果在任何字符读取前就到达了文件尾,缓冲区就未进行修改,fgets函数返回一个NULL指针。否则,fgets返回它的第1个参数(指向缓冲区的指针)。这个返回值通常只用于检查是否到达了文件尾。
二进制IO
fread用于读取二进制数据,fwrite用于写入二进制数据:
1 | size_t fread(void *buffer, size_t size, size_t count, FILE *stream); |
buffer是一个指向用于保存数据的内存位置的指针,size是缓冲区中每个元素的字节数,count是读取或写入的元素数,当然stream是数据读取或写入的流。buffer参数被解释为一个或多个值的数组。count参数指定数组中有多少个值,所以读取或写入一个标量时,count的值应为函数的返回值是实际读取或写入的元素(并非字节)数目。
1 | struct VALUE { |
这个程序从一个输入文件读取二进制数据,对它执行某种类型的处理,把结果写入到一个输出文件。这种类型的IO效率很高,因为每个值中的位直接从流读取或向流写入,不需要任何转换。
刷新和定位函数
fflush迫使一个输出流的缓冲区内的数据进行物理写入,不管它是不是已经写满。int fllush(FILE *stream)
。
C同时支持随机访问I/O,也就是以任意顺序访问文件的不同位置。随机访问是通过在读取或写入先前定位到文件中需要的位置来实现的。有两个函数用于执行这项操作:
1 | long ftell(FILE *stream); |
ftell函数返回流的当前位置,也就是说,下一个读取或写入将要开始的位置距离文件起始位置的偏移量。这个函数允许你保存一个文件的当前位置,这样你可能在将来会返回到这个位置。在二进制流中,这个值就是当前位置距离文件起始位置之间的字节数。在文本流中,这个值表示一个位置,但它并不一定准确地表示当前位置和文件起始位置之间的字符数,因为有些系统将对行末字符进行翻译转换。
fseek函数允许你在一个流中定位。这个操作将改变下一个读取或写入操作的位置。它的第1个参数是需要改变的流。它的第2和第3个参数标识文件中需要定位的位置。
试图定位到一个文件的起始位置之前是一个错误。定位到文件尾之后并进行写入将扩展这个文件。定位到文件尾之后并进行读取将导致返回一条“到达文件尾”的信息。在二进制流中,从SEEK_END进行定位可能不被支持,所以应该避免。在文本流中,如果from是SEEK_CUR或SEEK_END,offset必须是零。如果from是SEEK_SET,offset必须是一个从同一个流中以前调用ftell所返回的值。
用fseek改变一个流的位置会带来三个副作用。
- 首先,行末指示字符被清除。
- 其次,如果在fseek之前使用ungetc把一个字符返回到流中,那么这个被退回的字符会被丢弃,因为在定位操作以后,它不再是“下一个字符”。
- 最后,定位允许你从写入模式切换到读取模式,或者回到打开的流以便更新。
另外还有三个额外的函数,用一些限制更严的方式执行相同的任务。它们的原型如下:
1 | void rewind(FILE *stream); |
rewind函数将读/写指针设置回指定流的起始位置。它同时清除流的错误提示标志。fgetpos和fsetpos函数分别是ftell和fseek函数的替代方案。它们的主要区别在于这对函数接受一个指向fpos_t的指针作为参数。fgetpos在这个位置存储文件的当前位置,fsetpos把文件位置设置为存储在这个位置的值。
改变缓冲方式
下面两个函数可以用于对缓冲方式进行修改。这两个函数只有当指定的流被打开但还没有在它上面执行任何其他操作前才能被调用。
1 | void setbuf(FILE *stream, char *buf); |
setbuf设置了另一个数组,用于对流进行缓冲。这个数组的字符长度必须为BUFSIZ(它在stdio.h中定义)。为一个流自行指定缓冲区可以防止IO函数库为它动态分配一个缓冲区。如果用一个NULL参数调用这个函数,setbuf函数将关闭流的所有缓冲方式。字符准确地将程序所规引的方式进行读取和写入。
为流缓冲区使用一个自动数组是很危险的。如果在流关闭之前,程序的执行流离开了数组声明所在的代码块,流就会继续使用这块内存,但此时它可能已经分配给了其他函数另作它用。
setvbuf函数更为通用。mode参数用于指定缓冲的类型。_IOFBF指定一个完全缓冲的流,_IONBF指定一个不缓冲的流,_IOLBF指定一个行缓冲流。所谓行缓冲,就是每当一个换行符写入到缓冲区时,缓冲区便进行刷新。buf和size参数用于指定需要使用的缓冲区。如果buf为NULL,那么size的值必须是0。一般
而言,最好用一个长度为BUFSIZ的字符数组作为缓冲区。尽管使用一个非常大的缓冲区可能可以稍稍提高程序的效率,但如果使用不当,它也有可能降低程序的效率。
流错误函数
下面的函数用于判断流的状态:
1 | int feof(FILE *stream); |
如果流当前处于文件尾,feof函数返回真。这个状态可以通过对流执行fseek、rewind或fsetpos函数来清除。ferror函数报告流的错误状态,如果出现任何读/写错误函数就返回真。最后,clearerr函数对指定流的错误标志进行重置。
临时文件
tmpfile函数用于创建临时文件。
1 | FILE *tmpfile(void); |
这个函数创建了一个文件,当文件被关闭或程序终止时这个文件便自动删除。该文件以wb+
模式打开,这使它可用于二进制和文本数据。如果临时文件必须以其他模式打开或者由一个程序打开但由另一个程序读取,就不适合用tmpfile函数创建。
文件操纵函数
有两个函数用于操纵文件但不执行任何输入/输出操作。它们的原型如下所示。如果执行成功,这两个函数都返回零值。如果失败,它们都返回非零值。
1 | int remove(char const *filename); |
remove函数删除一个指定文件,如果当remove被调用时文件处于打开状态,其结果将取决于编译器。rename用于改变一个文件的名字。
标准函数库
整型函数
算数
1 | int abs(int value); |
abs函数返回绝对值。labs用于长整数。div函数把第二个参数除以第1个参数,产生商和余数,用一个div_t结构返回。这个结构包含
1 | int quot; //商 |
随机数
下面两个函数合在一起使用能够产生伪随机数pseudo-random number。他们通过计算差生随机数,因此有可能重复出现,并不是真正的随机数。
1 | int rand(void); |
rand返回一个范围在0和RAND_MAX(至少为32767)之间的伪随机数。当它重复调用时,函数返回这个范围内的其他数。为了得到一个更小范围的伪随机数,首先把这个函数的返回值根据所需范围的大小进行取模,然后通过加上或减去一个偏移量对它进行调整。
为了避免程序每次运行时获得相同的随机数序列,可以调用srand函数。它用它的参数值对随机数发生器进行初始化。一个常用的技巧是使用每天的时间作为随机数产生器的种子seed。srand((unsigned int)time(0))
字符串转换
把字符串转换为数值。atoi和atol执行基数为10的转换。strtol和strtoul允许在转换时指定基数,同时还允许访问字符串的剩余部分。
1 | int atoi(char const *string); |
如果任何一个上述函数的的第一个参数包含了前导空白字符,他们将被跳过。然后函数把合法的字符转换为指定类型的值。如果存在任何非法缀尾字符,他们也将被忽略。
atoi和atol分别把字符转换为整数和长整数值。strtol和atol同样把参数字符串转换为long。但是strtol保存一个指向转换至后面第1个字符的指针。如果函数的第二个参数并非NULL,这个指针便保存在第二个参数所指向的位置。这个指针允许字符串的剩余部分进行处理而无需推测转换在字符串的哪个位置终止。strtoul和strtol的执行方式仙童,但它产生一个无符号长整数。
这两个函数的第3个参数是转换所执行的基数。如果基数为0,任何在程序中用于书写整数字面值的形式都将被接受,包括指定数字基数的形式。否则基数值应该在2到36的范围内——然后转换根据这个给定的基数进行。对于基数11到36,字母A到Z分别被解释为10到35.在这个上下文环境中,小写字母a-z被解释为与对应的大写字母相同的意思。
如果这些函数的string参数中并不包含一个合法的值,函数就返回0。如果被转换的值无法表示,函数便在errno中存储ERANGE这个值,并返回以下一个值。
- strtol 返回值如果太大且为负返回LONG_MIN。如果值太大且为正返回LONG_MAX
- strtoul如果值太大返回ULONG_MAX
浮点型函数
math.h包含了函数库中剩余的数学函数的声明。
三角函数
1 | double sin(double angle); |
sin、cos、tan参数是一个用弧度表示的角度,返回正弦余弦正切。asin、acos、atan返回反正弦、反余弦、反正切。如果asin和acos的参数不位于-1和1之间,就出现一个定义域错误。asin和atan的返回值是在-π/2和π/2之间的一个弧度,acos的返回值是一个返回在0和π之间的弧度。
双曲函数
1 | double sinh(double angle); |
对数和指数函数
1 | double exp(double x); //e的x次幂 |
浮点表示形式
1 | double frexp(double value,int *exponet); |
frexp函数计算一个指数exponent和小数fraction,这样fraction × 2^exponent = value,函数返回fraction。ledexp返回值是fraction × 2^exponent。modf把一个浮点值分成整数和小数两个部分,整数部分以double类型存储在第二个参数所指向的内存位置,小数部分作为函数的返回值返回。
幂
1 | double pow(double x,double y); |
底数、顶数、绝对值和余数
1 | double floor(double x); |
floor函数返回不大于其参数的最大整数值,这个值以double返回,ceil函数返回不小于其参数的最小整数值。fabs返回其参数的绝对值。fmod返回x除以y所产生的余数。
字符串转换
1 | double atof(char const *string); |
如果任一函数的参数包含了前导的空白字符,这些字符将被忽略。函数随后把合法的字符转换为一个double值,忽略任何缀尾的非法字符。这两个函数都接受程序中所有浮点数字面值的书写形式。strtod函数把参数字符串转换为一个double值,其方法和atof类似,但它保存一个指向字符串中被转换的值后面的第1个字符的指针。如果函数的第2个参数不是NULL,那么这个被保存的指针就存储于第2个参数所指向的内存位置。这个指针允许对字符串的剩余部分进行处理,而不用猜测转换会在字符串中的什么位置结束。
如果这两个函数的字符串参数并不包含任何合法的数值字符,函数就返回零。如果转换值太大或太小,无法用double表示,那么函数就在errno中存储ERANGE这个值,如果值太大(无论是正数还是负数),函数返回HUGE_VALO如果值太小,函数返回零。
日期和时间函数
处理器时间
1 | clock_t clock(void); |
返回从程序开始执行器处理器所消耗的时间,应该把它除以常量CLOCKS_PER_SEC。
当天时间
1 | time_t time(time_t *returned_value); |
返回当前的日期和时间
日期和时间的转换
1 | char *ctime(time_t const *time_value); |
ctime的参数是一个指向time_t的指针,并返回一个指向字符串的指针:Sun Jul 4 04:02:28 1976\n\0
。difftime计算两个时间之差,并把结果转换成秒。gmtime把时间值转换为世界协调时间Coordinated Universal Time,UTC。以前被称为格林尼治标准时间Greenwich Mean Time,返回值为tm结构:
1 | char *asctime(struct tm const *tm_ptr); |
asctime将tm表示的时间值转换成ctime函数所用的一样的格式。
strftime函数把一个tm结构体转换为一个根据某个格式字符串而定的字符串。如果转换结果字符串的长度小于maxsize参数,返回字符串长度,否则返回-1且数组内容未定义。格式字符串包含了普通字符和格式代码。普通字符被复制到它们原先在字符串中出现的位置。格式代码则被一个日期或时间值代替。格式代码包括一个%字符,后面跟一个表示所需值的字符。
最后,mktime函数用于把tm结构转换为一个time_t的值。tm结构中的tm_wday和tm_yday值被忽略,其他字段的值也无需限制在它们的通常范围内。转换之后,该tm结构会进行规格化。
1 | time_t mktime( struct tm *tm_ptr ); |
非本地跳转
setjmp和longjmp函数提供一种类似goto语句的机制,但它并不局限于一个函数的作用域之内。这些函数常用于深层嵌套的函数调用链。如果在某个底层的函数中检测到一个错误,可以立即返回顶层的函数,不必向调用链中的每个中间层函数返回一个错误标志。
1 | int setjmp( jmp_buf state ); |
声明一个jmp_buf变量,并调用setjmp函数初始化,返回值为0。setjmp把程序的状态信息(例如,堆栈指针的当前位置和程序的计数器)保存到跳转缓冲区。调用该函数的函数成为“顶层”函数。以后,在顶层函数或者其他任何它所调用的函数(无论是直接调用还是间接调用)内调用longjmp函数,将会导致这个被保存的状态重新恢复。longjmp的效果是使执行流通过再次从setjmp返回,从而立即跳转回顶层函数中,此时,setjmp返回的值是longjmp的第2个参数。
信号
信号(signal)表示一种事件,它可能异步的发生,也就是并不与程序执行过程的任何事件同步。
信号名
信号 | 含义 |
---|---|
SIGABRT | 程序请求异常终止,由abort函数引发。 |
SIGFPE | 具体错误由编译器确定,常见有算术上溢、下溢以及除零错误 |
SIGILL | 检测到非法指令,可能由不正确的编译器设置导致 |
SIGSEGV | 检测到内存的非法访问,程序访问未分配内存或者访问超过内存访问的边界(segmentation violation) |
SIGINT | 程序外部产生,通常是用户尝试中断程序时发生,一般定义处理函数来执行日常维护和退出前保存数据(interrupt) |
SIGTERM | 程序外部产生,请求终止程序的信号(terminate) |
处理信号
raise函数用于显示的引发参数所指定的信号。当一个信号发生时,程序可以使用三种方式对其作出反应。默认的反应由编译器定义,一般是终止程序。程序也可以指定其他对信号的反应行为:忽略或者信号处理函数:
1 | int raise( int sig ); |
调用这个函数将引发它的参数所指定的信号。
signal函数将用于指定程序希望采取的反应。
1 | void ( *signal( int sig, void ( *handler )( int ) ) )( int ); |
signal接收2个参数,第1个参数是信号,第2个参数是希望为这个信号设置的信号处理函数的指针。返回值是一个接收1个整型参数返回值是空的函数指针。事实上,signal函数返回一个指向该信号以前的处理函数的指针。如果因为非法信号导致调用失败,signal返回SIG_ERR。SIG_DEF和SIG_IGN可以用作signal函数的第2个参数。
信号处理函数
当一个已经设置了信号处理函数的信号发生时,系统为了防止如果信号处理函数内部也产生这个信号可能导致的无限循环,将首先恢复对该信号的默认行为,然后调用信号处理函数。
信号处理函数可能执行的工作类型是很有限的。如果信号是异步的,也就是说不是由于调用abort或raise函数引起的,信号处理函数就不应调用除signal之外的任何的库函数,因为在这种情况下其结果是未定义的。而且,信号处理函数除了能向一个类型为volatile sig_atomic_t的静态变量赋一个值以外,可能无法访问其他静态数据。(信号处理函数修改的变量值可能会在任何时候发生改变,因此可能在两条相邻的程序语句语句中变量的值不同,volatile关键字将告诉编译器这个事实。即当要求使用volatile声明变量值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。精确地说就是,遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问)
从一个信号处理函数返回导致程序的执行流从信号发生的地点恢复执行(SIGFPE例外)。如果希望捕捉将来同种信号,从当前这个信号的处理函数返回之前注意要调用signal函数重新设置信号处理函数。否则,只有第1个信号才会被捕捉,接下来的同种信号将按默认处理。
打印可变参数列表
1 | int vprintf( char const *format, va_list arg ); |
这组函数用于可变参数列表必须被打印的场合。必须包含<stdio.h>
和<stdarg.h>
。在调用这些函数之前,arg参数必须使用va_start进行初始化,这些函数不需要调用va_end。
执行环境
这些函数与程序的执行环境进行通信或者对程序的执行环境施加影响。
终止执行
1 | void abort( void ); |
abort函数用于不正常地终止一个正在执行的程序,将触发SIGABRT信号,若设置了信号处理函数,在程序终止前可以采取任何措施,哪怕不终止程序。atexit函数可以把一些函数注册为退出函数(exit function)。当程序将要正常终止(或者由于调用exit,或者由于main函数返回),退出函数将被调用。当exit函数被调用时,所有被atexit函数注册为退出函数的函数将按照它们所注册的顺序被反序调用。然后,所有用于流的缓冲区被刷新,所有打开文件被关闭。用tmpfile函数创建的文件被删除。然后退出状态返回给宿主环境,程序停止执行。
断言
1 | void assert( int expression ); |
assert宏由ANSIC实现,常用于调试程序。当assert被执行时,这个宏对表达式参数进行测试。如果参数表达式值为0,它就向标准错误打印一条诊断信息并终止程序,这个消息格式由编译器定义,但会包含这个表达式和源文件的名字以及这个断言所在行号。
该宏提供了一个对应该为真的东西进行检查的方便方法,例如函数在对一个不能为NULL的指针参数进行调用前用assert进行验证。当程序被完整地测试完毕之后,可以在编译时通过定义NDEBUG消除所有断言(使用-DNDEBUG编译器命令行选项或在源文件assert.h被包含之前增加#define NDEBUG
语句)。
环境
环境是一个由编译器定义的名字/值对的列表,由操作系统进行维护。getenv函数在这个列表中查找一个特定的名字,如果找到,返回一个指向其对应值的指针,程序不能修改返回的字符串。如果名字未找到,函数就返回NULL指针。
1 | char *getenv( char const *name ); |
执行系统命令
1 | void system( char const *command ); |
system函数把它的字符串参数传递给宿主操作系统,由系统的命令处理器执行。如果参数是NULL,则system用于询问命令处理器是否实际存在。在这种情况下,如果存在一个可用的命令处理器,system返回非0值,否则返回0。
排序和查找
1 | void qsort( void *base, size_t n_elements, size_t el_size, int (*compare)(void const *, void const *) ); |
qsort函数在一个数组中以升序的方式对数据进行排序,与类型无关,只是数组内元素的长度需固定。第1个参数指向需要排序的数组,第2个参数指定数组中元素的数目,第3个参数指定每个元素的长度(以字节为单位)。第4个参数是一个函数指针,用于对需要排序的元素类型进行比较。比较函数应该返回一个整数,大于0、等于0和小于0表示第1个参数大于、等于和小于第2个参数。
bsearch函数在一个己经排好序的数组中用二分法查找一个特定的元素。如果数组尚未排序,其结果是未定义的。第1个参数指向你需要查找的值,第2个参数指向查找所在的数组,第3个参数指定数组中元素的数目,第4个参数是每个元素的长度(以字符为单位)。最后一个参数是和qsort中相同的指向比较函数的指针。bsech函数返回一个指向查找到的数组元素的指针。如果需要查找的值不存在,函数返回一个NULL指针。
1 | void *bsearch( void const *key, coid const *base, size_t n_elements, size_t el_size, int (*compare)(void const *, void const *) ); |
locale
为了使C语言在全世界的范围内更为通用,标准定义了locale,这是一组特定的参数,每个国家可能各不相同。
1 | char *setlocale( int category, char const *locale ); |
setlocale常用于修改整个或部分locale,可能影响库函数的运行方式。category参数指定locale的哪个部分需要进行修改,允许出现的值列于下表。如果第2个参数locale为NULL,函数将返回一个指向给定类型的当前locale的名字的指针。这个值可能被保存并继续在后续的setlocale中使用用以恢复。如果第2个参数不是NULL,它指定需要使用的新locale。如果函数调用成功,它将返回新locale的值,否则返回一个NULL指针,原来的locale不受影响。
数值和货币格式
1 | struct lconv *localeconv( void ); |
localeconv函数用于获得根据当前的locale对非货币值和货币值进行合适的格式化所需要的信息。该函数不实际执行格式化任务,只是提供一些如何进行格式化的信息。lconv结构包含两种类型的参数:字符和字符指针。字符参数为非负值,如果一个字符参数为CHAR_MAX,那么这个值就在当前的locale中不可用(不使用)。对于字符指针,如果指向一个空字符串,与前者同意。
格式化非货币数值的参数
字段和类型 | 含义 |
---|---|
char *decimal_point | 用作小数点的字符。这个值绝不能是个空字符串。例如:”.” |
char *thousands_sep | 用作分隔小数点左边各组数字的符号。例如:”,” |
char *grouping | 指定小数点左边多少数字组成。例如:”\3” |
格式化本地货币值的参数
字段和类型 | 含义 |
---|---|
char *currency_symbol | 本地货币符号 |
char *mon_decimal_point | 小数点字符 |
char *mon_thousands_sep | 用于分隔小数点左边各组数字的字符 |
char *mon_group | 指定出现在小数点左边各组数字的数字个数 |
char *postive_sign | 用于提示非负值的字符串 |
char *negative_sign | 用于提示负值的字符串 |
char frac_digits | 出现在小数点右边的数字个数 |
char p_cs_precedes | 如果currency_symbol出现在一个非负值之前,其值为’\1’;如果出现在后面,其值为’\0’ |
char n_cs_precedes | 如果currency_symbol出现在一个负值之前,其值为’\1’;如果出现在后面,其值为’\0’ |
char p_sep_by_space | 如果currency_symbol和非负值之间用一个空格字符分隔,其值为’\1’;否则其值为’\0’ |
char n_sep_by_space | 如果currency_symbol和负值之间用一个空格字符分隔,其值为’\1’;否则其值为’\0’ |
char n_sign_posn | 提示negative_sign出现在一个负值中的位置。用于p_sign_posn的值也可用于此处 |
char p_sign_posn | 提示positive_sign出现在一个非负值的位置 |
符号串和locale
1 | int strcoll( char const *s1, char const *s2 ); |
一个机器的字符集的对照序列是固定的。但setlocale提供了一种方法指定不同的序列,当使用一个并非默认的对照列表时,可以采用上面两个函数。strcoll函数对两个根据当前locale的LC_COLLATE类型参数指定的字符串进行比较,比较可能比strcmp需要多得多的计算了,因为其需要遵循一个并非本地机器的对照序列。当字符串必须以这种方式反复进行比较时,使用strcfrm函数可以减少计算量。strcfrm把根据当前locale解释的第2个参数转换成一个不依赖于locale的字符串,尽管转换后的字符串内容不确定,但比较结果和strcoll相同。
改变locale的效果
locale可能向正在执行的程序所使用的字符集增加字符(但可能不会改变现存字符的含义)。例如,许多欧洲语言使用了能够提示重音、货币符号和其他特殊符号的扩展字符集。
打印的方向可能会改变。尤其,locale决定一个字符应该根据前面一个被打印的字符的哪个方向进行打印。printf和scanf函数机组使用当前locale定义的小数点符号。如果locale扩展了正在使用的字符集,isalpha、islower、isspace和isupper函数可能比以前包含更多的字符。正在使用的字符集的对照序列可能会改变。这个序列有strcoll函数使用,用于字符串之间的相互比较。strftime函数产生的日期和时间格式的很多方面都是特定于locale的。
运行时环境
判断运行时环境
第一步骤是从你的编译器获得一个汇编语言代码列表。
- 测试程序
- 静态变量和初始化
- 堆栈帧
- 一个函数分成三个部分:函数序、函数体、函数跋。
- 寄存器变量
- 外部标识符的长度
- 判断堆栈帧布局
- 运行时堆栈保存了每个函数运行时所需要的数据,包括它的自动变量和返回地址。
- 传递函数参数
- 函数序
- 堆栈中的参数次序
- 最终的堆栈帧布局
- 函数跋
- 返回值
- 表达式的副作用
- 运行时堆栈保存了每个函数运行时所需要的数据,包括它的自动变量和返回地址。
C和汇编语言的接口
编写能够调用C程序或者被C程序调用的汇编语言程序所需的内容。与这个环境相关的结果总结如下—你的环境肯定在某些方面与它不同!
- 首先,汇编程序中的名字必须遵循外部标识符的规则。
- 其次,汇编程序必须遵循正确的函数调用/返回协议。有两种情况:从一个汇编语言程序调用一个C程序和从一个程序调用一个汇编程序。为了从汇编程序调用C程序:
- 如果寄存器d0、d1、a0或a1保存了重要的值,它们必须在调用C程序之前进行保存,因为C函数不会保存它们的值。
- 任何函数的参数必须以参数列表相反的顺序压入到堆栈中。
- 函数必须由一条“跳转子程序”类型的指令调用,它会把返回地址压入到堆栈中。
- 当C函数返回时,汇编程序必须清除堆栈中的任何参数。
- 如果汇编程序期望接受一个返回值,它将保持在d0(如果返回值的类型为double,它的另一半将位于d1)。
- 任何在调用之前进行过保存的寄存器此时可以恢复。
- 为了编写一个由C程序调用的汇编程序:
- 保存任何你希望修改的寄存器(除d0、d1、a0或a1之外)。
- 参数值从堆栈中获得,因为调用它的C函数把参数压入到堆栈中。
- 如果函数应该返回一个值,它的值应该保存在d0中(在这种情况下,d0不能进行保存和恢复)。
- 在返回之前,函数必须清除任何它压入到堆栈中的内容。
运行时效率
即使在一些现代的机器上,一个必须存储于ROM的程序必须相当小才有可能装入到有限的内存空间中。但许多现代计算机系统在这方面的限制大不如前,这是因为它们提供了虚拟内存。虚拟内存是由操作系统实现的,它在需要时把程序活动部分放入内存并把不活动的部分复制到磁盘中,这样就允许系统运行大型的程序。但程序越大,需要进行的复制就越多。所以大型程序不是想以前那样根本无法运行,而是随着程序的增大,它的执行效率逐渐降低。
如果一个程序太大或太慢,较之专研每个变量,看看把它们声明为register能不能提高效率,选一种效率更高的算法或数据结构往往效果要满意得多。然而这并不是说你可以在代码中胡作非为,因为风格恶劣的代码总是会把事情弄得更糟。
如果一个程序太大,很容易想到的着手方向:最大的函数和数据结构。如果程序太慢,着手方向:对程序进行性能测评,花费时间最多的部分程序和使用最频繁的那部分代码显然是需要优化的目标。如果这方面能够提升,将能大大提高程序的整体运行速度。
三个努力方向:
- 在耗时最多的函数中,有些是库函数。如果能减少或不用可帮助大大提升性能。
- 有效函数之所以耗费了大量的时间是因为它们被调用的次数非常多。
- 有些函数调用次数不多,但每次调用耗费时间却很长。寻找更优质的算法重构是努力的方向。
- 可以对单个函数进行汇编语言重新编码,函数越小,重新编码越容易。
总结
绝大多数环境都创建某种类型的堆栈帧,函数用它来保存它们的数据,堆栈帧的细节可能各不相同,但它们的基本思路是相当一致的。
提高效率的最好方法是为它选择一种更好的算法,接下来的一种提高程序执行速度的最佳手段是对程序进行性能测评,看看程序在哪个地方花费的时间最多,把优化措施集中在程序的这部分将产生最好的结果。
警告总结
- 是链接器而不是编译器决定外部标识符的最大长度;
- 你无法链接由不同编译器产生的程序;
指针详解
前言:复杂类型说明
要了解指针,多多少少会出现一些比较复杂的类型,所以我先介绍一下如何完全理解一个复杂类型,要理解复杂类型其实很简单,一个类型里会出现很多运算符,他们也像普通的表达式一样,有优先级,其优先级和运算优先级一样,所以我总结了一下其原则:从变量名处起,根据运算符优先级结合,一步一步分析.下面让我们先从简单的类型开始慢慢分析吧:
int p;
:这是一个普通的整型变量int *p;
:首先从P处开始,先与*
结合,所以说明P是一个指针,然后再与int 结合,说明指针所指向的内容的类型为int 型.所以P是一个返回整型数据的指针int p[3];
:首先从P处开始,先与[]结合,说明P是一个数组,然后与int 结合,说明数组里的元素是整型的,所以P是一个由整型数据组成的数组int *p[3];
:首先从P处开始,先与[]结合,因为其优先级比*高,所以P是一个数组,然后再与*结合,说明数组里的元素是指针类型,然后再与int 结合,说明指针所指向的内容的类型是整型的,所以P是一个由返回整型数据的指针所组成的数组int (*p)[3];
:首先从P处开始,先与*结合,说明P是一个指针,然后再与[]结合(与”()”这步可以忽略,只是为了改变优先级),说明指针所指向的内容是一个数组,然后再与int 结合,说明数组里的元素是整型的.所以P是一个指向由整型数据组成的数组的指针int **p;
:首先从P开始,先与*结合,说是P是一个指针,然后再与*结合,说明指针所指向的元素是指针,然后再与int 结合,说明该指针所指向的元素是整型数据.由于二级指针以及更高级的指针极少用在复杂的类型中,所以后面更复杂的类型我们就不考虑多级指针了,最多只考虑一级指针int p(int);
:从P处起,先与()结合,说明P是一个函数,然后进入()里分析,说明该函数有一个整型变量的参数,然后再与外面的int 结合,说明函数的返回值是一个整型数据int (*p)(int);
:从P处开始,先与指针结合,说明P是一个指针,然后与()结合,说明指针指向的是一个函数,然后再与()里的int 结合,说明函数有一个int 型的参数,再与最外层的int 结合,说明函数的返回类型是整型,所以P是一个指向有一个整型参数且返回类型为整型的函数的指针int *(*p(int))[3];
:可以先跳过,不看这个类型,过于复杂从P开始,先与()结合,说明P是一个函数,然后进入()里面,与int 结合,说明函数有一个整型变量参数,然后再与外面的*结合,说明函数返回的是一个指针,,然后到最外面一层,先与[]结合,说明返回的指针指向的是一个数组,然后再与*结合,说明数组里的元素是指针,然后再与int 结合,说明指针指向的内容是整型数据.所以P是一个参数为一个整数据且返回一个指向由整型指针变量组成的数组的指针变量的函数.
说到这里也就差不多了,我们的任务也就这么多,理解了这几个类型,其它的类型对我们来说也是小菜了,不过我们一般不会用太复杂的类型,那样会大大减小程序的可读性,请慎用,这上面的几种类型已经足够我们用了。
细说指针
指针是一个特殊的变量,它里面存储的数值被解释成为内存里的一个地址。要搞清一个指针需要搞清指针的四方面的内容:指针的类型、指针所指向的类型、指针的值或者叫指针所指向的内存区、指针本身所占据的内存区。让我们分别说明。先声明几个指针放着做例子:
例一:
1 | int*ptr; |
指针的类型
从语法的角度看,你只要把指针声明语句里的指针名字去掉,剩下的部分就是这个指针的类型。这是指针本身所具有的类型。让我们看看例一中各个指针的类型:
1 | (1)int*ptr;//指针的类型是int* |
怎么样?找出指针的类型的方法是不是很简单呢?
指针所指向的类型
当你通过指针来访问指针所指向的内存区时,指针所指向的类型决定了编译器将把那片内存区里的内容当做什么来看待。从语法上看,你只须把指针声明语句中的指针名字和名字左边的指针声明符*去掉,剩下的就是指针所指向的类型。例如:
1 | (1)int*ptr; //指针所指向的类型是int |
在指针的算术运算中,指针所指向的类型有很大的作用。指针的类型(即指针本身的类型)和指针所指向的类型是两个概念。当你对C 越来越熟悉时,你会发现,把与指针搅和在一起的”类型”这个概念分成”指针的类型”和”指针所指向的类型”两个概念,是精通指针的关键点之一。我看了不少书,发现有些写得差的书中,就把指针的这两个概念搅在一起了,所以看起书来前后矛盾,越看越糊涂。
指针的值
指针的值是指针本身存储的数值,这个值将被编译器当作一个地址,而不是一个一般的数值。在32 位程序里,所有类型的指针的值都是一个32 位整数,因为32 位程序里内存地址全都是32 位长。指针所指向的内存区就是从指针的值所代表的那个内存地址开始,长度为sizeof(指针所指向的类型)的一片内存区。以后,我们说一个指针的值是XX,就相当于说该指针指向了以XX 为首地址的一片内存区域;我们说一个指针指向了某块内存区域,就相当于说该指针的值是这块内存区域的首地址。指针所指向的内存区和指针所指向的类型是两个完全不同的概念。在例一中,指针所指向的类型已经有了,但由于指针还未初始化,所以它所指向的内存区是不存在的,或者说是无意义的。以后,每遇到一个指针,都应该问问:这个指针的类型是什么?指针指的类型是什么?该指针指向了哪里?(重点注意)
指针本身所占据的内存区
指针本身占了多大的内存?你只要用函数sizeof(指针的类型)测一下就知道了。在32 位平台里,指针本身占据了4 个字节的长度。指针本身占据的内存这个概念在判断一个指针表达式(后面会解释)是否是左值时很有用。
指针的算术运算
指针可以加上或减去一个整数。指针的这种运算的意义和通常的数值的加减运算的意义是不一样的,以单元为单位。例如:
1 | char a[20]; |
在上例中,指针ptr 的类型是int*
,它指向的类型是int,它被初始化为指向整型变量a。接下来的第3句中,指针ptr被加了1,编译器是这样处理的:它把指针ptr 的值加上了sizeof(int),在32 位程序中,是被加上了4,因为在32 位程序中,int 占4 个字节。由于地址是用字节做单位的,故ptr 所指向的地址由原来的变量a 的地址向高地址方向增加了4 个字节。由于char 类型的长度是一个字节,所以,原来ptr 是指向数组a 的第0 号单元开始的四个字节,此时指向了数组a 中从第4 号单元开始的四个字节。我们可以用一个指针和一个循环来遍历一个数组,看例子:
1 | int array[20] = {0}; |
这个例子将整型数组中各个单元的值加1。由于每次循环都将指针ptr加1 个单元,所以每次循环都能访问数组的下一个单元。再看例子:
1 | char a[20] = "You_are_a_girl"; |
在这个例子中,ptr 被加上了5,编译器是这样处理的:将指针ptr 的值加上5 乘sizeof(int),在32 位程序中就是加上了5 乘4=20。由于地址的单位是字节,故现在的ptr 所指向的地址比起加5 后的ptr 所指向的地址来说,向高地址方向移动了20个字节。在这个例子中,没加5前的ptr指向数组a的第0号单元开始的四个字节,加5后,ptr已经指向了数组a的合法范围之外了。虽然这种情况在应用上会出问题,但在语法上却是可以的。这也体现出了指针的灵活性。
如果上例中,ptr 是被减去5,那么处理过程大同小异,只不过ptr 的值是被减去5 乘sizeof(int),新的ptr 指向的地址将比原来的ptr 所指向的地址向低地址方向移动了20 个字节。下面请允许我再举一个例子:(一个误区)
1 | #include<stdio.h> |
误区一:输出答案为Y 和o
误解:ptr 是一个char 的二级指针,当执行ptr++;
时,会使指针加一个sizeof(char)
,所以输出如上结果,这个可能只是少部分人的结果.
误区二:输出答案为Y 和a
误解:ptr 指向的是一个char *
类型,当执行ptr++;
时,会使指针加一个sizeof(char *)
(有可能会有人认为这个值为1,那就会得到误区一的答案,这个值应该是4,参考前面内容), 即&p+4; 那进行一次取值运算不就指向数组中的第五个元素了吗?那输出的结果不就是数组中第五个元素了吗?答案是否定的。
正解: ptr 的类型是char **
,指向的类型是一个char *
类型,该指向的地址就是p的地址(&p),当执行ptr++;
时,会使指针加一个sizeof(char*)
,即&p+4;
那*(&p+4)
指向哪呢,这个你去问上帝吧,或者他会告诉你在哪?所以最后的输出会是一个随机的值,或许是一个非法操作.
总结一下:一个指针ptrold
加(减)一个整数n 后,结果是一个新的指针ptrnew
,ptrnew
的类型和ptrold
的类型相同,ptrnew
所指向的类型和ptrold
所指向的类型也相同。ptrnew
的值将比ptrold
的值增加(减少)了n乘sizeof(ptrold 所指向的类型)
个字节。就是说,ptrnew
所指向的内存区将比ptrold
所指向的内存区向高(低)地址方向移动了n乘sizeof(ptrold 所指向的类型)
个字节。
指针和指针进行加减:两个指针不能进行加法运算,这是非法操作,因为进行加法后,得到的结果指向一个不知所向的地方,而且毫无意义。两个指针可以进行减法操作,但必须类型相同,一般用在数组方面,不多说了。
运算符&
和*
这里&
是取地址运算符,*
是间接运算符。&a
的运算结果是一个指针,指针的类型是a的类型加个*
,指针所指向的类型是a 的类型,指针所指向的地址嘛,那就是a 的地址。*P
的运算结果就五花八门了。总之*P
的结果是P所指向的东西,这个东西有这些特点:它的类型是P指向的类型,它所占用的地址是p所指向的地址。
例六:
1 | int a=12; |
指针表达式
一个表达式的结果如果是一个指针,那么这个表达式就叫指针表式。下面是一些指针表达式的例子:
例七:
1 | int a,b; |
例八:
1 | char *arr[20]; |
由于指针表达式的结果是一个指针,所以指针表达式也具有指针所具有的四个要素:指针的类型,指针所指向的类型,指针指向的内存区,指针自身占据的内存。好了,当一个指针表达式的结果指针已经明确地具有了指针自身占据的内存的话,这个指针表达式就是一个左值,否则就不是一个左值。在例七中,&a
不是一个左值,因为它还没有占据明确的内存。*ptr
是一个左值,因为*ptr
这个指针已经占据了内存,其实*ptr
就是指针pa,既然pa 已经在内存中有了自己的位置,那么*ptr
当然也有了自己的位置。
数组和指针的关系
数组的数组名其实可以看作一个指针。看下例:
1 | int array[10]={0,1,2,3,4,5,6,7,8,9},value; |
上例中,一般而言数组名array 代表数组本身,类型是int[10],但如果把array 看做指针的话,它指向数组的第0 个单元,类型是int*
所指向的类型是数组单元的类型即int。因此*array
等于0 就一点也不奇怪了。同理,array+3 是一个指向数组第3 个单元的指针,所以*(array+3)
等于3。其它依此类推。
1 | char *str[3]={ |
上例中,str 是一个三单元的数组,该数组的每个单元都是一个指针,这些指针各指向一个字符串。把指针数组名str 当作一个指针的话,它指向数组的第0 号单元,它的类型是char **
,它指向的类型是char *
。*str
也是一个指针,它的类型是char *
,它所指向的类型是char,它指向的地址是字符串”Hello,thisisasample!”的第一个字符的地址,即’H’的地址。
注意:字符串相当于是一个数组,在内存中以数组的形式储存,只不过字符串是一个数组常量,内容不可改变,且只能是右值.如果看成指针的话,他即是常量指针,也是指针常量。str+1 也是一个指针,它指向数组的第1 号单元,它的类型是char**
,它指向的类型是char*
。*(str+1)
也是一个指针,它的类型是char*
,它所指向的类型是char,它指向”Hi,goodmorning.”的第一个字符’H’
下面总结一下数组的数组名(数组中储存的也是数组)的问题:
声明了一个数组TYPE array[n],则数组名称array 就有了两重含义:
- 第一,它代表整个数组,它的类型是TYPE[n];
- 第二,它是一个常量指针,该指针的类型是
TYPE*
,该指针指向的类型是TYPE,也就是数组单元的类型,该指针指向的内存区就是数组第0 号单元,该指针自己占有单独的内存区,注意它和数组第0 号单元占据的内存区是不同的。该指针的值是不能修改的,即类似array++的表达式是错误的。在不同的表达式中数组名array 可以扮演不同的角色。在表达式sizeof(array)中,数组名array 代表数组本身,故这时sizeof 函数测出的是整个数组的大小。在表达式*array
中,array 扮演的是指针,因此这个表达式的结果就是数组第0 号单元的值。sizeof(*array)
测出的是数组单元的大小。表达式array+n(其中n=0,1,2,…..)中,array 扮演的是指针,故array+n 的结果是一个指针,它的类型是TYPE *
,它指向的类型是TYPE,它指向数组第n号单元。故sizeof(array+n)
测出的是指针类型的大小。在32 位程序中结果是4。
1 | int array[10]; |
上例中ptr 是一个指针,它的类型是int(*)[10]
,他指向的类型是int[10]
,我们用整个数组的首地址来初始化它。在语句ptr = &array
中,array 代表数组本身。本节中提到了函数sizeof()
,那么我来问一问,sizeof(指针名称)测出的究竟是指针自身类型的大小呢还是指针所指向的类型的大小?
答案是前者。例如:
1 | int(*ptr)[10]; |
则在32 位程序中,有:
1 | sizeof(int(*)[10])==4 |
实际上,sizeof(对象)测出的都是对象自身的类型的大小,而不是别的什么类型的大小。
指针和结构类型的关系
可以声明一个指向结构类型对象的指针。
1 | struct MyStruct |
请问怎样通过指针ptr 来访问ss 的三个成员变量?
答案:
1 | ptr->a; //指向运算符,或者可以这们(*ptr).a,建议使用前者 |
又请问怎样通过指针pstr 来访问ss 的三个成员变量?
答案:
1 | *pstr; //访问了ss 的成员a。 |
虽然我在我的MSVC++6.0 上调式过上述代码,但是要知道,这样使用pstr 来访问结构成员是不正规的,为了说明为什么不正规,让我们看看怎样通过指针来访问数组的各个单元: (将结构体换成数组)
例十三:
1 | int array[3]={35,56,37}; |
从格式上看倒是与通过指针访问结构成员的不正规方法的格式一样。所有的C/C++编译器在排列数组的单元时,总是把各个数组单元存放在连续的存储区里,单元和单元之间没有空隙。但在存放结构对象的各个成员时,在某种编译环境下,可能会需要字对齐或双字对齐或者是别的什么对齐,需要在相邻两个成员之间加若干个”填充字节”,这就导致各个成员之间可能会有若干个字节的空隙。所以,在例十二中,即使*pstr
访问到了结构对象ss 的第一个成员变量a,也不能保证*(pstr+1)
就一定能访问到结构成员b。因为成员a 和成员b 之间可能会有若干填充字节,说不定*(pstr+1)
就正好访问到了这些填充字节呢。这也证明了指针的灵活性。要是你的目的就是想看看各个结构成员之间到底有没有填充字节,嘿,这倒是个不错的方法。不过指针访问结构成员的正确方法应该是象例十二中使用指针ptr 的方法。
指针和函数的关系
可以把一个指针声明成为一个指向函数的指针。
1 | int fun1(char *,int); |
可以把指针作为函数的形参。在函数调用语句中,可以用指针表达式来作为实参。
例十四:
1 | int fun(char *); |
这个例子中的函数fun 统计一个字符串中各个字符的ASCII 码值之和。前面说了,数组的名字也是一个指针。在函数调用中,当把str作为实参传递给形参s 后,实际是把str 的值传递给了s,s 所指向的地址就和str 所指向的地址一致,但是str 和s 各自占用各自的存储空间。在函数体内对s 进行自加1 运算,并不意味着同时对str 进行了自加1 运算。
指针类型转换
当我们初始化一个指针或给一个指针赋值时,赋值号的左边是一个指针,赋值号的右边是一个指针表达式。在我们前面所举的例子中,绝大多数情况下,指针的类型和指针表达式的类型是一样的,指针所指向的类型和指针表达式所指向的类型是一样的。
例十五:
1 | float f = 12.3; |
在上面的例子中,假如我们想让指针P指向实数f,应该怎么办?是用下面的语句吗?
1 | p = &f; |
不对。因为指针P的类型是int *
,它指向的类型是int。表达式&f
的结果是一个指针,指针的类型是float *
,它指向的类型是float。两者不一致,直接赋值的方法是不行的。至少在我的MSVC++6.0 上,对指针的赋值语句要求赋值号两边的类型一致,所指向的类型也一致,其它的编译器上我没试过,大家可以试试。
为了实现我们的目的,需要进行”强制类型转换”:p=(int*)&f;
如果有一个指针p,我们需要把它的类型和所指向的类型改为TYEP *TYPE
, 那么语法格式是: (TYPE *)p
;
这样强制类型转换的结果是一个新指针,该新指针的类型是TYPE *
,它指向的类型是TYPE,它指向的地址就是原指针指向的地址。而原来的指针P的一切属性都没有被修改。(切记)
一个函数如果使用了指针作为形参,那么在函数调用语句的实参和形参的结合过程中,必须保证类型一致,否则需要强制转换。
1 | void fun(char*); |
注意这是一个32 位程序,故int 类型占了四个字节,char 类型占一个字节。函数fun 的作用是把一个整数的四个字节的顺序来个颠倒。注意到了吗?在函数调用语句中,实参&a
的结果是一个指针,它的类型是int *
,它指向的类型是int。形参这个指针的类型是char *
,它指向的类型是char。这样,在实参和形参的结合过程中,我们必须进行一次从int *
类型到char *
类型的转换。
结合这个例子,我们可以这样来想象编译器进行转换的过程:编译器先构造一个临时指针char *temp
,然后执行temp=(char *)&a
,最后再把temp的值传递给s。
所以最后的结果是:s 的类型是char *
,它指向的类型是char,它指向的地址就是a 的首地址。我们已经知道,指针的值就是指针指向的地址,在32 位程序中,指针的值其实是一个32 位整数。那可不可以把一个整数当作指针的值直接赋给指针呢?就象下面的语句:
1 | unsigned int a; |
编译一下吧。结果发现后面两条语句全是错的。那么我们的目的就不能达到了吗?不,还有办法:
1 | unsigned int a; |
严格说来这里的(TYPE *)
和指针类型转换中的(TYPE *)
还不一样。这里的(TYPE*)
的意思是把无符号整数a 的值当作一个地址来看待。上面强调了a 的值必须代表一个合法的地址,否则的话,在你使用ptr 的时候,就会出现非法操作错误。想想能不能反过来,把指针指向的地址即指针的值当作一个整数取出来。完全可以。下面的例子演示了把一个指针的值当作一个整数取出来,然后再把这个整数当作一个地址赋给一个指针:
例十七:
1 | int a = 123, b; |
现在我们已经知道了,可以把指针的值当作一个整数取出来,也可以把一个整数值当作地址赋给一个指针。
指针的安全问题
看下面的例子:
1 | char s = 'a'; |
指针ptr 是一个int *
类型的指针,它指向的类型是int。它指向的地址就是s 的首地址。在32 位程序中,s 占一个字节,int 类型占四个字节。最后一条语句不但改变了s 所占的一个字节,还把和s 相临的高地址方向的三个字节也改变了。这三个字节是干什么的?只有编译程序知道,而写程序的人是不太可能知道的。也许这三个字节里存储了非常重要的数据,也许这三个字节里正好是程序的一条代码,而由于你对指针的马虎应用,这三个字节的值被改变了!这会造成崩溃性的错误。让我们再来看一例:
例十九:
1 | char a; |
该例子完全可以通过编译,并能执行。但是看到没有?第3句对指针ptr 进行自加1 运算后,ptr 指向了和整形变量a 相邻的高地址方向的一块存储区。这块存储区里是什么?我们不知道。有可能它是一个非常重要的数据,甚至可能是一条代码。而第4 句竟然往这片存储区里写入一个数据!这是严重的错误。
所以在使用指针时,程序员心里必须非常清楚:我的指针究竟指向了哪里。在用指针访问数组的时候,也要注意不要超出数组的低端和高端界限,否则也会造成类似的错误。在指针的强制类型转换:ptr1=(TYPE *)ptr2
中,如果sizeof(ptr2的类型)
大于sizeof(ptr1 的类型)
,那么在使用指针ptr1 来访问ptr2所指向的存储区时是安全的。如果sizeof(ptr2 的类型)
小于sizeof(ptr1 的类型)
,那么在使用指针ptr1 来访问ptr2 所指向的存储区时是不安全的。
预处理的工作方式
在C语言的程序中可包括各种以符号#开头的编译指令,这些指令称为预处理命令。预处理命令属于C语言编译器,而不是C语言的组成部分。通过预处理命令可扩展C语言程序设计的环境。
预处理的功能
在集成开发环境中,编译,链接是同时完成的。其实,C语言编译器在对源代码编译之前,还需要进一步的处理:预编译。
所以,完整的步骤是:预编译 -> 编译 -> 链接
预编译的主要作用如下:
- 将源文件中以”include”格式包含的文件复制到编译的源文件中。
- 用实际值替换用“#define”定义的字符串。
- 根据“#if”后面的条件决定需要编译的代码。
预处理的工作方式
预处理的行为是由指令控制的。这些指令是由#字符开头的一些命令。
#define
指令定义了一个宏—-用来代表其他东西的一个命令,通常是某一个类型的常量。预处理会通过将宏的名字和它的定义存储在一起来响应#define指令。当这个宏在后面的程序中使用到时,预处理器”扩展”了宏,将宏替换为它所定义的值。例如:下面这行命令:1
#define PI 3.141592654
#include
指令告诉预处理器打开一个特定的文件,将它的内容作为正在编译的文件的一部分“包含”进来。例如:下面这行命令:1
#include<stdio.h>
指示预处理器打开一个名字为stdio.h的文件,并将它的内容加到当前的程序中。
预处理器的输入是一个C语言程序,程序可能包含指令。预处理器会执行这些指令,并在处理过程中删除这些指令。预处理器的输出是另外一个程序:原程序的一个编辑后的版本,不再包含指令。预处理器的输出被直接交给编译器,编译器检查程序是否有错误,并经程序翻译为目标代码。
预处理指令
预处理指令
大多数预处理器指令属于下面3种类型:
- 宏定义:#define 指令定义一个宏,#undef指令删除一个宏定义。
- 文件包含:#include指令导致一个指定文件的内容被包含到程序中。
- 条件编译:#if,#ifdef,#ifndef,#elif,#else和#endif指令可以根据编译器可以测试的条件来将一段文本包含到程序中或排除在程序之外。
剩下的#error,#line和#pragma指令更特殊的指令,较少用到。
指令规则
指令都是以#开始。#符号不需要在一行的行首,只要她之前有空白字符就行。在#后是指令名,接着是指令所需要的其他信息。
在指令的符号之间可以插入任意数量的空格或横向制表符。
指令总是第一个换行符处结束,除非明确地指明要继续。
指令可以出现在程序中任何地方。我们通常将#define和#include指令放在文件的开始,其他指令则放在后面,甚至在函数定义的中间。
注释可以与指令放在同一行。
宏定义命令——#define
使用#define命令并不是真正的定义符号常量,而是定义一个可以替换的宏。被定义为宏的标示符称为“宏名”。在编译预处理过程时,对程序中所有出现的“宏名”,都用宏定义中的字符串去代换,这称为“宏代换”或“宏展开”。
在C语言中,宏分为有参数和无参数两种。
无参数的宏
其定义格式如下:1
#define 宏名 字符串
在以上宏定义语句中,各部分的含义如下:
:表示这是一条预处理命令(凡是以“#”开始的均为预处理命令)。
- define:关键字“define”为宏定义命令。
- 宏名:是一个标示符,必须符合C语言标示符的规定,一般以大写字母标示宏名。
- 字符串:可以是常数,表达式,格式串等。在前面使用的符号常量的定义就是一个无参数宏定义。
Notice:预处理命令语句后面一般不会添加分号,如果在#define最后有分号,在宏替换时分号也将替换到源代码中去。在宏名和字符串之间可以有任意个空格。1
#define PI 3.14
在使用宏定义时,还需要注意以下几点:
- 宏定义是宏名来表示一个字符串,在宏展开时又以该字符串取代宏名。这只是一种简单的代换,字符串中可以含任何字符,可以是常数,也可以是表达式,预处理程序对它不作任何检查。如有错误,只能在编译已被宏展开后的源程序时发现。
- 宏定义必须写在函数之外,其作用域为宏定义命令起到源程序结束。
- 宏名在源程序中若用引号括起来,则预处理程序不对其作宏替换。
- 宏定义允许嵌套,在宏定义的字符串中可以使用已经定义的宏名。在宏展开时由预处理程序层层替换。
- 习惯上宏名可用大写字母表示,以方便与变量区别。但也允许用小写字母。
带参数的宏
#define
命令定义宏时,还可以为宏设置参数。与函数中的参数类似,在宏定于中的参数为形式参数,在宏调用中的参数称为实际参数。对带参数的宏,在调用中,不仅要宏展开,还要用实参去代换形参。
带参宏定义的一般形式为:1
#define 宏名(形参表) 字符串
在定义带参数的宏时,宏名和形参表之间不能有空格出现,否则,就将宏定义成为无参数形式,而导致程序出错。1
#define ABS(x) (x)<0?-(x):(x)
以上的宏定义中,如果x的值小于0,则使用一元运算符(-)对其取负,得到正数。
带参的宏和带参的函数相似,但其本质是不同的。使用带参宏时,在预处理时将程序源代码替换到相应的位置,编译时得到完整的目标代码,而不进行函数调用,因此程序执行效率要高些。而函数调用只需要编译一次函数,代码量较少,一般情况下,对于简单的功能,可使用宏替换的形式来使用。
预处理操作符#和
操作符
在使用#define定义宏时,可使用操作符#在字符串中输出实参。Eg:1
#define AREA(x,y) printf(“长为“#x”,宽为“#y”的长方形的面积:%d\n”,(x)*(y));
操作符
与操作符#类似,操作符##也可用在带参宏中替换部分内容。该操作符将宏中的两个部分连接成一个内容。例如,定义如下宏:1
#define VAR(n) v##n
当使用一下方式引用宏:VAR(1)
预处理时,将得到以下形式:v1
如果使用以下宏定义:1
#define FUNC(n) oper##n
当实参为1时,预处理后得到一下形式:1
oper1
文件包含———include
当一个C语言程序由多个文件模块组成时,主模块中一般包含main函数和一些当前程序专用的函数。程序从main函数开始执行,在执行过程中,可调用当前文件中的函数,也可调用其他文件模块中的函数。
如果在模块中要调用其他文件模块中的函数,首先必须在主模块中声明该函数原型。一般都是采用文件包含的方法,包含其他文件模块的头文件。
文件包含中指定的文件名即可以用引号括起来,也可以用尖括号括起来,格式如下:1
#include< 文件名>
或1
#include“文件名”
如果使用尖括号<>括起文件名,则编译程序将到C语言开发环境中设置好的 include文件中去找指定的文件。
因为C语言的标准头文件都存放在include文件夹中,所以一般对标准头文件采用尖括号;对编程自己编写的文件,则使用双引号。
如果自己编写的文件不是存放在当前工作文件夹,可以在#include命令后面加在路径。
#include
命令的作用是把指定的文件模块内容插入到#include所在的位置,当程序编译链接时,系统会把所有#include指定的文件链接生成可执行代码。文件包含必须以#开头,表示这是编译预处理命令,行尾不能用分号结束。
#include
所包含的文件,其扩展名可以是“.c”,表示包含普通C语言源程序。也可以是 “.h”,表示C语言程序的头文件。C语言系统中大量的定义与声明是以头文件形式提供的。 “.h”是接口文件,如果想理解C语言接口的写法,有必要琢磨一下 “.h”。
通过#define包含进来的文件模块中还可以再包含其他文件,这种用法称为嵌套包含。嵌套的层数与具体C语言系统有关,但是一般可以嵌套8层以上。
条件编译
预处理器还提供了条件编译功能。在预处理时,按照不同的条件去编译程序的不同部分,从而得到不同的目标代码。
使用条件编译,可方便地处理程序的调试版本和正式版本,也可使用条件编译使程序的移植更方便。
使用#if
与C语言的条件分支语句类似,在预处理时,也可以使用分支,根据不同的情况编译不同的源代码段。
#if
的使用格式如下:1
2
3
4
5#if 常量表达式
程序段
#else
程序段
#endif
该条件编译命令的执行过程为:若常量表达式的值为真(非0),则对程序段1进行编译,否则对程序段2进行编译。因此可以使程序在不同条件下完成不同的功能。
举个例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18#define DEBUG 1
int main()
{
int i,j;
char ch[26];
for(i='a';j=0;i<='z';i++,j++)
{
ch[j]=i;
#if DEBUG
printf("ch[%d]=%c\n",j,ch[j]);
#endif
}
for(j=0;j<26;j++)
{
printf("%c",ch[j]);
}
return 0;
}
#if
预编译命令还可使用多分支语句格式,具体格式如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19#if 常量表达式 1
程序段 1
#elif 常量表达式 2
程序段 2
… …
#elif 常量表达式 n
程序段 n
#else
程序段 m
#endif
关键字#elif与多分支if语句中的else if类似。
举个例子1
2
3
4
5
6
7
8
9
10
11
12
13
14
15#define os win
#if os=win
#include"win.h"
#elif os=linux
#include"linux.h"
#elif os=mac
#include"mac.h"
#endif
#if
和#elif
还可以进行嵌套,C89标准中,嵌套深度可以到达8层,而C99允许嵌套达到63层。在嵌套时,每个#endif,#else或#elif与最近的#if或#elif配对。
Eg:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41#define MAX 100
#define OLD -1
int main()
{
int i;
#if MAX>50
{
#if OLD>3
{
i=1;
{
#elif OLD>0
{
i=2;
}
#else
{
i=3;
}
#endif
}
#else
{
#if OLD>3
{
i=4;
}
#elif OLD>4
{
i=5;
}
#else
{
i=6;
}
#endif
}
#endif
return 0;
}
使用#ifdef和#ifndef
在上面的#if条件编译命令中,需要判断符号常量定义的具体值。在很多情况下,其实不需要判断符号常量的值,只需要判断是否定义了该符号常量。这时,可不使用#if命令,而使用另外一个预编译命令———#ifdef.1
2
3
4
5
6
7
8
9#ifdef命令的使用格式如下:
#ifdef 标识符
程序段 1
#else
程序段 2
#endif
其意义是,如果#ifdef后面的标识符已被定义过,则对“程序段1”进行编译;如果没有定义标识符,则编译“程序段2”。一般不使用#else及后面的“程序2”。
而#ifndef的意义与#ifdef相反,其格式如下:1
2
3
4
5
6
7
8#ifndef 标识符
程序段 1
#else
程序段 2
#endif
其意义是:如果未定义标识符,则编译“程序段1”;否则编译“程序段2”。
使用#defined和#undef
与#ifdef类似的,可以在#if命令中使用define来判断是否已定义指定的标识符。例如:1
2
3
4#if defined 标识符
程序段 1
#endif
与下面的标示方式意义相同。1
2
3
4#ifdef 标识符
程序段 1
#endif
也可使用逻辑运算符,对defined取反。例如:1
2
3
4#if ! define 标识符
程序段 1
#endif
与下面的标示方式意义相同。1
2
3
4#ifndef 标识符
程序段 1
#endif
在#ifdef和#ifndef命令后面的标识符是使用#define进行定义的。在程序中,还可以使用#undef取消对标识符的定义,其形式为:1
#undef 标识符
举个例子:1
2
3#define MAX 100
……
#undef MAX
在以上代码中,首先使用#define定义标识符MAX,经过一段程序代码后,又可以使用#undef取消已定义的标识符。使用#undef命令后,再使用#ifdef max,将不会编译后的源代码,因为此时标识符MAX已经被取消定义了。
其他预处理命令
预定义的宏名
ANSI C标准预定义了五个宏名,每个宏名的前后均有两个下画线,避免与程序员定义相同的宏名(一般都不会定义前后有两个下划线的宏)。这5个宏名如下:
__DATE__
:当前源程序的创建日期。__FILE__
:当前源程序的文件名称(包括盘符和路径)。__LINE__
:当前被编译代码的行号。__STDC__
:返回编译器是否位标准C,若其值为1表示符合标准C,否则不是标准C.__TIME__
:当前源程序的创建时间。
举个例子:1
2
3
4
5
6
7
8
9
10
11#include<stdio.h>
int main()
{
int j;
printf("日期:%s\n",__DATE__);
printf("时间:%s\n",__TIME__};
printf("文件名:%s\n",__FILE__);
printf("这是第%d行代码\n",__LINE__);
printf("本编译器%s标准C\n",(__STD__)?"符合":"不符合");
return 0;
}
重置行号和文件名命令——————#line
使用__LINE__
预定义宏名赈灾编译的程序行号。使用#line命令可改变预定义宏__LINE__
与__FILE__
的内容,该命令的基本形如下:1
#line number[“filename”]
其中的数字为一个正整数,可选的文件名为有效文件标识符。行号为源代码中当前行号,文件名为源文件的名字。命令为#line主要用于调试以及其他特殊应用。
举个例子:1
2
3
4
5
6
7
8
9#include<stdio.h>
#include<stdlib.h>
#line 1000
int main()
{
printf("当前行号:%d\n",__LINE__);
return 0;
}
在以上程序中,在第4行中使用#line定义的行号为从1000开始(不包括#line这行)。所以第5行的编号将为1000,第6行为1001,第7行为1002,第8行为1003.
修改编译器设置命令 pragma
#pragma
命令的作用是设定编译器的状态,或者指示编译器完全一些特定的动作。#pragma命令对每个编译器给出了一个方法,在保持与C语言完全兼容的情况下,给出主机或者操作系统专有的特征。其格式一般为:1
#pragma Para
其中,Para为参数,可使用的参数很多,下面列出常用的参数:
- Message参数,该参数能够在编译信息输出窗口中输出对应的信息,这对于源代码信息的控制是非常重要的,其使用方法是:当编译器遇到这条指令时,就在编译输出窗口中将消息文本显示出来。
1
#pragma message(消息文本)
- 另外一个使用比较多得pragma参数是code_seg.格式如:它能够设置程序中函数代码存放的代码段,在开发驱动程序的时候就会使用到它。
1
#pragma code_seg([“section_name”[,section_class]])
参数once,可保证头文件被编译一次,其格式为:1
#pragma once
只要在头文件的最开始加入这条指令就能够保证头文件被编译一次。
产生错误信息命令 ——————#error
#error
命令强制编译器停止编译,并输出一个错误信息,主要用于程序调试。其使用如下:1
#error 信息错误
注意,错误信息不用双括号括起来。当遇到#error命令时,错误信息将显示出来。
例如,以下编译预处理器命令判断预定义宏__STDC__
,如果其值不为1,则显示一个错误信息,提示程序员该编译器不支持ANSI C标准。1
2
3
4#if __STDC__!=1
#error NOT ANSI C
#endif
内联函数
在使用#define定义带参数宏时,在调用函数时,一般需要增加系统的开销,如参数传递,跳转控制,返回结果等额外操作需要系统内存和执行时间。而使用带参数宏时,通过宏替换可再编译前将函数代码展开导源代码中,使编译后的目标文件含有多段重复的代码。这样做,会增加程序的代码量,都可以减少执行时间。
在C99标准钟,还提供另外一种解决方法:使用内联函数。
在程序编译时,编译器将程序中出现的内联函数的调用表达式用内联函数的函数体来进行替代。显然,这种做法不会产生转去转回得问题。都是由于在编译时将函数体中的代码被替代到程序中,因此会增加目标代码量,进而增加空间的开销,而在时间开销上不像函数调用时那么大,可见它是以增加目标代码为代码来换取时间的节省。
定义内联函数的方法很简单,只要在定义函数头的前面加上关键字inline即可。内联函数的定义与一般函数一样。例如,定于一个两个整数相加的函数:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18#include<stdio.h>
#include<stdlib.h>
inline int add(int x,int y);
inline int add(int x,int y)
{
return x+y;
}
int main()
{
int i,j,k;
printf("请输入两个整数的值:\n");
scanf("%d %d",&i,&j);
k=add(i,j);
printf("k=%d\n",k);
return 0;
}
在程序中,调用函数add时,该函数在编译时会将以上代码复制过来,而不是像一般函数那样是运行时被调用。
内联函数具有一般函数的特性,它与一般函数所不同之处在于函数调用的处理。一般函数进行调用时,要讲程序执行权转导被调函数中,然后再返回到调用到它的函数中;而内联函数在调用时,是将调用表达式用内联函数体来替换。在使用内联函数时,应该注意如下几点:
- 在内联函数内部允许用循环语句和开关语句。但是,程序在经过编译之后,这个函数是不会作为内联函数进行调用的。
内联函数的定义必须出现在内联函数第一次被调用之前。 - 其实,在程序中声明一个函数为内联时,编译以后这个函数不一定是内联的,
即程序只是建议编译器使用内联函数,但是编译器会根据函数情况决定是否使用内联,所以如果编写的内联函数中出现循环或者开关语句,程序也不会提示出错,但那个函数已经不是内联函数了。
一般都是将一个小型函数作为内联函数。
函数指针
定义
顾名思义,函数指针就是函数的指针。它是一个指针,指向一个函数。看例子:
1 | A) char * (*fun1)(char * p1,char * p2); |
看看上面三个表达式分别是什么意思?
- C)这很容易,fun3是函数名,p1,p2是参数,其类型为char 型,函数的返回值为char 类型。
- B) 也很简单,与C)表达式相比,唯一不同的就是函数的返回值类型为char**,是个二级指针。
- A) fun1是函数名吗?回忆一下前面讲解数组指针时的情形。我们说数组指针这么定义或许更清晰:
1 | int (*)[10] p; |
再看看A)表达式与这里何其相似!明白了吧。这里fun1不是什么函数名,而是一个指针变量,它指向一个函数。这个函数有两个指针类型的参数,函数的返回值也是一个指针。同样,我们把这个表达式改写一下:
1 | char * (*)(char * p1,char * p2) fun1; |
这样子是不是好看一些呢?只可惜编译器不这么想。^_^。
使用的例子
上面我们定义了一个函数指针,但如何来使用它呢?先看如下例子:
1 | #include <stdio.h> |
我们使用指针的时候,需要通过钥匙(“*”)来取其指向的内存里面的值,函数指针使用也如此。通过用(*pf)取出存在这个地址上的函数,然后调用它。
这里需要注意到是,在Visual C++6.0里,给函数指针赋值时,可以用&fun或直接用函数名fun。这是因为函数名被编译之后其实就是一个地址,所以这里两种用法没有本质的差别。这个例子很简单,就不再详细讨论了。
复杂的例子
也许上面的例子过于简单,我们看看下面的例子:
1 | void Function() |
这是在干什么?*(int*)&p=(int)Function;
表示什么意思?
别急,先看这行代码:
1 | void (*p)(); |
这行代码定义了一个指针变量p,p指向一个函数,这个函数的参数和返回值都是void。&p是求指针变量p本身的地址,这是一个32位的二进制常数(32位系统)。
(int*)&p
表示将地址强制转换成指向int类型数据的指针。(int)Function
表示将函数的入口地址强制转换成int类型的数据。分析到这里,相信你已经明白*(int*)&p=(int)Function;
表示将函数的入口地址赋值给指针变量p。
那么(*p) ();
就是表示对函数的调用。
讲解到这里,相信你已经明白了。其实函数指针与普通指针没什么差别,只是指向的内容不同而已。
使用函数指针的好处在于,可以将实现同一功能的多个模块统一起来标识,这样一来更容易后期的维护,系统结构更加清晰。或者归纳为:便于分层设计、利于系统抽象、降低耦合度以及使接口与实现分开。
另一个复杂的例子
是不是感觉上面的例子太简单,不够刺激?好,那就来点刺激的,看下面这个例子:
1 | (*(void(*) ())0)(); |
这是《C Traps and Pitfalls》这本经典的书中的一个例子。没有发狂吧?下面我们就来分析分析:
- 第一步:
void(*) ()
,可以明白这是一个函数指针类型。这个函数没有参数,没有返回值。 - 第二步:
(void(*) ())0
,这是将0强制转换为函数指针类型,0是一个地址,也就是说一个函数存在首地址为0的一段区域内。 - 第三步:
(*(void(*) ())0)
,这是取0地址开始的一段内存里面的内容,其内容就是保存在首地址为0的一段区域内的函数。 - 第四步:
(*(void(*) ())0)()
,这是函数调用。
好像还是很简单是吧,上面的例子再改写改写:
1 | (*(char**(*) (char **,char **))0) ( char **,char **); |
如果没有上面的分析,肯怕不容易把这个表达式看明白吧。不过现在应该是很简单的一件事了。读者以为呢?
函数指针数组
现在我们清楚表达式
1 | char * (*pf)(char * p); |
定义的是一个函数指针pf。既然pf是一个指针,那就可以储存在一个数组里。把上式修改一下:
1 | char * (*pf[3])(char * p); |
这是定义一个函数指针数组。
它是一个数组,数组名为pf,数组内存储了3个指向函数的指针。这些指针指向一些返回值类型为指向字符的指针、参数为一个指向字符的指针的函数。
这念起来似乎有点拗口。不过不要紧,关键是你明白这是一个指针数组,是数组。函数指针数组怎么使用呢?这里也给出一个非常简单的例子,只要真正掌握了使用方法,再复杂的问题都可以应对。
如下:
1 | #include <stdio.h> |
函数指针数组的指针
看着这个标题没发狂吧?函数指针就够一般初学者折腾了,函数指针数组就更加麻烦,现在的函数指针数组指针就更难理解了。
其实,没这么复杂。前面详细讨论过数组指针的问题,这里的函数指针数组指针不就是一个指针嘛。只不过这个指针指向一个数组,这个数组里面存的都是指向函数的指针。仅此而已。
下面就定义一个简单的函数指针数组指针:
1 | char * (*(*pf)[3])(char * p); |
注意,这里的pf和上一节的pf就完全是两码事了。上一节的pf并非指针,而是一个数组名;这里的pf确实是实实在在的指针。这个指针指向一个包含了3个元素的数组;这个数字里面存的是指向函数的指针;这些指针指向一些返回值类型为指向字符的指针、参数为一个指向字符的指针的函数。
这比上一节的函数指针数组更拗口。其实你不用管这么多,明白这是一个指针就ok了。其用法与前面讲的数组指针没有差别。下面列一个简单的例子:
1 | #include <stdio.h> |
内存泄漏问题原理
堆内存在C代码中的存储方式
内存泄漏问题只有在使用堆内存的时候才会出现,栈内存不存在内存泄漏问题,因为栈内存会自动分配和释放。C代码中堆内存的申请函数是malloc,常见的内存申请代码如下:
1 | char *info = NULL; /**转换后的字符串**/ |
由于malloc函数返回的实际上是一个内存地址,所以保存堆内存的变量一定是一个指针(除非代码编写极其不规范)。再重复一遍,保存堆内存的变量一定是一个指针,这对本文主旨的理解很重要。当然,这个指针可以是单指针,也可以是多重指针。
malloc函数有很多变种或封装,如g_malloc、g_malloc0、VOS_Malloc等,这些函数最终都会调用malloc函数。
堆内存的获取方法
看到本小节标题,可能有些同学有疑惑,上一小节中的malloc函数,不就是堆内存的获取方法吗?的确是,通过malloc函数申请是最直接的获取方法,如果只知道这种堆内存获取方法,就容易掉到坑里了。一般的来讲,堆内存有如下两种获取方法:
方法一:将函数返回值直接赋给指针,一般表现形式如下:
1 | char *local_pointer_xx = NULL; |
该类涉及到内存申请的函数,返回值一般都指针类型,例如:
1 | GSList* g_slist_append (GSList *list, gpointer data) |
方法二:将指针地址作为函数返回参数,通过返回参数保存堆内存地址,一般表现形式如下:
1 | int ret; |
该类涉及到内存申请的函数,一般都有一个入参是双重指针,例如:
1 | __STDIO_INLINE _IO_ssize_t |
前面说通过malloc申请内存,就属于方法一的一个具体表现形式。其实这两类方法的本质是一样的,都是函数内部间接申请了内存,只是传递内存的方法不一样,方法一通过返回值传递内存指针,方法二通过参数传递内存指针。
内存泄漏三要素
最常见的内存泄漏问题,包含以下三个要素:
- 要素一:函数内有局部指针变量定义;
- 要素二:对该局部指针有通过上一小节中“两种堆内存获取方法”之一获取内存;
- 要素三:在函数返回前(含正常分支和异常分支)未释放该内存,也未保存到其它全局变量或返回给上一级函数。
内存释放误区
稍微使用过C语言编写代码的人,都应该知道堆内存申请之后是需要释放的。但为何还这么容易出现内存泄漏问题呢?一方面,是开发人员经验不足、意识不到位或一时疏忽导致;另一方面,是内存释放误区导致。很多开发人员,认为要释放的内存应该局限于以下两种:
- 直接使用内存申请函数申请出来的内存,如malloc、g_malloc等;
- 该开发人员熟悉的接口中,存在内存申请的情况,如iBMC的兄弟,都应该知道调用如下接口需要释放list指向的内存:
1 | dfl_get_object_list(const char* class_name, GSList **list) |
按照以上思维编写代码,一旦遇到不熟悉的接口中需要释放内存的问题,就完全没有释放内存的意识,内存泄漏问题就自然产生了。
内存泄漏问题检视方法
检视内存泄漏问题,关键还是要养成良好的编码检视习惯。与内存泄漏三要素对应,需
要做到如下三点:
- 在函数中看到有局部指针,就要警惕内存泄漏问题,养成进一步排查的习惯
- 分析对局部指针的赋值操作,是否属于前面所说的“两种堆内存获取方法”之一,如果是,就要分析函数返回的指针到底指向啥?是全局数据、静态数据还是堆内存?对于不熟悉的接口,要找到对应的接口文档或源代码分析;又或者看看代码中其它地方对该接口的引用,是否进行了内存释放;
- 如果确认对局部指针存在内存申请操作,就需要分析该内存的去向,是会被保存在全局变量吗?又或者会被作为函数返回值吗?如果都不是,就需要排查函数所有有”return“的地方,保证内存被正确释放。