C——文件操作

1.前言

为什么要使用文件呢?

文件是储存在电脑的磁盘中的,如果没有文件,我们写程序的数据就会存储在电脑的内存中,程序退出,操作系统就会收回内存,数据就丢失了等再次运行程序的时候,是看不到上次的数据的,如果想要将数据持久化的保存,我们就可以使用文件。

2.什么是文件?

在磁盘上存储的文件就是文件。磁盘就是电脑上的硬盘存储,而文件就是电脑中的C盘、D盘等等上的文件。但是在程序操作中,我们一般谈的文件有两种:程序文件、数据文件(从文件的功能来分类的)。

2.1程序文件

程序文件包括源文件(.c为后缀的文件),目标文件(.obj为后缀的文件),可执行程序(.exe为后缀的文件)。

2.2数据文件

文件的内容不⼀定是程序,而是程序运行时读写的数据,比如程序运行需要从中读取数据的文件,或者输出内容的文件。

我们在此讨论的是数据文件。

在以前的学习中所处理的数据的输入输出都是以终端为对象的,即从终端键盘上获取数据,将数据的运行结果打印到终端屏幕上。

而我们有时候需要将数据输出到磁盘中,或者从磁盘中得到我们需要的数据然后在内存上进行使用。这里我们就是对数据文件进行操作。

2.3文件名

文件要有一个唯一的标识符,以便用户识别和引用。

文件名包含三部分:文件路径+文件名主干+文件后缀。

为了方便起见,文件标识常被称为文件名。我们将下面的就叫做文件名。

3.二进制文件和文本文件

根据数据的组织形式,数据文件被分为二进制文件和文本文件。

数据在内存中以二进制的形式存储,如果不加任何转化直接存入文件中,就是二进制文件。

如果要求外存时以ASCII码值的形式存储,则需要在存储前进行转化。以ASCII字符的形式存储的文件就是文本文件。

如果用通俗的语言来说,文本文件是我们用眼睛能够看懂的,而二进制文件我们是看不懂的。

下面我来写段代码给大家看一下文本文件和二进制文件的区别。

我利用该代码创建了一个test1.txt的文本文件和test2.txt的二进制文件。我给两个文件内存的东西是一样的数组,我们现在来看一下这两个文件的内容。

而当我们打开test2.txt时,系统就会提示该文件是二进制文件。

而当我们以记事本的形式打开的时候发现里面是一些我们看不懂的东西。这就是二进制文本与文本文件最明显的区别。 我们利用VS里面的二进制编辑器来打开该文件看看。

打开后为:

我们刚才给该文件存的是1,2,3,4,5这5个整数,而在该文件中确实是以二进制的形式存储了这5个数。

4.文件的打开和关闭

4.1流和标准流

4.1.1流

我们程序的数据需要输出到各种外部设备,也需要从外部设备获取数据,不同的外部设备的输入输出操作各不相同,为了方便程序员对各种设备进行方便的操作,我们抽象出了流的概念,我们可以把流想象成流淌着字符的河。C程序针对文件、画面、键盘等的数据输入输出操作都是通过流操作的。⼀般情况下,我们要想向流里写数据,或者从流中读取数据,都是要打开流,然后操作。

4.1.2标准流

那为什么我们从键盘输⼊数据,向屏幕上输出数据,并没有打开流呢? 那是因为C语言程序在启动的时候,默认打开了3个流:

  1. stdin-标准输入流,在大多数的环境中从键盘输入,scanf函数就是从标准输入流中读取数据。
  2. stdout-标准输出流,大多数的环境中输出至显示器界面,printf函数就是将信息输出到标准输出流中。
  3. stderr-标准错误流,大多数环境中输出到显示器界面。

这是默认打开了这三个流,我们使用scanf、printf等函数就可以·1直接进行输入输出操作。stdin、stdou、stderr这三个流的类型是FILE*,通常被称为文件指针。在C语言中,就是通过FILE*的文件指针来维护流的各种操作的。

