这一章在干嘛?

你一直在用 coutcin,但未必知道它们背后是一整套类库。C++ 的输入输出不是语言的关键字,而是 <iostream><fstream> 里的类。这一章先建立”流 + 缓冲”的心智模型,再逐层拆开:cout 怎么格式化、cin 怎么处理坏输入、流状态怎么管理,最后把同一套接口延伸到文件——文本文件、二进制文件、随机存取,甚至连”对字符串做 IO”的 stringstream 都一并讲清。

17.1 流与缓冲

17.2 用 cout 输出

17.3 格式化输出

17.4 用 cin 输入

17.5 流状态

17.6 文件 I/O

17.7 文件模式

17.8 二进制文件

17.9 随机存取

17.10 字符串流

17.1 流与缓冲

C++ 程序眼中的输入输出就是一串字节(stream of bytes):输入时从流中抽取字节,输出时把字节插入流。字节可以来自键盘、硬盘、另一个程序,也可以流向屏幕、打印机、文件。流是程序与数据源/目的地之间的中间层——好处是程序完全不关心字节从哪来到哪去,读写键盘和读写文件用的是同一套代码。管道是个好类比:流的两端各接一个”接口”,一端连文件(或设备),一端连程序。

▲ 图 17.1 C++ 输入输出:程序只面对流,不直接面对设备

**缓冲(buffer)**是为了匹配两种悬殊的速度:硬盘按 512 字节甚至更大的块传输数据,程序却常常一个字节一个字节地处理。于是引入一块内存做中转:从硬盘读一大块放进缓冲区,程序再从缓冲区里逐字节取;写的时候反过来,攒满一缓冲区一次性刷(flush)给硬盘。就像水库把暴雨的径流先蓄起来,再按 civilized 的流量供到你家。

▲ 用一整块数据填充流缓冲区

▲ 流的出口把字节逐个喂给程序

▲ 缓冲区耗尽后再填充下一块——图 17.2 带缓冲的流

键盘输入虽然一次一个字符不需要缓冲来配速,但缓冲让你能在按 Enter 前退格修改;按 Enter 时输入缓冲被刷新,程序才开始处理。屏幕输出通常在遇到换行符时刷新,多数实现还会在即将发生输入时刷新输出缓冲——这就是 cout << "Enter a number: "; cin >> num; 不带换行也能立刻看到提示的原因。

<iostream> 头文件带来一整套管理流和缓冲的类:

▲ 图 17.3 I/O 类家族:streambuf 管缓冲,ios_base 管通用状态,ostream/istream 提供方法

  • streambuf:缓冲区的内存与读写管理
  • ios_base:流的通用属性(格式常量 ios_base::fixed 等都在这)
  • ios:继承 ios_base,内含指向 streambuf 的指针
  • ostream / istream:分别提供输出/输入方法
  • iostream:同时继承前两者

包含 <iostream> 会自动创建 8 个流对象:cin/cout/cerr/clog 及宽字符版 wcin/wcout/wcerr/wclogcerr无缓冲的错误流,出错信息立刻上屏;clog 是有缓冲版。

重定向:操作系统可以把标准输入/输出接到别处,如 counter <oklahoma >cow_cnt 让 cin 读文件、cout 写文件。但 cerr/clog 不受 cout 重定向影响——错误信息永远打到屏幕,这正是错误流存在的意义(Unix 下 2> 可单独重定向标准错误)。

17.2 用 cout 输出

<< 本是左移运算符,ostream 类把它重载成插入运算符(insertion operator)。它为所有基本类型各提供了一个重载版本,且每个版本都返回调用它的 ostream 对象的引用——这就是输出能串联的原因:

cout << "We have " << count << " unhatched chickens.\n";
// cout << "We have " 返回 cout,接着处理 count,再返回 cout……

▲ 图 17.4 输出串联:每个 << 的返回值都是下一个 << 的调用对象

指针的输出有个特殊规则char*(以及 signed/unsigned char*)输出的是字符串本身;其他任何指针都匹配 void* 版本,输出地址的数值形式。所以想打印字符串的地址必须强转:

int eggs = 12;
char * amount = "dozen";
cout << &eggs;              // eggs 的地址
cout << amount;             // 打印字符串 "dozen"
cout << (void *) amount;    // 打印字符串所在的地址

除了 <<,ostream 还有两个成员方法:

  • put(char):输出单个字符,返回 ostream&,可串联 cout.put('I').put('t');。参数可传数值,自动转 char(cout.put(65) 输出 A)。
  • write(const char* s, streamsize n):输出 s 起 n 个字符。它不认 \0,说几个就打几个,越界照样打:
// write.cpp -- using cout.write()(核心片段)
const char * state2 = "Kansas";
int len = std::strlen(state2);
for (int i = 1; i <= len; i++)
    cout.write(state2, i) << endl;      // 递增打印 K/Ka/Kan/...
cout.write(state2, len + 5) << endl;    // 越界打印 "Kansas Euph"

write() 也能配合 (char*) 强转输出数字的内存位模式——这正是二进制文件存储的雏形(见 17.8)。

刷新缓冲区的两个操纵符:endl(换行 + 刷新)和 flush(只刷新)。它们本质是函数,<< 被重载成调用它们。

17.3 格式化输出

默认格式:整数按十进制、字段宽度刚好够用;浮点数默认显示 6 位有效数字、去掉末尾零,指数 ≥6 或 ≤-5 时切换 E 记法。各值紧挨着打印,所以要自己补空格。

控制进制dec/hex/oct 操纵符,一经设置持续生效:

// manip.cpp -- using format manipulators(核心)
cout << hex;                       // 之后整数按十六进制打印
cout << n << "    " << n * n << " (hexadecimal)\n";
cout << oct << n << "    " << n * n << " (octal)\n";
dec(cout);                         // 等价的函数式写法

字段宽度 width():最重要的特性是只对下一个输出项生效,然后自动恢复默认

cout << '#';
cout.width(12);
cout << 12 << "#" << 24 << "#\n";   // 只有 12 用了 12 宽,# 和 24 都用默认宽

C++ 从不截断数据:宽度不够时字段自动扩张——内容优先于排版。默认右对齐,填充字符是空格。

填充字符 fill()cout.fill('*') 之后一直生效,直到再次修改。打印支票金额防篡改神器:$****900

精度 precision():默认模式下指总有效位数;fixed/scientific 模式下指小数点后的位数。与 fill 一样持续生效

// precise.cpp -- setting the precision
float price1 = 20.40;
float price2 = 1.9 + 8.0 / 9.0;
cout << price1;          // 20.4
cout.precision(2);
cout << price1;          // 20(默认模式按总位数算)

setf() 设置格式标志:单参数版 setf(ios_base::showpoint) 用于置位单个标志,常用常量有:boolalpha(true/false 字面量)、showbase(进制前缀 0x)、showpoint(显示小数点和末尾零)、uppercase(十六进制大写)、showpos(正数带 +)。

双参数版用于”一组互斥选项里选一个”——第二个参数先清空相关位,第一个参数再设置:

cout.setf(ios_base::hex, ios_base::basefield);     // 等价于 hex 操纵符
cout.setf(ios_base::fixed, ios_base::floatfield);  // 定点记法
cout.setf(ios_base::left, ios_base::adjustfield);  // 左对齐

三组互斥选项:basefield(dec/oct/hex)、floatfield(fixed/scientific)、adjustfield(left/right/internal)。fixed 与 scientific 模式下精度变成”小数位位数”且末尾零会显示。恢复默认浮点格式用 cout.unsetf(ios_base::floatfield);

更省事的操纵符leftrightfixedscientificboolalphashowpos 等都是对 setf() 的封装,直接往流里插就行:

cout << left << fixed;   // 左对齐 + 定点记法

<iomanip> 三个带参操纵符是格式化的主力:

// iomanip.cpp -- 核心片段
cout << fixed << right;
cout << setw(6) << "N" << setw(14) << "square root"
     << setw(15) << "fourth root\n";
for (int n = 10; n <= 100; n += 10)
{
    root = sqrt(double(n));
    cout << setw(6) << setfill('.') << n << setfill(' ')
         << setw(12) << setprecision(3) << root
         << setw(14) << setprecision(4) << sqrt(root) << endl;
}

setprecision() 管精度、setfill() 管填充、setw() 管宽度(同样只管下一个输出项)。因为可以直接内联在输出语句里,排对齐表格远比调成员函数顺手。

常见坑:width/setw 是"一次性"的

width()setw(n) 只影响紧随其后的一个输出项,然后立刻恢复默认。想在循环里对齐,就得每行都写 setw。而 precision() 和 fill() 是持久的。三者生效周期不同,混用最容易翻车。

通关标准:

能不看资料说出 setw/precision/fill 三个操纵符的生效周期差异;能解释默认浮点格式与 fixed/scientific 模式下 precision 的含义区别;会用 showpoint 让价格打印出 20.40 而不是 20.4

17.4 用 cin 输入

