C++ProgrammingC++ Software Engineer

During which category of initialization does **std::span** constructed from a prvalue container produce a dangling reference, and why does the **C++20** specification preclude compiler warnings for this undefined behavior?

Pass interviews with Hintsage AI assistant

Answer to the question

History of the question

The introduction of std::span in C++20 marked the standardization of a long-standing idiom from the C++ Core Guidelines' gsl::span. Its design goal was to provide a zero-cost abstraction over contiguous sequences, replacing raw pointer-length pairs in APIs. The committee explicitly rejected owning semantics to maintain performance characteristics matching raw pointers, aligning with std::string_view's philosophy. This decision traced back to the need for interoperability with C-style arrays and legacy code without imposing allocation overhead. Consequently, std::span inherited the fundamental limitations of non-owning views, particularly regarding lifetime management.

The problem

The hazard emerges when a std::span is initialized from a prvalue container, such as the return value of a factory function returning std::vector<T> by value. In this scenario, the temporary vector is destroyed at the end of the full-expression, yet the std::span retains internal pointers to the vector's deallocated heap storage. Because std::span is a trivially copyable type indistinguishable from a raw pointer pair to the compiler's lifetime analysis, the language provides no mandatory diagnostic for this dangling reference. The C++20 standard specifies that std::span models a borrowed range, but this concept only affects range-based for loops and algorithms, not the fundamental lifetime rules of the underlying storage. This creates a false sense of security, as the syntax resembles safe container usage while harboring undefined behavior similar to returning a pointer to a local variable.

The solution

Mitigation requires strict adherence to lifetime extension principles and leveraging static analysis. Developers must ensure that the owning container outlives any std::span referencing it, ideally by declaring the container as a named variable before creating the view. Employing tools like Clang-Tidy with the cppcoreguidelines-pro-bounds-lifetime check can catch initializations from temporaries. For API design, functions should accept std::span by value for lvalue arguments but document preconditions requiring the caller to maintain storage validity. When ownership semantics are necessary, prefer std::unique_ptr<T[]> or std::vector itself, using std::span only for function parameter passing where the caller guarantees lifetime.

#include <span> #include <vector> #include <iostream> std::vector<int> generate_buffer() { return std::vector<int>(1024, 42); // Temporary vector } void process(std::span<int> data) { // Undefined behavior if data is dangling std::cout << data.front() << ' '; } int main() { // Dangling: temporary destroyed after full-expression process(generate_buffer()); // Safe: container outlives the span auto buffer = generate_buffer(); std::span<int> safe_view(buffer); process(safe_view); }

Situation from life

In a real-time audio processing engine, a mixer thread received decoded PCM data from a codec wrapper that returned std::vector<float> by value. The mixer immediately constructed a std::span<float> to pass to a DSP algorithm, aiming to avoid copying kilobytes of audio data per callback. During quality assurance, the application crashed intermittently with corrupted audio artifacts when the garbage collector (in a bridged C# environment) triggered, coinciding with the C++ buffer access.

The engineering team contemplated three distinct approaches to resolve the lifetime mismatch.

The first approach involved copying the vector data into a pre-allocated circular buffer owned by the mixer thread. This guaranteed that the std::span always pointed to valid memory, eliminating dangling references completely. However, the memcpy operation consumed approximately 5 microseconds per channel, which exceeded the 1-millisecond hard real-time deadline for the audio callback, making this solution unsuitable for low-latency requirements.

The second approach proposed changing the codec wrapper to populate a reference parameter std::vector<float>& instead of returning by value. This would extend the vector's lifetime to the caller's scope. While this eliminated the temporary, it broke the API's immutability guarantees and forced the caller to manage the vector's capacity, leading to cumbersome object pooling logic at every call site and reducing code clarity.

The third approach utilized a custom AudioBufferHandle class that held a std::shared_ptr<std::vector<float>> and implicitly converted to std::span<float>. The mixer accepted the handle, extracted the span for immediate processing, and the handle's destructor kept the vector alive until the DSP finished. This approach was selected because it maintained the zero-copy requirement while ensuring lifetime safety through RAII, and the reference counting overhead was negligible compared to the audio processing load.

The result was a crash-free audio pipeline that passed ASAN (AddressSanitizer) and TSAN (ThreadSanitizer) checks under heavy load, though it required careful documentation to prevent developers from storing the span beyond the handle's lifetime.

What candidates often miss

Why does initializing a std::span from a braced-init-list like std::span<int> s = {1, 2, 3}; result in a dangling pointer, whereas std::vector<int> v = {1, 2, 3}; remains valid indefinitely?

The braced-init-list creates a temporary std::initializer_list<int>, which conceptually holds pointers to a temporary array of integers with automatic storage duration. When std::span binds to this initializer list via its deduction guides, it captures pointers to that temporary array. The temporary array is destroyed at the end of the full-expression, leaving the span dangling. In contrast, std::vector has an allocator and copies the elements into heap storage that persists until the vector is destroyed. Candidates often conflate the syntax of initialization lists with container constructors, forgetting that std::span performs no allocation or copying, acting merely as a view.

How does the constexpr capability of std::span interact with automatic storage duration, and why might a constexpr span pointing to a local non-static array lead to undefined behavior if returned from a function?

std::span is a literal type, allowing constexpr usage, but constexpr only mandates that the initialization can be evaluated at compile time; it does not change the storage duration of the underlying array. If a function defines a local non-static array and returns a constexpr std::span to it, the array has automatic storage duration and is destroyed upon function exit, invalidating the span immediately. The confusion arises because candidates assume constexpr variables implicitly have static storage or that the compiler prevents dangling in constant expressions, but std::span simply encapsulates pointers, and pointers to automatic variables become invalid regardless of constexpr qualification.

What specific limitation prevents std::span from being safely returned from a function that constructs a container internally, and how does this contrast with std::string_view which faces similar but subtly different constraints?

Both std::span and std::string_view are non-owning views, but std::string_view is often used with string literals that have static storage duration, masking the dangling issue. When a function constructs a std::vector or std::string internally and attempts to return a span/view to it, the container is destroyed on function exit, invalidating the view. The key difference is that std::string_view can bind to null-terminated string literals (const char[]) which have static lifetime, making patterns like std::string_view get() { return "literal"; } safe, whereas std::span cannot bind to array literals in the same way without creating a temporary array. Candidates frequently overlook that std::span is more general than std::string_view and lacks the special case for string literal storage, making all returns of spans from local containers unconditionally unsafe.