缓冲区(Buffer)又称为缓存(Cache),是内存空间的一部分。计算机在内存中预留了一定的存储空间,用来暂时保存输入或输出的数据,这部分预留的空间就叫做缓冲区(缓存)。

缓冲区的引入

缓冲区是为了让低速的输入输出设备和高速的用户程序(CPU的执行速度)能够协调工作。

我们都知道硬盘的速度要远低于 CPU,它们之间有好几个数量级的差距,当向硬盘写入数据时,程序需要等待,不能做任何事情,就好像卡顿了一样,用户体验非常差。计算机上绝大多数应用程序都需要和硬件打交道,例如读写硬盘、向显示器输出、从键盘输入等,如果每个程序都等待硬件,那么整台计算机也将变得卡顿。

降低输入输出设备的读写次数。

我们的程序并不能直接读写硬件,它必须告诉操作系统,让操作系统内核(Kernel)去调用驱动程序,只有驱动程序才能真正的操作硬件。
从用户程序到硬件设备要经过好几层的转换,每一层的转换都有时间和空间的开销,而且开销不一定小;一旦用户程序需要密集的输入输出操作,这种开销将变得非常大,会成为制约程序性能的瓶颈。
这个时候,分配缓冲区就是必不可少的。每次调用读写函数,先将数据放入缓冲区,等数据都准备好了再进行真正的读写操作,这就大大减少了转换的次数。实践证明,合理的缓冲区设置能成倍提高程序性能。

缓冲区类型

根据缓冲区对应的是输入设备还是输出设备,可以分为输入缓冲区和输出缓冲区。
根据数据刷新(也可以称为清空缓冲区,就是将缓冲区中的数据“倒出”)的时机,可以分为全缓冲、行缓冲、不带缓冲。

全缓冲

在这种情况下,当缓冲区被填满以后才进行真正的输入输出操作。缓冲区的大小都有限制的,比如 1KB、4MB 等,数据量达到最大值时就清空缓冲区。
在实际开发中,将数据写入文件后,打开文件并不能立即看到内容,只有清空缓冲区,或者关闭文件,或者关闭程序后,才能在文件中看到内容。这种现象,就是缓冲区在作怪。

行缓冲

在这种情况下,当在输入或者输出的过程中遇到换行符时,才执行真正的输入输出操作。行缓冲的典型代表就是标准输入设备(也即键盘)和标准输出设备(也即显示器)。

  • 比如printf()使用\n刷新缓冲
  • 对于 scanf(),不管用户输入多少内容,只要不按下回车键,就不进行真正的读取。

不带缓冲

不带缓冲区,数据就没有地方缓存,必须立即进行输入输出。

getche()、getch() 就不带缓冲区,输入一个字符后立即就执行了,根本不用按下回车键。

Windows 下的 printf() 也不带缓冲区,不管最后有没有换行符\n,都会立即输出。

C语言标准

C语言标准规定,输入输出缓冲区要具有以下特征:

  • 当且仅当输入输出不涉及交互设备时,它们才可以是全缓冲的。
  • 错误显示设备不能带有缓冲区。

现代计算机已经没有了专门的错误显示设备,所有的信息都显示到一个屏幕上,这里的错误显示设备只能是计算机的显示器。上面提到的 perror() 其实就是向错误显示设备上输出信息,但是现代计算机已经把显示器作为了错误显示设备,所以 perror() 也是向显示器上输出内容。

所谓交互设备,就是现代计算机上的显示器和键盘。C标准虽然规定它们不能是全缓冲的,但并没有规定它们到底是行缓冲还是不带缓冲,这就导致不同的平台有不同的实现。

输入设备

scanf()、getchar()、gets() 就是从输入设备(键盘)上读取内容。对于输入设备,没有缓冲区将导致非常奇怪的行为,比如,我们本来想输入一个整数 947,没有缓冲区的话,输入 9 就立即读取了,根本没有机会输入 47,所以,没有输入缓冲区是不能接受的。Windows、Linux、Mac OS 在实现时都给输入设备带上了行缓冲,所以 scanf()、getchar()、gets() 在每个平台下的表现都一致。

但是在某些特殊情况下,我们又希望程序能够立即响应用户按键,例如在游戏中,用户按下方向键人物要立即转向,而且越快越好,这肯定就不能带有缓冲区了。Windows 下特有的 getche() 和 getch() 就是为这种特殊需求而设计的,它们都不带缓冲区。

输出设备

printf()、puts()、putchar() 就是向输出设备(显示器)上显示内容。对于输出设备,有没有缓冲区其实影响没有那么大,顶多是晚一会看到内容,不会有功能性的障碍,所以 Windows 和 Linux、Mac OS 采用了不同的方案:

  • Windows 平台下,输出设备是不带缓冲区的;
  • Linux 和 Mac OS 平台下,输出设备带有行缓冲区。

所谓刷新缓冲区,就是将缓冲区中的内容送达到目的地。缓冲区的刷新遵循以下的规则:

  • 不管是行缓冲还是全缓冲,缓冲区满时会自动刷新;
  • 行缓冲遇到换行符\n时会刷新;
  • 关闭文件时会刷新缓冲区;
  • 程序关闭时一般也会刷新缓冲区,这个是由标准库来保障的;
  • 使用特定的函数也可以手动刷新缓冲区,如fflush()。

清空(刷新)缓冲区

1
fflush(stdout);

fflush() 是一个专门用来清空缓冲区的函数,stdout 是 standard output 的缩写,表示标准输出设备,也即显示器。整个语句的意思是,清空标准输出缓冲区,或者说清空显示器的缓冲区。

