🔷 第十五章 文件操作
📄 一句话概括

"追加"就是在文件末尾接着写,有两条路: - 打开时用 ios::app ——一劳永逸:之后每次写都被强制拉到末尾(连 seekp 都拉不回来); - 打开后用 seekp(0, ios::end) ——手动搬一次写位置,之后还能自由挪到别处写。 而它们的共同点、也是本节反复强调的:千万别忘了 app ——忘了它,ofstream 的默认 trunc 会把原文件清空。 一句话概括:"追加"不是"写到末尾"这么简单,它是"打开方式"和"写位置"两件事的配合。

📝 本节和前面几节的关系

本节把散在三处的知识缝在一起: - 15.2.1 讲过 app vs ate 的差别;15.2.2 讲过 ofstream 默认清空;15.4 讲了写位置 ——本节是这三条的交汇点; - 15.4.1 的失败状态在这里再次出现(在失败的流上写不会生效); - ate 与 app 的分水岭在本节得到最清楚的实证。 一句话:本节是"写位置"这一侧最实用的一节——因为"追加"是真实工作里最常见的写文件方式(日志、记录、累积数据)。

一、三种打开方式,三种"写位置"命运

这是本节的核心对照表:

打开方式 打开后写位置在哪 之后能不能 seekp 到别处写
ios::out \| ios::trunc(ofstream 默认) 开头 能(但旧内容已被清空)
ios::out \| ios::app 末尾 不能 ——每次写都被强制拉回末尾
ios::out \| ios::ate 末尾 能
ios::in \| ios::out(fstream 默认) 开头(不清空) 能

从表里能读出三句关键结论:

  1. app 是"强制"的 ——它的语义是"以后所有写都追加",所以你手动 seekp 也无效;
  2. ate 只是"开局站到末尾" ——之后再挪照样能写;
  3. in | out 打开已有文件时,写位置在开头 ——直接写就会覆盖开头的内容(这是最容易被忽略的一条)。

二、两条实现"追加"的路

路一:打开时就用 app(推荐,最省心)

ofstream log("app.log", ios::app);
if (!log) { /* 处理失败 */ }
log << "一条新的记录" << endl;         // 自动写到末尾
log.close();

优点:一句话表达清楚意图,再也不用管位置。 适合:日志、累积记录、追加导出 ——这也是 app 存在的意义。

路二:打开后手动 seekp 到末尾(更灵活)

fstream f("data.txt", ios::in | ios::out);      // 可读可写,不清空
if (!f) { /* 处理失败 */ }
f.seekp(0, ios::end);                           // ★ 把写位置搬到末尾
f << "追加的内容" << endl;
f.close();

优点:搬完之后还能再 seekp 到别处(比如"追加完一条,再回头改前面的某个字段")。 缺点:多一步,容易忘 ——忘了 seekp 就从开头覆盖了。

两条路的取舍

ios::app 手动 seekp(0, ios::end)
写法 简单 多一行
意图清晰度 高(一眼看出是追加) 中
之后能否改别处 不能 能
适合 纯追加(日志) 追加 + 回头修改

三、为什么"日志文件"必须用 app

日志的三个特点,正好是 app 的三个卖点:

  1. 只往后加,从不回头改 → 正好符合 app 的"强制末尾";
  2. 文件常常已经存在(昨天、前天的日志都还在)→ app 不会清空(换默认 ofstream 就把历史日志全毁了);
  3. 程序可能被强制结束 → 即使那样,已经写进去的部分也还在(app 每次都定位到末尾,不会覆盖)。

一句话:

写日志,永远 ofstream log("x.log", ios::app); 这一行如果你写成了 ofstream log("x.log"); ——跑第一次就把日志清空了。

四、动手 Demo:五种情形,一眼看清

#include <iostream>
#include <fstream>
#include <string>
using namespace std;

void show(const string &tag, const string &f) {
    ifstream in(f);
    cout << tag << " [" << f << "] ";
    string line;
    while (getline(in, line)) cout << line << " / ";
    cout << endl;
    in.close();
}

