Lecture 4 (Week 2 - Thursday): 流 (Streams)

目录 · ← l3 · l5 →

Lecture 4 (Week 2 - Thursday): 流 (Streams)

概述

本讲介绍 C++ 的流(stream)体系:一个统一”读数据 / 写数据”的通用 I/O 抽象。你将理解流的类层次(ios_basebasic_iosistream/ostream 及其派生类型),掌握 std::cout/std::cinstringstreamofstream/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"(八进制)。
    • 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;(换行 + 刷新)。
  • 何时刷新
    1. std::cout << std::flush;
    2. std::cout << std::endl;
    3. 程序正常结束时
    4. 缓冲区满时
    5. tied 流交互时:例如 coutcin 绑定,cin 取输入前 cout 会先 flush,保证提示语先显示。
  • std::endl vs '\n'std::endl = 换行 + flush'\n' 只换行。循环里用 std::endl 会导致每次迭代都 flush,严重拖慢程序——课程明确建议 “Use ‘\n’!”。
  • cerr vs clogcerr 用于输出错误,无缓冲(立即送出);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 >> namecin >> 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 \| 92Bob \| 87)。
  • 特性机制解说
    • while (in >> name >> grade)读取循环的标准形态>> 返回流本身,流在 failbit 置位(读到 EOF 或类型失败)时 operator boolfalse,循环自然终止——绝不要写 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.14Rachel Fernandez6.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 use getline() and std::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(<<>>getlineflushopen/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_stringstd::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 方式都适用。

关键要点

  1. 流是一条”传送带”:输入流用 >> 读、输出流用 << 写,同一接口覆盖控制台、文件、字符串——这就是抽象的力量。
  2. >> 按空白分词、不消费末尾分隔符getline 读整行、消费分隔符。两者混用前,先想清楚缓冲区里还剩什么。
  3. 输出有缓冲:'\n' 只换行,std::endl 换行 + flush;大量循环输出用 '\n',flush 交给程序结束、缓冲区满与 tied 流。
  4. 文件流:构造后先查 is_open()close() 后写入静默失败;追加用 std::ios::app;读文件用 while (in >> x) 而非 !in.eof()
  5. cin 提取失败会置 failbit 并锁死后续读取;遇到 >>getline 混用时,用”额外一次 getline”消费残留换行。

常见陷阱与注意事项

  1. getline 与 cin >> 混用cin >> pi 之后缓冲区残留 '\n',紧跟的 getline 读到空串。修复:多调一次 getline(或 std::cin.ignore())吃掉换行。课程原话:”Don’t use getline() and std::cin together, unless you really really have to!”
  2. 对已关闭的流写入ofs.close() 之后 ofs << ... 静默失败、不报错,排查时极易忽略。
  3. std::endl 当换行符:循环内每次迭代都 flush,I/O 次数暴涨,程序明显变慢——'\n' 才是换行符。
  4. cin >> double 读到非数字:如 cin >> tao 遇到 "Fernandez",提取失败、failbit 置位、之后所有读取失效(示例 4 的垃圾值场景)。
  5. 忘记检查 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::ofstreamstudent_output/courses_offered.csvcourses_not_offered.csv;务必先写回列名行,且格式严格为 <Title>,<Units>,<Quarter>逗号后无空格),否则 autograder 不通过;写完用 close()
  • 流与类型Course 结构体的字段(std::stringint)正是流的 <</>> 能直接处理的类型——回忆幻灯片问题:”remember what types streams deal with?”(流处理的都是可 <</>> 的类型)。
  • 提醒:不要在遍历 all_courses 的同时删除元素(A1 明确警告)——先把要保留的课程收集到新容器,再统一删除。