Dangling Iterators and std::ranges::dangling in C++

A dangling iterator in C++ points to memory that has been deallocated, which leads to undefined behavior if dereferenced. This issue frequently occurs when an algorithm operates on a temporary container (an rvalue) that ceases to exist at the end of the full expression. To eliminate this category of runtime bugs, C++20 introduced std::ranges::dangling, a placeholder type that replaces the expected iterator return type at compile time whenever an algorithm is invoked with a non-borrowed temporary range.

What Is a Dangling Iterator?

An iterator is an abstraction representing a position within a range or container. An iterator becomes "dangling" when the underlying data structure it points to is destroyed or modified in a way that invalidates its memory references.

In traditional C++ (prior to C++20), a common pitfall involved passing a temporary container to a utility or algorithm:

#include <vector>
#include <algorithm>

std::vector<int> get_data() {
    return {1, 2, 3, 4, 5};
}

// Undefined Behavior:
auto it = std::find(get_data().begin(), get_data().end(), 3);
// get_data() creates two distinct temporary vectors.
// Even if called once, the temporary vector dies at the semicolon,
// leaving 'it' pointing to deallocated memory.

Dereferencing it produces undefined behavior, which often passes undetected during testing because the memory might not be immediately overwritten.

The C++20 Ranges Approach

C++20 algorithms in the std::ranges namespace operate directly on whole ranges rather than iterator pairs. For example, you can write:

auto result = std::ranges::find(get_data(), 3);

Because get_data() returns a temporary std::vector<int>, returning a valid iterator from std::ranges::find would still leave the caller with an invalid pointer as soon as the expression finishes evaluating.

Instead of allowing this bug to manifest at runtime, C++20 introduces the concept of borrowed ranges and the tag type std::ranges::dangling.

What Is std::ranges::dangling?

std::ranges::dangling is an empty utility class defined in the <ranges> header:

namespace std::ranges {
    struct dangling {
        constexpr dangling() noexcept = default;
        template <typename... Args>
        constexpr dangling(Args&&...) noexcept {}
    };
}

It is not an iterator; it defines no operator*, operator++, operator->, or comparison operators. It exists solely to absorb returned iterators from expressions involving temporary containers.

How Compile-Time Prevention Works

When an algorithm in std::ranges executes, it checks whether the provided range satisfies the concept std::ranges::borrowed_range. A borrowed range is a range whose iterators remain valid even after the range object itself has been destroyed.

  1. Lvalues: If you pass an lvalue (such as a named variable std::vector<int> vec), the container will outlive the algorithm call. The algorithm returns a standard iterator.
  2. Borrowed Rvalues: If you pass an rvalue that is safe to borrow from (such as std::string_view or std::span), the algorithm also returns a standard iterator because views do not own their underlying storage.
  3. Non-Borrowed Rvalues: If you pass an rvalue container that owns its elements (such as a temporary std::vector or std::string), the algorithm returns std::ranges::dangling instead of an iterator.

Consider the following example:

#include <vector>
#include <ranges>
#include <algorithm>

std::vector<int> get_numbers() {
    return {10, 20, 30};
}

int main() {
    // get_numbers() returns a non-borrowed rvalue
    auto result = std::ranges::find(get_numbers(), 20);

    // 'result' is of type std::ranges::dangling
    // The following line causes a compile-time error:
    // int val = *result; 
}

Because result is of type std::ranges::dangling, attempting to dereference it via *result causes the compiler to fail immediately with an error stating that operator* is not defined for std::ranges::dangling.

Safe Alternatives

To safely capture the result of a range algorithm, ensure the container's lifetime extends beyond the algorithm's execution:

auto numbers = get_numbers(); // Named lvalue
auto it = std::ranges::find(numbers, 20); // Returns std::vector<int>::iterator

if (it != numbers.end()) {
    int value = *it; // Safe: 'numbers' is still in scope
}

By substituting unsafe iterators with std::ranges::dangling, C++ converts what was once an insidious source of undefined behavior into an immediate, explicit compile-time error.