13、文件与异常处理:文件流(fstream)、文本文件与二进制文件读写、异常处理(try/catch/throw)

大家好,我是老李。今天咱们聊聊C++里两个特别实在的话题——文件读写和异常处理。说实话,这两个东西在真实项目中几乎是天天见。你写个工具要保存配置吧?得用文件。你写个数据处理程序,用户给个坏文件怎么办?得用异常处理兜底。

我刚开始学的时候,总觉得文件操作就是打开、读写、关闭,没啥技术含量。直到有一次,我写的一个日志模块在线上跑了三天,突然把硬盘写满了……嗯,从那以后我再也不敢小看文件流了。

本章核心脉络: 文件流(fstream)家族 → 文本文件读写 → 二进制文件读写 → 异常处理机制(try/catch/throw)→ 综合实战
文件与异常处理知识体系 文件与异常处理 文件流:fstream / ifstream / ofstream 文本文件读写(<< / getline) 二进制文件读写(read / write) 异常处理:try / catch / throw 逐行读取 / 格式化输出 文件状态检查(good/fail/eof) 结构体序列化 / 大块数据 seekg / seekp 定位 自定义异常 / 栈展开

13.1 文件流三兄弟:fstream、ifstream、ofstream

C++ 标准库给我们准备了三个文件流类,都在 <fstream> 头文件里。说白了就是:

  • ifstream:只管读文件(input file stream)
  • ofstream:只管写文件(output file stream)
  • fstream:既能读又能写(file stream)

我个人习惯是,能明确读写方向就用 ifstream/ofstream,只有确实需要同时读写时才用 fstream。为啥?因为接口更清晰,不容易搞混。

小技巧: 打开文件时记得检查是否成功。我见过太多新手直接读写,结果文件没打开程序就崩了。用 if (file.is_open()) 或者 if (!file) 判断一下,花不了半秒钟。

13.2 文本文件读写

文本文件,说白了就是你能用记事本打开看的那种。读写方式最直观,用 <<>> 就行。

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

int main() {
    // 写入文本文件
    ofstream outFile("data.txt");
    if (!outFile) {
        cerr << "文件打开失败!" << endl;
        return 1;
    }
    
    outFile << "姓名:张三" << endl;
    outFile << "年龄:25" << endl;
    outFile << "分数:92.5" << endl;
    outFile.close();
    
    // 读取文本文件
    ifstream inFile("data.txt");
    string line;
    while (getline(inFile, line)) {
        cout << line << endl;
    }
    inFile.close();
    
    return 0;
}

这里有个坑,我踩过好几次——用 >> 读取时,遇到空格就停了。比如读取 "Hello World",只能读到 "Hello"。所以读整行一定要用 getline()

注意: 文件流对象析构时会自动调用 close(),但显式调用 close() 是个好习惯。尤其是你要用同一个流对象打开另一个文件时,必须先 close()。

13.3 二进制文件读写

二进制文件,说白了就是按内存中的原始字节来读写。适合存结构体、图片、音频这些数据。读写用 read()write() 成员函数。

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

struct Student {
    char name[32];
    int age;
    double score;
};

int main() {
    Student stu1 = {"李四", 22, 88.5};
    
    // 写入二进制文件
    ofstream outFile("student.dat", ios::binary);
    if (!outFile) {
        cerr << "打开文件失败" << endl;
        return 1;
    }
    outFile.write(reinterpret_cast<const char*>(&stu1), sizeof(Student));
    outFile.close();
    
    // 读取二进制文件
    Student stu2;
    ifstream inFile("student.dat", ios::binary);
    if (!inFile) {
        cerr << "打开文件失败" << endl;
        return 1;
    }
    inFile.read(reinterpret_cast<char*>(&stu2), sizeof(Student));
    inFile.close();
    
    cout << "姓名:" << stu2.name << endl;
    cout << "年龄:" << stu2.age << endl;
    cout << "分数:" << stu2.score << endl;
    
    return 0;
}

看到 reinterpret_cast<char*> 别慌。这是 C++ 风格的类型转换,意思就是「把这块内存当字节数组看」。我刚开始也觉得这写法别扭,但用多了就习惯了——它比 C 风格的强制转换安全得多。

文本 vs 二进制,怎么选?
  • 文本文件:人类可读,跨平台性好,但占空间大,读写慢
  • 二进制文件:紧凑高效,读写快,但不可读,跨平台要注意字节序

我的经验是:配置文件、日志用文本;大数据、序列化用二进制。

13.4 文件状态与定位

文件流有几个状态标志位,你得知道:

