本教程视频已同步到B站: 程序员的C——实用编程,不玩虚的!
文件与 I/O
程序必须能够将数据写入文件或者物理输出设备,例如显示器或打印机,并且能够从文件或输入设备(例如键盘)读取数据。C语言标准库提供了许多函数以让我们实现该目的。所有基本输入输出函数、宏以及为其定义的类型,全部都在头文件stdio.h
中进行声明。
流
从C程序角度来看,无论程序一次读写的是一个字符、字节、文本行还是给定大小的数据块,作为输入输出的各种文件和设备,它们都统一的以逻辑数据流的方式展现。C中的数据流可以是文本流或者二进制流,甚至在某些系统上,不存在差异。C语言将文件的管理工作交给运行环境(也就是操作系统)。因此,流是一个传输通道,利用该通道,数据可以从运行环境流入程序中,也可以从程序流向运行环境。
C语言对设备(如控制台)的处理机制与对文件的机制是一样的。每个流都有一个锁机制,当出现多个线程访问同一个流,I/O库函数利用该锁以保证同步性。
文本流
文本流用来传输文本中的字符,这里的文本被分割成许多行。文本行都包含一个字符序列,并以换行符作为该序列结尾。文本行也可以为空,也就是说只包含一个换行符。
二进制流
二进制流是字节序列,它们不作修改直接传送。也就是说,当操作二进制流时,I/O函数不会翻译任何控制字符。二进制流通常用于编写二进制数据(例如,图片、数据库等),而不将它转换为文本。
标准流
每个C程序在开始时就有三个标准流可以用,这些流不需要对其进行声明,也不用打开或关闭它们,使用时包含<stdio.h>
即可。
文件指针 | 流 | 默认的含义 |
---|---|---|
stdin |
标准输入 | 表示键盘 |
stdout |
标准输出 | 表示屏幕 |
stderr |
标准错误 | 屏幕 |
输出函数
输出一个字符:
int fputc(int c, FILE *stream)
int putc(int c, FILE *stream)
int putchar(int c)
输出字符串:
int puts(const char *s)
int fputs(const char *s, FILE *stream)
putchar
函数可以向标准输出流stdout
输出一个字符,而fputc
和putc
函数是putchar
函数向任意流输出字符的通用版本:
fputc('h',stdout);
putc('h',stdout);
putchar('h');
putc
和fputc
函数作用相同,但在C语言中,putc
通常是一个宏,而fputc
是一个函数。注意,putchar
通常也是一个宏,程序员们一般更偏好使用宏,因为它速度更快。puts
和fputs
类似,但它们是字符串的输出函数。
注意,puts
函数在写入字符串后,总会添加一个换行符,而fputs
函数不会自己写入换行符,除非字符串中本身含有换行符。
输入函数
int fgetc(FILE *stream)
int getc(FILE *stream)
int getchar(void)
int ungetc(int c, FILE *stream)
getchar
函数从标准输入流stdin
中读入一个字符,而fgetc
和getc
函数则从任意流中读入一个字符。getc
和fgetc
之间的关系类似于putc
和fputc
之间的关系。getc
、getchar
通常是一个宏,而fgetc
则是一个函数。
注意,ungetc
函数是把从流中读入的字符“放回”流中,可以放回至少一个字符,但是反复地尝试可能成功,也可能失败。如果失败,函数会返回EOF,如果成功,则会返回放回到流中的字符。我们在输入过程中需要往前多看一个字符,那么这种能力可能会非常有用。
从流中读取字符串:
char *fgets(char *s, int size, FILE *stream)
格式化输出
int printf(const char *format, ...)
:写入到标准输出流
int fprintf(FILE *stream, const char *format, ...)
:写入到指定的流
int sprintf(char *buf, const char *format, ...)
:将格式化的字符串写入buf
指向的数组
int snprintf(char *buf, size_t size, const char *format, ...)
:与sprintf()
一样,但不会写入超过size
个字节到输出流中
format
是每个printf
系列函数都具有的一个参数,它定义了数据的输出格式,并包含了一些占位符。每个占位符都定义了函数该如何将可选参数转换并格式化,以供输出。printf()
函数将格式化字符串写入到输出,使用对应可选参数的格式化值来替代占位符。占位符以百分号%
开始,并以一个字母结尾,一般称为转换修饰符。(如果想输出%
,则需要转义,用两个%
表示:%%
)
这种占位符的通用格式如下:
%[标记][字段宽度][.精度][长度修饰符]修饰符
方括号表示可选的,但是若要使用它们,就必须遵循上述顺序。
- 字段宽度:必须是正整数。(或者是一个星号)它指定对应的数据项所输出的最少字符数量。
- 标记:默认情况下,字段中的被转换数据为右对齐,左边多的位置用空格填补。使用标记减号(
-
),则为左对齐,超出的字段宽度采用空格向右填补
示例:
#include <stdio.h>
int main(void){
printf("1234567890\n");
printf("%-8s|%s\n","Name","Score");
printf("%-8s|%d\n","John",80);
printf("%-8s|%d\n","Bob",90);
return 0;
}
输出:
1234567890
Name |Score
John |80
Bob |90
可以看到,以上字段宽度设置为8个字符,输出了类似表格的效果。注意,如果想使用一个变量来指定字段宽度,那么就将字段宽度指定为一个星号(*),然后在printf
函数调用时多添加一个额外的参数。
- 输出精度:精度表示为一个点后接一个十进制整数。
// 精度设置为2,表示仅输出两位小数
printf("double value: %.2f\n",3.1415926);
// 字段宽度为3,精度为0
printf("double value: %3.0f\n",3.1415926);
输出结果:
double value: 3.14
double value: 3
注意,printf
会将浮点数按向上或向下取近似值输出。如果指定精度为0,那么小数部分包括小数点本身会被省略。如果仅仅想把小数部分直接去掉,而不是取近似值,应当直接将它强制转换为整数类型。
- 长度修饰符:对于
long
或unsigned long
类型的参数,必须在d
、i
、u
、o
、x
和X
修饰符前面加上长度修饰符小写 l。类似地,如果参数是long long
或unsigned long long
类型,则其长度修饰符是两个小写ll
long l = 12345L;
unsigned long long u = 999999ULL;
printf("%ld,%lld",l,u);
附,printf
转换修饰符表:
长度修饰符表:
注:① 仅C99
格式化输入
当从一个格式化数据源中读取数据时,C语言提供了scanf()
函数系列。与printf()
函数一样,scanf()
函数需要一个格式化字符串作为其参数,以控制I/O格式与程序内部数据之间的转换。
int scanf(const char *format, ...)
:从标准输入流中读取数据int fscanf(FILE *stream, const char *format, ...)
:从指定流中读取数据int sscanf(const char *src, const char *format, ...)
:从src
数组中读取数据
注意,C11标准为这些函数都提供了一个新的“安全”的版本。这些对应的新函数均带有后缀_s
(如fscanf_s()
)。新函数测试在读入一个字符串到数组之前,是否超出了数组边界。
占位符的通用格式如下:
%[*][字段宽度][长度修饰符]修饰符
字符*
是可选的。*
用于赋值屏蔽。即读入此数据项,但是不会把它赋值给对象。用*
匹配的数据项不包含在函数返回的计数中。scanf()
函数所使用的大多数转换修饰符都与printf()
函数所定义的一样。然而,scanf()
函数的转换说明没有标记和精度选项。
特别注意:scanf
与printf
函数很相似。然而,这种相似可能会产生误导,实际上scanf
函数的工作原理完全不同于printf
函数。我们应该把scanf
系列函数看成是“模式匹配”函数。format
参数表示的是scanf
函数在读取输入时试图匹配的模式。如果输入和格式串不匹配,函数就会返回。不匹配的输入字符将被“放回”流中。
scanf
函数的format
参数可能含有三种信息:
- 转换说明(即占位符):大多数转换说明(
%[
、%c
和%n
例外)会跳过输入项开始处的空白字符。但是,转换说明不会跳过尾部的空白字符。如果输入含有•123¤,那么转换说明%d
会读取•、1、2和3,但是留下¤不读取。(用•表示空格符,¤表示换行符) - 空白字符:
format
参数中的一个或多个连续的空白字符与输入流中的零个或多个空白字符相匹配 - 非空白字符:除了
%
之外的非空白字符和输入流中的相同字符相匹配
下面是三组示例(删除线显示的字符会被读取):
附,scanf
转换说明符表:
流的重定向
默认情况下,stdin
表示键盘,而stdout
和stderr
则表示屏幕。然而,许多操作系统允许通过一种称为重定向(redirection)的机制来改变这些默认的含义。通常,我们可以强制程序从文件而不是从键盘获得输入,方法是在命令行中放上文件的名字,并在前面加上字符<
,示例:
test < file.txt
这种方法称为输入重定向,它本质上是使stdin
流表示文件(此例中为file.txt)而非键盘。重定向的绝妙之处在于,test程序不会意识到正在从文件file.txt中读取数据,它会认为从stdin
获得的任何数据都是从键盘上录入的。
输出重定向也是类似的。对stdout
流的重定向通常是通过在命令行中放置文件名,并在前面加上字符>
实现:
test > out.txt
现在所有写入stdout
的数据都将写入out.txt文件中,而不是出现在屏幕上了
文件
<stdio.h>
支持两种类型的文件:文本文件和二进制文件。
在文本文件(text file)中,字节表示字符,这使人们可以检查或编辑文件。例如,C程序的源代码是存储在文本文件中的。另一方面,在二进制文件(binary file)中,字节不一定表示字符;字节数组还可以表示其他类型的数据,比如整数和浮点数。
文本文件具有两种二进制文件没有的特性:
- 文本文件分为若干行。文本文件的每一行通常以一两个特殊字符结尾,特殊字符的选择与操作系统有关。在Windows中,行末的标记是回车符(
'\r'
)与一个紧跟其后的换行符('\n'
)。在UNIX和Mac OS操作系统的较新版本中,行末的标记是一个单独的换行符'\n'
。旧版本的Mac OS使用一个单独的回车符。 - 文本文件可以包含一个特殊的“文件末尾”标记。一些操作系统允许在文本文件的末尾使用一个特殊的字节作为标记。在Windows中,标记为
'\x1a'
(EOF标记,也是Ctrl+Z控制字符)。它不是必需的,但如果存在,它就标志着文件的结束,其后的所有字节都会被忽略。使用Ctrl+Z的这一习惯继承自DOS,而DOS中的这一习惯又是从CP/M(早期用于个人电脑的一种操作系统)来的。大多数其他操作系统(包括UNIX)没有专门的文件末尾字符。
二进制文件不分行,也没有行末标记和文件末尾标记,所有字节都是平等对待的。
文件操作
文件代表一系列的字节。函数fopen()
将一个文件和一个流关联起来,并初始化一个类型为FILE
的对象,该对象包含了控制该流的所有信息。这些信息包括指向缓冲区的指针;文件位置指示器,它指定了获取文件的位置;以及指示错误和文件结尾情况的标志。
每个用于打开文件的函数(即fopen
、freopen
和tmpfile
)都会返回一个指向FILE
对象的指针,该FILE
对象包含与被打开文件相关联的流。一旦打开了文件,就可以调用函数传递数据并对流进行处理。这些函数都把指向FILE
对象的指针(通常称为FILE
指针)作为它们的参数之一。FILE指针指定了正在进行操作的流。
FILE *fopen(const char *pathname, const char *mode)
FILE *freopen(const char *pathname, const char *mode, FILE *stream)
FILE *tmpfile(void)
int fclose(FILE *stream)
fopen
和freopen
都可以打开一个文件,但freopen
不会建立新的流,而是将文件与已有的流关联,已有的流通过该函数的第三个参数指定。之前与该流关联的文件会被关闭。常被用来重新定向到标准流stdin
、stdout
和stderr
。
tmpfile
函数会建立一个新的临时文件,其文件名与所有已有文件名都不一样,然后打开该文件,进行二进制数据的读写操作。如果该程序正常地结束,则该文件会被自动删除。
fclose
函数把缓冲区中存在的所有数据保存到文件中,并关闭文件,释放所有用于该流输入输出缓冲区的内存。返回0表示成功,返回EOF表示产生错误。在完成文件处理后,应该主动关闭文件。否则,一旦程序非正常终止,就可能会丢失数据。而且,一个程序可以同时打开的文件数量是有限的,数量上限小于等于常量FOPEN_MAX
的值。
#include <stdio.h>
#define N 100
int main() {
FILE *fp = NULL;
char str[N+1] = {0};
//是否成功打开
if((fp = fopen("d:\\test.txt", "r")) == NULL) {
puts("Failed to open !");
return -1;
}
// 循环读取文件的每一行
while(fgets(str, N, fp) != NULL ) {
printf("%s", str);
}
// 关闭文件
fclose(fp);
return 0;
}
由于文件打开后和一个流关联到一起,所以流相关的函数都可以用于文件操作。此外,还可以使用如下两个函数读写文件:
size_t fread(void *buf, size_t size, size_t n, FILE *fp)
size_t fwrite(const void *buf, size_t size, size_t n,FILE *fp)
函数fread
从fp
所引用的流中读取最多n
个对象,对象的空间大小为size
,并将这些对象存储在buf
所指向的数组中。该函数的返回值是被传输的对象数量。如果返回值小于函数参数n,表示读取到达了文件结尾,或者发生了错误。函数fwrite
把buf
所指向的数组中的n
个对象,对象的空间大小为size
,写入到fp
所引用的流中。
文件操作模式
以上fopen
函数的第二个参数需要传入一个操作模式的字符串。传递哪种模式字符串不仅依赖于稍后将要对文件采取哪种操作,还取决于文件中的数据是文本形式还是二进制形式。
用于文本文件的模式字符串:
用于二进制文件的模式字符串:
<stdio.h>
对写数据和追加数据进行了区分。当给文件写数据时,通常会对先前的内容进行覆盖。然而,当为追加打开文件时,向文件写入的数据添加在文件末尾,因而可以保留文件的原始内容。当打开文件用于读和写(模式字符串包含字符+
)时,有一些特殊的规则。如果没有先调用一个文件定位函数,那么就不能从读模式转换成写模式,除非读操作遇到了文件的末尾。类似地,如果既没有调用fflush
函数,也没有调用文件定位函数,那么就不能从写模式转换成读模式。
文件缓冲
向磁盘写入数据或者从磁盘读出数据都是相对较慢的操作。因此,在每次程序想读或写字符时都直接访问磁盘文件是不可行的。获得较好性能的诀窍就是使用缓冲(buffering):把写入流的数据存储在内存的一块区域内(缓冲区);当缓冲区满了(或者关闭流)时,对缓冲区进行“清洗”(写入实际磁盘)。输入流可以用类似的方法进行缓冲:缓冲区包含来自输入设备的数据,从缓冲区读数据而不是从设备本身读数据。缓冲在效率上可以取得巨大的收益,因为从缓冲区读字符或者在缓冲区内存储字符几乎不花什么时间。当然,把缓冲区的内容传递给磁盘,或者从磁盘传递给缓冲区是需要花时间的,但是一次大的“块移动”比多次小字节移动要快很多。
<stdio.h>
中的函数会在缓冲有用时自动进行缓冲操作。缓冲是在后台发生的,我们通常不需要关心它的操作。然而,极少的情况下可能需要我们主动操作。这时可以使用fflush
、setbuf
和setvbuf
函数。当程序向文件中写数据时,数据通常先放入缓冲区中。当缓冲区满了或者关闭文件时,缓冲区会自动清洗。但是,通过调用fflush
函数,程序可以按我们所希望的频率来清洗缓冲区。
int fflush(FILE *fp)
:刷新缓冲区void setbuf(FILE *fp, char *buf)
:对setvbuf
的封装。设定了缓冲模式和缓冲区大小的默认值。int setvbuf(FILE *fp, char *buf, int mode, size_t size)
:允许改变缓冲区的大小和位置
setvbuf
函数的第三个参数指明了期望的缓冲类型,该参数应为以下三个宏之一
宏 | 描述 |
---|---|
_IOFBF |
全缓冲:当缓冲区为空时,从流读入数据;当缓冲区满时,向流写入数据 |
_IOLBF |
行缓冲:对于输出,数据在遇到换行符或者在缓冲填满时被写入;对于输入,当缓冲区为空时,从流读入数据,直到遇到下一个换行符。 |
_IONBF |
无缓冲:不使用缓冲。每个 I/O 操作都被即时写入。buf 和 size 参数被忽略。 |
setvbuf
函数的第四个参数size
设置缓冲的大小,以字节为单位。
一定要注意,使用setvbuf
或setbuf
函数时,一定要确保在释放缓冲区之前已经关闭了流。特别是,如果缓冲区是局部于函数的,并且具有自动存储期限,一定要确保在函数返回之前关闭流。
#include <stdio.h>
int main(void) {
FILE *input, *output;
char buff[512] = {0};
input = fopen("file.in", "w+");
output = fopen("file.out", "w");
if (setvbuf(input, buff, _IOFBF, 512) != 0){
printf("failed to set up buffer for input file\n");
}else {
printf("buffer set up for input file\n");
}
if (setvbuf(output, NULL, _IOLBF, 132) != 0) {
printf("failed to set up buffer for output file\n");
}else {
printf("buffer set up for output file\n");
}
fclose(input);
fclose(output);
return 0;
}
文件随机访问
文件随机访问是指在某个文件内直接读写任何给定位置数据的能力。通过获取与设定文件位置指示符可以实现这一功能,文件位置指示符指定了文件中的当前访问位置,该文件与一个给定的流关联。
获取当前文件位置:
long ftell( FILE *fp )
int fgetpos(FILE * fp, fpos_t * pos)
修改文件位置指示符:
int fsetpos( FILE *fp, const fpos_t *ppos )
int fseek( FILE *fp, long offset, int origin )
其他操作
I/O库也包含了用于操作文件系统的函数,这些函数把文件名作为它们的参数之一。使用这些函数不需要事先打开文件。它们包括:
-
remove()
:删除一个文件(或空目录) -
rename()
:修改文件(或目录)名称
关注公众号:编程之路从0到1