Chapter 17 of 24 | C++
File input and output
File streams use the same stream model as terminal I/O. Include <fstream> for std::ifstream (input), std::ofstream (output), and std::fstream (both). These objects manage their file handles and close them automatically when destroyed.
#include <fstream>
#include <iostream>
#include <string>
int main()
{
std::ifstream input{"notes.txt"};
if (!input) {
std::cerr << "Could not open notes.txt\n";
return 1;
}
std::string line;
while (std::getline(input, line)) {
std::cout << line << '\n';
}
if (input.bad()) {
std::cerr << "A file read error occurred\n";
return 1;
}
}
Opening can fail because a file does not exist, the path is wrong, or permission is denied. Check the stream before using it. A loop of the form while (std::getline(file, line)) processes each successfully read line and stops at end-of-file or an error.
Writing data
An std::ofstream creates or truncates a file by default. Use the appropriate open mode when appending or reading and writing. Check that the output stream opened successfully, and check its state after writing when data loss matters. Flush and close errors can occur, so critical applications should treat output completion as something to verify.
Paths and file formats
Use std::filesystem::path for path composition instead of joining path strings with hard-coded separators. A relative path is interpreted from the program's current working directory, which may not be the source file's directory. Make the expected working directory clear in your run instructions.
Text files are convenient but need a format. Delimited values require quoting and escaping rules if fields can contain delimiters or newlines. Validate parsed values and handle malformed records without silently accepting corrupted data.
Opening modes and file position
An input stream normally opens an existing file for reading. An output stream normally creates a file or truncates its existing contents. Use std::ios::app when new output should be appended, and use the combination of modes appropriate to a read/write stream when both directions are required. Opening a file in the wrong mode can silently erase data, so verify the intended behavior with a disposable test file first.
Streams keep state about whether reading and writing are possible. End-of-file becomes known when a read attempts to go beyond the available data, which is why the idiomatic loop tests each read rather than checking eof() first. A file can also fail for reasons other than reaching its end; check bad() or the stream state when distinguishing a hardware or I/O error matters.
Parse records defensively
A file is external input, even when your own program wrote it previously. It might be incomplete, edited by a person, or generated by another version of the program. Check that each record has the expected fields, reject values outside their allowed range, and decide whether one malformed record should stop the whole operation or be reported and skipped.
A simple line-oriented format is often easier to validate than a stream of tokens because you can reject unexpected trailing text on each record. For production interchange, use an established format and parser when quoting, escaping, nested data, or Unicode rules become important. Do not invent a partial parser that mishandles delimiters inside data.
Paths, permissions, and current directory
std::filesystem::path represents a path using platform-aware operations. Joining path components with the path operator is more portable than embedding slash or backslash characters in a string. A relative path is interpreted from the process's current working directory. Running the same executable from a different directory can therefore change which file it opens.
Opening a path is not a security guarantee. A program may lack permissions, the path may point somewhere unexpected, or a file may change between checking and opening it. Report failures clearly and avoid assuming that checking a path first makes a later operation safe in every environment.
Writing data reliably
Writing to a stream may buffer data in memory before it reaches the filesystem. Closing or flushing can report an error, for example when a disk fills up. For important output, check stream state after writing and closing, and consider a safer replace strategy so a failure does not leave a partially written final file. Exact durability requirements are operating-system-specific and may require facilities beyond ordinary streams.
Practice
- Read a text file line by line and count its lines.
- Write a summary to an output file and confirm the stream is good afterward.
- Try a missing path and display a helpful error.
- Explain how the current working directory affects a relative path.
- Run the same program from two working directories and observe how a relative path resolves.
- Create a malformed record and verify that the parser reports which input is invalid.
- Append to a disposable file and contrast it with the default truncating output mode.
Key idea: file I/O can fail. Check open and operation status, define your data format, and let stream objects manage their resource lifetime.
Text formats and locale independence
Human-readable text is convenient for inspection but needs a precise grammar. Define line endings, encoding, delimiters, escaping, numeric syntax, and whether extra fields are allowed. Locale-sensitive stream formatting can alter decimal separators and grouping, so machine interchange should use a specified locale-independent representation. Parse a full record and validate its complete structure before committing it to application state.
Versioning a file format can make future changes manageable. A version field lets a reader select the appropriate parser or migration path. Without versioning, a new program may misinterpret an old record or silently accept fields it does not understand. Unknown fields should be handled according to the format's forward-compatibility policy.
Binary I/O is not raw object serialization
Opening a stream with binary mode disables some text translation behavior on platforms that distinguish text and binary files. It does not define a portable representation for C++ objects. Writing sizeof(object) bytes can serialize padding, pointers, implementation-specific enum widths, and endianness. Such a file may only be readable by a compatible build on a compatible target.
A portable binary format must specify field widths, byte order, string encoding, framing, versioning, and validation. Decode bytes into values explicitly and check lengths before allocating or indexing. Treat malformed or adversarial files as untrusted input. Specialized serialization libraries can reduce mistakes, but their format and compatibility guarantees still need review.
Transactional file updates
Writing directly over the only copy of an important file risks leaving it truncated if the process or device fails mid-write. A common strategy is to write a complete replacement to a temporary file, verify successful output, and then replace the original using an appropriate filesystem operation. Atomic replacement and durability guarantees vary by operating system and filesystem; a fully durable update may require explicit platform APIs for flushing data and directory metadata.
Do not assume that closing a stream means the data has been physically persisted to stable storage. Ordinary stream semantics report I/O state but do not provide every transactional guarantee needed by databases or critical configuration stores.
Advanced exercise
- Specify a versioned text format and write tests for valid, truncated, malformed, and future-version records.
- Demonstrate why dumping a struct's bytes is not a portable interchange format; design explicit field encoding instead.
- Implement a temporary-file update for a disposable configuration file and document platform assumptions.
- Fuzz a parser with random and boundary inputs while checking that it fails safely without out-of-bounds access.