而在我个人看来,流其实就是C程序所面对的一个对象,window终端可以被看作一个流,对其操作就是利用键盘获取数据,利用屏幕打印数据。而对文件来说,我们可以将文件看作一个文件流,C程序通过各种函数对该文件进行写数据和读数据等操作。

4.2文件指针

我们对文件的各种操作都要依靠于文件指针。

当我们打开一个文件时,此时系统会内存生成内存空间作为文件信息区,用来存放文件的相关信息。这些信息是保存在一个结构体变量中的。该结构体变量由系统进行声明,取名FILE。

例如VS2013编译环境提供的<stdio.h>的头文件中有以下的文件类型声明:

struct _iobuf 
{
	char* _ptr;
	int _cnt;
	char* _base;
	int _flag;
	int _file;
	int _charbuf;
	int _bufsiz;
	char* _tmpfname;
};
typedef struct _iobuf FILE;

不同的C语言编译器中FILE类型包含的内容是不完全相同的,但是大同小异。

每当我们打开一个文件时,程序就会自动创建一个FILE类型的变量,并自动填充里面的内容,使用者不必关心里面的细节。

我们一般都是通过一个FILE*类型的指针来维护这个FILE结构体变量,这样使用起来更加方便。

FILE* pf;//文件指针

定义pf为一个指向FILE结构体变量的结构体指针变量。我们可以使pf指向某个文件的文件信息区的任何一个位置。通过该文件信息区就能访问到该文件。也就是说,利用文件指针可以间接找到我们需要的文件。

4.3文件的打开和关闭

文件在读写之前应该先打开文件,读写完毕后关闭文件。打开文件的时候会返回一个FILE*类型的指针。ANSI 规定使用fopen打开文件,使用fclose来关闭文件。

const char *filename:需要打开的文件的文件名

const char * mode:打开文件的方式

打开文件的方式有:

|-----------|----------------------|-----------|
| 文件使用方式 | 含义 | 如果指定文件不存在 |
| "r"(只读) | 为了输入数据,打开一个已经存在的文本文件 | 出错 |
| "w"(只写) | 为了输出数据,打开一个文本文件 | 建立一个新的文件 |
| "a"(追加) | 向文本文件尾添加数据 | 建立一个新的文件 |
| "rb"(只读) | 为了输入数据,打开一个二进制文件 | 出错 |
| "wb"(只写) | 为了输出数据,打开一个二进制文件 | 建立一个新的文件 |
| "ab"(追加) | 向一个二进制文件尾添加数据 | 建立一个新的文件 |
| "r+"(读写) | 为了读和写,打开一个文本文件 | 出错 |
| "w+"(读写) | 为了读和写,建立一个新的文件 | 建立一个新的文件 |
| "a+"(读写) | 打开一个文件,在文件尾进行读写 | 建立一个新的文件 |
| "rb+"(读写) | 为了读和写打开一个二进制文件 | 出错 |
| "wb+"(读写) | 为了读和写,新建一个新的二进制文件 | 建立一个新的文件 |
| "ab+"(读写) | 打开一个二进制文件,在文件尾进行读写 | 建立一个新的文件 |

我们现在利用上面的知识来打开一个文件。

#include <stdio.h>

int main()
{
	//打开文件
	FILE* pf = fopen("demo.txt", "r");//我们要以读的形式打开demo.txt这个文件
	return 0;
}

但是我们一定可以打开成功么?我们来看看fopen的返回值

如果成功打开,则返回一个FILE*类型的指针,打开失败则返回空指针。为了避免对空指针的解引用操作,我们在使用该文件指针之前,最好对其进行判断,看其是否为空。如果打开失败,我们就没必要在运行该程序了,直接终止程序。

int main()
{
	//打开文件
	FILE* pf = fopen("demo.txt", "r");//我们要以读的形式打开demo.txt这个文件
	if (pf == NULL)
	{
		perror("fopen");
		return -1;
	}
	return 0;
}