int main() {
    // ══ ① app:两次打开都在末尾追加 ══
    { ofstream o("m_app.txt", ios::app); o << "A" << endl; }
    { ofstream o("m_app.txt", ios::app); o << "B" << endl; }
    show("① app 追加两次      :", "m_app.txt");

    // ══ ② 默认 out|trunc:每次打开都清空 ══
    { ofstream o("m_out.txt", ios::out); o << "A" << endl; }
    { ofstream o("m_out.txt");           o << "B" << endl; }   // ★ 默认含 trunc
    show("② 默认 out 覆盖两次 :", "m_out.txt");

    // ══ ③ in|out 直接写:从开头覆盖 ══
    { ofstream o("m_seek.txt"); o << "A" << endl; }
    {
        fstream f("m_seek.txt", ios::in | ios::out);   // 不清空,但位置在开头
        f << "X" << endl;                              // ★ 覆盖掉原来的 A
        f.close();
    }
    show("③ in|out 直接写     :", "m_seek.txt");

    // ══ ④ in|out + seekp 末尾:正确的手动追加 ══
    { ofstream o("m_seek2.txt"); o << "A" << endl; }
    {
        fstream f("m_seek2.txt", ios::in | ios::out);
        f.seekp(0, ios::end);                          // ★ 先搬到末尾
        f << "B" << endl;
        f.close();
    }
    show("④ in|out + seekp 末尾:", "m_seek2.txt");

    // ══ ⑤ app 模式下,seekp 也拉不回来 ══
    { ofstream o("m_app2.txt"); o << "AAAAA" << endl; }
    {
        ofstream o("m_app2.txt", ios::app);
        o.seekp(0, ios::beg);                          // 想回到开头
        o << "X" << endl;                              // ★ 实际还是写在末尾
        o.close();
    }
    show("⑤ app 里 seekp 无效 :", "m_app2.txt");
    return 0;
}

看到什么算成功:

① app 追加两次      : [m_app.txt] A / B / 
② 默认 out 覆盖两次 : [m_out.txt] B / 
③ in|out 直接写     : [m_seek.txt] X / 
④ in|out + seekp 末尾: [m_seek2.txt] A / B / 
⑤ app 里 seekp 无效 : [m_app2.txt] AAAAA / X / 

读结果——五行,五个结论:

行 结论
① app 两次打开都在末尾接(A、B 都在)——这就是追加
② 默认 out 每次都清空(只剩 B)——trunc 的实锤(15.2.2)
③ in \| out 打开后写位置在开头 → X 覆盖了原来的 A ——这条最容易被忽略
④ 加一句 seekp(0, ios::end) 就变成正确追加 ——这就是"手动追加"
⑤ 在 app 模式下 seekp(0, ios::beg) 完全无效,X 还是落在末尾 ——证明 app 是"强制末尾"

这一张输出,就把"追加"的全部要点讲完了。

五、30 秒实验

  1. 把 ① 的 ios::app 删掉(变成默认 ofstream),再跑 → 只剩 B。这就是"忘了写 app 的代价"。
  2. 在 ⑤ 里把 ios::app 换成 ios::ate,再跑 → X 会覆盖开头(因为 ate 只负责"开局末尾",之后 seekp(0) 真的能回到开头)——结果变成 XAAAA。这就是 app 与 ate 的分水岭。
  3. 把 ③ 改成 fstream f("m_seek.txt", ios::in | ios::out | ios::app); → X 落在末尾,A 保住了。这就是"既想读写、又想追加"的写法。

六、常见误区

  1. "想追加就写 ios::out" ——out 会清空(ofstream 默认含 trunc)。追加必须 app(或手动 seekp 到末尾)。
  2. "app 和 ate 都是末尾,写 ate 更灵活" ——看需求:纯追加用 app(更安全,不会被误 seek 破坏);要"末尾 + 回头改"用 ate。
  3. "用 in | out 打开就能追加" ——不能。写位置在开头,直接写会覆盖。 要追加必须 seekp(0, ios::end)。
  4. "seekp 之后就一直有效" ——在 app 模式下 seekp 对写操作无效(每次写前都会被拉回末尾)。要自由定位就别用 app。
  5. "追加的内容会另起一行" ——不会。文件没有"行"的概念,只有字节。你上次写的内容如果没有以换行结尾,新内容会直接接在它后面(AAAAA 后面接 X 就是 AAAAAX)。要换行就自己写 \n 或 endl。
  6. "写完不关也没事,反正下次还会追加" ——有事:这次的数据可能还在缓冲区,而且文件可能被系统标记为"正在使用"。追加完就 close()。
自测三问(点开对答案) 1. **"追加"有哪两条实现路径?各有什么特点?** ——**① 打开时用 `ios::app`**:写位置**每次都强制到末尾**(**`seekp` 也拉不回来**),最省心,适合日志;**② 打开后 `seekp(0, ios::end)`**:手动搬一次写位置,**之后还能自由挪**,适合"追加完还要回头改"。 2. **用 `fstream` 以 `ios::in | ios::out` 打开一个已有文件,直接 `<<` 会发生什么?** ——**从文件开头开始覆盖**。**因为 `in | out` 打开时写位置在开头**(且不清空)。**要追加必须先 `seekp(0, ios::end)`。** 3. **为什么日志文件必须用 `ios::app` 打开?** ——**因为日志文件通常已经存在**,而**`ofstream` 的默认模式含 `trunc`,会把历史日志清空**。**`app` 既不删旧内容,又保证每次都写在末尾**,正好符合日志"只往后加"的性质。

相关笔记