Skip to content
VLSI Mentor

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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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: YES

Byte-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 linens/linerangevs a fresh stream
a fresh ostringstream per line457406–5211.00×
one reused ostringstream422417–4911.08× (0.97–1.09)
snprintf into a stack buffer182176–2052.53× (2.29–2.64)
std::format257246–2861.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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
std::ostringstream o;
build(o);
return std::move(o).str();        // C++20: moves, does not copy

The 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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();
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  "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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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&.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
std::string line = std::move(o).str();
if (line.empty() || !o) { /* this is a defect, not a blank record */ }

8. Choosing Among the Four

situationusewhy
assembling a line, C++20 availablestd::formatreadable, no sticky state, compile-checked, 1.8×
assembling a line, C++17ostringstream, or snprintf in a hot path2.53× for snprintf, at the cost of safety
a hot path emitting millions of lineswrite straight to the destinationremoves both allocations
returning a large built stringstd::move(o).str()1 allocation becomes 0
a value with a custom operator<<ostringstreamit is the only one that dispatches to your inserter
parsing one recordistringstream over the linecontains the failure, gives the whole-line check
testing a stream functionistringstream with literal textno 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 questionthe answermeasured
do the four methods produce the same textyes, byte-identical§1
what does snprintf buy2.53× (2.29–2.64)§2
what does std::format buy1.80× (1.57–2.09), plus safety§2
does reusing the stream helpno — 1.08× with a range crossing 1.0§2
why notthe allocations are the strings, not the stream§3, 2.0/line both ways
what allocates nothingsnprintf into a stack buffer§3, 0 allocations
does str() copyyes — 1 allocation on 5 KB§4
how do I avoid thatstd::move(o).str() in C++20§4, 0 allocations
why parse per linethe failure is contained and you get the line number§5
how do I test stream codeistringstream and std::istream&§6
what does a failed ostringstream givean empty string, not an error§7

10. What to Actually Do

  • Take std::istream& and std::ostream&, and keep the file handling in one thin function. This is what makes the logic testable.
  • Prefer std::format for 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 ostringstream out 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_to into 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::format and concatenation produce byte-identical text, so the choice is cost, readability and safety.
  • The measured costs: snprintf 2.53×, std::format 1.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() and str(std::string()), not the stream's construction, and snprintf'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 istringstream contains 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::string behind it, and its most valuable property is structural: taking std::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: snprintf 2.53× (2.29–2.64) and std::format 1.80× (1.57–2.09) against a fresh ostringstream.
  • 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 and str(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_to into a buffer you keep, or std::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 ostringstream yields an empty string, not an error — and a custom operator<< 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.