C++ Part I · Module 23 · Files, Streams, and Data Processing
File Streams: Opening, Reading, Closing
A file stream is the stream model of Chapter 23.1 attached to a file, and almost everything about using it correctly was already covered by that model and by Chapter 22.4's resource discipline. What is genuinely new is the beginning and the end. The beginning is that opening a file can fail, for reasons entirely outside your program — the path is wrong, the directory does not exist, the file belongs to somebody else, the disk is full — and the measured consequence of not checking is not a crash but a clean run that reports nothing wrong. That failure mode deserves a section of its own because it is the single most common defect in file-handling code and because it survives testing: the test fixture exists, so the test passes. The end is that the stream closes itself, which is RAII applied to a file descriptor and removes an entire class of bug, with one honest limit — a close that fails has nowhere to report, and for a file that matters, because a failed close means the data may not be there. In between sit the open modes, which quietly truncate a file you meant to append to, and the buffer, which means written and saved are different events.
Intermediate18 min readC++Part IFundamentalsStreams
Part I · Module 23 · Chapter 23.4 · File Streams: Opening, Reading, Closing
1. The Unchecked Open
Here is a function that counts records, and the same function run twice — once on a file that exists and once on a path that does not:
int countRecords(std::istream& in)
{
std::string line;
int n = 0;
while (std::getline(in, line))
if (!line.empty()) ++n;
return n;
}
std::ifstream in("report-that-does-not-exist.txt"); // not checked
const int n = countRecords(in); opening a missing file and reading it: 0 records, and the program
would print "0 errors found" and exit 0. is_open=0 fail=1
the same code on a real file : 3 records, is_open=1Zero records, and the program exits 0. There is no exception, no message on stderr, and no non-zero status. A tool built this way reports "0 errors found" for a file it never opened, and that sentence is indistinguishable from the same sentence on a clean run.
This is the defect worth dwelling on, because of how it interacts with testing. A test writes a fixture file and reads it; the file exists, so the path is correct, so the test passes. The defect only appears in production, where the path comes from a command line or a configuration file or a directory that a previous step failed to create — and it appears as a success. Chapter 23.8 §2 measures the same shape one level up: a gate that passes because nothing ran.
The fix is three characters and a branch:
std::ifstream in(path);
if (!in) {
std::cerr << "cannot open " << path << '\n';
return 2;
}Report the path. A message saying "cannot open input file" without naming it costs the reader a guess, and the path is the one piece of information that identifies the problem.
2. The Three Ways to Check, and What Each Tells You
!in.is_open() = 1 (did the OPEN work)
!in = 1 (is the stream usable NOW)
in.fail() = 1
errno = 2 (No such file or directory) <-- NOT set by the standard, but libc++ leaves itAll three report the failure, and they answer slightly different questions:
is_open()asks specifically whether a file is attached. It is the right question if you want to distinguish "never opened" from "opened and then something went wrong" — for instance in a function that receives an already-constructedstd::ifstream&.operator bool, that is!in, asks whether the stream is usable now. This is the idiom for the common case, it is the same check you use after every read, and it covers both a failed open and a subsequent failure.fail()is the same thing spelled out, and per Chapter 23.1 §2 it is also true whenbadbitis set.
Use if (!in) immediately after construction. It reads as the same check you use everywhere else, which is a real advantage.
The errno row is worth knowing and not relying on. The standard does not require a file stream to set errno, but libc++ opens the file with a C library call that does, so the value is there and std::strerror turns it into "No such file or directory" — which is much more useful to a user than "cannot open". It is a quality-of-message improvement with a portability caveat, so if you use it, treat a value of 0 as "no detail available" rather than as success. For a guaranteed reason, std::filesystem with an error_code is the portable route:
std::error_code ec;
if (!std::filesystem::exists(path, ec))
report(path, ec ? ec.message() : "no such file");That is Chapter 22.1 §7's per-interface principle in the standard library: std::filesystem offers a throwing and an error_code form of every operation, and here the error_code form is right because a missing input file is an expected outcome.
3. The Stream Closes Itself
An std::ifstream or std::ofstream owns a file descriptor and releases it in its destructor. That is Chapter 22.4's subject applied to the resource people most often leak, and the measurement is the one that matters:
inside the scope: is_open=1
after the scope the object is gone, so there is nothing left to close
re-opening the same file succeeds: is_open=1
5000 sequential open/destroy cycles: 5000 opened, 0 leaked
(without RAII this is the classic descriptor exhaustion bug)Five thousand opens, five thousand closes, no leak, and not one close() call written. The equivalent C code needs a fclose on every exit path from every function, and Chapter 22.4 §2 measured what happens when one of those paths is an exception: opened=1 closed=0 LEAKED=1.
Three consequences are worth stating because they are the practical value:
You do not need close(). Writing it is not wrong, and §4 gives the one case where it is right, but the destructor is not a fallback — it is the mechanism.
Scope is the lifetime. If you want the file closed earlier, introduce a scope:
{
std::ofstream o(path);
write(o, data);
} // closed and flushed here
process(path); // safe: the file is completeThat brace is doing real work. Without it, process may see a partially written file, because §5 measures that the bytes are not on disk until the stream flushes.
An exception on the way out still closes the file. This is the half that hand-written cleanup gets wrong, and Chapter 22.3 §3 measured destructors running during unwinding in frames that knew nothing about the failure.
4. What close() Reports and What the Destructor Cannot
explicit close(): fail=0 -> you can TEST it
the destructor closes too, but it returns nothing and sets nothing you
can read afterwards -- the object is gone. This is 22.4 section 7's limit.
calling close() twice: fail=1 -> the second one FAILSA close can fail. Closing a file flushes the remaining buffer, and that write can fail — a full disk, a quota, a network filesystem that went away. When it fails during close(), the stream sets failbit and you can test it. When it fails during the destructor, the stream sets failbit on an object that is about to stop existing, and nobody will ever read it.
This is Chapter 22.4 §7's honest limit of RAII, and a file is the canonical instance of it. The guard pattern guarantees the release happens; it cannot report that the release failed. So:
std::ofstream o(path);
o << data;
o.close(); // flush and release, while we can still look
if (!o) {
std::cerr << "failed to write " << path << '\n';
return 2;
}Call close() explicitly when the file's contents matter — a results file, a report another tool will consume, anything you are about to tell the user succeeded. Let the destructor handle it for a file you read, or a scratch file, or a log where a lost tail is acceptable. The destructor is still there as the guarantee on the exception path; the explicit close() adds the ability to report on the normal path. They are not alternatives.
The second measured row is a small trap: calling close() twice sets failbit. If you write an explicit close() and then check the stream in a destructor or a later branch, be aware that a second close is itself a failure, so if (!o) after a double close reports a problem that is not the one you were looking for.
5. The Buffer, Through the Filesystem
Chapter 23.1 §6 observed the buffer from inside the program. Here it is through std::filesystem::file_size, which is what another process would see:
after writing 100 bytes, before the stream closed: file is 0 bytes
after an explicit flush(): file is 100 bytes
after the destructor: file is 100 bytesOne hundred bytes written, zero bytes in the file. Chapter 23.1 §6 measured the block size at 4,096 bytes on this libc++, which is the amount an abnormal termination can discard.
Three practical rules follow:
- Close or scope the stream before anything reads the file. The brace in §3 is the fix; passing a path to another function while your
ofstreamis still alive is a race against your own buffer. - Flush before anything that might not return. Before a long computation, before a call into unfamiliar code, on the error path. Chapter 22.9's logging chapter makes the same point about the diagnostic you most want being from the run that died.
- Do not flush per line. Chapter 23.1 §7 measured
std::endlat a median 17× the cost of'\n'for byte-identical output.
A note on std::abort and std::exit, because they interact with this. std::exit runs static destructors and flushes the standard streams; std::abort does neither. Chapter 22.3 §4 covers the unwinding half of that difference. The consequence here is that a failing assertion — which calls abort, per Chapter 22.8 §1 — discards up to a buffer's worth of your own diagnostic output, including the lines explaining what was about to go wrong. If you have diagnostics you need to survive an abort, flush them when you write them.
6. The Open Modes, Measured on One File
The mode flags are the part of file streams that silently destroys data, so here they are applied in sequence to a single file:
ofstream (default = trunc) -> 4 bytes
ofstream again, same file -> 2 bytes <-- TRUNCATED
ofstream with ios::app -> 5 bytes
contents now -> "BBCCC"
in|out (overwrite in place) -> 5 bytes
contents now -> "ZBCCC" <-- first byte replacedConstructing an std::ofstream on an existing file truncates it, before you have written anything. The second row is four bytes becoming two: the file was emptied at open and then two bytes were written. If your program opens an output file, then discovers it has nothing to write and returns, the previous contents are gone.
The modes in full:
| mode | effect | use for |
|---|---|---|
std::ios::out | implied by ofstream; truncates | a file you are generating |
std::ios::trunc | explicit truncation | documenting the intent |
std::ios::app | every write goes to the end | a log |
std::ios::ate | position at the end, writes can seek | rare |
std::ios::in | implied by ifstream | reading |
std::ios::in + out | read and write, no truncation | patching in place |
std::ios::binary | no character translation | §7, and Chapter 23.6 |
Two of the measured rows deserve comment. ios::app appended and gave "BBCCC" — and with app, every write goes to the end regardless of any seek, which is what makes it the correct mode for a log file that more than one writer may have open. in | out replaced the first byte and left the rest, giving "ZBCCC" with no truncation; that is the mode for editing a file in place, and it requires the file to exist already.
The habit worth forming: write the mode you mean, even when it is the default. std::ofstream o(path, std::ios::out | std::ios::trunc) says what it does. A reader of std::ofstream o(path) has to remember.
For a results file that must not be half-written if the program fails, the standard approach is to write to a temporary path and rename:
const auto tmp = path.string() + ".tmp";
{
std::ofstream o(tmp);
write(o, results);
o.close();
if (!o) { std::filesystem::remove(tmp); return 2; }
}
std::filesystem::rename(tmp, path); // atomic on the same filesystemThe rename is atomic, so a consumer sees either the old complete file or the new complete file and never a partial one. This combines everything in the chapter: the mode, the explicit testable close, and the fact that a crash before the rename leaves the original intact.
7. Bytes on Disk Against Characters Read
A text-mode stream is permitted to translate line endings. On this platform it does not:
wrote "a\nb\n": file_size = 4 bytes
read back in BINARY mode: 4 characters
read back in TEXT mode : 4 charactersFour bytes, four characters, both modes. The equality holds here and is not portable. On Windows, text mode writes \r\n for every \n, so the file would be 6 bytes while the program wrote 4, and reading it back in text mode would give 4 characters again while binary mode gives 6.
This produces a specific category of bug: code that computes a file offset from the number of characters it has written, or that compares file_size() against a character count, works on macOS and Linux and silently misbehaves on Windows. Chapter 23.6 §7 measures the same flag from the binary side and concludes that it costs nothing here and is the difference between working and silently truncating there — so pass std::ios::binary whenever the bytes matter, even on a platform where it is a no-op.
8. Reading a Whole File
Three ways, with different trade-offs:
// 1. line by line -- the default choice
std::string line;
while (std::getline(in, line)) handle(line);
// 2. the whole thing into a string
std::string all((std::istreambuf_iterator<char>(in)),
std::istreambuf_iterator<char>());
// 3. size first, then one read -- for binary
in.seekg(0, std::ios::end);
const auto n = in.tellg();
in.seekg(0);
std::vector<char> buf((std::size_t)n);
in.read(buf.data(), n);Form 1 is right for almost all text processing: it is bounded in memory regardless of file size, it gives you line numbers for diagnostics, and Chapter 23.3 §6 showed why per-line parsing contains failures.
Form 2 is convenient and has a sharp edge — it reads the entire file into memory, so a log that is normally 2 MB and is occasionally 9 GB, which Chapter 22.9 §3 measured as a realistic outcome of per-cycle logging, becomes an out-of-memory failure rather than a slow one. The double parentheses are required: without them the declaration parses as a function declaration, which is the most vexing parse and which Chapter 7.6 covers.
Form 3 is for binary files of known structure and is what Chapter 23.6 §3 uses. Note that tellg() returns a std::streampos, that seeking to the end is not guaranteed to give a meaningful size for a text stream, and that gcount() after the read tells you how many characters you actually got — which is the number to check, not the stream's bool, because a short read succeeds.
9. The Table
| the question | the answer | measured |
|---|---|---|
| what happens if I do not check the open | 0 records, exit 0, no message | §1 |
| how do I check | if (!in) right after construction | §2 |
| why name the path in the message | it is the information that identifies the fault | §1 |
do I need close() | not for the release — the destructor does it | §3, 5,000 cycles, 0 leaked |
| when should I call it anyway | when the contents matter — it is testable | §4 |
| can the destructor report a failed close | no | §4, Chapter 22.4 §7 |
what does a second close() do | sets failbit | §4 |
| are my bytes on disk | not until flush or close | §5, 0 bytes at 100 written |
does ofstream keep the old contents | no — it truncates at open | §6, 4 bytes → 2 |
| how do I append | std::ios::app | §6, "BBCCC" |
| how do I patch in place | std::ios::in | std::ios::out | §6, "ZBCCC" |
does ios::binary matter here | no, and use it anyway | §7, 4 = 4 |
10. What to Actually Do
- Check every open, immediately, with
if (!in), and name the path in the message. Measured: the unchecked version reports zero errors and exits 0. - Better, make the failed state unrepresentable with a
mustOpenhelper that throws. A missing input file is Chapter 22.1 §1's exceptional category — once per run, ends the run. - Do not write
close()for the release. The destructor is the mechanism, on every path including a throw. - Do write
close()when the contents matter, and check the stream afterwards. That is the only way to learn that the final flush failed. - Use a scope to end the file's lifetime before anything reads it. 100 bytes written, 0 bytes visible.
- Write the open mode you mean.
std::ofstreamon an existing file truncates it before your first write. - Use
std::ios::appfor logs — every write goes to the end regardless of seeks. - Write-to-temp-then-rename for any file a consumer depends on. The rename is atomic; a crash leaves the old file intact.
- Pass
std::ios::binarywhenever the bytes matter, even where it is a no-op. - Prefer line-by-line reading. It is bounded in memory and gives you line numbers.
11. Mistakes Worth Making Once
Not checking the open. 0 records, exit 0, "0 errors found". The test passes because the fixture exists.
"Cannot open input file" without the path. The reader has to guess which one.
Relying on the destructor for a file whose write must be confirmed. It closes and cannot tell you the flush failed.
Calling close() twice and then checking the stream. The second close sets failbit by itself.
Opening an output file at the start of a program that might not write to it. The previous contents are already gone.
Passing a path to another function while your ofstream is still open. You are racing your own buffer.
std::endl on every line of a results file. Measured 17× for identical bytes.
Computing offsets from character counts without ios::binary. Correct here, broken on Windows.
Slurping a whole log into a string. Fine at 2 MB; an out-of-memory failure at 9 GB.
12. Interview Relevance
The frozen curriculum marks this chapter High and the question is reliably "what is wrong with this code?" over an unchecked std::ifstream. The answer that lands says the failure is silent — zero records and exit zero, indistinguishable from a clean run — and then adds the testing observation, that the defect survives every test whose fixture exists. Offering if (!in) is the minimum; offering a helper that cannot produce a failed stream, and explaining why throwing is right for a missing file and wrong for a malformed line, is the answer that shows you have thought about failure categories.
The natural follow-up is "do you need to call close()?" The complete answer is: not for the release, because the destructor does it on every path including an exception; yes when the contents matter, because close() is the only form you can test, and the destructor's inability to report a failed flush is RAII's one honest limit.
If modes come up, that std::ofstream truncates at open is the fact people are surprised by, and app versus in | out is the distinction worth being able to draw.
13. What You Should Be Able to Say Now
- That an unchecked open produces 0 records and exit 0, that no message appears, and that the defect is invisible to any test whose fixture exists.
- That
if (!in)is the idiom, thatis_open()answers the narrower question, and that libc++ leaves a usableerrnowhich the standard does not require. - That the destructor releases the descriptor on every path — 5,000 cycles, 0 leaked — so
close()is not needed for the release. - That
close()is needed when the contents matter, because it is the only testable form, and that a secondclose()setsfailbit. - That written is not saved: 100 bytes written, 0 bytes on disk, and up to 4,096 bytes lost to an abort.
- That
std::ofstreamtruncates an existing file at open, thatappalways writes at the end, and thatin | outpatches in place. - Why write-to-temp-then-rename is the pattern for a file somebody else consumes.
- That
ios::binaryis a no-op here and still required for portable code.
14. Exercises
Exercise 1 — the silent success. Write a tool that counts error lines in a log and prints "N errors found". Run it on a real log, then on a misspelled path. Confirm the two runs are indistinguishable. Add the check, then replace it with a mustOpen helper and compare the diagnostics.
Exercise 2 — the close that fails. Write a file, call close(), and check the stream. Then call close() again and check again; explain the second result. Then remove the explicit close() and find a way to learn whether the final flush succeeded — and conclude that there is not one.
Exercise 3 — truncation. Create a file with content. Open it with std::ofstream and immediately return without writing. Inspect the file. Then repeat with std::ios::app and with std::ios::in | std::ios::out.
Exercise 4 — the buffer race. Write 100 bytes to a file and, without closing or flushing, call a function that opens the same path and counts its bytes. Then wrap the write in a scope and repeat.
Exercise 5 — atomic results. Implement §6's write-to-temp-then-rename. Then simulate a failure between the write and the rename and confirm the original file is intact.
15. Summary
- The unchecked open is the defect of this chapter. Measured: a missing file gives 0 records and exit 0, identical to a clean run, and it survives any test whose fixture exists. Check with
if (!in)and name the path. - The two file bugs are different in kind. A leaked descriptor is a lifetime bug that RAII solves outright — 5,000 open/destroy cycles, 0 leaked. An unchecked open is a contract bug that no destructor can touch, because the stream hands you a usable object in a failed state.
- So remove the state rather than remembering to check it: a helper that returns an opened stream or throws. A missing input file is exceptional; a malformed line is not.
- The destructor closes on every path, and cannot report a failure. Call
close()explicitly when the contents matter — it is the only testable form — and know that a secondclose()setsfailbit. - Written is not saved. 100 bytes written, 0 bytes on disk until a flush or close; 4,096 bytes is what an abort can discard.
std::ofstreamtruncates an existing file at open, before your first write.appalways appends;in | outpatches in place without truncating. Write the mode you mean.- Write to a temporary and rename for any file another tool consumes — the rename is atomic, so nobody sees a partial file.
std::ios::binaryis a no-op on this platform and still correct, because the code that depends on bytes equalling characters breaks where text mode translates.
Chapter 23.5 keeps the same interface and removes the file, which turns out to be how you test everything in this module — and how you assemble and split a record.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
