The Span Reflex: When Concrete Thinking Blocks Compositional Design
Design Principles
Abstract
When C++ developers encounter “how should I represent a network buffer?”, the near-universal instinct is std::span<std::byte>. This reflexive answer reveals a deeper problem: the inability to see past concrete types to the concepts they model, and consequently, an inability to design for composition.
But the opposite failure—over-engineered conceptual frameworks divorced from practical use cases—is equally harmful, as WG21’s struggles with Ranges and Execution demonstrate. Good design requires judgment: knowing when abstraction enables composition, when concrete types are necessary, and when theoretical purity must yield to pragmatic usability. The span-fixated designer and the abstract theorist both lack this judgment; the skilled designer cultivates it.
1. The Span Reflex in Action
Ask a room of C++ developers questions about network I/O design. The answers arrive with remarkable consistency:
Question 1: “How should you represent a buffer?”
// The instinctive answer
std::span<std::byte>Simple. Modern. Type-safe. What’s not to like? Hold that thought.
Question 2: “How should you represent scatter/gather I/O?”
// The instinctive answer
std::span<std::span<std::byte>>Users familiar with POSIX scatter/gather will recognize this pattern:
// The system-level equivalent (POSIX iovec)
struct iovec {
void* iov_base; // pointer to buffer
size_t iov_len; // length of buffer
};
ssize_t writev(int fd, const struct iovec* iov, int iovcnt);The span<span<byte>> answer is natural—it’s the C++ equivalent of an iovec array. But it has a fundamental problem: arrays of buffers don’t compose.
Suppose you have HeaderBuffers (2 spans) and BodyBuffers (3 spans). To send them together:
// Must allocate to combine buffer sequences
std::vector<std::span<const std::byte>> combined;
combined.insert(combined.end(), header.begin(), header.end());
combined.insert(combined.end(), body.begin(), body.end());
send(combined); // allocation requiredEvery time you compose buffer sequences, you allocate. This is the span reflex at work: reaching for a concrete type that seems “simple,” without recognizing that the type choice prevents zero-allocation composition.
The overload proliferation follows inevitably:
void send(std::span<const std::byte> data); // single buffer
void send(std::span<std::span<const std::byte>> data); // scatter/gather
void send(std::string_view data); // string literals
void send(const void* data, size_t len); // C APIsEach new signature represents a failure to see the underlying abstraction. The designer is playing whack-a-mole with use cases instead of identifying the concept that unifies them.
2. The Concept-Driven Alternative
Boost.Asio, after twenty years of production use, arrived at a different answer. Its send operations accept any type modeling the ConstBufferSequence concept—where a buffer sequence is any iterable whose elements describe contiguous memory regions:
template<ConstBufferSequence Buffers>
void send(const Buffers& buffers);This single signature accepts:
A
std::span<const std::byte>(a single-element sequence)A
std::string_view(a single-element sequence of characters)A
std::array<const_buffer, N>(fixed-size scatter/gather)A
std::vector<const_buffer>(dynamic scatter/gather)A custom buffer chain type (linked list, rope, etc.)
Any composition of the above—without allocation
The key insight: because ConstBufferSequence is a concept rather than a concrete type, buffer sequences can compose at compile time:
// Type-level composition - no runtime allocation
auto combined = cat(header_buffers, body_buffers); // returns a view type
send(combined); // zero allocation, maps to single writev() callThis is impossible with span<span<byte>>—arrays must be allocated to combine sequences. Concepts enable what concrete types forbid.
The span-fixated designer asks: “What type should I accept?”
The concept-aware designer asks: “What operations does my function need to perform on its argument?”
The second question leads to composable designs. The first leads to overload proliferation and allocation.
Why span feels “right” but isn’t
The cognitive trap is understandable:
spanis concrete, familiar, and appears “simple”Concepts feel abstract, require template syntax, seem “complicated”
Developers optimize for local simplicity, missing global composition
But the “simplicity” of span creates complexity downstream:
Users must convert their data to match your signature
Scatter/gather requires a separate code path or runtime allocation
Extension requires library changes (new overloads)
Every new buffer representation demands another overload
The span-based API is locally simple and globally complex. The concept-based API is locally unfamiliar and globally simple. The experienced designer recognizes this tradeoff and chooses global simplicity.
3. Even std::byte is wrong!
The reflexive choice of std::byte for I/O buffers reveals another dimension of the problem. Consider the traditional POSIX signatures:
ssize_t read(int fd, void* buf, size_t count);
ssize_t write(int fd, const void* buf, size_t count);Why void* and not char* or unsigned char*? Because void* makes no semantic claim about the buffer’s contents. It says: “this is raw memory; I will move bytes without opining on what they represent.”
The reflexive modern C++ answer is std::span<std::byte>. But std::byte is an opinion. It says: “these are bytes, and you can perform bitwise operations on them.” This may or may not reflect valid operations for the actual data being transferred.
The deeper problem: span<void> doesn’t compile:
void send(std::span<const void> data); // error: incomplete type ‘void’Span requires a complete type with known size. The type-agnostic abstraction that void* provides cannot be expressed in span’s type system. This forces designers into a false choice:
Use
span<std::byte>and impose a semantic opinionUse
span<char>and impose a different semantic opinionUse
void*+size_tand lose span’s conveniencesUse a custom type (such as
asio::const_buffer)
The traditional I/O interfaces got this right. They used void* precisely because raw I/O should be agnostic about what the memory contains. The reflexive reach for span<std::byte> loses this semantic neutrality.
This connects to the larger thesis: The span-fixated designer doesn’t ask “what semantic claim am I making with this type?” They see “bytes” and reach for std::byte, not recognizing that the type system is a tool for expressing meaning, not just shuffling data.
4. The Design Skill Gap
This isn’t just about buffers—it’s a pattern. The span reflex manifests across the language:
std::string_viewinstead of a character range conceptstd::functioninstead of callable conceptsConcrete container types instead of range concepts
std::variantinstead of polymorphic concepts
Each choice trades composition for false simplicity. The developer who reaches for span cannot design APIs that compose, because they cannot see the abstraction layer where composition happens.
The skill gap is not about knowing C++ features. It’s about recognizing when a concrete type is a model of a concept, and designing for the concept rather than the model.
Historical Parallel: The STL’s Founding Insight
The Standard Template Library’s power comes from a simple insight: algorithms should be parameterized on iterators (a concept), not on containers (concrete types).
std::sort doesn’t accept std::vector<T>&. It accepts any random-access iterator range:
template<RandomAccessIterator I>
void sort(I first, I last);This single signature works with vectors, arrays, deques, and any user-defined container providing random-access iterators. The algorithm doesn’t know or care about the container—it only requires the operations the concept guarantees.
This was Stepanov’s founding insight, and it made the STL composable in ways that container-specific algorithms could never be.
The span reflex is a regression from this insight. It’s as if, after thirty years of generic programming, we forgot why it matters. The developer who reaches for span<span<byte>> instead of a buffer sequence concept has unlearned the lesson Stepanov taught.
Practical Consequences
The real-world costs of span-fixated design are measurable:
Asio’s buffer concepts enable:
Zero-copy I/O (data never moves unnecessarily)
Scatter/gather operations (multiple buffers in one syscall)
Custom allocators and memory-mapped buffers
Integration with any user-defined buffer type
None of these are possible with a span-only design.
Beast HTTP (built on Asio) defines the Body concept. Message bodies can be:
Strings (for simple responses)
Files (for static content, memory-mapped)
Streams (for generated content)
Any user type modeling the concept
A span-based design would require separate code paths for each.
Range-based libraries work with any range, not just vectors. This enables lazy evaluation, infinite sequences, and zero-allocation transformations.
Span-based APIs force users to:
Allocate contiguous buffers unnecessarily
Copy data to match expected types
Give up scatter/gather optimization
Write adapter code for every new buffer type
The span reflex produces APIs that work for the designer’s use case and impose costs on everyone else.
5. Case Study: Spans at the Virtual Boundary
This paper might seem to argue “concepts always, spans never.” The truth is more nuanced, and the author’s experience designing a coroutines-first networking library illustrates why:
When exploring how to structure a modern networking library built around C++20 coroutines, I found that spans are the natural concrete representation—but only at a specific architectural layer: the virtual boundary. For single buffers,
span<byte>; for scatter/gather,span<span<byte>>.
Why virtual boundaries change the calculus:
At a virtual function call, you’ve already accepted:
Type erasure (the concrete type is hidden behind an interface)
Indirection cost (virtual dispatch)
Loss of inlining opportunities
At this boundary, concept-based flexibility provides no benefit—you must pick a concrete representation. And spans are precisely that representation: span<byte> for single buffers, span<span<byte>> for buffer sequences:
// The virtual interface - concrete span types are correct here
class socket_stream {
public:
// Single buffer operations
virtual task<size_t> read(std::span<std::byte> buffer) = 0;
virtual task<size_t> write(std::span<const std::byte> buffer) = 0;
// Scatter/gather operations
virtual task<size_t> readv(std::span<std::span<std::byte>> buffers) = 0;
virtual task<size_t> writev(std::span<std::span<const std::byte>> buffers) = 0;
};
// The generic layer - concepts are correct here
template<MutableBufferSequence Buffers>
task<size_t> read_some(socket_stream& stream, Buffers&& buffers) {
// Convert concept-modeled type to span<span<byte>> at the virtual boundary
auto spans = to_span_array(buffers); // stack allocation for small sequences
co_return co_await stream.readv(spans);
}The insight that supports this paper’s thesis:
The reflexive span user would look at this design and say “See? Spans are the answer.” But they’re missing the architecture:
User-facing APIs accept any type modeling the buffer sequence concept (composition, zero allocation)
Internal virtual boundaries use
span<byte>orspan<span<byte>>(necessary type erasure)The library handles the conversion between layers
The span-only designer creates APIs that force users to deal with spans everywhere. The concept-aware designer uses spans where type erasure demands them, while giving users the compositional flexibility of concepts at the API surface.
The deeper lesson: Neither “always span” nor “always concepts” represents mature design thinking. The skilled designer asks: Where am I in the abstraction stack? What are the constraints at this layer? What serves users best?
This nuanced view—understanding when concrete types are appropriate versus when concepts enable composition—is precisely the design skill the span reflex lacks.
6. The Opposite Failure: Over-Engineered Abstraction
Lest anyone conclude “concepts always,” WG21’s own history provides stark warnings about the opposite extreme. When concept-driven design loses touch with pragmatism, the results are equally harmful.
std::ranges: Can’t Compose Buffers
The Ranges library embodies sophisticated concept design. Surely it makes buffer composition trivial? After all, this paper argues for composition over concrete types, and Ranges is all about composable views.
// The promise: Ranges makes composition easy
// The reality: composing two byte spans is surprisingly hard
std::span<const std::byte> header = ...;
std::span<const std::byte> body = ...;
// There’s no views::concat until C++26
// views::join needs a range-of-ranges, so try this:
auto combined = std::views::join(std::array{header, body}); // works!
// But only if both ranges have exactly the same type.
// Mix string_view and span<byte>? Common in real networking code:
std::string_view h = “GET / HTTP/1.1\r\n\r\n”;
auto mixed = std::views::join(std::array{h, body}); // error: incompatible typesThe irony is sharp: Asio’s ConstBufferSequence concept accepted heterogeneous buffer types—strings, spans, arrays, user-defined types—in 2003. Ranges shipped in C++20 without even a views::concat for homogeneous ranges. That finally arrives in C++26 (P2542), twenty-three years after Asio solved the problem.
The sophisticated concept framework cannot do what a pragmatic design handled two decades ago. This is what happens when abstraction loses touch with use cases.
std::execution: Framework Without Foundation
P2300 (std::execution) represents years of theoretical work on sender/receiver abstractions. It ships in C++26 without:
A standard thread pool
A coroutine task type
Any async I/O integration
As P3109 acknowledges, users “will need to go to third party libraries for thread-pools, or write their own.” The irony: a framework justified by avoiding third-party dependencies requires third-party dependencies to be useful.
Meanwhile, Boost.Asio—twenty years of production deployment, proven async model, complete and usable—remains non-standard because it doesn’t fit the theoretical framework the committee preferred.
The Pattern
Both failures share a root cause: concept design divorced from use cases. The designers asked “what properties should these abstractions have?” instead of “what do users need to accomplish?”
Ranges: Elaborate view machinery that can’t concatenate two spans of different types
Execution: Theoretical generality over shipping something complete
The span reflex fails by never reaching the abstraction layer. Over-engineered frameworks fail by never descending from it. Both represent incomplete design thinking.
The Lesson
Good design lives in the middle:
Abstract enough to enable composition (not span-fixated)
Concrete enough to be usable (not over-engineered)
Grounded in real use cases (not theoretical purity)
Asio’s buffer concepts hit this sweet spot. They’re abstract enough to accept any buffer type, concrete enough to map directly to OS scatter/gather I/O, and grounded in twenty years of production feedback. The span reflex would never have produced them; neither would pure concept theorizing.
7. Teaching the Missing Skill
The remedy isn’t “use concepts everywhere”—it’s developing the mental habit of asking:
What operations does my function actually need?
What concept describes those operations?
Could a user have a type that models this concept but isn’t the type I’m imagining?
Am I at a type-erasure boundary where a concrete type is required?
What semantic claim am I making with my type choice?
Does my abstraction serve real use cases, or theoretical purity?
Can a user accomplish common tasks without fighting the framework?
If a user might have a type that models the concept, design for the concept. If you’re at a virtual boundary, use the appropriate concrete type. If your concept rejects obviously-meaningful code, your concept is too restrictive. The skill is recognizing which situation you’re in—and having the judgment to find the pragmatic middle ground.
For Framework Designers: A Pragmatism Checklist
Before proposing a conceptual framework to the committee:
Vertical slice: Can you ship a complete, usable subset first? If your framework requires “third-party libraries for the basics,” you’ve built scaffolding without a building.
Heterogeneity: Real code is messy. Can your abstraction compose heterogeneous inputs (
string_view+span<byte>) without forcing users to homogenize first?Show me the code: Write user code for five common tasks. If any require boilerplate, workarounds, or multiple lines for simple operations, the abstraction serves the framework, not users.
Production feedback: Demand years of deployment before standardization. Theoretical frameworks discover their gaps after standardization, when fixes require breaking changes. Numerous in-flight papers for theoretical frameworks to “land before the deadline” is a signal of design immaturity.
Existing solutions: If a battle-tested library already solves the problem, the burden is on the new framework to demonstrate practical superiority.
std::executionhas provided no guidance for networking—arguably the greater need than GPU execution. A framework that cannot demonstrate with working examples how it improves on Asio for the most common async use case has not earned its place in the standard.
8. Conclusion
The span reflex is a symptom of thinking about types instead of type requirements. It produces APIs that work for the designer’s use case but break composition for everyone else.
But the cure is not “always use concepts.” Over-engineered abstraction—concepts divorced from use cases, theoretical purity over practical usability—produces equally dysfunctional designs. WG21’s struggles with Ranges and Execution demonstrate that going too far toward abstraction is as harmful as never reaching it.
Actionable Advice: Red Flags to Watch For
In design discussions and code reviews, watch for these warning signs:
Red Flag #1: Dismissing composable designs as “too complicated”
When someone waves away a concept-based approach because “span is simpler” or “I don’t understand why we need templates here,” this signals span-fixated thinking. The inability to see past concrete types to the abstraction layer is a design skill gap. These designers will produce APIs that force unnecessary allocations, break scatter/gather optimization, and require endless overloads as new use cases emerge.
What to do: Ask “What happens when a user has data in a different form?” If the answer is “they convert it,” the design has failed.
Red Flag #2: Pushing theoretical complexity over working solutions
When someone insists on elaborate concept hierarchies, dismisses practical objections as “not understanding the theory,” or ships frameworks without usable primitives, this signals over-engineering. These designers will produce APIs that reject obviously-meaningful code, require extensive boilerplate, and prioritize abstract purity over getting work done.
What to do: Ask “Show me the code a user writes for common tasks.” If it’s verbose, surprising, or requires workarounds for simple operations, the design has failed.
The Middle Ground
The skilled designer asks both questions. They design for composition and usability. They understand concepts and know when concrete types are appropriate. They can explain why a design is abstract and demonstrate that it serves real use cases simply.
Asio’s buffer concepts exemplify this balance: abstract enough to accept any buffer representation, concrete enough to map directly to OS primitives, and simple enough that common operations are one-liners. Neither the span-fixated designer nor the abstract theorist would have produced them.
The goal is not to choose a side. It is to develop the judgment to find the pragmatic middle ground—and to recognize the red flags when others are stuck at either extreme.
This paper argues for design judgment, not dogma. The individuals working on span, Ranges, and Execution contribute valuable work to C++; the criticism here is of design patterns, not personal efforts.


Nice and clear! Thanks!
Great breakdown of design tradeoffs in modern C++! The Asio vs std::ranges comparison really drives home how 20+ years ofproduction use trumps theoretical elegance. I've defintely run into this when working with network buffers - ended up with a mess of overloads before realizing the issue was thinking in types instead of operations. The virtual boundary insight is lowkey brilliant, that's exactly where concrete types make sense while keeping the API layer compositional.