Lecture 4 (Week 2 - Thursday): 流 (Streams)
Lecture 4 (Week 2 - Thursday): 流 (Streams)
概述
本讲介绍 C++ 的流(stream)体系:一个统一”读数据 / 写数据”的通用 I/O 抽象。你将理解流的类层次(ios_base → basic_ios → istream/ostream 及其派生类型),掌握 std::cout/std::cin、stringstream、ofstream/ifstream 的用法,弄清输出缓冲与 flush 机制,并避开 getline 与 >> 混用、cin 读取失败等经典陷阱。流是 C++ 一切输入输出的地基:A1(SimpleEnroll)的 CSV 解析与文件写出几乎完全建立在流之上,后续课程的几乎所有程序也离不开它。
核心特性与语法详解
1. 什么是流(Stream)
- 定义与目的:流是 C++ 的通用输入/输出设施——”a general input/output facility for C++”。它把”数据从外部来源(键盘、文件、字符串)进入程序”和”数据从程序流向外部目的地(终端、文件)”抽象成一条传送带(conveyor belt):输出流把数据送出去,输入流把数据接进来。
- 核心语法:输出
std::cout << x << '\n';;输入std::cin >> x;。 - 设计意图与最佳实践:Bjarne Stroustrup 说过:”为程序设计通用的 I/O 设施是出了名的困难。”流的答案是抽象——隐藏不必要的细节(电机、线路、缓冲、硬件),只暴露相关的接口:
<<(插入运算符 insertion operator)与>>(提取运算符 extraction operator)。就像开车不用关心引擎原理、只需踩油门和转方向盘,用流时你也不必关心数据具体怎么被搬进搬出。
2. 流的类层次(ios_base → basic_ios → istream/ostream)
- 定义与目的:所有流类型共享同一个继承体系,因此接口一致、类型可互换。
核心语法(层次结构):
ios_base └─ basic_ios ├─ istream ──→ std::cin, std::ifstream, std::istringstream └─ ostream ──→ std::cout, std::cerr, std::clog, std::ofstream, std::ostringstream- 机制与职责:
ios_base:一切流的地基,维护两类信息。- 状态信息(State Information):流的”健康指标”,例如
failbit(逻辑错误,如类型不匹配)、eofbit(到达流末尾)。 - 控制信息(Control Information):数据如何呈现,例如 255 应打印成
"255"、"FF"(十六进制)还是"377"(八进制)。
- 状态信息(State Information):流的”健康指标”,例如
basic_ios:在ios_base之上保证流工作正常,并管理数据的来源(控制台、键盘、文件……)。istream:输入流基类,主要操作是>>;ostream:输出流基类,主要操作是<<。两者交集即iostream,同时具备读写能力。
- 头文件:
#include <iostream>(cin 与 cout)、<istream>(cin)、<ostream>(cout)、<fstream>(文件流)、<sstream>(字符串流)。 - 设计意图:同一套
<</>>操作符可以作用于任何流——这正是抽象带来的接口一致性(幻灯片强调:”notice the<<and>>? That’s abstraction at work!”)。
3. 提取/插入运算符的”空白分词”规则
- 定义与目的:
<<和>>以空白(whitespace)为界切分数据,这是流最核心的行为规则。 - 核心语法:
ss >> first >> last >> language; - 机制:
>>一直读取到下一个空白字符为止。空白包括:空格' '、'\n'、'\t'、'\r'、'\f'、'\v'。注意:>>会跳过前导空白,但不会消费末尾的分隔符——这会引发后续getline的经典 bug(见陷阱部分)。
4. std::getline
- 定义与目的:
>>只能读到空白为止,无法读取”一整行”(例如带空格的名字或引语)。getline解决”按行读取”的需求。 - 核心语法:
std::getline(std::istream& is, std::string& str, char delim = '\n'); - 机制:从
is一直读取到分隔符delim(默认'\n'),把内容存入str,并且消费(吞掉)分隔符本身。幻灯片特意强调:”getline()consumes the delim character! PAY ATTENTION TO THIS :)”——这是理解一切换行残留问题的钥匙。
5. stringstream(字符串流)
- 定义与目的:把字符串当作流来使用,尤其擅长”混合数据类型的解析与格式化”(mixing data types)。
- 核心语法:构造
std::stringstream ss(initial_quote);;或先ss << ...填充再ss >> ...读出。 - 设计意图:
istringstream/ostringstream/stringstream位于类层次底部,与cin/cout共享同一接口,因此同一套<</>>代码既能处理终端也能处理字符串。CS106B 中按行读取文件、A1 提供的split工具函数,背后都是stringstream。
6. 输出流的缓冲与刷新(flush)
- 定义与目的:输出字符先进入中间缓冲区(buffer),在显式 flush 时才真正写到目的地,从而减少慢速 I/O 的次数、提升性能。
- 核心语法:
std::cout << std::flush;(只刷新)与std::cout << std::endl;(换行 + 刷新)。 - 何时刷新:
std::cout << std::flush;std::cout << std::endl;- 程序正常结束时
- 缓冲区满时
- tied 流交互时:例如
cout与cin绑定,cin取输入前cout会先 flush,保证提示语先显示。
std::endlvs'\n':std::endl= 换行 + flush;'\n'只换行。循环里用std::endl会导致每次迭代都 flush,严重拖慢程序——课程明确建议 “Use ‘\n’!”。- cerr vs clog:
cerr用于输出错误,无缓冲(立即送出);clog用于非关键事件日志,有缓冲。
7. 文件流:std::ofstream / std::ifstream
- 定义与目的:把数据写到文件 / 从文件读取数据。
- 核心语法:
- 构造:
std::ofstream ofs("hello.txt");(构造时即关联文件) - 打开标志:
std::ios::trunc(默认,截断重建)、std::ios::app(追加)、std::ios::ate(打开后立即跳到文件末尾) - 常用成员:
is_open()、open()、close()、fail()
- 构造:
- 机制与陷阱:关闭(
close())后再<<写入会静默失败(不报错、不写入);调用open()可重新关联文件(可换打开模式)。输入/输出文件流在同一来源/目的地上互补:ofstream写、ifstream读。
8. std::cin 的行为与失败
- 定义与目的:标准输入流,有缓冲:用户输入先整体进入缓冲区,程序再按空白切词从缓冲区读取。
- 核心语法:
std::cin >> pi; - 机制:若缓冲区为空,则阻塞等待用户输入;若非空,则读到下一空白为止。当
cin >> double时遇到非数字的单词(如"Fernandez"),提取失败:流置failbit,目标变量保持原值或变为 0,且之后所有>>读取都会直接失败——直到你clear()恢复状态(本讲先认识现象,A1 不会深究恢复)。
代码示例与逐步解说(核心)
示例 1:stringstream 混合类型解析(课堂代码 recap.cpp 改写)
// C++11(课程以 C++20 编译,本示例仅用 C++11 特性)
#include <iostream>
#include <sstream>
#include <string>
int main() {
// 1. 构造一个装着整段文本的字符串流(等价于 ss << initial_quote)
std::string initial_quote =
"Bjarne Stroustrup C makes it easy to shoot yourself in the foot\n";
std::stringstream ss(initial_quote);
// 2. 按空白分词,依次取走 "Bjarne" / "Stroustrup" / "C"
std::string first, last, language;
ss >> first >> last >> language;
// 3. 剩余部分用 getline 整行取走(含前导空格)
std::string extracted_quote;
std::getline(ss, extracted_quote);
std::cout << first << " " << last << " said this: '" << language
<< " " << extracted_quote << "'\n";
return 0;
}
- 代码做什么:
ss >> first >> last >> language用空白把引语前三个词切出来;std::getline(ss, extracted_quote)把剩下的" makes it easy to shoot yourself in the foot"(前面带一个空格,因为>>不消费分隔符)整行装入extracted_quote;最后打印。 - 特性机制解说:
>>是”按空白分词器”——它跳过前导空白、提取到下一个空白为止、把分隔符留在流里。而getline一直读到'\n'并把'\n'吃掉。两者一前一后配合,才能既按词取、又按行取。
示例 2:cout / cin 基本输入输出(课堂练习 Exercise 1 & 2)
// C++11
#include <iostream>
#include <string>
int main() {
// 输出:三个值用同一个 << 接口
std::cout << "Hello, Streams!\n";
std::cout << 42 << '\n';
std::cout << 3.14 << '\n';
// 输入:先提示、再读取
std::string name;
int number;
std::cout << "Enter your name: ";
std::cin >> name; // 读到下一个空白为止(假设名字是单个单词)
std::cout << "Enter your favourite number: ";
std::cin >> number;
std::cout << "Hello " << name << ", your favourite number is "
<< number << "!\n";
return 0;
}
- 代码做什么:先打印三行(字符串、int、double),再提示用户输入名字和数字并回显。
- 特性机制解说:
<<对每种内置类型都有重载(ostream& operator<<(ostream&, int/double/const char*/...)),编译器按实参类型自动挑选——这就是”类型安全”:printf("%d", 3.14)是未定义行为,而std::cout << 3.14永远正确。cin >> name与cin >> number之间由空白分隔,所以输入Alice 7或分行输入Alice\n7效果相同。
示例 3:ofstream / ifstream 文件读写(课堂练习 Exercise 4/6 改写,含追加)
// C++11
#include <fstream>
#include <iostream>
#include <string>
int main() {
// 写:默认 std::ios::trunc —— 文件被截断重建
std::ofstream out("grades.txt");
if (!out.is_open()) {
std::cerr << "Error: could not open file\n";
return 1;
}
out << "Alice" << " " << 92 << "\n";
out.close(); // 关闭后不能再写
// 追加:std::ios::app 保留原有内容
std::ofstream app("grades.txt", std::ios::app);
if (app.is_open()) {
app << "Bob" << " " << 87 << "\n";
}
app.close();
// 读回:while 循环直到文件末尾
std::ifstream in("grades.txt");
if (!in.is_open()) {
std::cerr << "Error: could not open file\n";
return 1;
}
std::string name;
int grade;
while (in >> name >> grade) { // 1. 尝试读取 2. 检查是否成功
std::cout << "Name: " << name << " | Grade: " << grade << "\n";
}
return 0;
}
- 代码做什么:写入一条成绩 → 追加第二条 → 逐条读回打印(
Alice \| 92、Bob \| 87)。 - 特性机制解说:
while (in >> name >> grade)是读取循环的标准形态:>>返回流本身,流在failbit置位(读到 EOF 或类型失败)时operator bool为false,循环自然终止——绝不要写while (!in.eof())。std::ios::trunc是默认行为:每次运行文件内容被清空重写;std::ios::app让每次打开都在文件尾追加(课堂 post-practice 的成绩追踪器正是靠它累计 3 名学生)。cerr无缓冲、立即输出,适合错误信息。
示例 4:cin 失败与 getline 混用的经典 bug(幻灯片 cinGetline 场景)
// C++11
#include <iostream>
#include <string>
int main() {
double pi, tao;
std::string name;
std::cin >> pi; // 输入: 3.14
std::getline(std::cin, name); // 第一次 getline:吃掉 >> 残留的 '\n' → name = ""
std::getline(std::cin, name); // 第二次 getline:真正读入 "Rachel Fernandez"
std::cin >> tao; // 输入: 6.2
std::cout << "my name is: " << name
<< " tao is: " << tao
<< " pi is: " << pi << '\n';
return 0;
}
- 代码做什么:输入
3.14、Rachel Fernandez、6.2,期望打印my name is: Rachel Fernandez tao is: 6.2 pi is: 3.14。 - 特性机制解说:这是本讲最经典的 bug 演示。
cin >> pi读到3.14后把'\n'留在缓冲区;紧接着的getline立刻读到这个'\n',返回空串并吞掉换行。若不补一次getline,后续cin >> tao会去读"Fernandez"——double解析失败,tao变成垃圾值且流进入failbit。解法(幻灯片):在数字读取后多调一次getline吃掉残留换行。课程结论:”Don’t usegetline()andstd::cin >>together, unless you really really have to!”
示例 5:逐行读文件 + stringstream 解析 CSV(A1 预演)
// C++11
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
int main() {
std::ifstream in("courses.csv");
if (!in.is_open()) {
std::cerr << "Error: could not open file\n";
return 1;
}
std::string line;
std::getline(in, line); // 跳过列名(header)行
while (std::getline(in, line)) { // 每个记录是一整行
std::stringstream ss(line); // 把这一行当流处理
std::string title, units, quarter;
std::getline(ss, title, ','); // 用逗号作分隔符
std::getline(ss, units, ',');
std::getline(ss, quarter);
std::cout << title << " | " << units << " | " << quarter << '\n';
}
return 0;
}
- 代码做什么:逐行读取 CSV,跳过第一行列名,把每行按逗号切成三列并打印。
- 特性机制解说:
getline的第三个参数可以换成任意分隔符——这里把默认的'\n'换成',',实现”按列读取”;外层再按'\n'按行读取,形成”行内套列”的两级解析。这正是 A1 中parse_csv的思路:ifstream按行 +stringstream/split按逗号分列。
与旧标准(如C++98)的对比
- 流本身是 C++98 时代就存在的设施:
<iostream>、<fstream>、<sstream>及其所有核心 API(<<、>>、getline、flush、open/close/is_open)从 C++98 至今几乎没有变化。本讲内容的”新旧对比”主要发生在外围工具上: - 对比 C 风格 I/O(printf / scanf / FILE*):C 的 I/O 类型不安全(格式串
%d与实参类型不匹配是未定义行为)、不可扩展(无法为自定义类型重载)、失败检测靠返回值容易漏。C++ 流对每种内置类型都有重载,自定义类型可在后续课程(L12 运算符重载)中扩展<</>>,失败有failbit/eofbit状态可查。 - 字符串 ↔ 数字转换:C++98 只能靠
stringstream(如ss << 42; ss >> str;)或 C 函数;C++11 起引入std::to_string、std::stoi/std::stod等,简单转换不再需要流。 std::endl与'\n':自 C++98 起就存在”endl附带 flush”这一行为,现代 C++ 社区一致建议循环输出用'\n'(本讲幻灯片也明确要求 “Use ‘\n’!”)。- 展望 C++23:
<print>头文件引入std::print/std::println,格式化输出更简洁高效;但流仍是通用、可扩展 I/O 的基石,理解缓冲与状态机制对任何 I/O 方式都适用。
关键要点
- 流是一条”传送带”:输入流用
>>读、输出流用<<写,同一接口覆盖控制台、文件、字符串——这就是抽象的力量。 >>按空白分词、不消费末尾分隔符;getline读整行、消费分隔符。两者混用前,先想清楚缓冲区里还剩什么。- 输出有缓冲:
'\n'只换行,std::endl换行 + flush;大量循环输出用'\n',flush 交给程序结束、缓冲区满与 tied 流。 - 文件流:构造后先查
is_open();close()后写入静默失败;追加用std::ios::app;读文件用while (in >> x)而非!in.eof()。 cin提取失败会置failbit并锁死后续读取;遇到>>与getline混用时,用”额外一次 getline”消费残留换行。
常见陷阱与注意事项
- getline 与
cin >>混用:cin >> pi之后缓冲区残留'\n',紧跟的getline读到空串。修复:多调一次getline(或std::cin.ignore())吃掉换行。课程原话:”Don’t use getline() and std::cin together, unless you really really have to!” - 对已关闭的流写入:
ofs.close()之后ofs << ...静默失败、不报错,排查时极易忽略。 - 把
std::endl当换行符:循环内每次迭代都 flush,I/O 次数暴涨,程序明显变慢——'\n'才是换行符。 cin >> double读到非数字:如cin >> tao遇到"Fernandez",提取失败、failbit置位、之后所有读取失效(示例 4 的垃圾值场景)。- 忘记检查
is_open():文件不存在或路径错误时直接读写,读侧得到空数据、写侧悄悄丢弃——先检查再操作。
关联作业提示
A1: SimpleEnroll 几乎完全建立在流之上:
- Part 1
parse_csv:用std::ifstream打开courses.csv;注意”每个记录是一整行“,所以外层用std::getline(ifs, line)逐行读,跳过第一行列名(header);再用提供的split(内部是stringstream)或getline(ss, field, ',')按逗号分列,把 Title / Units / Quarter 填入Course结构体。 - Part 2/3 写文件:用
std::ofstream写student_output/courses_offered.csv与courses_not_offered.csv;务必先写回列名行,且格式严格为<Title>,<Units>,<Quarter>(逗号后无空格),否则 autograder 不通过;写完用close()。 - 流与类型:
Course结构体的字段(std::string与int)正是流的<</>>能直接处理的类型——回忆幻灯片问题:”remember what types streams deal with?”(流处理的都是可<</>>的类型)。 - 提醒:不要在遍历
all_courses的同时删除元素(A1 明确警告)——先把要保留的课程收集到新容器,再统一删除。