我们下来运行该程序看看结果如何?

打印了错误信息,说明打开失败了。这是为什么呢?我们以读的形式打开该文件,我们知道以读的形式打开文件时,如果文件不存在会报错。那么就说明我们当前目录下没有demo.txt这个文件。

我们打开该目录发现确实没有该文件,所以以读的形式打开时就会出错。

而我们先前已经知道以写的形式,如果文件存在则正常打开,如果文件不存在,则会创建一个该文件名的文件。

int main()
{
	//打开文件
	FILE* pf = fopen("demo.txt", "w");//我们要以写的形式打开demo.txt这个文件
	if (pf == NULL)
	{
		perror("fopen");
		return -1;
	}
	return 0;
}

我们当前目录下并没有该文件,我们看是否可以生成一个以demo.txt为文件名的文件。

运行成功了,并没有报错。因为我们并没有任何打印语句,所以黑框框上面没有打印信息。我们再看下图,的确在当前路径下面生成了一个新的文件,文件名与我们写的相同。

通过上述操作我们就成功打开了一个文件,然后我们就可以对文件进行操作。等我们操作完后,文件就这么放这么?当然不行。操作系统打开文件的数量是有上限的。我们在对文件完成操作后应该进行关闭文件的操作。这就要用到另一个函数------fclose。

我们就以上面打开的文件,再对其进行关闭操作。fclose的参数就是一个文件指针,打开文件会返回一个文件指针,我们只需把文件指针传给fclose即可。

int main()
{
	//打开文件
	FILE* pf = fopen("demo.txt", "w");//我们要以写的形式打开demo.txt这个文件
	if (pf == NULL)
	{
		perror("fopen");
		return -1;
	}

	//对文件进行操作
	//....

	//关闭文件
	fclose(pf);
	return 0;
}

那这样就可以了嘛?我们只是关闭了文件,那就说明文件信息区就被关闭了,文件指针pf就没有指向的地址了,那它就有可能成为野指针。为了避免这种情况发生,我们在关闭文件后,最好将文件指针pf置为空,这样就规避了野指针的风险。

	//关闭文件
	fclose(pf);
	pf = NULL;

5.文件的顺序读写

5.1顺序读写函数介绍

|---------|---------|-------|
| 函数名 | 功能 | 适用于 |
| fgetc | 字符输入函数 | 所有输入流 |
| fputc | 字符输出函数 | 所有输出流 |
| fgets | 文本行输入函数 | 所有输入流 |
| fputs | 文本行输出函数 | 所有输出流 |
| fscanf | 格式化输入函数 | 所有输入流 |
| fprintf | 格式化输出函数 | 所有输出流 |
| fread | 二进制输入 | 文件 |
| fwrite | 二进制输出 | 文件 |

我现在依次简单介绍这几个函数。

5.1.1字符输入函数

fputc

该函数的功能是写一个字符到流中。参数character就是我们要写进去的字符,stream就是我们要写进去的流,其实就是文件流,这里我们把对应文件的文件指针传过去即可。

该函数是给一个文件写字符,所以我们打开文件应该以读的形式。

//fputc
#include <stdio.h>

int main()
{
	//打开文件
	FILE* pf = fopen("demo.txt", "w");//我们要以写的形式打开demo.txt这个文件
	if (pf == NULL)
	{
		perror("fopen");
		return -1;
	}

	//将一个字符写入该文件中
	fputc('a', pf);
	
	//关闭文件
	fclose(pf);
	pf = NULL;
	return 0;
}

我们看到,确实将字符'a'写进了该文件。那现在,我们将26个字母全都写进该文件中。

	//将26个字母全都写入该文件中
	char ch = 'a';
	while (ch<='z')
	{
		fputc(ch, pf);
		ch++;
	}

