摘要:从2021/07/19到2021/07/26阅读完毕
参考:
基础内容
#include
#include
这行代码是一条C预处理器指令(preprocessor directive)。通常,C编译器在编译前会对源代码做一些准备工作,即预处理(preprocessing)。
#include "hotels.h"
指令中的双引号表明被包含的源文件位于当前目录中(通常是包含源代码的目录)。
字符和字符串
字符串是以空字符(\0)(ASCII = 0)结尾的char类型数组。
scanf()
只会读取字符数组中的一个单词,而不是一整句,在遇到空格、换行时就停止了。
字符串常量”x”和字符常量’x’不同。区别之一在于’x’是基本类型(char),而”x”是派生类型(char数组);区别之二是”x”实际上由两个字符组成:’x’和空字符\0。
sizeof() 、size_t和 scanf()
sizeof
运算符,它以字节为单位给出对象的大小。sizeof
返回 size_t
类型的值。这是一个无符号整数类型,但它不是新类型。
size_t
是语言定义的标准类型,size_t
类型被定义为 sizeof 运算符的返回值类型——无符号整数类型。C有一个typedef
机制,允许程序员为现有类型创建别名。例如,typedef double real;
类似地,C 头文件系统可以使用 typedef
把 size_t
作为 unsigned int
或unsigned long
的别名。stddef.h
文件中包含了size_t
类型的typedef
或#define
定义。这样,在使用size_t
类型时,编译器会根据不同的系统替换标准类型。
scanf()
中把***放在%和转换字符之间时,会使得scanf()
跳过相应的输出项。如%*d、%.2f等
字符缓冲区
用户输入的字符被收集并储存在一个被称为缓冲区(buffer)的临时存储区,按下Enter键后,程序才可使用用户输入的字符。
为什么要有缓冲区?
首先,把若干字符作为一个块进行传输比逐个发送这些字符节约时间。其次,如果用户打错字符,可以直接通过键盘修正错误。当最后按下Enter键时,传输的是正确的输入。
虽然缓冲输入好处很多,但是某些交互式程序也需要无缓冲输入。例如,在游戏中,你希望按下一个键就执行相应的指令。因此,缓冲输入和无缓冲输入都有用武之地。
缓冲分为两类:完全缓冲I/O和行缓冲I/O。完全缓冲输入指的是当缓冲区被填满时才刷新缓冲区(内容被发送至目的地),通常出现在文件输入中。缓冲区的大小取决于系统,常见的大小是 512 字节和 4096字节。行缓冲I/O指的是在出现换行符时刷新缓冲区。键盘输入通常是行缓冲输入,所以在按下Enter键后才刷新缓冲区。
行缓冲输入getchar()
回显无缓冲输入的getche()
无回显无缓冲输入的getch()
缓冲输入和无缓冲输入
从概念上看,C程序处理的是流而不是直接处理文件。流(stream)是一个实际输入或输出映射的理想化数据流。这意味着不同属性和不同种类的输入,由属性更统一的流来表示。于是,打开文件的过程就是把流与文件相关联,而且读写都通过流来完成。
如果包含<stdio.h>
文件,并使用EOF符号,就不必担心EOF值不同的问题。这里关键要理解EOF是一个值,标志着检测到文件结尾,并不是在文件中找得到的符号。
重定向输入 、 重定向输出
1 | ./helloworld < words # 将words文件中的内容以字符流的方式输入到helloworld中 |
递归既有优点也有缺点。优点是递归为某些编程问题提供了最简单的解决方案。缺点是一些递归算法会快速消耗计算机的内存资源。另外,递归不方便阅读和维护。
指针和运算符
地址运算符:&
一元&运算符给出变量的存储地址。如果pooh是变量名,那么&pooh是变量的地址。可以把地址看作是变量在内存中的位置。
1 | pooh = 24; |
指针
指针(pointer)是一个值为内存地址的变量(或数据对象)。正如char类型变量的值是字符,int类型变量的值是整数,指针变量的值是地址,大小为4B。
要创建指针变量,先要声明指针变量的类型。假设想把ptr声明为储存int类型变量地址的指针,就要使用间接运算符:*。
间接运算符:*
1 | int bah = 500; |
scanf("%d", &num) //scanf()读取一个值,然后把该值储存到指定的地址上。
1 |
|
指针和数组
我们的系统中,地址按字节编址,short
类型占用2字节,double
类型占用8字节。在C中,指针加1指的是增加一个存储单元。对数组而言,这意味着把加1后的地址是下一个元素的地址,而不是下一个字节的地址。这是为什么必须声明指针所指向对象类型的原因之一。只知道地址不够,因为计算机要知道储存对象需要多少字节。
1 |
|
对形式参数使用const
如果函数的意图不是修改数组中的数据内容,那么在函数原型和函数定义中声明形式参数时应使用关键字const
。
1 | int sum(const int ar[], int n); /* 函数原型 */ |
多维数组
zippo[2][1] = ((zippo+2) + 1)
zippo[0] = *zippo
zippo[0][0] = *zippo[0] = **zippo = 2
数组的数组
1 | void somefunction( int pt[][4]); |
命令行参数
命令行参数
字符串
什么是字符串
gets()、gets_s()、fgets()、puts()、fputs()、strcat()、strncat()、strcmp()、strncmp()、strcpy()、strncpy()、sprintf()、strchr()
字符串是以空字符(\0)结尾的char类型数组。在指定数组大小时,要确保数组的元素个数至少比字符串长度多1(为了容纳空字符)。
初始化数组
字符串常量属于静态存储类别(static storage class),这说明如果在函数中使用字符串常量,该字符串只会被储存一次,在整个程序的生命期内存在,即使函数被调用多次。
1 |
|
初始化数组把静态存储区的字符串拷贝到数组中,而初始化指针只把字符串的地址拷贝给指针。
字符串中数组和指针的区别
初始化字符数组来储存字符串和初始化指针来指向字符串有何区别(“指向字符串”的意思是指向字符串的首字符)?
例如,假设有下面两个声明:
char heart[] = “I love Tillie!”;
const char *head = “I love Millie!”;
两者主要的区别是:数组名heart是常量,而指针名head是变量,字符串常量属于静态存储类别(static storage class)。
1 |
|
1 | char * word = "frame"; |
这样的语句可能导致内存访问错误。原因前面提到过,编译器可以使用内存中的一个副本来表示所有完全相同的字符串字面量。如果编译器使用这种单次副本表示法,并允许word[1]修改为’1’,那将影响所有使用该字符串的代码。
因此,建议在把指针初始化为字符串字面量时使用const限定符:const char * word = "frame"; // 推荐用法
1 |
|
读入字符串scanf()、gets()和fgets()
为字符串分配内存后,便可读入字符串。C 库提供了许多读取字符串的函数:scanf()、gets()和fgets()。
scanf()和转换说明%s只能读取一个字符。
gets()函数读取整行输入,直至遇到换行符,然后丢弃换行符,储存其余字符,并在这些字符的末尾添加一个空字符’\0’使其成为一个 C 字符串。它经常和 puts()函数配对使用,该函数用于显示字符串,并在末尾添加换行符。
如果输入的字符串过长,会导致缓冲区溢出,该函数的不安全行为造成了安全隐患。【运行时异常】
过去通常用fgets()来代替gets()。C11标准新增的gets_s()函数也可代替gets()。用一个参数限制读入的字符数。
fgets()和fputs()
fgets()函数通过第2个参数限制读入的字符数来解决溢出的问题。该函数专门设计用于处理文件输入,所以一般情况下可能不太好用。fgets()和gets()的区别如下。
fgets()函数的第2个参数指明了读入字符的最大数量。如果该参数的值是n,那么fgets()将读入n-1个字符,或者读到遇到的第一个换行符为止。如果fgets()读到一个换行符,会把它储存在字符串中。这点与gets()不同,gets()会丢弃换行符。
fgets()函数的第3个参数指明要读入的文件。如果读入从键盘输入的数据,则以stdin
(标准输入)作为参数,该标识符定义在stdio.h中。
因为 fgets()函数把换行符放在字符串的末尾(假设输入行不溢出),通常要与 fputs()函数(和puts()类似)配对使用,除非该函数不在字符串末尾添加换行符。fputs()函数的第2个参数指明它要写入的文件。如果要显示在计算机显示器上,应使用stdout
(标准输出)作为该参数。
1 |
|
字符串函数
unsigned long strlen(const char *p);
用以统计字符串的长度。(不包含‘\0’)char *strcat(char *p, const char *q);
用以拼接字符串,返回第一个参数
char *strncat(char *dest, const char *src, size_t count);
用以拼接字符串,返回第一个参数,第三个参数是能够添加字符数的大小int strcmp(const char *cs, const char *ct);
由于非零值都为“真”,while (strcmp(str1, str2)) //当str1和str2不相同时
如果两个字符串相等,则返回0char *strcpy(char *p, const char *q); char *strncpy(char *p, const char *q, unsigned long size);
将str2拷贝到str1中char *strchr(const char * s, int c);
【参数】str 为要查找的字符串,c 为要查找的字符。
【返回值】如果找到指定的字符则返回该字符所在地址,否则返回 NULL。
strchr() 将会找出 str 字符串中第一次出现的字符 c 的地址,然后将该地址返回。1
2
3
4
5
6
7
8
9
10
11
int main() {
char* s = "0123456789012345678901234567890";
char* p = strchr(s, '5');
printf("%p\n", s);
printf("%p\n", p);
return 0;
}int sprintf( char *buffer, const char *format, [ argument] … );
buffer: char型指针,指向将要写入的字符串的缓冲区。
format:格式化字符串。
***[argument]…***:可选参数,可以是任何类型的数据。
return:成功则返回参数buffer
字符串长度,失败则返回-1,错误原因存于errno 中。
1
2
sprintf(s, "%d", 123); //把整数123打印成一个字符串保存在s中
sprintf(buf, "The length of the string is more than %d\n", 10);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main(void)
{
char str[100];
int offset = 0;
int i = 0;
srand(time(0)); // *随机种子
for (i = 0; i < 10; i++)
{
offset += sprintf(str + offset, "%d,", rand() % 100); // 格式化的数据写入字符串
}
str[offset - 1] = '\0';
printf(str);
return 0;
}
1 |
|
存储类别、链接和内存管理
存储类别
从硬件方面来看,被储存的每个值都占用一定的物理内存,C 语言把这样的一块内存称为对象(object)。对象可以储存一个或多个值。一个对象可能并未储存实际的值,但是它在储存适当的值时一定具有相应的大小。(面向对象编程中的对象指的是类对象,其定义包括数据和允许对数据进行的操作,C不是面向对象编程语言)
从软件方面来看,程序需要一种方法访问对象。这可以通过声明变量来完成:
1 | int entity = 3; |
程序根据该声明把相应的字符串字面量储存在内存中,内含这些字符值的数组就是一个对象。由于数组中的每个字符都能被单独访问,所以每个字符也是一个对象。该声明还创建了一个标识符为pc的对象,储存着字符串的地址。由于可以设置pc重新指向其他字符串,所以标识符pc是一个可修改的左值。const只能保证被pc指向的字符串内容不被修改,但是无法保证pc不指向别的字符串。
可以用存储期(storage duration)描述对象,所谓存储期是指对象在内存中保留了多长时间。标识符用于访问对象,可以用作用域(scope)和链接(linkage)描述标识符,标识符的作用域和链接表明了程序的哪些部分可以使用它。不同的存储类别具有不同的存储期、作用域和链接。标识符可以在源代码的多文件中共享、可用于特定文件的任意函数中、可仅限于特定函数中使用,甚至只在函数中的某部分使用。对象可存在于程序的执行期,也可以仅存在于它所在函数的执行期。对于并发编程,对象可以在特定线程的
执行期存在。可以通过函数调用的方式显式分配和释放内存。
链接
C 变量有 3 种链接属性:外部链接、内部链接或无链接。具有文件作用域的变量可以是外部链接或内部链接。外部链接变量可以在多文件程序中使用,内部链接变量只能在一个翻译单元中使用。
C 标准用“内部链接的文件作用域”描述仅限于一个翻译单元(即一个源代码文件和它所包含的头文件)的作用域,用“外部链接的文件作用域”描述可延伸至其他翻译单元的作用域。
一些程序员把“内部链接的文件作用域”简称为“文件作用域”,把“外部链接的文件作用域”简称为“全局作用域”或“程序作用域”。
1 | int giants = 5; // 文件作用域,外部链接 |
存储期
C对象有4种存储期:静态存储期、线程存储期、自动存储期、动态分配存储期。
- 静态存储期:它在程序的执行期间一直存在。无论是内部链接(static)还是外部链接,所有的文件作用域变量都具有静态存储期。
- 线程存储期:用于并发程序设计,程序执行可被分为多个线程。具有线程存储期的对象,从被声明(_Thread_local)时到线程结束一直存在。
- 自动存储期:局部变量。块作用域的变量通常都具有自动存储期,当程序进入定义这些变量的块时,为这些变量分配内存;当退出这个块时,释放刚才为变量分配的内存。
- 动态分配存储期:malloc()、free()。
静态变量
块作用域的静态变量
“局部静态变量”是描述具有块作用域的静态变量的另一个术语。
1 |
|
内部链接的静态变量
普通的外部变量可用于同一程序中任意文件中的函数,但是内部链接的静态变量只能用于同一个文件中的函数。
1 | static int svil = 1; // 静态变量,内部链接 |
C通过在一个文件中进行定义式声明,然后在其他文件中进行引用式声明来实现共享。也就是说,除了一个定义式声明外,其他声明都要使用extern关键字。而且,只有定义式声明才能初始化变量。
外部链接的静态变量
把变量的定义性声明(defining declaration)放在在所有函数的外面便创建了外部变量。当然,为了指出该函数使用了外部变量,可以在函数中用关键字extern
再次声明。
1 | int Errupt; /* 外部定义的变量 ,定义式声明(defining declaration)*/ |
外部变量只能初始化一次,且必须在定义该变量时进行,只有定义式声明才能初始化变量。
1 | /* file1.c */ |
存储类别说明符
C语言有6个关键字作为存储类别说明符:auto
、register
、static
、extern
、_Thread_local
和typedef
。
下面用一个简短的程序使用了5种存储类别。
1 | // parta.c --- 不同的存储类别 |
1 | // partb.c -- 程序的其余部分 |
存储类别和函数
函数也有存储类别,可以是外部函数(默认)或静态函数。
1 | double gamma(double); /* 该函数默认为外部函数 */ |
在同一个程序中,其他文件中的函数可以调用gamma()和delta(),但是不能调用beta(),因为以static存储类别说明符创建的函数属于特定模块私有。这样做避免了名称冲突的问题,由于beta()受限于它所在的文件,所以在其他文件中可以使用与之同名的函数。
通常的做法是:用 extern 关键字声明定义在其他文件中的函数。这样做是为了表明当前文件中使用的函数被定义在别处。除非使用static关键字,否则一般函数声明都默认为extern。
随机数函数和静态变量
ANSI C库提供了rand()
函数生成随机数。生成随机数有多种算法,ANSI C允许C实现针对特定机器使用最佳算法。然而,ANSI C标准还提供了一个可移植的标准算法,在不同系统中生成相同的随机数。实际上,rand(
)是“伪随机数生成器”,意思是可预测生成数字的实际序列。但是,数字在其取值范围内均匀分布。
可移植版本的方案开始于一个“种子”数字。该函数使用该种子生成新的数,这个新数又成为新的种子。然后,新种子可用于生成更新的种子,以此类推。
1 | /* rand0.c --生成随机数*/ |
问题:伪随机,每次运行的结果都相同。
改进:可以引入另一个函数srand1()重置种子来解决这个问题。关键是要让next成为只供rand1()和srand1()访问的内部链接静态变量(srand1()相当于C库中的srand()函数)。
1 | /* s_and_r.c -- 包含 rand1() 和 srand1() 的文件 */ |
如果 C 实现允许访问一些可变的量(如,时钟系统),可以用这些值(可能会被截断)初始化种子值。
1 |
|
掷骰子
1 | //diceroll.h |
分配内存:malloc()和free()
malloc()分配内存,但是不会为其赋名。然而,它确实返回动态分配内存块的首字节地址。从ANSI C标准开始,C使用一个新的类型:指向void的指针。该类型相当于一个“通用指针”。malloc()函数可用于返回指向数组的指针、指向结构的指针等,所以通常该函数的返回值会被强制转换为匹配的类型。在ANSI C中,应该坚持使用强制类型转换,提高代码的可读性。然而,把指向 void的指针赋给任意类型的指针完全不用考虑类型匹配的问题。如果 malloc()分配内存失败,将返回空指针。
1 | double * ptd; |
通常,malloc()要与free()配套使用。free()函数的参数是之前malloc()返回的地址,该函数释放之前malloc()分配的内存。
设想malloc()和free()管理着一个内存池。每次调用malloc()分配内存给程序使用,每次调用free()把内存归还内存池中,这样便可重复使用这些内存。free()的参数应该是一个指针,指向由 malloc()分配的一块内存。
不能用 free()释放通过其他方式(如,声明一个数组)分配的内存。malloc()和free()的原型都在stdlib.h头文件中。
calloc()函数
1 | long * newmem; |
变长数组
1 | int n; |
变长二位数组
正确的做法是先分配行,再分配列。
释放内存的时候,要先释放列,再释放行。
注意,顺序反了的话,会把列的地址擦除,导致释放列时内存时找不到地址,程序崩溃。
正确的分配空间代码如下:
1 |
|
这种分配方式得到的其实并不是真正意义上的二维数组,因为其行与行之间的内存并不连续,虽然可以用下标arr[i][j]的方式访问,但当用指向该二维数组的指针来访问时候,不能通过指针值的增加来跨行获取元素,不过这种情况一般用的也不多,因此上述分配方式在大多数情况下的操作都能得到正确的结果。
ANSI C类型限定符
const类型限定符
以const关键字声明的对象,其值不能通过赋值或递增、递减来修改。
1 | const int nochange = 12; |
就近原则:const 跟 float 还是 * 更近。
创建了 pf 指向的值不能被改变,而 pf 本身的值可以改变。
创建的指针pt本身的值不能更改。pt必须指向同一个地址,但是它所指向的值可以改变。
const放在左侧任意位置,限定了指针指向的数据不能改变;const放在的右侧,限定了指针本身不能改变。
volatile类型限定符
1、为什么用volatile?
volatile 限定符告知计算机,代理(而不是变量所在的程序)可以改变该变量的值。通常,它被用于硬件地址以及在其他程序或同时运行的线程中共享数据。例如,一个地址上可能储存着当前的时钟时间,无论程序做什么,地址上的值都随时间的变化而改变。或者一个地址用于接受另一台计算机传入的信息。
1 | vall = x; |
现在,如果声明中没有volatile关键字,编译器会假定变量的值在使用过程中不变,然后再尝试优化代码智能的(进行优化的)编译器会注意到以上代码使用了两次 x,但并未改变它的值。于是编译器把 x的值临时储存在寄存器中,然后在val2需要使用x时,才从寄存器中(而不是从原始内存位置上)读取x的值,以节约时间。这个过程被称为高速缓存(caching)。
volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。声明时语法:int volatile vInt; 当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。而且读取的数据立刻被保存。例如:
1 | volatile int i=10; |
volatile 指出 i 是随时可能发生变化的,每次使用它的时候必须从 i的地址中(内存)读取,因而编译器生成的汇编代码会重新从i的地址读取数据放在 b 中。volatile 可以保证对特殊地址的稳定访问。
1 |
|
一般说来,volatile用在如下的几个地方:
- 中断服务程序中修改的供其它程序检测的变量需要加 volatile;
- 多任务环境下各任务间共享的标志应该加 volatile;
- 存储器映射的硬件寄存器通常也要加 volatile 说明,因为每次对它的读写都可能由不同意义;
可以同时用const和volatile限定一个值。例如,通常用const把硬件时钟设置为程序不能更改的变量,但是可以通过代理改变,这时用 volatile。
1 | volatile const int loc; |
restrict类型限定符
restrict 关键字允许编译器优化某部分代码以更好地支持计算。它只能用于指针,表明该指针是访问数据对象的唯一且初始的方式。
1 | int ar[10]; |
由于之前声明了 restar 是访问它所指向的数据块的唯一且初始的方式,编译器可以把涉及 restar的两条语句替换成下面这条语句,效果相同:restar[n] += 8; /* 可以进行替换 */
restrict 限定符还可用于函数形参中的指针。这意味着编译器可以假定在函数体内其他标识符不会修改该指针指向的数据,而且编译器可以尝试对其优化,使其不做别的用途。例如,C 库有两个函数用于把一个位置上的字节拷贝到另一个位置。在C99中,这两个函数的原型是:
1 | void * memcpy(void * restrict s1, const void * restrict s2, size_t n); |
_Atomic类型限定符(C11)
并发程序设计把程序执行分成可以同时执行的多个线程。这给程序设计带来了新的挑战,包括如何管理访问相同数据的不同线程。C11通过包含可选的头文件stdatomic.h和threads.h,提供了一些可选的(不是必须实现的)管理方法。值得注意的是,要通过各种宏函数来访问原子类型。当一个线程对一个原子类型的对象执行原子操作时,其他线程不能访问该对象。
1 | _Atomic int hogs; // hogs 是一个原子类型的变量 |
文件输入/输出
函数:fopen()、getc()、putc()、exit()、fclose()
fprintf()、fscanf()、fgets()、fputs()
rewind()、fseek()、ftell()、fflush()
fgetpos()、fsetpos()、feof()、ferror()
ungetc()、setvbuf()、fread()、fwrite()
C程序把输入看作是字节流(文本流或二进制流),输入流来源于文件、输入设备(如键盘),或者甚至是另一个程序的输出。类似地,C程序把输出也看作是字节流,输出流的目的地可以是文件、视频显示等。
与文件进行通信
二进制模式和文本模式
C 提供两种访问文件的途径:二进制模式和文本模式。在二进制模式中,程序可以访问文件的每个字节。而在文本模式中,程序所见的内容和文件的实际内容不同。
I/O的级别
可以选择I/O的两个级别(即处理文件访问的两个级别)。底层I/O(low-level I/O)使用操作系统提供的
基本I/O服务。标准高级I/O(standard high-level I/O)使用C库的标准包和stdio.h头文件定义。因为无法保证所有的操作系统都使用相同的底层I/O模型,C标准只支持标准I/O包。有些实现会提供底层库,但是C标准建立了可移植的I/O模型,我们主要讨论这些I/O。
标准文件
C程序会自动打开3个文件,它们被称为标准输入(standard input)、标准输出(standard output)和标准错误输出(standard error output)。在默认情况下,标准输入是系统的普通输入设备,通常为键盘;标准输出和标准错误输出是系统的普通输出设备,通常为显示屏。
通常,标准输入为程序提供输入,它是 getchar()和 scanf()使用的文件。程序通常输出到标准输出,它是putchar()、puts()和printf()使用的文件。
标准I/O
与底层I/O相比,标准I/O包除了可移植以外还有两个好处。第一,标准I/O有许多专门的函数简化了处理不同I/O的问题。例如,printf()把不同形式的数据转换成与终端相适应的字符串输出。第二,输入和输出都是缓冲的。也就是说,一次转移一大块信息而不是一字节信息(通常至少512字节)。
1 | /* count.c -- 使用标准 I/O */ |
getc()和putc()函数
1 | ch = getchar(); //“从标准输入中获取一个字符”: |
文件结尾
getc()函数在读取一个字符时发现是文件结尾,它将返回一个特殊值EOF。
fclose()函数
如果成功关闭,fclose()函数返回0,否则返回EOF:
1 | if (fclose(fp) != 0) |
指向标准文件的指针
stdio.h头文件把3个文件指针与3个标准文件相关联,C程序会自动打开这3个标准文件。
这些文件指针都是指向FILE的指针,所以它们可用作标准I/O函数的参数,如fclose(fp)中的fp。
一个简单的文件压缩程序
问题描述:把一个文件中选定的数据拷贝到另一个文件中。该程序同时打开了两个文件,以”r”模式打开一个,以”w”模式打开另一个。以保留每3个字符中的第1个字符的方式压缩第1个文件的内容。最后,把压缩后的文本存入第2个文件。第2个文件的名称是第1个文件名加上.red后缀。
1 | // reducto.c –把文件压缩成原来的1/3! |
文件I/O:fprintf()、fscanf()、fgets()和fputs()
I/O函数都类似于文件I/O函数。它们的主要区别是,文件I/O函数要用FILE指针指定待处理的文件。与 getc()、putc()类似,这些函数都要求用指向 FILE 的指针(如,stdout)指定一个文件,或者使用fopen()的返回值。
1 | fprintf(stdout, "Can't open \"wordy\" file.\n"); |
fgets(buf, STLEN, fp);
输入函数,读操作,buf是char类型数组的名称,STLEN是字符串的大小,fp是指向FILE的指针。
fputs(buf, fp);
输出函数,写操作,这里,buf是字符串的地址,fp用于指定目标文件。
ch = getc(fp); //输入流→输入缓冲区
putchar(ch);
putc(ch,stdout); //输出缓冲区→输出流
随机访问:fseek()和ftell()
有了 fseek()函数,便可把文件看作是数组,在 fopen()打开的文件中直接移动到任意字节处。
fseek(fp, offset, SEEK_MODE);
fseek()的第1个参数是FILE指针,指向待查找的文件,fopen()应该已打开该文件。
fseek()的第2个参数是偏移量(offset)。该参数表示从起始点开始要移动的距离(参见表13.3列出的起始点模式)。该参数必须是一个long类型的值,可以为正(前移)、负(后移)或0(保持不动)
fseek()的第3个参数是模式,该参数确定起始点。
如果一切正常,fseek()的返回值为0;如果出现错误(如试图移动的距离超出文件的范围),其返回值为-1。
1 | fseek(fp, 0L, SEEK_SET); // 定位至文件开始处 |
example:
1 | fseek(fp, 0L, SEEK_END); // 把当前位置设置为距文件末尾 0 字节偏移量。 |
fgetpos()和fsetpos()函数
ANSI C新增了两个处理较大文件的新定位函数:fgetpos()和 fsetpos()。这两个函数不使用 long 类型的值表示位置,它们使用一种新类型:fpos_t(代表file, position, type,文件定位类型)。fpos_t类型不是基本类型,它根据其他类型来定义。
int fgetpos(FILE * restrict stream, fpos_t * restrict pos);
调用该函数时,它把fpos_t类型的值放在pos指向的位置上,该值描述了文件中的一个位置。如果成功,fgetpos()函数返回0;如果失败,返回非0。
int fsetpos(FILE *stream, const fpos_t *pos);
调用该函数时,使用pos指向位置上的fpos_t类型值来设置文件指针指向该值指定的位置。如果成功,fsetpos()函数返回0;如果失败,则返回非0。fpos_t类型的值应通过之前调用fgetpos()获得。
标准I/O的机理
调用fopen()打开文件
fopen()函数不仅打开一个文件,还创建了一个缓冲区(在读写模式下会创建两个缓冲区)以及一个包含文件和缓冲区数据的结构。另外,fopen()返回一个指向该结构的指针,以便其他函数知道如何找到该结构。
文件输入:调用fscanf()、getc()或 fgets()将输入流拷贝到输入缓冲区
调用这些函数,文件中的数据块就被拷贝到缓冲区中。缓冲区的大小因实现而异,一般是512字节或是它的倍数,如4096。最初调用函数,除了填充缓冲区外,还要设置fp所指向的结构中的值。尤其要设置流中的当前位置和拷贝进缓冲区的字节数。通常,当前位置从字节0开始。
当输入函数发现已读完缓冲区中的所有字符时,会请求把下一个缓冲大小的数据块从文件拷贝到该缓冲区中。
文件输出:调用fprinf()、putc()或 fputs()将输出缓冲区拷贝到输出流
输出函数以类似的方式把数据写入缓冲区。当缓冲区被填满时,数据将被拷贝至文件中。
ungetc()、fllush()、setvbuf()函数
int ungetc()函数把c指定的字符放回输入流中。如果把一个字符放回输入流,下次调用标准输入函数时将读取该字符。
int ungetc(int c, FILE *fp);
int fflush(FILE *fp);
调用fflush()函数引起输出缓冲区中所有的未写入数据被发送到fp指定的输出文件。这个过程称为刷新缓冲区。只要最近一次操作不是输入操作,就可以用该函数来更新流(任何读写模式)。
int setvbuf(FILE * restrict fp, char * restrict buf, int mode, size_t size);
setvbuf()函数创建了一个供标准I/O函数替换使用的缓冲区。在打开文件后且未对流进行其他操作之前,调用该函数。
假设一个程序要储存一种数据对象,每个数据对象的大小是3000字节。可以使用setvbuf()函数创建一个缓冲区,其大小是该数据对象大小的倍数。
二进制I/O:fread()和fwrite()
为什么需要用二进制文件存储?
之前用到的标准I/O函数都是面向文本的,用于处理字符和字符串。如何要在文件中保存数值数据?用 fprintf()函数和%f转换说明只是把数值保存为字符串。例如:
1 | double num = 1./3.; |
把num储存为8个字符:0.333333。
为保证数值在储存前后一致,最精确的做法是使用与计算机相同的位组合来储存。因此,double 类型的值应该储存在一个 double 大小的单元中。如果以程序所用的表示法把数据储存在文件中,则称以二进制形式储存数据。这样就不存在从数值形式到字符串的转换过程。
实际上,所有的数据都是以二进制形式储存的,甚至连字符都以字符码的二进制表示来储存。如果文件中的所有数据都被解释成字符码,则称该文件包含文本数据。如果部分或所有的数据都被解释成二进制形式的数值数据,则称该文件包含二进制数据(另外,用数据表示机器语言指令的文件都是二进制文件)
fwrite()函数
size_t fwrite(const void * restrict ptr, size_t size, size_t nmemb,FILE * restrict fp);
fwrite()函数把二进制数据写入文件。fwrite()函数返回成功写入项的数量。正常情况下,该返回值就是nmemb,但如果出现写入错误,返回值会比nmemb小。
1 | char buffer[256]; |
fwrite()原型中的const void * restrict ptr声明。fwrite()的一个问题是,它的第1个参数不是固定的类型。例如,第1个例子中使用buffer,其类型是指向char的指针;而第2个例子中使用earnings,其类型是指向double的指针。在ANSI C函数原型中,这些实际参数都被转换成指向void的指针类型,这种指针可作为一种通用类型指针(在ANSI C之前,这些参数使用char*类型,需要把实参强制转换成char *类型)。
fread()函数
size_t fread(void * restrict ptr, size_t size, size_t nmemb,FILE * restrict fp);
fwrite()函数把文件中的二进制数据读入数据指针。ffread()函数返回成功读取项的数量。正常情况下,该返回值就是nmemb,但如果出现读取错误或读到文件结尾,该返回值就会比nmemb小。
1 | double earnings[10]; |
如果标准输入函数返回 EOF,则通常表明函数已到达文件结尾。然而,出现读取错误时,函数也会返回EOF。feof()和ferror()函数用于区分这两种情况。当上一次输入调用检测到文件结尾时,feof()函数返回一个非零值,否则返回0。当读或写出现错误,ferror()函数返回一个非零值,否则返回0。
程序:使用fread()和fwrite()函数进行拷贝
1 | /* append.c -- 把文件附加到另一个文件末尾 */ |
程序:用二进制I/O进行随机访问
1 | /* randbin.c -- 用二进制I/O进行随机访问 */ |
结构和其他数据形式
关键字:struct、union、typedef
运算符:.和->
1 | //* book.c -- 一本书的图书目录 */ |
C 库函数 char *fgets(char *str, int n, FILE *stream) 从指定的流 stream 读取一行,并把它存储在 str 所指向的字符串内。当读取 (n-1) 个字符时,或者读取到换行符时,或者到达文件末尾时,它会停止,具体视情况而定。
参考:C 库函数 - fgets()
定义结构变量
初始化结构
初始化变量和数组如下:
1 | int count = 0; |
结构变量是否也可以这样初始化?是的,可以。初始化一个结构变量与初始化数组的语法类似:
1 | struct book {/* 结构模版:标记是 book */ |
访问结构成员
1 | struct book bill; |
其他结构特性
1 | o_data = n_data; // 把一个结构赋值给另一个结构 |
结构中的字符数组和字符指针
1 |
|
对于struct names类型的结构变量veep,以上字符串都储存在结构内部,结构总共要分配40字节储存姓名。然而,对于struct pnames类型的结构变量treas,以上字符串储存在编译器储存常量的地方。结构本身只储存了两个地址,在我们的系统中共占16字节(一个指针在64位的计算机上,占8个字节;一个指针在32位的计算机上,占4个字节)。
如果要用结构储存字符串,用字符数组作为成员比较简单。用指向 char 的指针也行,但是误用会导致严重的问题。
如果使用malloc()分配内存并使用指针储存该地址,那么在结构中使用指针处理字符串就比较合理。
1 | struct namect { |
复合字面量
1 | (struct book) {"The Idiot", "Fyodor Dostoyevsky", 6.99} |
伸缩型数组成员(flexible array member)
利用这项特性声明的结构,其最后一个数组成员具有一些特性。第1个特性是,该数组不会立即存在。第2个特性是,使用这个伸缩型数组成员可以编写合适的代码,就好像它确实存在并具有所需数目的元素一样。
1 | struct flex |
带伸缩型数组成员的结构确实有一些特殊的处理要求。第一,不能用结构进行赋值或拷贝:
*pf2 = *pf1; // 不要这样做
这样做只能拷贝除伸缩型数组成员以外的其他成员。确实要进行拷贝,应使用memcpy()函数。
第二,不要以按值方式把这种结构传递给结构。原因相同,按值传递一个参数与赋值类似。要把结构的地址传递给函数。
第三,不要使用带伸缩型数组成员的结构作为数组成员或另一个结构的成员。
匿名结构(C11)
1 | struct person |
把结构内容保存到文件中
储存记录最没效率的方法是用fprintf()
fprintf(pbooks, "%s %s %.2f\n", primer.title,primer.author, primer.value);
更好的方案是使用fread()和fwrite()函数读写结构大小的单元。这两个函数使用与程序相同的二进制表示法。
fwrite(&primer, sizeof(struct book), 1, pbooks);
缺点移植性较差。以二进制表示法储存数据的缺点是,不同的系统可能使用不同的二进制表示法,所以数据文件可能不具可移植性。甚至同一个系统,不同编译器设置也可能导致不同的二进制布局。
联合简介
联合(union)是一种数据类型,它能在同一个内存空间中储存不同的数据类型(不是同时储存)。其典型的用法是,设计一种表以储存既无规律、事先也不知道顺序的混合类型。使用联合类型的数组,其中的联合都大小相等,每个联合可以储存各种数据类型。
根据以下形式声明的结构可以储存一个int类型、一个double类型和char类型的值。然而,声明的联合只能储存一个int类型的值或一个double类型的值或char类型的值。
声明时,编译器分配足够的空间以便它能储存联合声明中占用最大字节的类型。其中double类型占64位,即8字节。
1 | //联合定义 |
匿名联合(C11)
匿名联合和匿名结构的工作原理相同,即匿名联合是一个结构或联合的无名联合成员。
1 | struct owner { |
如果flits是car_data类型的结构变量,通过以下方式访问
flits.owncar.socsecurity
枚举类型
可以用枚举类型(enumerated type)声明符号名称来表示整型常量。使用enum关键字,可以创建一个新“类型”并指定它可具有的值(实际上,enum常量是int类型,因此,只要能使用int类型的地方就可以使用枚举类型)。枚举类型的目的是提高程序的可读性。它的语法与结构的语法相同。例如,可以这样声明:
1 | enum spectrum {red, orange, yellow, green, blue, violet}; //0~5 |
C枚举的一些特性并不适用于C++。例如,C允许枚举变量使用++运算符,但是C++标准不允许。
赋值
1 | enum levels {low = 100, medium = 500, high = 2000}; |
枚举类型的目的是为了提高程序的可读性和可维护性。
因为枚举类型是整数类型,所以可以在表达式中以使用整数变量的方式使用enum变量。它们用在case语句中很方便。
共享名称空间
C语言使用名称空间(namespace)标识程序中的各部分,即通过名称来识别。作用域是名称空间概念的一部分:两个不同作用域的同名变量不冲突;两个相同作用域的同名变量冲突。
在特定作用域中的结构标记、联合标记和枚举标记都共享相同的名称空间,该名称空间与普通变量使用的空间不同。这意味着在相同作用域中变量和标记的名称可以相同,不会引起冲突,但是不能在相同作用域中声明两个同名标签或同名变量。
1 | struct rect { double x; double y; }; |
尽管如此,以两种不同的方式使用相同的标识符会造成混乱。另外,C++不允许这样做,因为它把标记名和变量名放在相同的名称空间中。
typedef简介
利用typedef可以为某一类型自定义名称。这方面与#define类似,但是两者有3处不同:
- 与#define不同,typedef创建的符号名只受限于类型,不能用于值。
- typedef由编译器解释,不是预处理器。
- 在其受限范围内,typedef比#define更灵活。
1 | typedef unsigned char BYTE; |
使用typedef的第1个原因是:为经常出现的类型创建一个方便、易识别的类型名。
使用typedef的第2个原因是:typedef常用于给复杂的类型命名。
通过结构、联合和typedef,C提供了有效处理数据的工具和处理可移植数据的工具。
其他复杂的声明
1 | int board[8][8]; // 声明一个内含int数组的数组 |
- 数组名后面的[]和函数名后面的()具有相同的优先级。它们比*(解引用运算符)的优先级高。
- []和()的优先级相同,且都是从左往右结合
函数和指针
1 | void ToUpper(char *); // 把字符串中的字符转换成大写字符 |
1 | void ToUpper(char *); |
1 | void ToUpper(char *); |
位操作
运算符:~、&、|、^、
<<、>>
&=、|=、^=、>>=、<<=
处理一个值中的位的两个C工具:位运算符和位字段
关键字:_Alignas、_Alignof
按位运算符
逻辑运算符、移位运算符
按位逻辑运算符
二进制反码或按位取反:~
1
newval = ~val;
按位与:&(1&1=1,其余为0)
1
2val &= 0377;
val = val & 0377;按位或: |(0|0=0,其余为1)
1
2val |= 0377;
val = val | 0377;按位异或:^(0^1=1; 1^0=1)
1
2val ^= 0377;
val = val ^ 0377;
用法:掩码&
按位与(&)运算符常用于掩码(mask)。所谓掩码指的是一些设置为开(1)或关(0)的位组合。
例如,假设定义符号常量MASK为2 (即,二进制形式为00000010),
1 | flags = flags & MASK; //把flags中除1号位以外的所有位都设置为0(值小于MASK、flags) |
把掩码中的0看作不透明,1看作透明。表达式flags&MASK
相当于用掩码覆盖在flags的位组合上,只有MASK为1的位才可见。
用法:打开位(设置位)|
有时,需要打开一个值中的特定位,同时保持其他位不变。例如,一台IBM PC 通过向端口发送值来控制硬件。例如,为了打开内置扬声器,必须打开 1 号位,同时保持其他位不变。这种情况可以使用按位或运算符(|)。
1 | flags = flags | MASK; //把flags的1号位设置为1,且其他位不变。(值大于MASK、flags) |
用法:关闭位(清空位)&~
和打开特定的位类似,有时也需要在不影响其他位的情况下关闭指定的位。假设要关闭变量flags中的1号位。同样,MASK只有1号位为1(即,打开)。可以这样做:
1 | flags = flags & ~MASK; |
例如:假设flags是00001111,MASK是10110110。
~MASK=01001001,flags & ~MASK = 00001001
用法:切换位^
切换位指的是打开已关闭的位,或关闭已打开的位。可以使用按位异或运算符(^)切换位。
如果使用^组合一个值和一个掩码,将切换该值与MASK为1的位相对应的位,该值与MASK为0的位相对应的位不变。
1 | flags = flags ^ MASK; |
例如:(00001111) ^ (10110110) // 表达式
其结果为:(10111001) // 结果值
用法:检查位的值
有时,需要检查某位的值。例如,flags中1号位是否被设置为1?
1 | if ((flags & MASK) == MASK) |
位移运算符
左移:<<
左移运算符(<<)将其左侧运算对象每一位的值向左移动其右侧运算对象指定的位数。左侧运算对象移出左末端位的值丢失,用0填充空出的位置。
1000 1000<<1 = 0010 000
1 | int stonk = 1; |
右移:>>
右移运算符(>>)将其左侧运算对象每一位的值向右移动其右侧运算对象指定的位数。左侧运算对象移出右末端位的值丢。对于无符号类型,用0填充空出的位置;对于有符号类型,其结果取决于机器(空出的位置可用0填充,或者用符号位的副本填充)。
下面是有符号值的例子:
(10001010) >> 2 // 表达式,有符号值
(00100010) // 在某些系统中的结果值
(10001010) >> 2 // 表达式,有符号值
(11100010) // 在另一些系统上的结果值
下面是无符号值的例子:
(10001010) >> 2 // 表达式,无符号值
(00100010) // 所有系统都得到该结果值
1 | int sweet = 16; |
用法:移位运算符
移位运算符针对2的幂提供快速有效的乘法和除法:
number << n number乘以2的n次幂
number >> n 如果number为非负,则用number除以2的n次幂
位字段
操控位的第2种方法是位字段(bit field)。位字段是一个signed int或unsigned int类型变量中的一组相邻的位(C99和C11新增了**_Bool类型**的位字段)。位字段通过一个结构声明来建立,该结构声明为每个字段提供标签,并确定该字段的宽度。
1 | struct { |
如果声明的总位数超过了一个unsigned int类型的大小会怎样?
会用到下一个unsigned int类型的存储位置。一个字段不允许跨越两个unsigned int之间的边界。编译器会自动移动跨界的字段,保持unsigned int的边界对齐。一旦发生这种情况,第1个unsigned int中会留下一个未命名的“洞”。
1 | struct { |
这里,在stuff.field1和stuff.field2之间,有一个2位的空隙;stuff.field3将储存在下一个unsigned int中。
位字段示例
我们假设方框具有如下属性:
方框是透明的或不透明的;
方框的填充色选自以下调色板:黑色、红色、绿色、黄色、蓝色、紫色、青色或白色;
边框可见或隐藏;
边框颜色与填充色使用相同的调色板;
边框可以使用实线、点线或虚线样式。
1 |
|
加上未命名的字段,该结构共占用 16 位。如果不使用填充,该结构占用 10 位。C语言以unsigned int作为位字段结构的基本布局单元。(unsigned int是32位)
对齐特性(C11)
C11 的对齐特性比用位填充字节更自然,它们还代表了C在处理硬件相关问题上的能力。在这种上下文中,对齐指的是如何安排对象在内存中的位置。
例如,把数据从一个硬件位置转移到另一个位置,或者调用指令同时操作多个数据项。
_Alignof
运算符给出一个类型的对齐要求,在关键字_Alignof后面的圆括号中写上类型名即可:
1 | size_t d_align = _Alignof(float); |
1 |
|
C11在stdlib.h库还添加了一个新的内存分配函数,用于对齐动态分配的内存。该函数的原型如下:void *aligned_alloc(size_t alignment, size_t size);
第1个参数代表指定的对齐,第2个参数是所需的字节数,其值应是第1个参数的倍数。与其他内存分配函数一样,要使用free()函数释放之前分配的内存。
C预处理器和C库
预处理指令:#define、#include、#ifdef、#else、#endif、#ifndef、#if、#elif、#line、#error、#pragma
关键字:_Generic、_Noreturn、_Static_assert
函数/宏:sqrt()、atan()、atan2()、exit()、atexit()、assert()、memcpy()、memmove()、va_start()、va_arg()、va_copy()、va_end()
C语言建立在适当的关键字、表达式、语句以及使用它们的规则上。然而,C标准不仅描述C语言,还描述如何执行C预处理器、C标准库有哪些函数,以及详述这些函数的工作原理。
GCC
预处理——编译——汇编——链接
使用C语编写test程序的源代码文件test.c过程:
首先进入GCC的预编译器进行预处理,对头文件、宏定义等进行展开,生成test.i文件;然后进入GCC的编译器,编译完生成汇编程序test.s;然后调用汇编器进行汇编,生成可重定位的目标程序test.o;最后调用链接器,将所有目标文件和C语言库链接成可执行的二进制文件。
预处理器
C预处理器在程序执行之前查看程序。根据程序中的预处理器指令,预处理器把符号缩写替换成其表示的内容。
翻译程序的第一步
在预处理之前,编译器必须对该程序进行一些翻译处理。
Step1: 编译器把源代码中出现的字符映射到源字符集。该过程处理多字节字符集或Unicode字符集——字符扩展让C更加国际化(要在编译器中设置相关选项才能激活这个特性)
Step2: 编译器定位每个反斜杠后面跟着换行符的实例,并删除它们。也就是说,把下面两个物理行(physical line):
1 | printf("That's wond\ |
将变成:
1 | printf("That's wonderful\n!"); |
Step3: 编译器把文本划分成预处理记号序列、空白序列和注释序列。编译器将用一个空格字符替换每一条注释。
1 | int/* 这看起来并不像一个空格*/fox; |
将变成:
1 | int fox; |
最后,程序已经准备好进入预处理阶段,预处理器查找一行中以#号开始的预处理指令。
明示常量:#define
1 |
|
将变为:
1 | int main(void) |
记号
从技术角度来看,可以把宏的替换体看作是记号(token)型字符串,而不是字符型字符串。
重定义常量
假设先把LIMIT定义为20,稍后在该文件中又把它定义为25。这个过程称为重定义常量。
如果需要重定义宏,使用#undef
指令(稍后讨论)。
如果确实需要重定义常量,使用const关键字和作用域规则更容易些。
在#define中使用参数
1 |
预处理器黏合剂:##运算符
1 |
|
变参宏:…和__VA_ARGS__
一些函数(如 printf())接受数量可变的参数。stdvar.h
头文件提供了工具,让用户自定义带可变参数的函数。C99/C11也对宏提供了这样的工具。
通过把宏参数列表中最后的参数写成省略号(...
)来实现这一功能。这样,预定义宏__VA_ARGS__
可用在替换部分中,表明省略号代表什么。
1 |
|
程序:
1 |
|
宏和函数的选择
宏的一个优点是,不用担心变量类型
1 |
文件包含:#include
当预处理器发现#include
指令时,会查看后面的文件名并把文件的内容包含到当前文件中,即替换源文件中的#include指令。
这相当于把被包含文件的全部内容输入到源文件#include
指令所在的位置。#include
指令有两种形式:#include <stdio.h>
←查找系统目录#include "hot.h"
←查找当前工作目录#include "/usr/biff/p.h"
←查找/usr/biff目录
头文件中最常用的形式如下。
- 明示常量——例如,stdio.h中定义的EOF、NULL和BUFSIZE(标准I/O缓冲区大小)。
- 宏函数——例如,getc(stdin)通常用getchar()定义,而getc()经常用于定义较复杂的宏,头文件ctype.h通常包含ctype系列函数的宏定义。
- 函数声明——例如,string.h头文件(一些旧的系统中是strings.h)包含字符串函数系列的函数声明。在ANSI C和后面的标准中,函数声明都是函数原型形式。
- 结构模版定义——标准I/O函数使用FILE结构,该结构中包含了文件和与文件缓冲区相关的信息。FILE结构在头文件stdio.h中。
- 类型定义——标准 I/O 函数使用指向 FILE 的指针作为参数。通常,stdio.h 用#define 或typedef把FILE定义为指向结构的指针。类似地,size_t和time_t类型也定义在头文件中。
- 外部变量——还可以使用头文件声明外部变量供其他文件共享。
其他指令
程序员可能要为不同的工作环境准备C程序和C库包。不同的环境可能使用不同的代码类型。预处理器提供一些指令,程序员通过修改#define的值即可生成可移植的代码。#undef
指令取消之前的#define
定义。#if
、#ifdef
、#ifndef
、#else
、#elif
和#endif
指令用于指定什么情况下编写哪些代码。#line
指令用于重置行和文件信息,#error
指令用于给出错误消息,#pragma
指令用于向编译器发出指令。
#undef指令
#undef
指令用于“取消”已定义的#define
指令。
1 |
|
从C预处理器角度看已定义
当预处理器在预处理器指令中发现一个标识符时,它会把该标识符当作已定义的或未定义的。
1 |
|
条件编译
可以使用其他指令创建条件编译(conditinal compilation)。也就是说,可以使用这些指令告诉编译器根据编译时的条件执行或忽略信息(或代码)块。
#ifdef
、#else
和#endif
指令1
2
3
4
5
6
7
#ifndef
指令#ifndef指令通常用于防止多次包含一个文件。
1
2
3
4
5// arrays.h文件
......1
2
3// main.c文件
SIZE则被设置为10。这里,当执行到#include “arrays.h”这行,处理array.h中的代码时,由于SIZE是已定义的,所以跳过了#define SIZE 100这行代码。
预定义宏
C99 标准提供一个名为__func__
的预定义标识符,它展开为一个代表函数名的字符串(该函数包含该标识符)。
1 | // predef.c -- 预定义宏和预定义标识符 |
#line和#error
#line
指令重置__LINE__
和__FILE__
宏报告的行号和文件名。
1 |
#error
指令让预处理器发出一条错误消息,该消息包含指令中的文本。如果可能的话,编译过程应该中断。
1 | // newish.c文件 |
编译以上代码生成后,输出如下:
1 | $ gcc newish.c |
如果编译器只支持旧标准,则会编译失败,如果支持C11标准,就能成功编译。
#pragma
在现在的编译器中,可以通过命令行参数或IDE菜单修改编译器的一些设置。#pragma把编译器指令放入源代码中。
例如,在开发C99时,标准被称为C9X,可以使用下面的编译指示(pragma)让编译器支持C9X:
1 |
一般而言,编译器都有自己的编译指示集。例如,编译指示可能用于控制分配给自动变量的内存量,或者设置错误检查的严格程度,或者启用非标准语言特性等。
C99还提供_Pragma预处理器运算符,该运算符把字符串转换成普通的编译指示。
1 | _Pragma("nonstandardtreatmenttypeB on") |
等价于:
1 |
由于该运算符不使用#符号,所以可以把它作为宏展开的一部分:
1 |
泛型选择(C11)
在程序设计中,泛型编程(generic programming)指那些没有特定类型,但是一旦指定一种类型,就可以转换成指定类型的代码。
C++在模板中可以创建泛型算法,然后编译器根据指定的类型自动使用实例化代码。C没有这种功能。C11新增了一种表达式,叫作泛型选择表达式(generic selection expression),可根据表达式的类型(即表达式的类型是int、double 还是其他类型)选择一个值。
1 | _Generic(x, int: 0, float: 1, double: 2, default: 3) |
_Generic
是C11的关键字。_Generic后面的圆括号中包含多个用逗号分隔的项。第1个项是一个表达式,后面的每个项都由一个类型、一个冒号和一个值组成,如float: 1。如果x的类型匹配是float:标签,那么整个表达式的值就是1。
下面的例子为:对泛型选择表达式求值得字符串。
1 |
内联函数
通常,函数调用都有一定的开销,因为函数的调用过程包括建立调用、传递参数、跳转到函数代码并返回。使用宏使代码内联,可以避免这样的开销。C99还提供另一种方法:内联函数(inline function)。
其实C99和C11标准中叙述的是:“把函数变成内联函数建议尽可能快地调用该函数,其具体效果由实
现定义”。因此,把函数变成内联函数,编译器可能会用内联代码替换函数调用,并(或)执行一些其他的优化,但是也可能不起作用。
标准规定具有内部链接的函数可以成为内联函数,还规定了内联函数的定义与调用该函数的代码必须在同一个文件中。因此,最简单的方法是使用函数说明符 inline 和存储类别说明符static。
1 |
|
编译器查看内联函数的定义(也是原型),可能会用函数体中的代码替换 eatline()函数调用。也就是说,效果相当于在函数调用的位置输入函数体中的代码:
1 |
|
由于并未给内联函数预留单独的代码块,所以无法获得内联函数的地址(实际上可以获得地址,不过这样做之后,编译器会生成一个非内联函数)。另外,内联函数无法在调试器中显示。
编译器优化内联函数必须知道该函数定义的内容。这意味着内联函数定义与函数调用必须在同一个文件中。鉴于此,一般情况下内联函数都具有内部链接。因此,如果程序有多个文件都要使用某个内联函数,那么这些文件中都必须包含该内联函数的定义。最简单的做法是,把内联函数定义放入头文件,并在使用该内联函数的文件中包含该头文件即可。
1 | // eatline.h |
一般都不在头文件中放置可执行代码,内联函数是个特例。因为内联函数具有内部链接,所以在多个文件中定义同一个内联函数不会产生什么问题。
_Noreturn函数(C11)
C99新增inline关键字时,它是唯一的函数说明符(关键字extern和static是存储类别说明符,可应用于数据对象和函数)。
C11新增了第2个函数说明符_Noreturn
,表明调用完成后函数不返回主调函数。
exit()
函数是_Noreturn
函数的一个示例,一旦调用exit()
,它不会再返回主调函数。void类型的函数在执行完毕后返回主调函数,只是它不提供返回值。
C库
使用库表述
C99/C11标准提供了下面的描述:
1 |
|
size_t
类型被定义为 sizeof 运算符的返回值类型——无符号整数类型,通常是unsignedint或unsigned long。stddef.h文件中包含了size_t类型的typedef或#define定义。其他文件(包括stdio.h)通过包含stddef.h来包含这个定义。许多函数(包括fread())的实际参数中都要使用sizeof运算符,形式参数的size_t类型中正好匹配这种常见的情况。
restrict
关键字允许编译器优化某部分代码以更好地支持计算。它只能用于指针,表明该指针是访问数据对象的唯一且初始的方式。
数学库#include <math.h>
#include <math.h>
利用C11新增的泛型选择表达式_Generic
定义一个泛型宏,根据参数类型选择最合适的数学函数版本。
1 | // 泛型平方根函数 |
<tgmath.h>
定义 sqrt()
宏展开为sqrtf()、sqrt()或 sqrtl()函数。
通用工具库
rand()
、srand()
、malloc()
和free()
函数。在ANSI C标准中,这些函数的原型都在<stdlib.h>
头文件中。
exit()和atexit()函数
在main()返回系统时将自动调用exit()
函数。
atexit()
函数通过退出时注册被调用的函数提供这种功能,atexit()
函数接受一个函数指针作为参数。
1 | /* byebye.c -- atexit()示例 */ |
如果在IDE中运行,可能看不到最后4行。
atexit()函数的用法
这个函数使用函数指针。要使用 atexit()函数,只需把退出时要调用的函数地址传递给 atexit()即可。函数名作为函数参数时相当于该函数的地址,所以该程序中把sign_off或too_bad作为参数。然后,atexit()注册函数列表中的函数,当调用exit()时就会执行这些函数。ANSI保证,在这个列表中至少可以放 32 个函数。最后调用 exit()函数时,exit()会执行这些函数(执行顺序与列表中的函数顺序相反,即最后添加的函数最先执行)。
exit()函数的用法
exit()执行完atexit()指定的函数后,会完成一些清理工作:刷新所有输出流、关闭所有打开的流和关闭由标准I/O函数tmpfile()创建的临时文件。然后exit()把控制权返回主机环境,如果可能的话,向主机环境报告终止状态。在main()以外的函数中使用exit()也会终止整个程序。
qsort()函数
对较大型的数组而言,“快速排序”方法是最有效的排序算法之一。
它把数组不断分成更小的数组,直到变成单元素数组。首先,把数组分成两部分,一部分的值都小于另一部分的值。这个过程一直持续到数组完全排序好为止。
1 | void qsort(void* base, size_t nmemb, size_t size, int (*compar)(const void*, const void*)); |
第1个参数是指针,指向待排序数组的首元素。ANSI C允许把指向任何数据类型的指针强制转换成指向void的指针,因此,qsort()的第1个实际参数可以引用任何类型的数组。
第2个参数是待排序项的数量。函数原型把该值转换为size_t类型。前面提到过,size_t定义在标准头文件中,是sizeof运算符返回的整数类型。
由于qsort()把第1个参数转换为void指针,所以qsort()不知道数组中每个元素的大小。为此,函数原型用第 3 个参数补偿这一信息,显式指明待排序数组中每个元素的大小。例如,如果排序 double类型的数组,那么第3个参数应该是sizeof(double)。
最后,qsort()还需要一个指向函数的指针,这个被指针指向的比较函数用于确定排序的顺序。该函数应接受两个参数:分别指向待比较两项的指针。如果第1项的值大于第2项,比较函数则返回正数;如果两项相同,则返回0;如果第1项的值小于第2项,则返回负数。qsort()根据给定的其他信息计算出两个指针的值,然后把它们传递给比较函数。
1 | /* qsorter.c -- 用 qsort()排序一组数字 */ |
string.h库中的memcpy()和 memmove()
不能把一个数组赋给另一个数组,所以要通过循环把数组中的每个元素赋给另一个数组相应的元素。有一个例外的情况是:使用strcpy()
和strncpy()
函数来处理字符数组。memcpy()
和memmove()
函数提供类似的方法处理任意类型的数组。
1 | void *memcpy(void * restrict s1, const void * restrict s2, size_t n); |
这两个函数都从 s2 指向的位置拷贝 n 字节到 s1 指向的位置,而且都返回 s1 的值。
所不同的是, memcpy()
的参数带关键字restrict
,即memcpy()
假设两个内存区域之间没有重叠;而memmove()
不作这样的假设,所以拷贝过程类似于先把所有字节拷贝到一个临时缓冲区,然后再拷贝到最终目的地。
可变参数:stdarg.h
1 | //varargs.c -- use variable number of arguments |
高级数据表示
从数组到链表
使用数组分配内存空间:
一种方法是调用malloc()一次,为5个movies结构请求分配足够的空间;另一种方法是调用malloc()5次,分别为每个movies结构请求分配足够的空间。
如果用不完500个指针,这种方法节约了大量的内存,因为内含500个指针的数组比内含500个结构的数组所占的内存少得多。尽管如此,如果用不到 500 个指针,还是浪费了不少空间。而且,这样还是有500个结构的限制。
还有一种更好的方法。每次使用 malloc()为新结构分配空间时,也为新指针分配空间。但是,还得需要另一个指针来跟踪新分配的指针,用于跟踪新指针的指针本身,也需要一个指针来跟踪,以此类推。要重新定义结构才能解决这个潜在的问题,即每个结构中包含指向 next 结构的指针。
1 |
|
虽然结构不能含有与本身类型相同的结构,但是可以含有指向同类型结构的指针。这种定义是定义链表(linked list)的基础,链表中的每一项都包含着在何处能找到下一项的信息。
假设要显示这个链表,每显示一项,就可以根据该项中已储存的地址来定位下一个待显示的项。然而,这种方案能正常运行,还需要一个指针储存链表中第1项的地址,因为链表中没有其他项储存该项的地址。此时,头指针就派上了用场。
1 | /* films2.c -- 使用结构链表 */ |
抽象数据类型(ADT)
计算机科学领域已开发了一种定义新类型的好方法,用3个步骤完成从抽象到具体的过程。
- 提供类型属性和相关操作的抽象描述。这些描述既不能依赖特定的实现,也不能依赖特定的编程语言。这种正式的抽象描述被称为抽象数据类型(ADT)。
- 开发一个实现 ADT 的编程接口。也就是说,指明如何储存数据和执行所需操作的函数。例如在 C中,可以提供结构定义和操控该结构的函数原型。这些作用于用户定义类型的函数相当于作用于 C基本类型的内置运算符。需要使用该新类型的程序员可以使用这个接口进行编程。
- 编写代码实现接口。这一步至关重要,但是使用该新类型的程序员无需了解具体的实现细节。
链表的ADT:
类型名: 简单链表
类型属性: 可以储存一系列项
类型操作: 初始化链表为空
确定链表为空
确定链表已满
确定链表中的项数
在链表末尾添加项
遍历链表,处理链表中的项
清空链表
1 | /* list.h -- 简单链表类型的头文件 */ |
接口函数的实现
1 | /* list.c -- 支持链表操作的函数 */ |
使用接口
1 | /* films3.c -- 使用抽象数据类型(ADT)风格的链表 */ |
队列
队列是一种“先进先出”(first in,first out,缩写为FIFO)的数据形式
1 | typedef int Item; |
链表和数组
二叉查找树
1 | typedef char* Item; |