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:

  1. It is an lvalue reference (the caller owns the container, so it outlives the algorithm call), or
  2. 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::iterator

Opting 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.