我们也成功将26个字母写进了文件,我们看到,第一次写进去的a已经不见了,所以以写的形式打开文件,每次写之前都会将文件清空再往里面写。

fgetc

fgetc的功能是从文件流中读取一个字符,参数就是文件指针。 我们现在将上面给文件写进去的字符取出来,打印在屏幕上。我们现在是要从文件读取字符,所以我们要以读的形式打开文件。

//fgetc
#include <stdio.h>

int main()
{
	//打开文件
	FILE* pf = fopen("demo.txt", "r");//我们要以读的形式打开demo.txt这个文件
	if (pf == NULL)
	{
		perror("fopen");
		return -1;
	}

	//将文件中的26个字母取出来
	int ch = 0;
	while ((ch = fgetc(pf)) != EOF)
	{
		printf("%c", ch);
	}

	//关闭文件
	fclose(pf);
	pf = NULL;
	return 0;
}

注意fgetc函数一次只能读取一个字符,读到文件末尾或者读取失败会返回EOF。

fputs

该函数的功能是将一个字符串写入流中。const char* str:就是字符串的首地址,FILE* stream就是指向流的指针。

//fputs
#include <stdio.h>

int main()
{
	char arr[] = "abcdefg";

	//打开文件
	FILE * pf = fopen("test.txt", "w");
	if (pf == NULL)
	{
		perror("fopen");
		return -1;
	}

	//将字符串写入文件流中
	fputs(arr, pf);

	//关闭文件
	fclose(pf);
	pf = NULL;
	return 0;
}

运行程序,我们就可以将arr字符数组的内容写入文件中。

fgets

该函数的功能是从文件流中得到一个字符串。

char *str:用来存储从文件中获得的字符串

int num:复制到str的最大字符数

FILE *stream:文件指针

我们事先在该文件下存放了这个字符串,我们利用fgets取出他,后将他打印在屏幕上。

我们还需要注意的是,fgets会将\0也放入字符数组中。

我们看,它会将字符串复制过去后在追加一个\0在末尾。该函数会优先考虑\0,如果最大复制数为1,那么他就会把\0放到数组中。所以实际上你的最大复制数是num-1。

换行符会使fgets停止读取。 我们给该文件中放入三行信息,是否能一次性打印呢?

尽管我们的最大字符数已经大于文件所有的字符数,但是依旧不能全部打印出来。这是因为fgets遇到换行符会自动停止读取。所以为了将这三行信息打印出来,我们可以分三次打印。

fprintf

fprintf与上面的区别在于,它可以将格式化的字符写入文件中。

//fprintf
#include <stdio.h>

struct S
{
	char name[20];
	int age;
	float weight;
};

int main()
{
	struct S s = { "zhangsan",18,65.5 };

	//打开文件
	FILE* pf = fopen("test.txt", "w");
	if (pf == NULL)
	{
		perror("fopen");
		return -1;
	}

	//将结构体中的格式化的数据写入文件中
	fprintf(pf, "%s %d %f", s.name, s.age, s.weight);

	//关闭文件
	fclose(pf);
	pf = NULL;

	return 0;
}

程序运行后,就将该格式化的字符写入文件中了

fscanf

fscanf的用法刚好和fprintf相反,它是从文件中读取格式化的数据。

	//将文件中的格式化数据读取后存放在结构体变量中
	fscanf(pf, "%s %d %f", s.name, &(s.age), &(s.weight));
	//读取后将内容打印在屏幕上
	printf("%s %d %f", s.name, s.age, s.weight);

因为我们要从文件中读取数据,所以我们要以读的形式打开文件。因为我们是要取出数据放入变量中,所以我们要用地址来接收。

fread和fwrite

这两个函数是以二进制的形式将数据写入文件流中或者从文件流中读取。

const void * ptr:存放数据的数组

size_t size:数组中每个数据的大小

size_t:数组中元素的个数

FILE * stream:文件流指针