>>抽取运算符,同样为各基本类型重载,参数和返回值都是引用——引用参数让 cin 能直接修改变量,返回引用让输入可以串联:cin >> name >> fee >> group;

cin >> 看待输入的统一方式:跳过所有空白(空格、换行、制表符),从第一个非空白字符开始读,直到遇到与目标类型不匹配的字符为止。即使读单个 char 也跳空白——这与 C 的 getchar() 完全不同。

▲ 图 17.5 cin >> 跳过空白继续读取

比如 cin >> elevation 遇到输入 -123Z,会读入 -123 并把 Z 留在输入队列里给下一次输入。而如果输入是 Zcar,抽取失败:变量值不变,流状态被置位,cin >> input 在 if/while 里表现为 false:

// check_it.cpp -- checking for valid input
int sum = 0;
int input;
while (cin >> input)     // 读到类型不匹配或 EOF 就退出循环
    sum += input;
cout << "Last value entered = " << input << endl;
cout << "Sum = " << sum << endl;

单字符输入的两个 get() 版本——不跳空白:

  • cin.get(ch):读到的字符存进参数 ch(包括空格和换行!),返回 istream 引用。到 EOF 时不赋值且触发 failbit,所以 while (cin.get(ch)) 是标准逐字符读法。
  • ch = cin.get():用返回值(int 类型)交出字符,EOF 时返回 EOF 常量。while ((ch = cin.get()) != EOF) 也是标准写法,注意 ch 要用 int。

若前面那段统计字符的代码改用 cin >> ch,空格全部被跳过不算数,而且换行永远不会被读到——死循环。这就是”想检查每个字符就用 get()“的铁律。菜单循环用 >> 反而合适,因为它自动吃掉回车。

整行输入get(char*, int, char)getline(char*, int, char) 签名相同,唯一区别是换行符的去留——get() 把分隔符留在输入队列(下次输入先撞上它),getline() 把分隔符读走丢弃。忘了这回事是新手两大经典 bug 之一(另一个是忘了处理坏输入)。

ignore(n, ch) 读取并丢弃最多 n 个字符或直到遇到 ch(默认 ignore(1, EOF)),常用来清行:cin.ignore(255, '\n');,返回引用可串联。

其他实用方法:

  • peek():偷看下一个字符但不取走
  • putback(ch):把刚读的字符塞回输入流。
  • gcount():报告上一次非格式化方法(get/getline/ignore/read)读了多少字符。
  • read(buf, n):裸读 n 个字节,不补 \0,主要配 write() 用于二进制文件。

peek() 的典型用法——get() 读满一行后判断截断:

// truncate.cpp -- 节选
cin.get(name, SLEN);            // 最多 SLEN-1 个字符
if (cin.peek() != '\n')         // 下一个不是换行 → 行没读完
    cout << "Sorry, we only have enough room for " << name << endl;
while (cin.get() != '\n')       // 丢弃本行剩余部分
    continue;

17.5 流状态

每个流对象都有一个从 ios_base 继承的状态字(iostate),三个关键位:

  • eofbit:到达文件尾
  • failbit:输入/输出没有读到/写出预期字符(如类型不匹配)
  • badbit:流可能已损坏(如硬件故障)

三个位全 0 就是 goodbit(一切正常)。常用方法:good()eof()bad()fail()(failbit 或 badbit 任一置位都返回 true)、rdstate()(读状态)、clear()(设状态,无参即全清)、setstate()(只加不减)。

关键后果:任何一个状态位置位后,流就”关闭”了,后续输入输出全部无效,直到 clear() 复位。而且光复位还不够——引发失败的坏输入还堵在队列里,得先清掉:

while (cin >> input) { sum += input; }   // 因坏输入退出
if (cin.fail() && !cin.eof())            // 确认是坏输入而非 EOF
{
    cin.clear();                         // 复位状态
    while (!isspace(cin.get()))          // 吃掉坏单词
        continue;
}
cout << "Now enter a new number: ";
cin >> input;                            // 现在才能继续

cin 也能抛异常:cin.exceptions(ios_base::failbit); 之后 failbit 一置位就抛 ios_base::failure。但要分场合——如果”类型不匹配”本来就是退出循环的正常手段(如 check_it.cpp),抛异常反而是滥用;badbit 这种意外故障才值得抛。

常见坑:循环退出后流已"报废"

while (cin >> input) 因坏输入或 EOF 退出后,cin 处于失败状态,再写 cin >> x 是无效的,不会阻塞也不会读入。必须 cin.clear() 复位、再清掉队列里的残留输入,两步缺一不可。

17.6 文件 I/O

