Skip to content
C++
Library
since C++23
Intermediate

<spanstream>

C++23 header providing stream I/O over std::span<char> buffers — fixed-capacity, zero-allocation alternatives to <sstream>.

<spanstream>since C++23

A header introducing stream classes backed by std::span<char>, enabling standard-stream I/O over caller-owned memory with no dynamic allocation.

Overview

<spanstream> fills the gap between <sstream> and raw memcpy. It gives you the full std::basic_istream/std::basic_ostream interface — >>, <<, getline, seekg, manipulators — without touching the heap. The backing buffer is a std::span<char> supplied by the caller; the stream never reallocates or resizes it.

This matters in three common scenarios:

  • Embedded / real-time code where heap allocation is forbidden or non-deterministic.
  • Protocol parsing where you already have a fixed-size wire-format packet and want to extract fields with type-safe stream operators.
  • Stack-allocated formatting where you need to build a string into a local array and hand a std::string_view back to the caller.

The header provides four class templates and eight char/wchar_t typedefs:

Templatechar typedefwchar_t typedefRole
std::basic_spanbuf<C,T>spanbufwspanbufStream buffer backed by span
std::basic_ispanstream<C,T>ispanstreamwispanstreamInput-only stream
std::basic_ospanstream<C,T>ospanstreamwospanstreamOutput-only stream
std::basic_spanstream<C,T>spanstreamwspanstreamBidirectional stream

All four inherit from the usual basic_istream/basic_ostream/basic_iostream hierarchy, so existing code that accepts stream references works without modification.

Ownership and lifetime

The stream does not own the underlying buffer. The std::span<char> you pass in must remain alive and unmodified for the entire lifetime of the stream object. Violating this is undefined behaviour.

For input streams (ispanstream), the constructor accepts std::span<const char>. For output and bidirectional streams, the span must be span<char> because the stream writes into it.

The .span() accessor

After writing with ospanstream or spanstream, the member function .span() returns a std::span<char> whose size reflects the number of bytes written so far — not the full buffer capacity. This is the idiomatic way to get a string_view over the formatted output:

cpp
// since C++23
char buf[128];
std::ospanstream out{buf};
out << "errno=" << 42 << " flags=0x" << std::hex << 0xFF;
std::string_view result{out.span().data(), out.span().size()};
// result == "errno=42 flags=0xff"

Syntax

cpp
// #include <spanstream>   // C++23

// Construction
std::ispanstream  in { std::span<const char>{data, n} };
std::ospanstream  out{ std::span<char>{buf, sizeof buf} };
std::spanstream   io { std::span<char>{buf, sizeof buf}, std::ios::in | std::ios::out };

// Querying written region
std::span<char> written = out.span();        // sub-span up to put pointer

// Replacing the backing buffer
out.span( std::span<char>{new_buf, N} );     // resets stream state and buffer

// Free swap (ADL)
swap(a, b);   // std::swap specialisation provided

The openmode argument to spanstream follows the same rules as stringstream: it defaults to ios::in | ios::out.

Examples

Parsing a fixed-format packet

cpp
#include <spanstream>  // C++23
#include <cstdint>
#include <array>

struct SensorPacket {
    std::uint16_t device_id;
    float         temperature;
    std::uint8_t  flags;
};

// Wire format: "DEV=0042 TEMP=21.50 FL=03\n"
bool parse_packet(std::span<const char> wire, SensorPacket& out) {
    std::ispanstream in{wire};
    std::string tag;

    if (!(in >> tag) || tag != "DEV=") return false;
    if (!(in >> out.device_id))        return false;
    if (!(in >> tag) || tag != "TEMP=") return false;
    if (!(in >> out.temperature))      return false;
    if (!(in >> tag) || tag != "FL=")  return false;
    unsigned tmp;
    if (!(in >> std::hex >> tmp))      return false;
    out.flags = static_cast<std::uint8_t>(tmp);
    return true;
}

ispanstream performs no allocation here. The packet buffer lives wherever the caller put it — stack, static, DMA region — and the stream reads directly from it.

Zero-allocation formatting into a stack buffer

cpp
#include <spanstream>  // C++23
#include <string_view>

// Returns a string_view into the local buffer — caller must not escape it.
void log_event(std::uint32_t code, std::string_view msg) {
    char buf[256];
    std::ospanstream out{buf};
    out << "[0x" << std::hex << std::uppercase << code << "] " << msg;

    if (out.fail()) {
        // Buffer overrun: put pointer hit the end and badbit set.
        // Truncate gracefully.
    }

    std::string_view formatted{out.span().data(), out.span().size()};
    write_log(formatted);   // pass to whatever sink
}

Bidirectional in-place editing

cpp
#include <spanstream>   // C++23

// Re-format a fixed record in place: swap two space-separated tokens.
void swap_tokens(std::span<char> record) {
    std::spanstream io{record};
    std::string a, b;
    io >> a >> b;

    io.seekp(0);
    io << b << ' ' << a;
    // Pad to original length so record size is unchanged.
    auto pos = static_cast<std::size_t>(io.tellp());
    while (pos < record.size()) { io.put(' '); ++pos; }
}

Best Practices

Check for overflow. When ospanstream exhausts the buffer, it sets badbit and failbit. Subsequent writes silently do nothing. Always test out.fail() or out.good() after writing, especially when the output length is not statically bounded.

Use .span() not .span().data() alone. The returned span carries the correct size. Converting only the pointer to const char* will read past the written region unless you also capture .size() or null-terminate manually.

Prefer ispanstream over stringstream for read-only parsing. The std::stringstream constructor copies its std::string argument; ispanstream does not. When parsing a large buffer, this avoids a potentially costly allocation-and-copy.

Do not let the span outlive its source. If you construct an ispanstream from a temporary std::string, that string must not be destroyed while the stream is in use. Pin the lifetime explicitly.

wchar_t streams require a wide buffer. wspanstream operates on std::span<wchar_t>, not std::span<char>. Wide and narrow variants are not interchangeable.

Common Pitfalls

Buffer size is in elements, not bytes. std::span<char>{buf, N} treats N as the number of char elements. Passing sizeof buf is correct for char arrays but would be wrong for wchar_t arrays where sizeof returns byte count.

badbit after overflow is sticky. After ospanstream overflows, no amount of seekp or clear will make subsequent writes succeed unless you call span(new_buf) to reset the stream entirely. If you need overflow recovery, supply a larger buffer upfront.

seekp(0) does not reset the written region reported by .span(). The put pointer moves, but .span() reflects the high-water mark of the put pointer, not its current position. If you seek back and overwrite, .span() still shows the original length until you provide a new backing span.

Missing #include <spanstream>. std::span is in <span> (C++20); std::spanstream is in a separate <spanstream> header. Including one does not pull in the other.

See Also

  • <sstream> — heap-backed string streams; same interface, dynamic allocation
  • <span> (C++20) — the non-owning view type that backs these streams
  • <charconv>std::from_chars / std::to_chars for locale-independent, allocation-free number conversions; often complementary to spanstream in embedded contexts