C++ Part I · Module 23 · Files, Streams, and Data Processing
String Streams and In-Memory Formatting
A string stream is the same stream, with a std::string where the file would be. That sounds like a convenience and it is actually the most structurally useful class in the module, for a reason that has nothing to do with strings: it is what makes stream code testable. A function that takes std::istream& can be handed three lines of text in a test that runs in microseconds and touches no disk, which means the parsing logic of an entire tool can be covered by unit tests while the file handling stays in one thin layer. Beyond that there are two everyday jobs. Assembling a line — a log message, a report row, an error string with numbers in it — where the alternatives are string concatenation, snprintf and C++20's std::format, and it is worth knowing what each costs because output-heavy tools spend real time here. And splitting a record, where an istringstream over one line is the containment boundary that keeps a malformed line from poisoning the whole parse. The measurements in this chapter include one result that contradicts the obvious optimisation, which is the most useful kind.
Intermediate17 min readC++Part IFundamentalsStreams
Part I · Module 23 · Chapter 23.5 · String Streams and In-Memory Formatting
1. Four Ways to Build One Line
The line is a realistic log record — Chapter 22.9's format — and here it is assembled four ways:
// 1. ostringstream
std::ostringstream o;
o << "[" << std::setw(12) << std::setfill('0') << cyc << "] ERROR axi0 addr 0x"
<< std::uppercase << std::hex << std::setw(8) << std::setfill('0') << addr
<< std::dec << std::nouppercase << " master=" << master;
// 2. snprintf into a stack buffer
char buf[128];
std::snprintf(buf, sizeof buf, "[%012llu] ERROR axi0 addr 0x%08X master=%d",
(unsigned long long)cyc, addr, master);
// 3. std::format (C++20)
const std::string s = std::format("[{:012}] ERROR axi0 addr 0x{:08X} master={}",
cyc, addr, master);
// 4. concatenation, with streams for the formatted parts ostringstream : [000004823994] ERROR axi0 addr 0x4000A010 master=2
snprintf : [000004823994] ERROR axi0 addr 0x4000A010 master=2
std::format : [000004823994] ERROR axi0 addr 0x4000A010 master=2
concatenated : [000004823994] ERROR axi0 addr 0x4000A010 master=2
all four identical: YESByte-identical. So the choice among them is about cost, readability and safety, not about output — which is the premise the rest of the chapter needs, because otherwise a performance comparison would be comparing different things.
On readability the std::format version is clearly ahead: the whole layout is one readable string, and the relationship between the width, the fill and the base lives in {:08X} rather than spread across four stream operations with two of them needing to be undone. On safety it is ahead too — Chapter 23.2 §8 covers why — and the iostream version's std::dec << std::nouppercase is the Chapter 23.2 §3 leak being repaired by hand.
2. What Each One Costs
Twenty thousand lines per repetition, each figure already a minimum of 31 repetitions, reported as a median over nine whole-process runs:
| building one line | ns/line | range | vs a fresh stream |
|---|---|---|---|
a fresh ostringstream per line | 457 | 406–521 | 1.00× |
one reused ostringstream | 422 | 417–491 | 1.08× (0.97–1.09) |
snprintf into a stack buffer | 182 | 176–205 | 2.53× (2.29–2.64) |
std::format | 257 | 246–286 | 1.80× (1.57–2.09) |
Two of those rows are the chapter's findings.
snprintf is about two and a half times faster than an ostringstream, and std::format about 1.8×. Both ranges are comfortably clear of 1, so these are real. For a tool emitting millions of lines that is worth having; for one emitting thousands it is irrelevant, and Chapter 22.9 §2 measured a much larger effect — about 145× — from simply not formatting lines you are going to discard. Fix the discarded lines first.
Reusing the stream is not a speedup. The ratio's range is 0.97–1.09, which includes 1.0: across nine runs the reused version was sometimes slower than constructing a fresh one. The honest statement is that reuse is indistinguishable from fresh construction at this size, and §3 explains why by measuring the thing I had assumed reuse would fix.
3. The Optimisation That Does Nothing
The standard advice for ostringstream in a loop is to hoist it out and reset it:
std::ostringstream o; // constructed once
for (...) {
o.str(std::string()); // reset the contents
o.clear(); // reset the state bits
o << ...;
emit(o.str());
}The reasoning is that constructing a stream allocates, so constructing one per line allocates per line. Counting every call to the global operator new over a thousand lines:
1000 lines, fresh stream each : 2000 allocations (2.0 per line)
1000 lines, one reused stream : 2000 allocations (2.0 per line)
1000 lines, snprintf : 0 allocations (0.0 per line)Exactly the same number of allocations. Two per line either way. The hoisting changed nothing, which is why §2's timing ratio straddles 1.0.
The reason is that the allocations are not where the reasoning assumed. They come from the two std::string operations that bracket every iteration: o.str() returns the contents by value, which allocates, and o.str(std::string()) replaces the buffer, which allocates a fresh one. Hoisting the stream out of the loop removes the stream's construction and leaves both of those in place. The buffer is reused; the strings are not.
snprintf's zero is the control that makes this legible: writing into a stack buffer allocates nothing at all, which confirms the two allocations are the string handling rather than anything inherent to formatting.
So if you genuinely need to eliminate the allocations, the options are to format into a reused std::string with std::format_to, to write directly to the destination stream instead of building a line at all, or to use a fixed buffer:
// write straight to the destination -- no intermediate string
out << "[" << std::setw(12) << std::setfill('0') << cyc << "] ...";
// or format into a buffer you own
std::string line;
line.reserve(128);
std::format_to(std::back_inserter(line), "[{:012}] ...", cyc);4. str() Copies, and C++20 Lets You Take the Buffer
o.str() on a 5000-char buffer: 1 allocation(s)
std::move(o).str() on a 5000-char buffer: 0 allocation(s)str() returns the contents by value, so on a five-kilobyte buffer it allocates and copies five kilobytes. C++20 added an rvalue overload: calling str() on an rvalue stream moves the buffer out, which is zero allocations.
std::ostringstream o;
build(o);
return std::move(o).str(); // C++20: moves, does not copyThe std::move applies to the stream, not the string — you are saying "this stream is finished, take its buffer". The stream is left in a valid empty state, so it is safe to destroy and safe to reuse, though reusing it after moving from it is confusing to read.
This is worth using whenever you build a large string and return it, which is the common shape for a report generator or a message assembler. For a short log line it is noise. And it is a C++20 feature, so Chapter 21.8 §1's advice to check the toolchain applies before relying on it.
5. Parsing a Record
The input side is the containment boundary Chapter 23.3 §6 depends on:
const std::string rec = "axi0 412 1.2345 SLVERR";
std::istringstream in(rec);
std::string unit, resp;
long cycles = 0;
double us = 0;
const bool ok = (in >> unit >> cycles >> us >> resp) && (in >> std::ws).eof(); "axi0 412 1.2345 SLVERR"
-> unit=axi0 cycles=412 us=1.2345 resp=SLVERR whole line consumed=1
the same record as CSV -> 4 fields: [axi0] [412] [1.2345] [SLVERR]Three properties make this the right structure for parsing a line-oriented file.
The failure is contained. A malformed record fails its own istringstream and leaves the file stream untouched, so there is no recovery to write — which is Chapter 23.3 §1's whole problem, removed by construction rather than solved.
The whole-line check comes for free. (in >> std::ws).eof() on a per-line stream means "this line had exactly these fields", which Chapter 23.3 §6 measured catching an extra field that four successful extractions had ignored.
You get the line number. The outer getline loop counts lines, so a rejection can name one. Chapter 23.7 §3 measures a parser reporting the line numbers it could not classify, which is the difference between a usable diagnostic and a count.
For delimiter-separated fields, getline with a delimiter over the same istringstream splits the record, and Chapter 23.3 §7 covers its limits — an empty field between two delimiters is real data, and this is not an RFC 4180 CSV reader.
6. Testing Without a File
This is the structural reason the class matters, and it changes how you organise a tool.
// production code takes the abstraction
int countErrors(std::istream& in);
// the test needs no file, no fixture directory, no cleanup
TEST("counts errors across both log formats")
{
std::istringstream in(
"[ 100] NOTE tb reset released\n"
"[ 200] ERROR axi0 resp=SLVERR\n"
"UVM_ERROR @ 300: sb mismatch\n");
assert(countErrors(in) == 2);
}A test like that runs in microseconds, needs no filesystem, cannot fail because of a leftover file from a previous run, and can express inputs that are awkward to keep on disk — a file with no trailing newline, a line with trailing whitespace, a file containing a NUL byte, a 10,000-line input generated in the test.
The design rule that follows: put the file handling in one thin layer and the logic behind an std::istream&.
// thin, hard to unit test, and almost nothing happens here
int run(const std::filesystem::path& p)
{
std::ifstream in(p);
if (!in) { report(p); return 2; }
return countErrors(in);
}Chapter 23.4 §1's unchecked-open defect lives entirely in run, which is four lines you can read in one glance. Chapter 23.8 builds a model this way specifically so its vectors can come from a string in a test and a file in a regression.
7. The Stream That Produces an Empty Line
An output string stream can fail, and the failure does not look like one:
writing to a stream with badbit set: str() is "" (length 0)Writes to a stream in a failed state are discarded. str() then returns an empty string, and an empty string is a plausible value — a record with no content, a line that was meant to be blank. Nothing throws and nothing returns an error.
In practice an ostringstream rarely fails on its own; it fails because something set badbit, or because a custom operator<< for one of your types left the stream in a bad state. The second case is the realistic one, and it is why a user-defined inserter should not leave the stream failed:
std::ostream& operator<<(std::ostream& os, const Reg& r)
{
// if this function can fail, it must not silently poison the stream
// for every later insertion
return os << "0x" << std::hex << std::setw(8) << std::setfill('0') << r.value;
}That one is also a Chapter 23.2 §3 leak — it changes the base and the fill and does not restore them — which is the more common defect in a custom inserter. Both are the same underlying problem: an inserter mutating shared stream state it does not own.
If you are assembling something whose emptiness would be wrong, check:
std::string line = std::move(o).str();
if (line.empty() || !o) { /* this is a defect, not a blank record */ }8. Choosing Among the Four
| situation | use | why |
|---|---|---|
| assembling a line, C++20 available | std::format | readable, no sticky state, compile-checked, 1.8× |
| assembling a line, C++17 | ostringstream, or snprintf in a hot path | 2.53× for snprintf, at the cost of safety |
| a hot path emitting millions of lines | write straight to the destination | removes both allocations |
| returning a large built string | std::move(o).str() | 1 allocation becomes 0 |
a value with a custom operator<< | ostringstream | it is the only one that dispatches to your inserter |
| parsing one record | istringstream over the line | contains the failure, gives the whole-line check |
| testing a stream function | istringstream with literal text | no file, microseconds, any input you can write |
The row about custom inserters is the one that keeps ostringstream relevant. std::format needs a std::formatter specialisation for a user-defined type and snprintf cannot take one at all, while operator<< is the mechanism your own types already have. For a codebase full of types with inserters, the stream is the path of least resistance and the cost is known.
9. The Table
| the question | the answer | measured |
|---|---|---|
| do the four methods produce the same text | yes, byte-identical | §1 |
what does snprintf buy | 2.53× (2.29–2.64) | §2 |
what does std::format buy | 1.80× (1.57–2.09), plus safety | §2 |
| does reusing the stream help | no — 1.08× with a range crossing 1.0 | §2 |
| why not | the allocations are the strings, not the stream | §3, 2.0/line both ways |
| what allocates nothing | snprintf into a stack buffer | §3, 0 allocations |
does str() copy | yes — 1 allocation on 5 KB | §4 |
| how do I avoid that | std::move(o).str() in C++20 | §4, 0 allocations |
| why parse per line | the failure is contained and you get the line number | §5 |
| how do I test stream code | istringstream and std::istream& | §6 |
what does a failed ostringstream give | an empty string, not an error | §7 |
10. What to Actually Do
- Take
std::istream&andstd::ostream&, and keep the file handling in one thin function. This is what makes the logic testable. - Prefer
std::formatfor assembling lines where C++20 is available — readable, no sticky state, compile-time checked, and measured 1.8× faster than a stream. - Do not hoist an
ostringstreamout of a loop expecting a win. Measured: identical allocation counts and a speedup range that includes 1.0. - If the per-line cost actually matters, write straight to the destination stream and build no intermediate string, or
std::format_tointo a buffer you keep. - Return large built strings with
std::move(o).str(). One allocation becomes zero. - Parse each record through its own
istringstream, which contains the failure and makes the whole-line check from Chapter 23.3 §6 natural. - Do not let a custom
operator<<change formatting state or leave the stream failed. Restore with a guard; Chapter 23.2 §3 has the pattern. - Treat an empty assembled string as a possible defect, since a failed output stream yields exactly that.
11. Mistakes Worth Making Once
Hoisting the stream and reporting a win from one run. 457 against 422 looks like 8%; the range is 0.97–1.09.
Assuming formatting must allocate. snprintf does it in zero.
Returning o.str() for a large buffer. A full copy, avoidable with std::move(o).str().
A custom operator<< that leaves std::hex set. Chapter 23.2 §3's leak, exported to every caller.
Parsing a whole file field-by-field off the file stream. One bad field and you are in Chapter 23.3 §1's recovery problem.
Testing a parser through a temporary file. Slower, flakier, and it cannot express a file with no trailing newline as easily as a string literal can.
Trusting a non-empty result without checking the stream. A failed ostringstream returns "".
12. Interview Relevance
The frozen curriculum marks this chapter Medium, and it tends to come up as a design question rather than a syntax one: "how would you test this parsing code?" The answer is to take std::istream& rather than a filename or an ifstream&, so the test hands it an istringstream with literal text — no fixture, no cleanup, microseconds, and inputs like a missing trailing newline that are awkward to keep on disk. That answer demonstrates you have organised a tool rather than just used a class.
If performance comes up, the reuse result is a good thing to be able to tell: the obvious optimisation of hoisting the stream out of the loop changes the allocation count not at all, because the allocations are str() copying out and str(string()) installing a new buffer, and the apparent 8% was inside the run-to-run range. It shows the habit of measuring the quantity the explanation is about.
13. What You Should Be Able to Say Now
- That an
ostringstream,snprintf,std::formatand concatenation produce byte-identical text, so the choice is cost, readability and safety. - The measured costs:
snprintf2.53×,std::format1.80×, and reuse 1.08× with a range crossing 1.0 — that is, no reliable gain. - Why reuse does not help: the two allocations per line are
str()andstr(std::string()), not the stream's construction, andsnprintf's zero allocations is what proves it. - That
std::move(o).str()moves the buffer out in C++20 — 1 allocation becomes 0. - That parsing each record through its own
istringstreamcontains the failure, supplies the whole-line check, and keeps the line number available. - That taking
std::istream&is what makes stream logic unit-testable, and that the file handling should be one thin layer. - That a failed output string stream yields an empty string, which is a plausible value rather than an error.
14. Exercises
Exercise 1 — identical output. Build the §1 line all four ways and assert the four strings are equal. Then change the std::format spec to {:08x} and find which assertion fails and why.
Exercise 2 — the refutation. Reproduce §2's four timings and §3's allocation counts with a global operator new counter. Confirm the two stream rows allocate identically. Then remove the o.str() call from the loop body and re-count; explain the new number.
Exercise 3 — move-from-str. Build a 100 KB string in an ostringstream and return it with o.str() and with std::move(o).str(), counting allocations each way. Then inspect the stream after the move and confirm it is valid and empty.
Exercise 4 — testable design. Take a function that currently opens a file by name and parses it, and split it into a four-line run(path) and a parse(std::istream&). Write three tests for parse using string literals, including one input with no trailing newline.
15. Summary
- A string stream is the stream model with a
std::stringbehind it, and its most valuable property is structural: takingstd::istream&makes parsing logic testable with a string literal, in microseconds, with no filesystem. - Four ways of building a line give byte-identical output. Measured, their costs are not identical:
snprintf2.53× (2.29–2.64) andstd::format1.80× (1.57–2.09) against a freshostringstream. - Reusing the stream is not an optimisation. 1.08× with a range of 0.97–1.09, and exactly the same 2.0 allocations per line — because the allocations are
str()copying out andstr(std::string())installing a new buffer, which hoisting does not touch.snprintf's zero allocations is the control that shows this. - So the real fixes remove a string: write straight to the destination,
std::format_tointo a buffer you keep, orstd::move(o).str()— which takes 1 allocation to 0 on a 5 KB buffer. - Parse each record through its own
istringstream. The failure is contained, so Chapter 23.3's recovery problem does not arise; the whole-line check is natural; the line number stays available for the diagnostic. - A failed
ostringstreamyields an empty string, not an error — and a customoperator<<that leaves the stream failed or the base changed exports that defect to every caller. - Measure the quantity your explanation is about. The claim was about allocations, so only the allocation counter could settle it; the timer agreed by coincidence and the range revealed the coincidence.
Chapter 23.6 drops below the character level to the bytes themselves, where the padding in your struct is written to the file and reading it back with a slightly different declaration produces wrong numbers and no error at all.
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.