状态函数 说明
good() 一切正常,没有错误也没有到达文件尾
eof() 到达文件末尾
fail() 上次操作失败(比如类型不匹配)
bad() 发生严重错误(比如磁盘损坏)
clear() 重置所有状态标志

文件定位也很常用。比如你要读取一个二进制文件的第 N 条记录:

ifstream file("data.bin", ios::binary);
// 跳到第5条记录(假设每条记录100字节)
file.seekg(4 * 100, ios::beg);
// 读取记录
char record[100];
file.read(record, 100);

seekg 是给输入流用的,seekp 是给输出流用的。我当年搞混过一次,debug 了一下午才发现……

13.5 异常处理:try / catch / throw

好了,文件读写说完了,咱们聊聊异常处理。为啥要把这两个放一起?因为文件操作太容易出错了——文件不存在、权限不够、磁盘满了……没有异常处理,程序动不动就崩。

C++ 的异常处理机制就三个关键字:

  • try:把可能出错的代码包起来
  • catch:捕获并处理异常
  • throw:抛出异常(可以抛任何类型)
#include <iostream>
#include <fstream>
#include <stdexcept>
using namespace std;

void readConfig(const string& filename) {
    ifstream file(filename);
    if (!file) {
        throw runtime_error("无法打开配置文件: " + filename);
    }
    
    string line;
    while (getline(file, line)) {
        // 处理配置...
        cout << "读取配置: " << line << endl;
    }
}

int main() {
    try {
        readConfig("config.ini");
    }
    catch (const runtime_error& e) {
        cerr << "错误: " << e.what() << endl;
        return 1;
    }
    catch (...) {
        cerr << "发生了未知错误" << endl;
        return 1;
    }
    
    return 0;
}
建议: 尽量捕获具体的异常类型,不要一上来就用 catch (...)。它就像个垃圾桶,啥都往里扔,但你不知道扔了啥。我一般用它做最后的兜底。

13.6 自定义异常类

有时候标准异常不够用,你可以自己定义。比如文件操作中,我想区分「文件不存在」和「权限不足」:

#include <stdexcept>
#include <string>
using namespace std;

class FileException : public runtime_error {
public:
    FileException(const string& msg, int errCode)
        : runtime_error(msg), m_errCode(errCode) {}
    
    int getErrorCode() const { return m_errCode; }
    
private:
    int m_errCode;
};

// 使用示例
void openFile(const string& path) {
    ifstream file(path);
    if (!file) {
        if (errno == EACCES) {
            throw FileException("权限不足", 13);
        }
        else {
            throw FileException("文件打开失败", errno);
        }
    }
}

为什么要自定义异常?说白了,就是让调用方能根据不同的错误码做不同的处理。比如权限不足可以提示用户,文件不存在可以自动创建。

13.7 综合实战:带异常处理的配置文件读写

最后来个综合例子,把今天学的串起来:

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

class ConfigManager {
public:
    void load(const string& filename) {
        ifstream file(filename);
        if (!file) {
            throw runtime_error("无法打开配置文件: " + filename);
        }
        
        m_data.clear();
        string line;
        while (getline(file, line)) {
            if (line.empty() || line[0] == '#') continue;
            
            size_t pos = line.find('=');
            if (pos == string::npos) {
                throw runtime_error("配置格式错误: " + line);
            }
            
            string key = line.substr(0, pos);
            string value = line.substr(pos + 1);
            m_data[key] = value;
        }
    }
    
    string get(const string& key, const string& defaultVal = "") {
        auto it = m_data.find(key);
        if (it != m_data.end()) {
            return it->second;
        }
        return defaultVal;
    }
    
private:
    map<string, string> m_data;
};

int main() {
    ConfigManager config;
    
    try {
        config.load("app.conf");
        cout << "数据库地址: " << config.get("db_host", "localhost") << endl;
        cout << "端口: " << config.get("db_port", "3306") << endl;
    }
    catch (const runtime_error& e) {
        cerr << "配置加载失败: " << e.what() << endl;
        return 1;
    }
    
    return 0;
}

这个例子我实际项目中改过好几版。一开始没加异常处理,用户给个格式不对的配置文件,程序直接崩。后来加了 try/catch,至少能给出友好的错误提示了。

避坑指南: 我曾经在析构函数里抛异常,结果程序直接 terminate() 了。记住:析构函数、new 操作符、线程函数里尽量不要抛异常,否则后果很严重。

好了,文件与异常处理就聊到这儿。记住一句话:文件操作必检查,异常处理要具体。这两样用好了,你的程序会健壮很多。


公众号:蓝海资料掘金营,微信deep3321