Borrowed Ranges and Iterator Invalidation in C++
In modern C++, passing temporary containers to algorithms often risks
producing dangling iterators, leading to undefined behavior when those
iterators outlive the temporary object. Introduced in C++20, the
std::ranges::borrowed_range concept mitigates this lifetime
issue by distinguishing between ranges whose iterators are tied to the
container's lifetime and ranges whose iterators remain valid
independently. By enforcing compile-time checks, the Ranges library
prevents the creation of dangling iterators from rvalue containers,
transforming subtle runtime memory bugs into immediate compilation
errors.
The Problem: Dangling Iterators from Temporaries
In pre-C++20 code, obtaining an iterator from an rvalue container was a common source of bugs:
auto it = std::find(get_temporary_vector().begin(), get_temporary_vector().end(), 42);
// *it leads to undefined behavior because the temporary vector is already destroyed.Even with algorithms accepting ranges directly, passing a temporary
container like std::vector by value creates a problem. If
the algorithm returns an iterator pointing inside that temporary, the
iterator becomes invalid as soon as the statement finishes
executing.
What Is a Borrowed Range?
A range is considered a borrowed range if its iterators do
not depend on the lifetime of the range object itself. In standard C++,
this concept is formalized by
std::ranges::borrowed_range.
A range models borrowed_range if:
- It is an lvalue reference (the caller owns the container, so it outlives the algorithm call), or
- It satisfies the type trait
std::ranges::enable_borrowed_range<T>.
Types that naturally model borrowed ranges as rvalues include
std::string_view and std::span. Because these
types are non-owning views, destroying the view object does not destroy
the underlying data. Conversely, owning containers like
std::vector, std::string, and
std::list do not satisfy enable_borrowed_range
when passed as rvalues.
The Mechanism:
std::ranges::dangling
Range-based algorithms use conditional return types based on whether
the input range satisfies borrowed_range. When an
algorithm, such as std::ranges::find, is passed an rvalue
that is not a borrowed range, it does not return an iterator. Instead,
it returns an instance of std::ranges::dangling.
std::ranges::dangling is an empty placeholder type
designed to catch lifetime errors:
// Returns a std::vector by value
std::vector<int> get_data();
// Attempting to find an element in a temporary container:
auto result = std::ranges::find(get_data(), 10);In this example, result has the type
std::ranges::dangling rather than
std::vector<int>::iterator. Because
std::ranges::dangling does not implement the dereference
(*) or member access (->) operators, any
attempt to access the element via *result causes a
compilation failure.
Safe Usage with Views
If you pass an rvalue that models borrowed_range, the
algorithm returns a normal iterator because the referenced elements are
guaranteed to live elsewhere:
std::string text = "Modern C++";
// std::string_view is a borrowed range
auto it = std::ranges::find(std::string_view(text), 'C');
char c = *it; // Valid: 'it' is std::string_view::iteratorOpting In Custom Types
If you implement a custom view type that does not own its elements,
you can specialize the std::ranges::enable_borrowed_range
variable template to enable borrowed range behavior:
template <>
inline constexpr bool std::ranges::enable_borrowed_range<MyCustomView> = true;This tells the compiler and standard algorithms that it is safe to
return iterators referencing elements inside a temporary
MyCustomView.
By combining the borrowed_range concept with the
std::ranges::dangling return type, C++ provides a
zero-overhead, compile-time defense against one of the most persistent
classes of iterator invalidation bugs.