//fwrite
#include <stdio.h>

int main()
{
	int arr[] = { 1,2,3,4,5 };
	//打开文件
	FILE* pf = fopen("test.txt", "w");
	if (pf == NULL)
	{
		perror("fopen");
		return -1;
	}

	//将数组arr的数据以二进制的形式写入文件中
	fwrite(arr,sizeof(int),5,pf);

	//关闭文件
	fclose(pf);
	pf = NULL;

	return 0;
}

程序运行结束后,该数组中的内容就以二进制的形式存放进了该文件中。而数据以二进制的形式存放在文件中我们是看不懂的。

存放后,我们在利用fread函数,以二进制的形式将上述数据读取出来并打印在屏幕上。 我们这下利用fread函数来实现。

fread函数的功能就是从文件中读取数据块。

void * ptr:存放数据块的数组

size_t size:每个数据的大小

size_t count:有多少个数据

FILE * stream:文件流指针

	//将文件中储存的5个整形读取出来存放在arr中,并打印在屏幕上
	fread(arr, sizeof(int), 5, pf);
	int i = 0;
	for (i = 0; i < 5; i++)
	{
		printf("%d ", arr[i]);
	}

存放数据的数据必须足够大。我们要从文件中读取数据,所以要采用读的形式打开文件。

5.2对比一组函数

scanf/fscanf/sscanf

printf/fprintf/sprintf

5.2.1scanf/fscanf/sscanf

scanf和fscanf我们已经了解,现在我们了解一下sscanf这个函数。

//sscanf
#include <stdio.h>

struct S
{
	char name[20];
	int age;
	float weight;
};
int main()
{
	struct S s = { 0 };
	char str[20] = "zhagnsan 18 65.5";

	//从字符串中读取格式化的数据放入结构体变量中
	sscanf(str, "%s %d %f", s.name, &(s.age), &(s.weight));
	//打印结构体变量
	printf("%s %d %f",s.name,s.age,s.weight);

	return 0;
}

我们使用该函数后,再利用printf格式化打印结构体变量可以成功打印。说明sscanf确实将字符串中的内容转化成了格式化的数据。

5.2.2printf/fprintf/sprintf

我们来看一下sprintf函数:

//sprintf
#include <stdio.h>

struct S
{
	char name[20];
	int age;
	float weight;
};
int main()
{
	struct S s = { "zhagnsan", 18, 65.5 };
	char str[30] = { 0 };

	//从结构体变量中读取格式化数据转化成字符串存放在str中
	sprintf(str, "%s %d %f", s.name, s.age, s.weight);
	//打印字符串
	printf(str);

	return 0;
}

使用完该函数后,我们成功将格式化的数据存放进了字符串中,并通过打印字符串显示出了刚才的数据。

6.文件的随机读写

我们利用上面的函数,只能按照顺序从前到后的读取数据或者写入数据。如果我们想指哪打哪可不可以呢?

当然是可以的。下面来介绍随机读写可能用到的几个函数。

6.1fseek

fseek的功能就是使文件指针指向以orign为参考位置偏移量为offset的位置处。

FILE * stream:文件指针

long int offset:偏移量

int origin:参考位置

对于参考位置来说,有三种位置:

第一个指的是文件起始位置,第二个指的是文件指针的当前位置,第三个指的是文件末尾。

如果我们直接使用fgetc读取的话,只会按照顺序一个一个读取,如果我们想取出a后,然后直接取出f呢?我们就可以利用fseek函数实现。

//fseek
#include <stdio.h>

int main()
{
	//以读的形式打开文件
	FILE* pf = fopen("test.txt", "r");
	if (pf == NULL)
	{
		perror("fopen");
		return -1;
	}

	//文件中已经存放了abcdefghi这个字符串,如果我们直接使用fgetc就会拿到a
	int ch = fgetc(pf);
	printf("%c\n", ch);

	//我们以起始位置为参考,只需偏移5个位置,就可以拿到f
	fseek(pf, 5, SEEK_SET);
	//此时,pf已经指向f了,我们现在在利用fgetc读取一个字符,读到的就是f
	ch = fgetc(pf);
	printf("%c\n", ch);

	//关闭文件
	fclose(pf);
	pf = NULL;
	return 0;
}

