刚开始学习C++时,我们通常会这样输入和输出数据:
int value=0; std::cin>>value; std::cout<<value;后来学习文件操作,又会遇到类似的代码:
std::ifstream input("data.txt"); input>>value;这里的数据来源从键盘变成了文件,但读取方式仍然是>>。这正是C++IO流的作用:通过一套相近的接口,处理不同源和去向的数据。
这篇文章从流的基本概念出发,介绍标准输入输出、文件流和字符串流,并整理使用过程中容易遇到的问题。
一、什么是流?
可以把流理解为程序读写数据的一条通道。判断输入还是输出时,要以程序为参照:
数据来源->输入->程序->输出->数据目标
例如:
- 从键盘读取一个整数,是输入。
- 从文件读取一行文本,也是输入。
- 在屏幕上显示结果,是输出。
- 将结果保存到文件,同样是输出。
因此,“输入”并不是指键盘输入,“输出”也不只指屏幕输出。
实际读写时,数据还可能经过缓冲区。缓冲区可以暂存数据,减少频繁进行底层I/O的开销。不过,向流中输入数据,不一定意味着数据已经立即显示在屏幕上或写到磁盘中。
C++标准库提供了不同的流类型:
| 操作对象 | 输入 | 输出 | 输入与输出 |
| 基本流接口 | istream | ostream | iostream |
| 文件 | ifstream | ofstream | fstream |
| 字符串 | istringstream | ostringstream | stringstream |
先记住两个方向:
input>>value;//从流中提取数据 output<<value;//向流中插入数据在整数运算中,>>和<<可以表示移位;用于流对象时,通过运算符重载,它们具有了提取和插入数据的含义。
二、标准输入输出:不只是cin和cout
包含<iostream>,可以使用标准库提供的几个流对象:
| 对象 | 用途 |
| std::cin | 标准输入 |
| std::cout | 标准输出 |
| std::cerr | 错误和诊断输出 |
| std::clog | 日志和诊断输出 |
它们是已经创建号的对象,不是函数。
下面是一个完整的示例:
#include<iostream> int main() { int number = 0; std::cout << "请输入一个整数:"; if (std::cin >> number) { std::cout << "你输入的是:" << number << '\n'; } else { std::cerr << "输入的内容无法解析为整数\n"; return 1; } return 0; }cin通常从键盘读取,cout通常输出到终端,但也可以通过重定向连接文件等其他目标。
cerr和clog都适合输出诊断信息,不过它们的默认刷新行为不同:cerr默认启动unitbug,输出操作完成后会触发刷新;clog默认没有这一设置。使用clog也不会自动生成日志文件。
另一个常见区别是:
std::cout<<'\n'; //换行 std::cout<<std::endl; //换行,并刷新流只需要换行时,一般使用'\n'即可。
三、为什么cin>>读不到带空格的完整句子?
不同读取方式有不同的规则。
对于常见的格式化提取操作,>>默认会跳过前导空白,再按照目标类型读取内容。例如:
int a=0,b=0; std::cin>>a>>b;输入:
10 20或者:
10 20都能读到两个整数。对这里的读取操作来说,空格和换行都可以起分隔作用。
但如果目标是std::string:
std::string text; std::cin>>text;输入:
hello C++text只会得到"hello",后面的内容留待后续读取。
原因是:>>向字符串提取数据时,遇到后续空白就结束当前字符串的读取。
要读取整行,应使用std::getline:
#include<iostream> #include<string> int main(){ std::string line; if(std::getline(std::cin,line)){ std::cout<<line<<'\n'; } }默认情况下,getline:
- 保留行内空格。
- 遇到换行时结束当前行。
- 取走换行符,但不把它存入字符串。
- 也可能因为输入结束或错误而停止。
它还支持指定分隔符,例如:
std::getline(std::cin,line,',');此时逗号成为分隔符/
混用>>和getline时,要处理残留换行。
假设先读取年龄,再读取下一行的姓名:
std::cin >> age; std::getline(std::cin, name);输入年龄后留下的换行,可能被getline立即读到,使name成为空字符串。
如果姓名应当从下一行开始,可以先丢弃当前行剩余内存:
#include<iostream> #include<string> #include<limits> int main() { int age = 0; std::string name; if (!(std::cin >> age)) { std::cerr << "年龄输入错误\n"; return 1; } std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); std::getline(std::cin, name); std::cout << name << "," << age << " 岁\n"; }这里的ignore会丢弃当前行剩余的所以内容,直到取走换行符,并非只删除一个字符。
四、读写操作可能失败:理解流状态
输入一个整数看似简单,但用户可能输入字母,文件可能读到末尾,底层设备也可能发生错误。
流对象使用状态位记录这些情况:
| 状态位 | 含义 |
| goodbit | 没有设置错误状态位 |
| eofbit | 读取过程中输入末尾 |
| failbit | 格式转换或提取操作失败 |
| badbit | 流的底层读写过程发生故障 |
这些状态可以同时存在。
最常见的判断方式,是直接检查读取表达式:
int value = 0; while (std::cin >> value) { std::cout << value * 2 << '\n'; }这段代码表示:每成功读取一个整数,就处理一次。
之所以成立,是因为>>返回流自身的引用,而流在条件判断中会检查是否存在failbait或badbit。
不要把它简单理解成“判断有没有到文件末尾”。到达末尾和本次读取失败并不完全相同。例如,最后一个生疏可能已被成功读出,同时流记录了eofbit。
因此,读取文件应优先使用:
while (intput >> value) { //使用本次成功读到的数据 }而不是提取判断:
while (!input.eof()) { //此时还不知道下一次读取是否成功 }如果希望在格式错误后重新接受输入,通常需要:
std::cin.clear(); std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');//丢弃本行剩余的错误输入 }两者解决的是不同问题:clear()重置状态,ignore()消费输入内容。
五、文件流:把数据来源和目标换成文件
文件操作需要包含<fstream>:
| 类型 | 主要用途 | 默认打开方式 |
| ifstream | 读取文件 | in |
| ofstream | 写入文件 | out |
| fstream | 读取和写入 | in | out |
可以在常见对象时打开文件,也可以之后调用open()。
例如,将两行文本保存到文件:
#include<fstream> #include<iostream> int main() { std::ofstream output("notes.txt"); if (!output) { std::cerr << "文件打开失败\n"; return 1; } output << "学习C++IO流\n"; output << "练习文件读写\n"; output.close(); if (!output) { std::cerr << "文件写入或关闭失败\n"; return 1; } return 0; }文件流对象销毁时会自动关闭文件。这里显示调用close(),是为了在程序结束前检查写入和关闭时否成功。
读取文件时,同样可以逐行处理:
#include<fstream> #include<iostream> #include<string> int main() { std::ifstream input("notes.txt"); if (!input) { std::cerr << "文件打开失败\n"; return 1; } std::string line; while (std::getline(input, line)) { std::cout << line << '\n'; } return 0; }可以看到,getline的第一个参数从cin换成了文件流,其它用法基本不变。
打开模式模式绝对如何访问文件。
| 模式 | 作用 |
| std::ios::in | 允许读取 |
| std::ios::out | 允许写入 |
| std::ios::app | 每次写入前定位到末尾 |
| std::ios::ate | 打开后立即定位到末尾 |
| std::ios::trunc | 打开时清空已有内容 |
| std::ios::binary | 使用二进制模式 |
模式之间可以用 | 组合:
std::ofstream output("notes.txt", std::ios::out | std::ios::app);需要特别区分:
- app:每次写入都追加到文件末尾。
- ate:仅在打开后定位末尾,之后仍可重新定位。
另外,默认创建ofstream并打开已有文件,会截断原内容;想保留内容并追加,应指定app。默认的fstream读写模式则通常要求文件已经存在。
六、二进制和read、write是什么关系?
这两个概念容易被混淆:
- 打开模式决定访问时是否进行平台相关的文本转换。
- 读写接口决定是否进行数据格式转换。
std::ofstream output("number.bin", std::ios::binary); output << 123;虽然使用了二进制模式,但<<仍会将整数转换尾文本表示。
二进制模式不会自动把123写成一个int的内存表示,扩展名.bin也不会改变这一点。
常见读写接口可以分为:
| 接口 | 功能 |
| >>、<< | 格式化提取和插入 |
| get()、put() | 字符读写 |
| read()、write() | 按指定数量读写字符单位 |
| tellg()、seekg() | 获取、调整读取位置 |
| tellp()、seekp() | 获取、调整写入位置 |
对普通char文件流来说,read和write常用于字节数据处理。
使用read时尤其要注意:它不会自动补充字符串结束符'\0'。因此,读取到字符数组后,不能直接假定它时合法的C风格字符串。
下面演示分块读取,并按照实际读取量输出:
#include<fstream> #include<iostream> int main() { std::ifstream input("notes.txt", std::ios::binary); if (!input) { std::cerr << "文件打开失败\n"; return 1; } char buffer[1024]; while (input.read(buffer, sizeof buffer) || input.gcount() > 0) { std::cout.write(buffer, input.gcount()); } if (input.bad() || (input.fail() && !input.eof())) { std::cerr << "文件读取失败\n"; return 1; } return 0; }最后一次读取可能不足1024字节。gcount()返回实际读取的数量,因此这部分数据也能被处理。
这里使用cout.write(),无需依赖'\0'判断结束位置。
七、字符串流:在内存中完成格式化和解析
文件流连接文件,字符串流连接内存中的字符串。
使用字符串流需要包含<sstream>:
| 类型 | 用途 |
| ostringstream | 将数据格式化到字符串 |
| istringstream | 从字符串解析数据 |
| stringstream | 同时支持输入与输出 |
例如,拼接一条包含不同类型数据的信息:
#include<iostream> #include<sstream> #include<string> int main() { std::ostringstream output; output << "姓名:" << "小林" << ",年龄:" << 20 << ",成绩:" << 92.5; std::string message = output.str(); std::cout << message << endl; return 0; }str()返回字符串流当前保存的完整字符内容。
反过来,也可以从字符串中解析数据:
std::istringstream input("20 92.5"); int age = 0; double score = 0.0; if (input >> age >> score) { std::cout << age << ' ' << score << endl; }字符串流仍然遵循流的读取规则:
- >>默认按格式解析,跳过前导空白。
- 向std::string提取时,遇到后续空白会停止。
- 格式不匹配扔会失败。
- 成功提取几个字段,并不意味着已经消费了字符串中的全部内容。
例如,从"abc"中提取整数就会失败。字符串流省去了许多手动管理数组的工作,但不能省略结果检查。
复用字符串时,要分清内容和状态。
std::stringstream stream; stream << "123"; int number = 0; stream >> number; //准备下一次使用 stream.str("");//清空底层字符串 stream.clear();//清除状态位 stream << "456"; stream >> number;可以用下面的表格记忆:
| 操作 | 作用 |
| stream.str() | 获取完整字符串内容 |
| stream.str("") | 清空字符串内容 |
| stream.clear() | 重置流状态 |
| stream>>value | 从当前位置解析数据 |
读取到末尾可能设置eofbit,提取失败可能设置failbit,不能笼统地认为每次转换结束都会设置badbit。
八、让自定义类型也能使用流
标准库为常见类型提供了流操作重载。对于自己的类型,也可以定义相应接口。
例如,一个二维坐标:
#include<iostream> struct Point { int x = 0; int y = 0; }; std::ostream& operator<<(std::ostream& out, const Point& point) { return out << point.x << ' ' << point.y; } std::istream& operator>>(std::istream& in, Point& point) { int x = 0; int y = 0; if (in >> x >> y) { point.x = x; point.y = y; } return in; } int main() { Point point; if (std::cin >> point) { std::cout << "坐标:" << point << '\n'; } }这里有三个设计要点:
- 输出使用const Point&,因为输出不需要修改对象。
- 输入使用Point&,因为读取结果需要保存到对象中/
- 返回流引用,以支持连续操作,并保留状态供调用者检查。
输入时先读入局部变量,两个值都读取成功后再更新对象,可以避免只更新一半坐标。
这些运算符采用非成员函数形式,因为左操作数时流对象。如果需要访问类型的私有成员,可以通过公共接口实现,也可以将函数声明为友元。
由于参数类型时istream&和ostream&,这些重载也能用于文件流和字符串流:
std::ofstream output("point.txt"); output << point;至此,控制台、文件、字符串和自定义类型就通过同一套流接口连接起来了。