文件读写的套路与 cout/cin 完全一致——ofstream 继承 ostream,ifstream 继承 istream,学过的方法全部通用:

写文件三步

#include <fstream>
ofstream fout;                    // ① 创建输出流对象
fout.open("jar.txt");             // ② 关联文件(或合并写:ofstream fout("jar.txt");)
fout << "Dull Data";              // ③ 当 cout 用
fout.close();                     // 显式关闭(对象过期也会自动关)

读文件三步

ifstream fin("jamjar.txt");       // 创建并关联
char ch; fin >> ch;               // 当 cin 用
string line; getline(fin, line);  // string 版 getline 也支持流参数

常见坑:打开输出文件 = 清空文件

ofstream 默认模式是 ios_base::out | ios_base::trunc——文件存在就截断为 0,原有内容灰飞烟灭。想保留原内容在尾部追加,必须显式用 app 模式(见 17.7)。

检查打开是否成功,推荐 is_open():

fin.open(argv[file]);
if (!fin.is_open())          // 优于 if (!fin),能查出"文件模式不当"这类错误
{
    cerr << "Could not open " << argv[file] << endl;
    fin.clear();
    continue;
}

一个文件一个流;若要依次处理多个文件,可以复用同一个流对象:fin.close(); fin.clear(); fin.open("rat.txt");(clear() 有些实现不需要,加上无害)。

命令行参数int main(int argc, char *argv[])。argc 是参数个数(含程序名),argv 可当作字符串指针数组:argv[0] 是程序名,argv[1] 起是各参数。经典应用——统计多个文件的字符数:

// count.cpp -- counting characters in a list of files(核心)
int main(int argc, char * argv[])
{
    if (argc == 1)
    {
        cerr << "Usage: " << argv[0] << " filename[s]\n";
        exit(EXIT_FAILURE);
    }
    ifstream fin;
    long count, total = 0;
    char ch;
    for (int file = 1; file < argc; file++)
    {
        fin.open(argv[file]);
        if (!fin.is_open())
        {
            cerr << "Could not open " << argv[file] << endl;
            fin.clear();
            continue;
        }
        count = 0;
        while (fin.get(ch))
            count++;
        cout << count << " characters in " << argv[file] << endl;
        total += count;
        fin.clear();
        fin.close();
    }
    cout << total << " characters in all files\n";
    return 0;
}

注意错误信息打给 cerr(即使输出被重定向,报错照样上屏),提示里用 argv[0] 而非写死程序名——改名后提示自动跟着变。

17.7 文件模式

构造函数和 open() 都接受第二个参数指定模式,常量定义在 ios_base:

常量含义
ios_base::in
ios_base::out写(单独用等价于 out|trunc,会截断)
ios_base::ate打开后定位到文件尾
ios_base::app只在尾部追加
ios_base::trunc存在则截断
ios_base::binary二进制模式

多个模式用按位或组合:ofstream fout("bagels", ios_base::out | ios_base::app);

▲ 图 17.6 几种文件打开模式的行为差异

ateapp 都会把文件指针放到末尾,区别是:app 只许在尾部写,ate 只是初始定位在尾部,之后可以用 seek 移动。

追加写的完整流程——先展示旧内容,再追加,再展示新内容:

// append.cpp -- appending information to a file(核心)
const char * file = "guests.txt";
// 1. 先读:显示现有内容
ifstream fin;
fin.open(file);
if (fin.is_open())
{
    cout << "Here are the current contents of the "
         << file << " file:\n";
    while (fin.get(ch))
        cout << ch;
    fin.close();
}
// 2. 追加写
ofstream fout(file, ios::out | ios::app);
if (!fout.is_open())
{
    cerr << "Can't open " << file << " file for output.\n";
    exit(EXIT_FAILURE);
}
cout << "Enter guest names (enter a blank line to quit):\n";
string name;
while (getline(cin, name) && name.size() > 0)
    fout << name << endl;
fout.close();
// 3. 重新打开读一遍验证
fin.clear();                  // 有的实现需要复位流状态
fin.open(file);
...

17.8 二进制文件

文本文件把一切存成字符(数字 2.324216e+07 要存 13 个字符);二进制文件存的是计算机内部表示——double 就是那 64 个位,一个字节都不转换。

▲ 图 17.7 同一个数 0.375 的二进制表示与文本表示

各有优劣:文本可读可编辑、跨平台移植性好;二进制精确(无转换舍入误差)、省空间、读写快,但换到内部表示不同的系统就不通用了。

以保存结构体为例:

struct planet
{
    char name[20];
    double population;
    double g;
};
planet pl;
 