注意,Windows 平台下的 printf()、puts()、putchar() 等输出函数都是不带缓冲区的,所以不用清空。

清空输入缓冲区

没有一种既简洁明了又适用于所有平台的清空输入缓冲区的方案。只有一种很蹩脚的方案能适用于所有平台,那就是将输入缓冲区中的数据都读取出来,但是却不使用。

使用 getchar() 清空缓冲区

getchar() 是带有缓冲区的,每次从缓冲区中读取一个字符,包括空格、制表符、换行符等空白符,只要我们让 getchar() 不停地读取,直到读完缓冲区中的所有字符,就能达到清空缓冲区的效果。请看下面的代码:

1
2
int c;
while((c = getchar()) != '\n' && c != EOF);

该代码不停地使用 getchar() 获取缓冲区中的字符,直到遇见换行符\n或者到达文件结尾才停止。
在实际开发中,大家按照下面的形式使用即可:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
int main()
{
int a = 1, b = 2;
char c;
scanf("a=%d", &a);
while((c = getchar()) != '\n' && c != EOF); //在下次读取前清空缓冲区
scanf("b=%d", &b);
printf("a=%d, b=%d\n", a, b);

return 0;
}

输入示例:

1
2
3
a=100↙
b=200↙
a=100, b=200

按下第一个回车键后,只有第一个 scanf() 读取成功了,第二个 scanf() 并没有开始读取,等我们再次输入并按下回车键后,第二个 scanf() 才开始读取,这就符合我们的操作习惯了。如果没有清空缓冲区的语句,按下第一个回车键后,两个 scanf() 都读取了,只是第二个 scanf() 读取失败了,让人觉得很怪异。

改变输入方式,再次尝试一下:

1
2
3
a=100b=200↙
b=300↙
a=100, b=300

你看,第一次输入的多余内容并没有起作用,就是因为它们在第二个 scanf() 之前被清空了。

这种方案的关键之处在于,getchar() 是带有缓冲区的,并且一切字符通吃,或者说一切字符都会读取,不会忽略。不过这种方案有个缺点,就是要额外定义一个变量 c,对于有强迫症的读者来说可能有点难受。

使用 scanf() 清空缓冲区

scanf() 还有一种高级用法,就是使用类似于正则表达式的通配符,这样它就可以读取所有的字符了,包括空格、换行符、制表符等空白符,不会再忽略它们了。并且,scanf() 还允许把读取到的数据直接丢弃,不用赋值给变量。

请看下面的语句:
scanf(“%_[^\n]”); scanf(“%_c”);

第一个 scanf() 将逐个读取缓冲区中\n之前的其它字符,% 后面的 * 表示将读取的这些字符丢弃,遇到\n字符时便停止读取。此时,缓冲区中尚有一个\n遗留,第二个 scanf() 再将这个\n读取并丢弃,这里的星号和第一个 scanf() 的星号作用相同。由于所有从键盘的输入都是以回车结束的,而回车会产生一个\n字符,所以将\n连同它之前的字符全部读取并丢弃之后,也就相当于清除了输入缓冲区。

相信很多读者都不明白这种写法,没关系,下节我们在讲解 scanf() 的高级用法时还会再解释。

我们来演示这种方案的效果:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
int main()
{
int a = 1, b = 2;

scanf("a=%d", &a);
scanf("%*[^\n]"); scanf("%*c"); //在下次读取前清空缓冲区
scanf("b=%d", &b);
printf("a=%d, b=%d\n", a, b);

return 0;
}

输入示例 ①:

1
2
3
a=100↙
b=200↙
a=100, b=200

输入示例 ②:

1
2
3
a=100b=200↙
b=300↙
a=100, b=300

相比使用 getchar(),这种方案不用额外定义一个变量,看起来更加整洁。

两种不通用、不建议的方案

以上两种清空输入缓冲区的方案是通用的,在任何平台、任何编译器、任何情景下都奏效。除此以外,有些教材和老师可能还讲解过其它的方案,常见的有两种,分别是fflush(stdin)和rewind(stdin)。

fflush(stdin)

fflush(stdin) 常用于 Windows 平台,在 VC 6.0、VS2010 等较老的编译器下确实能够清空缓冲区。

C语言标准规定,当 fflush() 用于 stdout 时,必须要有清空输出缓冲区的作用;但是C语言标准并没有规定 fflush() 用于 stdin 时的作用,编译器的实现者可以自由决定,所以它的行为是未定义的。

较老的微软编译器进行了扩展,赋予了 fflush(stdin) 清空输入缓冲区的功能,例如 VC 6.0、VS2010 等;但是,较新的微软编译器又取消了这种扩展,不再支持 fflush(stdin),例如 VS2015、VS2017 等,在这些版本的编译器下,fflush() 是无效的。

较老的 GCC 是不支持 fflush(stdin) 的,但是最新的 GCC 又开始支持 fflush(stdin) 了。

LLVM/Clang 编译器始终不支持 fflush(stdin)。

总之,fflush(stdin) 这种不标准的写法只适用于一部分编译器,通用性非常差,所以不建议使用。如果你由于个人习惯坚持使用,请测试你的编译器是否支持。

rewind(stdin)

rewind() 函数并没有清空缓冲区的功能,但是 rewind(stdin) 偏偏在某些编译器下会导致清空缓冲区的假象,例如 VS2015、LLVM/Clang。在 GCC 下,rewind(stdin) 是没有任何效果的。