Skip to content

11. Files & Error Handling

What & why

Programs that forget everything when they exit aren't very useful. This lesson covers reading and writing files, working with paths, and handling the things that go wrong — missing files, bad input, corrupt data — without crashing.

Writing a file

#include <fstream>
#include <iostream>

int main() {
    std::ofstream out{"notes.txt"};          // opens (creates/truncates) for writing
    if (!out) {
        std::cerr << "could not open notes.txt\n";
        return 1;
    }
    out << "first line\n";
    out << "value: " << 42 << '\n';
}   // out goes out of scope → file is flushed and closed automatically
  • std::ofstream — output file stream. Same << you use with std::cout.
  • std::cerr — standard error; use it for error messages.
  • You don't need to call close(). The stream closes itself when it goes out of scope. This pattern — resources released automatically by scope — is called RAII, and it's central to C++.

Append instead of overwrite:

std::ofstream log{"app.log", std::ios::app};

Reading a file

Line by line (the most common):

std::ifstream in{"notes.txt"};
if (!in) {
    std::cerr << "could not open notes.txt\n";
    return 1;
}
std::string line;
int line_no = 0;
while (std::getline(in, line)) {
    std::cout << ++line_no << ": " << line << '\n';
}

Word by word or number by number:

std::ifstream nums{"numbers.txt"};
double x;
double total = 0;
while (nums >> x) {
    total += x;
}

The whole file into a string:

#include <sstream>

std::string read_all(const std::string& path) {
    std::ifstream in{path};
    std::ostringstream buffer;
    buffer << in.rdbuf();
    return buffer.str();
}

std::filesystem

#include <filesystem>
namespace fs = std::filesystem;

fs::path p{"data/tasks.txt"};
p.filename();        // "tasks.txt"
p.stem();            // "tasks"
p.extension();       // ".txt"
p.parent_path();     // "data"
fs::path full = fs::path{"data"} / "archive" / "old.txt";   // joins with the right separator

fs::exists(p);
fs::file_size(p);                   // throws if missing
fs::create_directories("data/archive");
fs::remove("old.txt");
fs::rename("a.txt", "b.txt");

for (const auto& entry : fs::directory_iterator{"."}) {
    std::cout << entry.path() << (entry.is_directory() ? "/" : "") << '\n';
}

for (const auto& entry : fs::recursive_directory_iterator{"src"}) {
    if (entry.path().extension() == ".cpp") {
        std::cout << entry.path() << '\n';
    }
}

Exceptions

When something goes wrong that the current function can't handle, it throws an exception. The runtime unwinds the call stack until it finds a matching catch.

#include <iostream>
#include <stdexcept>
#include <string>

int main() {
    std::string text = "12x";
    try {
        int n = std::stoi(text);      // throws std::invalid_argument for "abc"
        std::cout << n << '\n';       // prints 12 — stoi stops at 'x'!
        int big = std::stoi("99999999999");   // throws std::out_of_range
        std::cout << big << '\n';
    } catch (const std::invalid_argument& e) {
        std::cerr << "not a number: " << e.what() << '\n';
    } catch (const std::out_of_range& e) {
        std::cerr << "number too big: " << e.what() << '\n';
    }
}
  • Catch by const&.
  • Order matters: put more specific types first.
  • catch (const std::exception& e) catches every standard exception.
  • catch (...) catches anything — use sparingly, e.g. at the top of main.

Throwing your own

#include <stdexcept>

double safe_divide(double a, double b) {
    if (b == 0.0) {
        throw std::invalid_argument("division by zero");
    }
    return a / b;
}

Common standard exception types: std::runtime_error, std::invalid_argument, std::out_of_range, std::logic_error. You can also derive your own from std::runtime_error.

Why RAII and exceptions fit together

When an exception unwinds the stack, every local object's destructor runs. File streams close, vectors free memory, locks unlock. No finally needed.

Streams don't throw by default

std::ifstream signals failure with its state, not exceptions. Always check if (!in) after opening.

std::optional: "maybe a value"

Not every failure is exceptional. A lookup that finds nothing is normal. Return std::optional:

#include <charconv>
#include <optional>
#include <string_view>

std::optional<int> parse_int(std::string_view s) {
    int value{};
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
    if (ec != std::errc{} || ptr != s.data() + s.size()) {
        return std::nullopt;               // failed or trailing junk
    }
    return value;
}

if (auto n = parse_int("42")) {
    std::cout << "got " << *n << '\n';
} else {
    std::cout << "not a number\n";
}

int x = parse_int("oops").value_or(0);     // default if empty

std::from_chars is the fast, non-throwing, locale-independent way to parse numbers.

When to use which

Situation Tool
"Not found" / "no value" is a normal outcome std::optional<T>
Caller can't reasonably continue; error is rare throw an exception
Programmer bug (should never happen) assert(condition) from <cassert>
Need an error reason without exceptions std::expected<T, E> (C++23)

A robust main

int main(int argc, char* argv[]) {
    try {
        return run(argc, argv);                // all the real work
    } catch (const std::exception& e) {
        std::cerr << "error: " << e.what() << '\n';
        return 1;
    }
}

Common mistakes

  • Not checking that a file opened. A missing file silently reads nothing.
  • Relative paths are relative to the current working directory, not the executable's location.
  • Catching by value (catch (std::exception e)) — slices derived exceptions and copies.
  • Using exceptions for normal control flow — e.g. to exit a loop. Use optional or a return value.
  • std::stoi("12x") succeeding with 12. If trailing junk matters, use std::from_chars and check the end pointer.
  • Dereferencing an empty optional with * — undefined behavior. Check first or use .value() (throws).
  • Swallowing errors: catch (...) {} hides bugs. At minimum, log them.

Exercises

  1. Write line_count FILE that prints the number of lines, words, and characters in a file (like wc). Print a helpful error if the file doesn't exist.
  2. Write a program that appends a timestamped line to journal.txt each time it runs, and journal --show prints the file.
  3. Write std::optional<double> parse_double(std::string_view) using std::from_chars and read numbers from a file, skipping (and reporting) bad lines by line number.
  4. List every .txt file under a directory recursively, with its size, sorted largest first.
  5. Write copy_file SRC DST that refuses to overwrite DST unless --force is given. Use exceptions for I/O failures and catch them in main.
  6. Save a std::map<std::string, int> to a file as key=value lines, then load it back and verify it matches.

Recap

  • std::ofstream writes, std::ifstream reads; check if (!stream) after opening. Streams close themselves (RAII).
  • std::getline for lines, >> for tokens.
  • std::filesystem for paths, directories, and file metadata; join paths with /.
  • throw / try / catch (const T&) for exceptional failures; destructors clean up during unwinding.
  • std::optional for "maybe a value"; std::from_chars for strict number parsing.

Next: Capstone: task tracker →