6.2ftell

ftell的功能是返回当前文件指针的当前位置。

6.3rewind

该函数的功能就是使文件指针重新指向文件的起始位置。

//rewind
#include <stdio.h>

int main()
{
	//以读的形式打开文件
	FILE* pf = fopen("test.txt", "r");
	if (pf == NULL)
	{
		perror("fopen");
		return -1;
	}
	//文件事先已经写入了abcdefghi
	//我们以起始位置为参考,只需偏移5个位置,就可以拿到f
	fseek(pf, 5, SEEK_SET);
	int ch = fgetc(pf);
	printf("%c\n", ch);

	//我们在使用rewind函数,使文件指针重新指向起始位置
	rewind(pf);
	printf("%c\n", fgetc(pf));//现在在取出一个字符的话,就是a

	//关闭文件
	fclose(pf);
	pf = NULL;
	return 0;
}

7.文件读取结束的判定

7.1文本结束判定

文本文件读取是否结束,判断返回值是否为EOF(fgetc),或者NULL(fgets)

例如:

1.fgetc判断是否为EOF

2.fgets判断返回值是否为NULL

二进制文件的读取结束判断,判断返回值是否小于实际要读的个数

例如:

fread判断返回值是否小于实际要读的个数。

7.2feof的使用

该函数的作用使判断文件结束是否是因为遇到了文件末尾。如果确实是遇到文件末尾而停止,则返回非0数,如果是遇到错误而停止,则返回0。

7.3ferror的使用

该函数的作用是文件读取结束后,判断是否是因为遇到错误而停止的。 如果确实是因为遇到错误而停止,则返回非0数,如果是正常结束的,则返回0。

8.文件缓冲区

ANSIC标准采用"缓冲文件系统"处理的数据文件的,所谓缓冲文件系统是指系统自动地在内存中为程序中每一个正在使用的文件开辟一块"文件缓冲区"。从内存向磁盘输出数据会先送到内存中的缓冲区,装满缓冲区后才一起送到磁盘上。如果从磁盘向计算机读入数据,则从磁盘文件中读取数据输入到内存缓冲区(充满缓冲区),然后再从缓冲区逐个地将数据送到程序数据区(程序变量等)。缓冲区的大小根据C编译系统决定的。

这⾥可以得出⼀个结论:

因为有缓冲区的存在,C语言在操作文件的时候,需要做刷新缓冲区或者在文件操作结束的时候关闭文件。

如果不做,可能导致读写文件的问题。

完!

相关推荐
指尖上跳动的旋律14 分钟前
shell脚本定义特殊字符导致执行mysql文件错误的问题
数据库·mysql
一勺菠萝丶24 分钟前
MongoDB 常用操作指南(Docker 环境下)
数据库·mongodb·docker
m0_748244831 小时前
StarRocks 排查单副本表
大数据·数据库·python
C++忠实粉丝1 小时前
Redis 介绍和安装
数据库·redis·缓存
wmd131643067122 小时前
将微信配置信息存到数据库并进行调用
数据库·微信
是阿建吖!2 小时前
【Linux】基础IO(磁盘文件)
linux·服务器·数据库
凡人的AI工具箱2 小时前
每天40分玩转Django:Django国际化
数据库·人工智能·后端·python·django·sqlite
ClouGence2 小时前
Redis 到 Redis 数据迁移同步
数据库·redis·缓存
m0_748236582 小时前
《Web 应用项目开发:从构思到上线的全过程》
服务器·前端·数据库
苏三说技术2 小时前
Redis 性能优化的18招
数据库·redis·性能优化