c++在线编译工具,可快速进行实验: https://www.bejson.com/runcode/cpp920/
这段时间打算重新把c++捡起来, 实习给我的一个体会就是算法工程师是去解决实际问题的,所以呢,不能被算法或者工程局限住,应时刻提高解决问题的能力,在这个过程中,我发现cpp很重要, 正好这段时间也在接触些c++开发相关的任务,所有想借这个机会把c++重新学习一遍。 在推荐领域, 目前我接触到的算法模型方面主要是基于Python, 而线上的服务全是c++(算法侧, 业务那边基本上用go),我们所谓的模型,也一般是训练好部署上线然后提供接口而已。所以现在也终于知道,为啥只单纯熟悉Python不太行了, cpp,才是yyds。
和python一样, 这个系列是重温,依然不会整理太基础性的东西,更像是查缺补漏, 不过,c++对我来说, 已经5年没有用过了, 这个缺很大, 也差不多相当重学了, 所以接下来的时间, 重温一遍啦 😉
资料参考主要是C语言中文网和光城哥写的C++教程,然后再加自己的理解和编程实验作为辅助,加深印象,当然有些地方我也会通过其他资料进行扩充。 关于更多的细节,还是建议看这两个教程。
今天开始学习文件操作, 内存中存放的数据在计算机关机后就消失了,如果想长期保存,就需要把数据保存到硬盘里面进行持久化, 为了便于管理和检索,就引入了"文件"的概念。为了便于分类,还引入了文件夹(目录)。 操作系统以文件为单位管理磁盘中的数据。 从文件的功能角度,文件大致可分为文本文件,视频文件,音频文件,图像文件,可执行文件等, 但从数据存储角度,他们本质上都一样, 都是由字节组成(0,1比特串)。不同的文件之所以呈现出不同形态(文本,视频,图像),是因为文件创建的时候就事先约定好了格式(每一部分代表什么的约定), 比如纯文本文件(每个字节是一个可见的ASCII码), 二进制文件(包括图像,视频,可执行文件)。这俩的区别本质上是格式上的区别。
文件的读写在实际使用中非常重要,毕竟我们往往需要把数据进行持久化,读取数据的时候也不能总是手动黑窗口输入,一般我们都是会通过文件的方式读取数据,然后再把最后的结果写入文件,所以不管学习哪一门语言,文件的读写一般都会涉及到,而这篇文章,就是想比较系统的把C++的文件读写操作过一遍。
主要内容:
Ok, let’s go!
cin和cout通过重定向的方式可用于读取文件中的数据和写入数据到文件。其实,C++还提供了3个类用于实现文件操作,统称为文件流类:
根据流类的派生关系:

ifstream和fstrem类都是从istream中派生来的,所以ifstream类拥有istream类全部成员方法,同理ofstream和fstream也有用ostream类的全部成员方法。 即istream和ostream中提供的cin和cout的成员调用方法,同样适用于文件流。 比如常用的 operator <<()、operator >>()、peek()、ignore()、getline()、get() 等。
和 <iostream> 头文件中定义有 ostream 和 istream 类的对象 cin 和 cout 不同,<fstream> 头文件中并没有定义可直接使用的 fstream、ifstream 和 ofstream 类对象。因此,如果我们想使用该类操作文件,需要自己创建相应类的对象。
为啥C++标准库不提供现成的类似fin或者fout的对象? 这是因为文件输入流和文件输出流设备都是硬盘中文件, 而硬盘上很多文件, 到底使用哪一个没有办法写死。 所以C++标准库就把创建文件流对象的任务交给了用户。
fstream类拥有ifstream和ofstream类所有成员方法,常用的如下:

详细的看手册
读写文件的基本流程:
open()成员方法将文件与文件流关联起来,就相当于把一个管子接到了文件上close()成员方法关闭文件, 相当于拔掉管子比如:
const char *s = "hello world";
// 第一步
fstream fs;
// 第二步
fs.open("hello.txt", ios::out);
// 第三步
fs.write(s, 12);
// 第四步
fs.close()
在对文件读写之前,要先打开文件,这样就相当于在程序和文件之间架起了桥梁, 数据就可以在桥梁上飞奔过去啦。哈哈,当然没有这么动感, 主要是下面两个目的:
打开文件的两种常用方式:
open成员函数在ifstream, ofstream, fstream中皆有定义, 原型如下:
void open(const char* szFileName, int mode)
第一个参数是指向文件名的指针,第二个参数是文件打开模式标记。这个东西代表了文件的使用方式。主要有以下几种:
| 模式标记 | 适用对象 | 作用 |
|---|---|---|
| ios::in | ifstream、fstream | 打开文件读取数据。如果文件不存在,在打开出错 |
| ios::out | ofstream、fstream | 打开文件写入数据。如果文件不存在,则新建该文件,如果文件原来存在,打开时清除原来内容 |
| ios::app | ofstream、fstream | 打开文件,在其尾部添加数据。如果文件不存在,则新建该文件 |
| ios::ate | ifstream、fstream | 打开一个已有的文件,并将文件读指针指向文件末尾,如果文件不存在,报错 |
| ios::trunc | ofstream | 打开文件时会清空内部存储的所有数据,单独使用时和ios::out相同 |
| ios::binary | ifstream、ofstream、fstream | 二进制方式打开文件。 如果不指定这个,默认是文本模式打开 |
ios::binary可以和其他模式标记组合使用:
ios::in | ios::binary表示用二进制模式,以读取的方式打开文件。ios::in | ios::binary表示用二进制模式,以读取的方式打开文件。在流对象上执行 open 成员函数,给出文件名和打开模式,就可以打开文件。判断文件打开是否成功,可以看“对象名”这个表达式的值是否为 true,如果为 true,则表示文件打开成功。
ifstream inFile;
inFile.open("c:\\tmp\\test.txt", ios::in);
if (inFile) //条件成立,则说明文件打开成功
inFile.close();
else
cout << "test.txt doesn't exist" << endl;
ofstream oFile;
oFile.open("test1.txt", ios::out);
if (!oFile) //条件成立,则说明文件打开出错
cout << "error 1" << endl;
else
oFile.close();
定义流对象时, 在构造函数中给出文件名和打开模式。比如ifstream, 它有带参的构造函数:
ifstream::ifstream (const char* szFileName, int mode = ios::in, int);
第一个参数是指向文件名的指针;第二个参数是打开文件的模式标记,默认值为ios::in; 第三个参数是整型的,也有默认值,一般极少使用。
ifstream inFile("c:\\tmp\\test.txt", ios::in);
if (inFile)
inFile.close();
else
cout << "test.txt doesn't exist" << endl;
ofstream oFile("test1.txt", ios::out);
if (!oFile)
cout << "error 1";
else
oFile.close();
这里来简单总结下文本文件和二进制文件的区别:
文本文件,通常保存肉眼可见的字符,比如.txt文件,.c文件,.dat文件等,文本编辑器打开,我们能看得懂。
二进制文件通常保存视频,图片,程序等不可阅读的内容, 文本编辑器打开,会是一堆乱码。
但从物理上讲, 这两种文件并没有区别,都是二进制形式保存在磁盘上。
我们之所以能看懂文本文件的内容,是因为文本文件中采用的是 ASCII、UTF-8、GBK 等字符编码,文本编辑器可以识别出这些编码格式,并将编码值转换成字符展示出来。
而二进制文件使用的是 mp4、gif、exe 等特殊编码格式,文本编辑器并不认识这些编码格式,只能按照字符编码格式胡乱解析,所以就成了一堆乱七八糟的字符,有的甚至都没见过。
总的来说,不同类型的文件有不同的编码格式,必须使用对应的程序(软件)才能正确解析,否则就是一堆乱码,或者无法使用。
所以呢? 文本方式和二进制方式打开或写入文件并没有本质的区别, 只是对换行符的处理不同。
总的来说,Linux 平台使用哪种打开方式都行;Windows 平台上最好用 ios::in | ios::out 等打开文本文件,用 ios::binary 打开二进制文件。但无论哪种平台,用二进制方式打开文件总是最保险的。
open()方法打开文件, 是文件流对象和文件之间建立关联的过程, 而close()方法关闭已打开的文件,把管子拔掉, 切断文件流对象与文件之间的关联。 注意:close()方法的功能是仅切断文件流与文件之间的关联,该文件流并未被销毁, 后续还可以关联其他文件。
void close()
这个使用非常简单:
const char *s = "hello world";
ofstream outFile("test.txt", ios::out);
outFile.write(s, 12);
outFile.close();
// 这里也可以通过输入输出错误看看是否close失败
// 当文件流对象未关联任何文件时, 调用close方法会失败
if (outFile.fail()){
cout << "文件操作过程发生了错误" << endl;
}
虽然我们不调用close()方法, 也可以成功写入字符串,这是因为,文件流对象生命周期结束,会自行调用析构函数, 该函数在内部销毁对象之前, 先调用close()方法切断它与任何文件的关联,再销毁, 但还是建议open it, then close do。
那么问题来了, 既然文件流对象自行销毁时会隐式调用close()方法, 那么为啥我们对于打开的文件,还需要手动调用close方法将其关闭呢?这不是多此一举了嘛!
其实并没有,原因我理解是写入文件的时候, 当真正调用close的方法时, 数据才从缓冲区写入到文件。 而如果在close方法之前出现了一个异常, 那么此时程序退出, 缓冲区的数据并没有写入到文件。
这个也是看了下面例子感觉到的:
coust char *s = "hello world";
ofstream outFile("out.txt", ios::out);
if (!outFile){cout << "打开文件失败" << endl; return 0;}
// 向out.txt中写入hello world, 此时并没有真正写入文件,而是写到了缓冲区里面暂存
outFile << s;
// 假设此时抛出了一个异常, 这时候,如果没有捕捉,程序就崩溃了,缓冲区中的字符没法写到文件中
throw "Exceptrion";
// 这句话并不起作用了
outFile.close();
对于已经打开的文件,如果不及时关闭,一旦程序出现异常,则很可能会导致之前读写文件的所有操作失效。所以,解决这个的第一个方法, 就是即使close, 比如上面后两句话换一下。
当然, 很多场景中, 肯定不会只进行一次写操作, 可能后续还会执行其他写操作,而并不像频繁的打开/关闭文件,此时使用flush()方法及时刷新输出流缓冲区, 也可以起到防止写入文件失败的作用。
coust char *s = "hello world";
ofstream outFile("out.txt", ios::out);
if (!outFile){cout << "打开文件失败" << endl; return 0;}
// 向out.txt中写入hello world, 此时并没有真正写入文件,而是写到了缓冲区里面暂存
outFile << s;
// 写入之后,及时刷新输出流缓冲区
outFile.flush();
// 假设此时抛出了一个异常, 这时候,如果没有捕捉,程序就崩溃了,缓冲区中的字符没法写到文件中
throw "Exceptrion";
// 这句话并不起作用了
outFile.close();
总之,C++ 中使用 open() 打开的文件,在读写操作执行完毕后,应及时调用 close() 方法关闭文件,或者对文件执行写操作后及时调用 flush() 方法刷新输出流缓冲区。
将数据存储在文件中时, 可以将其存储为文本格式或二进制格式。 当然从文件读数据,也会有相应的这两种格式。
<<插入运算符完成的工作。我们打开文件看的时候,就直接看到0.375这个字符串了。0011|1110|1100|0000|0000|0000|0000|0000。如果直接将上面二进制数据转成float类型,仍然得到0.375, 但对于文件来说, 它只会存储二进制数据根据既定的编码格式转换得到的字符。此时如果打开文件,看到的是一堆乱码了。0|0111110|110000000000000000000000,对应符号位|指数位|二进制分数位, 而文本表示00110000|00101110|00110011|00110111|00110111, 对应着5个字符的编码文本格式便于读取,可以使用编译器或字处理器读取和编辑,可以很方便将本文文件从一个计算机系统传输到另一个计算机系统。
二进制格式对数字比较精确, 传输速度快,占用较少空间, 但可能无法直接从一个系统传到另一个系统(毕竟这是内部表示,不同系统之间内部结构布局可能不同)。
各有优劣, 下面分别看看这两个格式的具体读写方式。
当fstream或者ifstream类对象打开文件(通常ios::in),就可以直接借助>>输入流运算符,读取文件中存储的字符或字符串;同理, 当fstream或ofstream类对象打开文件(通常ios::out)后,可直接借助<<输出流运算符向文件写入字符或字符串。
看个例子, 由于我是在上面那个网站上进行演示,所以这里没法直接从文件中读数据,所以我这里是先将屏幕上的数据输出到文件,然后再从文件中读的数据。
#include <iostream>
#include <fstream>
using namespace std;
int main()
{
int x, sum = 0;
ofstream outFile("out.txt", ios::out); // 文本模式打开out.txt
if (!outFile){
cout << "打开out文件失败" << endl;
return 0;
}
// 这里我先从屏幕上读入数值
while (cin >> x){
sum += x;
// 写出到文件out.txt中
outFile << x << " ";
}
outFile << sum << endl;
outFile.close();
// 文本模式打开out.txt
ifstream srcFile("out.txt", ios::in);
if (!srcFile){
cout << "打开out失败" << endl;
return 0;
}
// 从文本中读数据,然后输出到屏幕
while (srcFile >> x){
cout << x + 10 << endl;
}
srcFile.close();
return 0;
}
结果如下:

这个程序的执行过程,首先会把屏幕上的输入,通过cin的方式读入到程序(经过了'10' -> 10的转换), 然后求和, 再通过outFile的方式写入到out.txt(经过了10->'10'的转换), 当然又写了和sum(150->'150)。
再从out.txt,通过srcFile的方式读取字符(经过了'10'->10), 然后在原来数字基础上加10,得到了最终的输出结果。
直观上,先理解下二进制读写文件的好处。
比如,要做一个学籍管理程序, 其中一个重要工作是记录学生学号、姓名、年龄等信息,这意味着需要用一个类表示学生:
class CStudent{
char szName[20]; //假设学生姓名不超过19个字符,以 '\0' 结尾
char szId[10]; //假设学号为9位,以 '\0' 结尾
int age; // 年龄
};
如果用文本形式存储学生信息,则最终可能是:
wuzhongqiang 110923412 25
zhangsan 110923413 18
......
这种存储学生信息方式浪费空间,另外不利于查找指定学生信息,因为每个学生的信息所占用的字节数不同。
而如果是二进制形式存储到文件, 可以直接把CStudent对象写入文件, 意味着每个学生的信息只占用sizeof(CStudent)个字节。
实现二进制形式读写文件,<<和>>不再适用,需要使用C++标准库提供的read()和write()。
read(): 二进制形式从文件中读取数据write(): 二进制形式将数据写入文件ofstream 和 fstream 的 write() 成员方法实际上继承自 ostream 类,其功能是将内存中 buffer 指向的 count 个字节的内容写入文件,基本格式如下:
ostream & write(char* buffer, int count);
// buffer是写入文件的二进制数据起始位置, count用于指定字节的个数
需要注意的一点是,write() 成员方法向文件中写入若干字节,可是调用 write() 函数时并没有指定这些字节写入文件中的具体位置。
事实上,
write()方法会从文件写指针指向的位置将二进制数据写入。所谓文件写指针,是 ofstream 或 fstream 对象内部维护的一个变量,文件刚打开时,文件写指针指向的是文件的开头(如果以 ios::app 方式打开,则指向文件末尾),用write()方法写入 n 个字节,写指针指向的位置就向后移动 n 个字节。
来个例子:
class CStudent{
public:
char szName[20];
int age;
};
int main()
{
CStudent s;
ofstream outFile("students.dat", ios::out | ios::binary); // 二进制形式写入
while (cin >> s.szName >> s.age){
outFile.write((char*)&s, sizeof(s));
}
outFile.close();
return 0;
}
此时, 会自动生成一个students.dat文件,但记事本打开的时候,就会看到一堆乱码。
wuzhongqiang 烫烫烫烫烫烫烫烫?
显然, 数字没法正确显式出来。毕竟文本文件和二进制文件的编码格式不同。
ifstream 和 fstream 的 read() 方法实际上继承自 istream 类,其功能正好和 write() 方法相反,即从文件中读取 count 个字节的数据。该方法的语法格式如下:
istream & read(char *buffer, int count);
和 write() 方法类似,read() 方法从文件读指针指向的位置开始读取若干字节。
所谓文件读指针,可以理解为是 ifstream 或 fstream 对象内部维护的一个变量。文件刚打开时,文件读指针指向文件的开头(如果以
ios::app方式打开,则指向文件末尾),用read()方法读取 n 个字节,读指针指向的位置就向后移动 n 个字节。因此,打开一个文件后连续调用read()方法,就能将整个文件的内容读取出来。
下面, 把上面那个students.dat文件的内容,通过read()方法读取查看下。

get方法和put方法在上一篇文章中, 其实整理过,只不过那里是和屏幕进行交互,而这里是和文件交互,但有用法是一样的。
fstream和ofstream文件流对象调用put()方法时, 是向指定文件中写入单个字符, 语法格式如下:
ostream& put (char c);
和 put() 成员方法的功能相对的是 get() 方法,其定义在 istream 类中,借助 cin.get() 可以读取用户输入的字符。在此基础上,fstream 和 ifstream 类继承自 istream 类,因此 fstream 和 ifstream 类的对象也能调用 get() 方法。
当 fstream 和 ifstream 文件流对象调用 get() 方法时,其功能就变成了从指定文件中读取单个字符(还可以读取指定长度的字符串), 最常用两种方法格式:
int get(); // 返回值是读到字符的ASCII码, 如果到末尾了,返回值为EOF
istream& get(char& c); // 读取字符给字符变量
下面用一个例子,来演示上面两种方式, 首先是从屏幕上读入一句话,然后通过put方法写入到文件,然后再通过get方法读文件, 把结果再输出到屏幕上。
int main()
{
char c;
ofstream outFile("test.txt", ios::out | ios::binary);
if (!outFile){
cout << "error" << endl;
return 0;
}
while (cin.get(c)){
// 将字符写入到文件
outFile.put(c);
}
// 上面其实暂存到了缓冲区里面, 下面这句话才真正持久化
outFile.close();
// 下面从文件中读入数据,然后输出到屏幕
ifstream inFile("test.txt", ios::in | ios::binary);
if (!inFile){
cout << "error" << endl;
return 0;
}
while ((c=inFile.get())&&c!=EOF){ //或者 while(inFile.get(c)),对应第二种语法格式
cout << c;
}
inFile.close();
return 0;
}
输出结果:

这里说一下缓冲区存在的意义:
由于文件存放在硬盘中,硬盘的访问速度远远低于内存。如果每次写一个字节都要访问硬盘,那么文件的读写速度就会慢得不可忍受。因此,操作系统在接收到
put()方法写文件的请求时,会先将指定字符存储在一块指定的内存空间中(称为文件流输出缓冲区),等刷新该缓冲区(缓冲区满、关闭文件、手动调用flush()方法等,都会导致缓冲区刷新)时,才会将缓冲区中存储的所有字符“一股脑儿”全写入文件。
和put()方法一样,操作系统在接收到get()方法的请求后,哪怕只读取一个字符,也会一次性从文件中将很多数据(通常至少是 512 个字节,因为硬盘的一个扇区是 512 B)读到一块内存空间中(可称为文件流输入缓冲区),这样当读取下一个字符时,就不需要再访问硬盘中的文件,直接从该缓冲区中读取即可。
和cin对象的方法一样, getline()方法也适用于读取指定文件中的一行数据, 方法定义在了istream类, 由于fstream和ifstream都继承istream类,所以这俩类对象都可以调用这个方法。
// 用于从文件输入流缓冲区中读取 bufSize-1 个字符到 buf,或遇到 \n 为止(哪个条件先满足就按哪个执行),该方法会自动在 buf 中读入数据的结尾添加 '\0'
istream & getline(char* buf, int bufSize);
// 第一个版本是读到 \n 为止,第二个版本是读到 delim 字符为止。\n 或 delim 都不会被读入 buf,但会被从文件输入流缓冲区中取走
istream & getline(char* buf, int bufSize, char delim);
注意,如果文件输入流中 \n 或 delim 之前的字符个数达到或超过 bufSize,就会导致读取失败。
看如何使用:
char c[40];
// 二进制模式打开in.txt
ifstream inFile("in.txt", ios::in | ios::binary);
// 判断文件是否正常打开
if (!inFile){cout << "error" << endl; return 0;}
// 从in.txt文件中读取一行字符串, 最多不错过39个
// 如果想遇到指定的字符结束 inFile.getline(c, 40, 'c');
inFile.getline(c, 40);
cout << c;
inFile.close();
// 如果想读取文件中多行数据
// 连续以行为单位,读取in.txt文件中的数据
while (inFile.getline(c, 40)){
cout << c << endl;
}
在读写文件时, 有时希望直接跳到文件的某个地方开始读写, 这时候需要先将文件的读写指针指向该处,然后进行读写操作。
所谓"位置", 就是值距离文件开头有多少个字节。 文件开头位置是0。两个函数原型如下:
ostream &seekp(int offset, int mode);
istream &seekg(int offset, int mode);
其中,mode代表文件读写指针的设置模式, 以下三种选项:
ios::beg: 让文件读指针或写指针指向从文件开始向后的offset字节处。offset为0即文件开头, 这时候, offset是非负数ios::cur: 在此情况下, offset为负数则表示将读指针或写指针从当前位置朝着开头方向移动offset字节,为正数表示从当前位置朝文件尾部移动offset个字节。ios::end: 让文件读指针或写指针指向文件结尾,往前移动。 此时offset是0或者负数。上面这是定位, 此外我们可以得到当前读写指针的具体位置:
两个函数原型:
int tellg();
int tellp();
要获取文件长度, 可以用seekg函数将文件读指针定位到文件尾部,再用tellg函数获取文件读指针的位置,此位置即为文件长度。
一直好奇,这里的g和p表示什么? 查了下,就比较好理解, 读取的时候对应g,而写入的时候对应p了, 这里的g代表get, 而p表示put。 所以这样seekp, tellp为啥对应着ofstream, 而seekg和tellg对应istream就了然了。 另外,seek代表寻找寻求寻觅,代表着设置读写位置, 也就是找合适的读写位置, 而tell是告诉,也就是找到位置了,告诉我位置是啥?
这样, 就容易记了, 下面就看怎么用。
假设学生记录文件students.dat是按照姓名排好序的, 编写程序, 在students.dat文件中用折半查找的方法找到姓名为Jack的学生记录,并将年龄改为20(假设文件很大,无法全部读入内存)
class CStudent{
public:
char szName[20];
int age;
};
int main()
{
CStudent s;
fstream ioFile("students.dat", ios::in | ios::out); // 用既读又写的方式打开
if (!ioFile){
cout << "error";
return 0;
}
// 定位读指针到文件尾部, 以便用以后的tellg获取文件长度
ioFile.seekg(0, ios::end);
// 折半查找
int L = 0, R;
R = ioFile.tellg() / sizeof(CStudent) - 1;
do{
int mid = (L+R) / 2;
ioFile.seekg(mid*sizeof(CStudent), ios::beg); // 定位到正中的记录
ioFile.read((char *)&s, sizeof(s));
int tmp = strcmp(s.szName, "Jack");
// 找到了
if (tmp == 0){
s.age = 20;
ioFile.seekp(mid*sizeof(CStudent), ios::beg);
ioFile.write((char*)&s, sizeof(s));
break;
}else if (tmp > 0){
R = mid - 1;
}else{
L = mid + 1;
}
}while(L <= R);
ioFile.close();
return 0;
}
下面依然是一张导图,把这篇文章的主要内容拎起来了,这次主要是围绕着文件操作进行展开。
