Stopping Buffer Overflows with C++20 std::span
Stop passing pointer-size pairs in C++. Learn how C++20's std::span provides a safer, non-owning view of contiguous memory to prevent buffer overflows.
07 Sept 2025, 01:49 UTC

Passing a pointer and a size as separate arguments is a classic C++ pattern, but it is inherently fragile. When a function signature requires (T* ptr, size_t len), the compiler cannot verify that the length provided actually matches the allocated size of the buffer. A single mismatched integer in a function call can lead to out-of-bounds reads or catastrophic buffer overflows.
C++20 introduces std::span to eliminate this synchronization risk. A span is a non-owning view over a contiguous sequence of objects. By encapsulating the pointer and the length into a single object, std::span allows you to pass a "window" into your data, ensuring the size always travels with the pointer.
The Danger of Pointer Decay
In C++, when a raw array is passed to a function, it undergoes "pointer decay," meaning it loses its size information and becomes a simple pointer to the first element. To compensate, developers traditionally write functions like this:
void process_buffer(const uint8_t* data, size_t size) {
// If 'size' is larger than the actual buffer, this is a security vulnerability
for (size_t i = 0; i < size; ++i) {
// process data[i]
}
}
This approach forces the caller to manually track the size, which is error-prone. Furthermore, if you want the function to support std::vector, std::array, and raw arrays, you often end up writing multiple template overloads or forcing the user to call .data() and .size() explicitly.
Unifying Contiguous Memory Access
std::span<T> provides a uniform interface for any contiguous memory. Because it is a lightweight view, it can be constructed implicitly from various container types. This allows a single function signature to handle multiple data sources without templates.
Consider this implementation of a data validator:
#include <iostream>
#include <vector>
#include <array>
#include <span>
// Accepts any contiguous memory of ints
void validate_range(std::span<const int> data) {
if (data.empty()) return;
std::cout <"Processing " < data.size() < " elements..." < std::endl;
for (const auto& val : data) {
if (val < 0) std::cout <"Negative value found!" < std::endl;
}
}
int main() {
std::vector<int> vec = {1, -2, 3};
std::array<int, 3> arr = {4, 5, -6};
int raw[] = {7, 8, 9};
// All of these work with the same function signature
validate_range(vec); // Implicitly converts vector to span
validate_range(arr); // Implicitly converts array to span
validate_range(raw); // Implicitly converts raw array to span
return 0;
}
Performance and Memory Constraints
A std::span is typically implemented as a pair consisting of a pointer and a size integer. In most modern ABIs, this is passed efficiently in registers, meaning there is virtually no performance penalty compared to passing a pointer and a size separately.
However, there are two critical engineering limitations to keep in mind:
- Lifetime Management: A span is a view, not a container. It does not own the memory it points to. If the underlying
std::vectoris destroyed or reallocated (e.g., viapush_back), the span becomes a dangling reference. Always ensure the source container outlives the span. - Contiguity Requirement:
std::spanonly works with contiguous memory. You cannot use it withstd::listorstd::map, as their elements are scattered in memory.
Verification and Safety
To verify that a std::span is behaving as expected, you can check the .size() member within the function to ensure it matches the source container. While the operator[] is unchecked by default for performance, many compilers provide bounds-checking in debug builds (such as using -D_GLIBCXX_ASSERTIONS in GCC) to catch off-by-one errors during development.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.