07 · File I/O & Streams¶
You've been using std::cout and std::cin since Level 1. Those are just two
particular streams. C++ models every character source and sink the same way
— files, the console, in-memory strings — so the << and >> operators you
already know work identically on all of them.
That uniformity is the payoff. A function taking std::ostream& writes to the
console, a file, or a string buffer depending only on what you hand it, which
also makes it trivially testable.
Writing a file¶
#include <fstream>
#include <iostream>
int main() {
std::ofstream out("scores.txt"); // opens for writing, TRUNCATES existing content
if (!out) { // always check -- opening can fail
std::cerr << "Could not open scores.txt for writing" << std::endl;
return 1;
}
out << "Ada 95" << std::endl;
out << "Alan 88" << std::endl;
out << "Grace 97" << std::endl;
} // <-- destructor closes the file automatically (RAII)
std::ofstream opens on construction and closes in its destructor. You do not
need out.close() unless you want to close early and check the result. If the
constructor fails, the stream is left in a failed state — if (!out) catches
it. Note the default: opening for writing wipes the file. Use
std::ios::app to append.
Reading a file¶
There are three common ways, and picking the wrong one is the usual source of bugs.
#include <fstream>
#include <iostream>
#include <string>
#include <sstream>
int main() {
// 1. Line by line -- the right default for text
std::ifstream in("scores.txt");
if (!in) {
std::cerr << "Cannot open scores.txt" << std::endl;
return 1;
}
std::string line;
while (std::getline(in, line)) { // stops at EOF or on error
std::cout << "[" << line << "]" << std::endl;
}
// 2. Token by token -- whitespace-separated
std::ifstream in2("scores.txt");
std::string name;
int score;
while (in2 >> name >> score) { // the whole expression is the condition
std::cout << name << " scored " << score << std::endl;
}
// 3. Whole file into one string
std::ifstream in3("scores.txt");
std::stringstream buffer;
buffer << in3.rdbuf(); // slurp the entire stream buffer
std::string contents = buffer.str();
std::cout << contents.size() << " bytes" << std::endl;
}
// Output:
// [Ada 95]
// [Alan 88]
// [Grace 97]
// Ada scored 95
// Alan scored 88
// Grace scored 97
// 24 bytes
The classic mistake:
while (!in.eof()) { // WRONG
std::getline(in, line);
std::cout << line << std::endl; // prints the last line twice
}
eof() reports whether the previous read hit end-of-file. On the final
iteration the read fails, line keeps its old value, and you process it a
second time. Always put the read itself in the condition:
while (std::getline(in, line)). That works because streams convert to bool
based on their error state.
Stream state flags¶
#include <sstream>
#include <iostream>
int main() {
std::istringstream in("42 abc");
int a = 0, b = 0;
in >> a;
std::cout << "a=" << a << " good=" << in.good() << std::endl; // a=42 good=1
in >> b; // "abc" is not an int
std::cout << "fail=" << in.fail() << std::endl; // fail=1
// A failed stream stays failed -- all later reads are no-ops until you clear it
in.clear(); // reset the flags
std::string word;
in >> word;
std::cout << "word=" << word << std::endl; // word=abc
}
| Flag | Meaning | Check with |
|---|---|---|
goodbit |
Everything fine | s.good() |
eofbit |
End of input reached | s.eof() |
failbit |
Last operation failed (bad format, open failed) | s.fail() |
badbit |
Unrecoverable stream corruption | s.bad() |
if (stream) is true exactly when neither failbit nor badbit is set.
After a formatting failure the bad characters are still in the buffer — you
must clear() the flags and then discard them, or you'll loop forever:
#include <limits>
std::cin.clear();
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); // skip the rest of the line
Parsing structured lines¶
#include <iostream>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
struct Record {
std::string name;
std::string city;
int age;
};
std::vector<Record> loadCsv(const std::string& path) {
std::vector<Record> records;
std::ifstream in(path);
if (!in) return records;
std::string line;
while (std::getline(in, line)) {
if (line.empty()) continue;
std::istringstream ss(line); // treat the line as its own stream
std::string name, city, ageText;
// getline with a delimiter splits on commas
if (!std::getline(ss, name, ',')) continue;
if (!std::getline(ss, city, ',')) continue;
if (!std::getline(ss, ageText)) continue;
try {
records.push_back({name, city, std::stoi(ageText)});
} catch (const std::exception& e) {
std::cerr << "Skipping bad row: " << line << std::endl;
}
}
return records;
}
std::istringstream turns a string into a stream, letting you reuse the same
extraction machinery on data you already have in memory. std::ostringstream
does the reverse — it's the idiomatic way to build a formatted string:
#include <sstream>
#include <iomanip>
std::ostringstream oss;
oss << "Total: " << std::fixed << std::setprecision(2) << 1234.5;
std::string label = oss.str(); // "Total: 1234.50"
Formatting with manipulators¶
#include <iostream>
#include <iomanip>
int main() {
double pi = 3.14159265358979;
std::cout << std::fixed << std::setprecision(3) << pi << std::endl; // 3.142
std::cout << std::scientific << pi << std::endl; // 3.142e+00
std::cout << std::defaultfloat << std::setprecision(6); // restore
std::cout << std::setw(10) << std::right << "Name"
<< std::setw(8) << "Score" << std::endl;
std::cout << std::setw(10) << std::left << "Ada"
<< std::setw(8) << 95 << std::endl;
std::cout << std::setfill('0') << std::setw(5) << 42 << std::endl; // 00042
std::cout << std::setfill(' '); // reset
std::cout << std::hex << 255 << std::endl; // ff
std::cout << std::oct << 8 << std::endl; // 10
std::cout << std::dec << 255 << std::endl; // 255
std::cout << std::boolalpha << true << std::endl; // true (instead of 1)
}
Crucially, all of these except setw are sticky. Set std::hex and every
subsequent integer prints in hex until you set std::dec. std::setw applies
to the very next output item only, which is why it's repeated on each column
above. Forgetting stickiness is a frequent source of "why is my number
printing weirdly three functions later".
| Manipulator | Effect | Sticky? |
|---|---|---|
std::setw(n) |
Minimum field width | no — next item only |
std::setfill(c) |
Pad character | yes |
std::setprecision(n) |
Digits of precision | yes |
std::fixed / std::scientific |
Float notation | yes |
std::left / std::right |
Alignment within the field | yes |
std::hex / std::oct / std::dec |
Integer base | yes |
std::boolalpha |
true/false instead of 1/0 |
yes |
std::endl |
Newline plus flush | n/a |
std::endl flushes the buffer every time. In a tight loop writing thousands of
lines, '\n' is meaningfully faster — flush only when you need the output to
appear immediately (progress messages, crash-adjacent logging).
Open modes and binary I/O¶
| Mode | Meaning |
|---|---|
std::ios::in |
Read (default for ifstream) |
std::ios::out |
Write, truncating (default for ofstream) |
std::ios::app |
Append — every write goes to the end |
std::ios::trunc |
Explicitly empty the file on open |
std::ios::ate |
Seek to end on open, but writes may go anywhere |
std::ios::binary |
No newline translation (essential on Windows) |
#include <fstream>
#include <vector>
int main() {
std::ofstream log("app.log", std::ios::app); // append, don't wipe
log << "started\n";
// Binary: write raw bytes, no formatting
std::vector<int> data{1, 2, 3, 4};
std::ofstream bin("data.bin", std::ios::binary);
bin.write(reinterpret_cast<const char*>(data.data()),
data.size() * sizeof(int));
bin.close();
std::vector<int> loaded(4);
std::ifstream binIn("data.bin", std::ios::binary);
binIn.read(reinterpret_cast<char*>(loaded.data()),
loaded.size() * sizeof(int));
// loaded == {1, 2, 3, 4}
}
Binary dumps like this are fast but not portable — they encode your platform's integer size and endianness. Fine for a local cache, wrong for a file format other machines will read.
Writing testable code with streams¶
#include <iostream>
#include <sstream>
#include <vector>
#include <string>
// Takes an abstract ostream, so it doesn't care where the output goes
void printReport(std::ostream& os, const std::vector<std::string>& items) {
for (std::size_t i = 0; i < items.size(); ++i) {
os << (i + 1) << ". " << items[i] << "\n";
}
}
int main() {
std::vector<std::string> items{"apples", "bread"};
printReport(std::cout, items); // to the console
std::ostringstream captured;
printReport(captured, items); // to a string, for a unit test
if (captured.str() == "1. apples\n2. bread\n") {
std::cout << "test passed" << std::endl;
}
}
This is the practical reason to type parameters as std::ostream& rather than
hardcoding std::cout: the same function becomes verifiable without touching
the file system.
How It Actually Works¶
Every stream (cin, cout, ifstream, ofstream, stringstream) inherits
from the same base classes (istream/ostream) and, crucially, each
concrete stream plugs in a different stream buffer (streambuf) that
does the actual byte movement — << and >> themselves are entirely
buffer-agnostic; they just format data and hand bytes to whatever buffer is
installed. That's the actual mechanism behind "the same operators work
identically everywhere": std::cout connects its streambuf to the OS's
standard-output file descriptor, std::ifstream connects its streambuf to
an OS file handle, and std::stringstream connects its streambuf to an
in-memory std::string — swap the buffer, and every formatting call above
it behaves the same but writes somewhere else.
File streams don't write to disk on every << call — the streambuf
maintains an internal memory buffer and only issues an actual OS-level
write() syscall when that buffer fills, when you explicitly call
flush(), or when the stream is destroyed/closed. This buffering is why a
program that crashes (not via a normal return/exception unwind, but e.g.
a hard abort() or power loss) can lose the last chunk of "written" output
that never made it past the in-memory buffer to disk — the destructor that
would flush it never got to run.
Opening a file also allocates real OS resources — a file descriptor, an
entry in the process's open-file table — which is exactly the resource RAII
wraps: ifstream's destructor closes the file handle automatically, so an
exception thrown between opening a file and finishing with it still results
in the OS-level handle being released during stack unwinding, the same
mechanism covered under exception handling.
Exercise¶
Write a small contact-book program.
- Define
struct Contact { std::string name, email; int age; }; saveContacts(const std::string& path, const std::vector<Contact>&)writes one comma-separated contact per line.loadContacts(const std::string& path)reads them back withstd::getlineandstd::istringstream, skipping malformed lines with a warning tostd::cerrinstead of crashing.printTable(std::ostream& os, const std::vector<Contact>&)prints an aligned table usingstd::setwandstd::left.
Verify a round trip: save, load, and confirm you got the same data back. Then feed it a deliberately corrupted file (a row with a missing field, a row with letters where the age should be) and confirm the program reports the bad rows and keeps going — Module 8 goes deeper on that error-reporting decision.