// 文本方式:逐成员手写,30 个成员能写到怀疑人生
fout << pl.name << " " << pl.population << " " << pl.g << "\n";
 
// 二进制方式:整个结构体一个字节不差地拷过去
ofstream fout("planets.dat",
              ios_base::out | ios_base::app | ios_base::binary);
fout.write((char *) &pl, sizeof pl);
 
// 读回来也是一行
ifstream fin("planets.dat", ios_base::in | ios_base::binary);
fin.read((char *) &pl, sizeof pl);

为什么必须 binary 模式?Windows 的文本模式会把 \n 翻译成回车+换行两个字符——二进制数据里某个字节恰好等于 \n 的编码时就被破坏了。Unix 只有一种文件模式,binary 与 text 等价。经验法则:存二进制就用 binary 模式 + write/read

两个必须知道的天坑:

常见坑一:string 成员不能整体二进制化

string 对象里存的不是字符串本身,而是指向堆内存的指针。把含 string 的结构直接 write 到文件,存下的是一个地址——下次运行时这个地址毫无意义。二进制存结构体时成员得用 char name[20] 这类定长数组。

常见坑二:带虚函数的类不能整体 write/read

有虚函数的对象内含指向虚函数表的隐藏指针,程序每次运行虚表位置都可能不同。从文件读回旧指针再调用虚函数,直接乱套。要存就逐个成员存。

17.9 随机存取

顺序文件只能从头读到尾;随机存取让你直接跳到文件任意字节——数据库的基础。前提通常是定长记录(一个结构体一条记录),这样”第 rec 条记录”的字节位置就是 rec * sizeof(pl)

读写都要用 fstream(同时继承 istream 和 ostream),模式组合 ios_base::in | ios_base::out | ios_base::binary

// random.cpp -- random access to a binary file(核心)
fstream finout;
finout.open(file, ios_base::in | ios_base::out | ios_base::binary);
if (finout.is_open())
{
    finout.seekg(0);                          // 定位到文件头
    while (finout.read((char *)&pl, sizeof pl))  // 逐条展示
        cout << ct++ << ": " << pl.name << ": " << pl.g << endl;
    if (finout.eof())
        finout.clear();                       // 读到尾置位了 eofbit,必须复位!
}
// 定位并修改第 rec 条记录
streampos place = rec * sizeof pl;
finout.seekg(place);                          // 输入指针跳过去
finout.read((char *) &pl, sizeof pl);         // 读出旧记录
// ...让用户修改 pl 的各成员...
finout.seekp(place);                          // 输出指针跳回同处
finout.write((char *) &pl, sizeof pl) << flush;  // 覆写并刷新

seek 家族两个方向:

  • seekg(pos):绝对定位(从文件头数第 pos 字节,首字节是 0)。
  • seekg(offset, base):相对定位,base 可取 ios_base::beg(文件头)/ cur(当前位置)/ end(文件尾)。如 fin.seekg(-1, ios_base::cur) 回退 1 字节,fin.seekg(0, ios_base::end) 跳到文件尾。
  • seekp() 是输出侧的对应版本。

tellg()/tellp() 返回当前指针位置(streampos 类型)。fstream 的输入输出指针是联动的(tellg 与 tellp 相等);用两个独立流对象分别读写同一文件时,两个指针各自独立。

注意:读满整个文件会置位 eofbit,之后想写文件必须先 clear() 复位——示例代码里的 if (finout.eof()) finout.clear(); 不是可有可无的仪式,是继续操作的通行证。

17.10 字符串流

<sstream> 头文件把 ostream/istream 的全套接口接到 string 对象上,术语叫内核格式化(incore formatting):

  • ostringstream:像用 cout 一样把格式化结果攒进字符串str() 取出成品:
// strut.cpp -- incore formatting(核心)
ostringstream outstr;
outstr << "The hard disk " << hdisk << " has a capacity of "
       << cap << " gigabytes.\n";
string result = outstr.str();   // 取出格式化好的字符串
cout << result;
  • istringstream:从 string 初始化,像用 cin 一样从字符串里抽取数据
// strin.cpp -- 节选
string lit = "It was a dark and stormy day...";
istringstream instr(lit);
string word;
while (instr >> word)     // 逐词抽出
    cout << word << endl;

典型用途:拼 SQL/报文前先做数值格式化;解析一行文本时先把整行读进 string,再用 istringstream 慢慢抽字段——比在原始输入流上反复折腾安全得多。

常见坑:str() 之后别再写

对 ostringstream 调用 str() 会”冻结”对象,之后写入的行为不可靠。需要复用就先取走结果,或重新创建对象。