我们会发现,如今的很多程序并不是仅仅在屏幕上根据我们的需求打印一个结果就结束了,你可能会用QQ或者微信保存一个视频,或者有的时候你清理手机空间的时候会发现很多软件创建了各种后缀的文件,这一切都是使用文件完成的操作。
使用文件的好处在于,我们可以把一些东西长期保存下来,例如浏览器中的cookies文件可以保证你在登录某个网页之后的一段时间内不用再次登录,我们使用的各种在线程序也需要通过文件的方式保存用户的各种信息,例如用户名、密码等等数据。
这玩意儿我们老早就提过了哈哈哈哈哈哈,之前在讲IO的时候我们就说过,标准输入输出流stdin和stdout都是文件指针,不过文件指针到底是什么呢?
typedef struct {
short level;
unsigned flags;
char fd;
unsigned char hold;
short bsize;
unsigned char *buffer;
unsigned ar *curp;
unsigned istemp;
short token;
} FILE;
(-。-)好复杂的东西,事实上我们在C语言中操作文件的时候不需要了解FILE结构体的细节,平时我们一般使用FILE *完成对文件的操作(因为如果是FILE,传入对应函数的时候就是传值了)。这样可以声明一个文件指针:
FILE* fp;
对于每一个文件,我们都可以有一个文件指针(FILE *)指向这个文件,然后利用C标准库中的函数对文件进行一系列的操作。如在IO篇中所说,C语言中将标准输入(stdin)、标准输出(stdout)以及标准错误(stderr)都视为文件,因此,之后的介绍的各种对于文件指针的操作,对于标准IO也是可以使用的。
C语言中有两种类型的文件:文本文件和二进制文件,一般来说文本文件就存储一些文字之类的内容,简单来说你平时创建一个txt文件,它就是一个简单的文本文件,文本文件的特点就在于它是写给人看的,你直接打开就应该可以看懂。
二进制文件就不同了,文件中存储的是一串串0和1,经过不同程序的处理之后可以展示出不同的内容,比如.jpg文件,你用MATLAB打开之后就会被处理成包含各种信息的矩阵,在C语言中,我们可以通过二进制文件来存储结构体等数据,这样做可以把一个结构体对象永久保存。
在C语言中我们使用fopen()函数打开文件并返回对应的文件指针,它的原型如下:
FILE* fopen(const char *filename, const char* mode);
filename参数就是文件的路径。这里提一下绝对路径与相对路径的区别(Windows下):
后面的mode也是一个字符串,它代表的是打开文件的模式,在C语言中有以下读写模式:
模式 | 含义 |
---|---|
r | 打开已有的文件,并读取内容 |
w | 打开文件,如果文件存在就从头开始写入。如果文件不存在,则会创建一个新文件 |
a | 打开文件,若文件存在就从后续开始追加写入。若文件不存在,则会创建一个新文件 |
+ | 加号是加在上述三个模式后面的,可以使得上述三种模式都变为可读写 |
b | 也是加在上述三种模式之后的,代表以二进制方式处理文件 |
所以假设我们需要读取一个相对路径下的text.txt,我们可以这么做:
#include
int main()
{
FILE* fp = fopen("text.txt", "r");
return 0;
}
打开文件之后当然也要记得关闭,我们用fclose()关闭,它的原型如下:
int fclose(FILE *fp);
补充一下关闭:
#include
int main()
{
FILE* fp = fopen("text.txt", "r");
fclose(fp);
return 0;
}
我们来介绍一下fgetc(),fgets()和fscanf() 三个函数。
首先是fgetc()函数,它的原型如下:
int fgetc(FILE* fp);
fgetc()每次从文件中读取一个字符,读取之后就会使光标(cursor)向后移动一个位置:
#include
int main()
{
FILE* fp = fopen("text.txt", "r");
char c = fgetc(fp);
while (c != EOF) {
printf("%c", c);
c = fgetc(fp);
}
fclose(fp);
return 0;
}
这样一个字一个字取出来就可以把整个文件原封不动的打印出来了。
不过这样好像还是有点麻烦的,因为要一个字符一个字符地读取呢。fscanf()函数可以一定程度上解决这个问题,它的原型如下:
int fscanf(FILE* fp, const char* format, ...)
这个函数用起来感觉有点怪怪的,比如我们来看下面这个例子:
#include
int main()
{
FILE* fp = fopen("text.txt", "r");
char str[1024];
fscanf(fp, "%s", str);
printf("%s", str);
fclose(fp);
return 0;
}
极简的艺术,事实上fscanf同scanf类似,需要首先有一个格式字符串用于从fp中接收符合格式的字符,然后将它存入到后面的字符数组中,fscanf是从指定的文件流中输入,无论是我们直接打开某个文件还是stdin都是可以的,而scanf就是只针对于stdin的。
他们的特性也是类似的,接受到了空白字符之后就会停止,所以The后面是一个空格,它就直接停下来了。
说到这里,聪明的你应该已经想到了如何利用fscanf把整篇文章打印出来了:
#include
int main()
{
FILE* fp = fopen("text.txt", "r");
char str[1024];
while (fscanf(fp, "%s", str) != EOF) {
printf("%s", str);
}
fclose(fp);
return 0;
}
这么一来,空行和空格全都没了,是因为fscanf会直接忽略掉空白字符,如果想要正常输出,我们可以补充这样两条代码在while循环体中:
char c = fgetc(fp);
printf("%c", c);
不过正常打印还是有条件的,每一行的最后不能有空格,否则换行符会被吃掉,它们还是会粘在一起。
fgets()这个函数我们之前在IO篇提过了,当时我们说的是从stdin中读取指定位数个字符到某个字符数组中,现在我们把fgets()的原型给出:
char* fgets(char* buf, int n, FILE* fp);
我们用它来读取一下text.txt吧:
#include
int main()
{
FILE* fp = fopen("text.txt", "r");
char str[10001] = {0};
fgets(str, 10000, fp);
printf("%s", str);
fclose(fp);
return 0;
}
fgets()只从文件中读出了一行,因为fgets()碰到了换行符就停止接收字符了。
读文件可以了,接下来我们就要尝试一下写文件了,在此我会介绍fputc(),fputs()以及fprintf()三个函数。
先说说fputc()和fputs(),这两个函数可以把一个字符或一个字符串写入到文件中,它们的原型如下:
int fputc(int c, FILE* fp);
int fputs(const char* s, FILE* fp);
fputc()和fputs()函数会根据fp的模式把字符或是字符串写入,这里就不再演示了。接下来看看fprintf(),它的原型如下:
int fprintf(FILE* fp, const char* format, ...);
参数中,除了要传入一个文件指针fp以外,后面的参数和printf()函数是一致的,让我们来看看下面这个例子:
#include
void outputText(FILE* fp);
int main()
{
FILE* fp = fopen("text.txt", "r");
outputText(fp);
fp = fopen("text.txt", "a");
double pi = 3.1415926, e = 2.71828;
fprintf(fp, "PI = %.3f, e = %.3f\n", pi, e);
fclose(fp);
fp = fopen("text.txt", "r");
outputText(fp);
return 0;
}
void outputText(FILE* fp)
{
char line[1024] = {0};
while (fscanf(fp, "%s", line) != EOF) {
printf("%s", line);
putchar(fgetc(fp));
}
fclose(fp);
}
我们就成功写入了文件,fprintf()函数就方便在我们可以通过格式字符串构造出一个特定的字符串写入到文件中。
操作二进制文件需要用到fread()和fwrite()两个函数,没错,fread()就是从文件中读取,fwrite()就是写入,它们的原型如下:
size_t fread(void* ptr, size_t size_of_elements,
size_t number_of_elements, FILE* fp);
size_t fwrite(const void* ptr, size_t size_of_elements,
size_t number_of_elements, FILE* fp);
写入二进制文件需要用到fwrite()函数,我们来说说它的几个参数都是什么:
这里的ptr并非只能是一个元素的地址,由于number_of_elements参数的存在,ptr也可以是一个结构体数组(这样可以一次性写入一大串内容),例如我们把之前定义student结构体拿过来:
typedef struct {
int score1;
int score2;
int score3;
ll ID;
} student;
现在我们可以尝试一下把一系列学生的信息写入到文件中去:
#include
#include
typedef long long ll;
typedef struct {
int score1;
int score2;
int score3;
ll ID;
} student;
int main()
{
FILE* fp = fopen("students.dat", "ab");
int n = 0;
scanf("%d", &n);
ll ID;
int score1, score2, score3;
student* stu = (student*)malloc(n*sizeof(student));
for (int i = 0; i < n; i++) {
scanf("%lld%d%d%d", &ID, &score1, &score2, &score3);
stu[i] = (student){score1, score2, score3, ID};
}
fwrite(stu, sizeof(student), n, fp);
fclose(fp);
free(stu);
return 0;
}
我们先把上述的数据输入程序,在这个程序执行结束之后,可执行文件的同目录下就会有一个students.dat文件,鉴于这是个二进制文件,我们可以用hexdump这个工具打开(Linux下自带,Windows不附带,但是可以上网搜索,并加到环境变量中,在此就不做演示):
看,这就是我们刚刚写入的数据,hexdump会将其转换为十六进制展现出来,我们来尝试理解一下这些十六进制数。
首先一个student占用24字节(想想为什么),两位十六进制数表示一个字节,那么score1,score2和score3分别占用四个字节,比如这里score1 = 62 00 00 00。
当前大部分处理器采用的是“小端”的模式,即数据的高字节保存在内存的高地址中。例如从地址0xA1B2,0100开始存储一个int值,那么从0xA1B2,0100到0xA1B2,0103的四个字节都是这个int值的地址,假设有一个十六进制数字 ( 01 , 02 , 03 , 04 ) 16 (01,02,03,04)_{16} (01,02,03,04)16存在这四个字节中:
这么一来,顺着地址读的时候就是 ( 04 , 03 , 02 , 01 ) 16 (04,03,02,01)_{16} (04,03,02,01)16,在写入文件的时候,也会按照内存中的内容直接写入,所以我们会发现按照字节读取的顺序和真正的数字顺序是相反的,我们再看看hexdump中的结果:
先读取到第一个score1是 ( 00 , 00 , 00 , 62 ) 16 (00,00,00,62)_{16} (00,00,00,62)16,转换为十进制就是98,这和我们写入的是一样的,后面的两个score就先不说了,接下来我们来试着读取一下ID,因为存在对齐的问题,ID要从16字节的位置开始读取,也就是 ( 00 , c 8 , c 1 , c c ) 16 (00,c8,c1,cc)_{16} (00,c8,c1,cc)16,转换为十进制也正好就是13156812。
我想,这么一来你就明白二进制文件的写入了吧?
写入完了,我们要尝试把它读取出来,读取要用到fread()函数,它的参数表和fwrite()一致,只是开头的void* ptr是你需要存储读取数据的地址。
我们来把刚刚写入的student信息读取并打印出来:
#include
#include
typedef long long ll;
typedef struct {
int score1;
int score2;
int score3;
ll ID;
} student;
int main()
{
FILE* fp = fopen("students.dat", "rb");
int n = 0;
scanf("%d", &n);
student* stu = (student*)malloc(n*sizeof(student));
fread(stu, sizeof(student), n, fp);
for (int i = 0; i < n; i++) {
printf("%lld %d %d %d\n", stu[i].ID, stu[i].score1, stu[i].score2, stu[i].score3);
}
fclose(fp);
free(stu);
return 0;
}
这样就完成了我们的需求,假设我们输入的数字大于实际文件中存储的内容,fread()不会报错,它会读取完所有能读取的,后续出现的奇怪的数字则是分配的内存中原本存在的数据。
前面的例子我想你也已经理解了,我们可以使用二进制文件构建我们自己的数据库,这样就相当方便了。除此之外,我们还可以通过操作二进制文件来生成图片:
Kyle McCormick 在 StackExchange 上发起了一个叫做 Tweetable Mathematical Art 的比赛,参赛者需要用三条推这么长的代码来生成一张图片。具体地说,参赛者需要用 C++ 语言编写 RD 、 GR 、 BL 三个函数,每个函数都不能超过 140 个字符。每个函数都会接到 i 和 j 两个整型参数(0 ≤ i, j ≤ 1023),然后需要返回一个 0 到 255 之间的整数,表示位于 (i, j) 的像素点的颜色值。举个例子,如果 RD(0, 0) 和 GR(0, 0) 返回的都是 0 ,但 BL(0, 0) 返回的是 255 ,那么图像的最左上角那个像素就是蓝色。参赛者编写的代码会被插进下面这段程序当中(我做了一些细微的改动),最终会生成一个大小为 1024×1024 的图片。
#include // 源代码均为C++代码,这里改成了C
#include
#include
#define DIM 1024
#define DM1 (DIM-1)
#define _sq(x) ((x)*(x)) // square
#define _cb(x) abs((x)*(x)*(x)) // absolute value of cube
#define _cr(x) (unsigned char)(pow((x),1.0/3.0)) // cube root
unsigned char GR(int,int);
unsigned char BL(int,int);
unsigned char RD(int i,int j){
// YOUR CODE HERE
}
unsigned char GR(int i,int j){
// YOUR CODE HERE
}
unsigned char BL(int i,int j){
// YOUR CODE HERE
}
void pixel_write(int,int);
FILE *fp;
int main(){
fp = fopen("MathPic.ppm","wb");
fprintf(fp, "P6\n%d %d\n255\n", DIM, DIM);
for(int j=0;j<DIM;j++)
for(int i=0;i<DIM;i++)
pixel_write(i,j);
fclose(fp);
return 0;
}
void pixel_write(int i, int j){
static unsigned char color[3];
color[0] = RD(i,j)&255;
color[1] = GR(i,j)&255;
color[2] = BL(i,j)&255;
fwrite(color, 1, 3, fp);
}
Martin Büttner的代码如下:
unsigned char RD(int i,int j){
return (char)(_sq(cos(atan2(j-512,i-512)/2))*255);
}
unsigned char GR(int i,int j){
return (char)(_sq(cos(atan2(j-512,i-512)/2-2*acos(-1)/3))*255);
}
unsigned char BL(int i,int j){
return (char)(_sq(cos(atan2(j-512,i-512)/2+2*acos(-1)/3))*255);
}
好看!你也可以尝试一下写出自己的三色代码然后生成一张图片,.ppm文件需要使用Photoshop打开。
这一章的内容比较少,主要是简单介绍了关于文件读写的一些内容,其实现在用C语言直接读写文件的情况已经是比较少的了,对于数据更多的情况,一般会选择使用MySQL或者轻量级的SQLite这些数据库来完成数据的管理和操作。
下一章将是最后一章,我们来讲讲神奇的位运算。