1. Dangling without magic: what it is
When a programmer says "dangling", they usually mean a simple situation: we have a reference/pointer/iterator/view that points to memory where the object no longer exists. The object "died", but we still keep the "address" or a "window" to it. It's like writing a colleague's phone number on a piece of paper, and then the colleague changed the number, moved away, and quit — the paper remains, but the connection to reality is gone.
In classic C++ dangling often happens with pointers and references. In ranges/views dangling most often happens with a view, because a view typically does not own data and only stores "how to reach it".
It's important to feel: dangling is not a "compile-time error" (although sometimes the standard tries to protect us). Dangling is a lifetime error, i.e. a mistake about how long objects live and who refers to whom.
Why views so easily become "dangling"
Views are designed so that they usually hold a "reference to the source" internally (in the form of iterators/sentinels or a view-wrapper over the original range) and sometimes also hold a predicate/transform function (a lambda). They are evaluated lazily: until you start iterating the view (for example, in a range-for), computations are "promised" but not performed.
This can be pictured like this diagram:
Owner container (vector<Task>)
|
| view refers to the owner
v
filter_view / transform_view / take_view (does not store data)
|
| iterating (for) triggers evaluation
v
elements (Task) "appear" one by one during iteration
And here's the trap: if the owner container disappears or changes "structurally", the view keeps trying to traverse the old reality.
Teaching context: mini-TaskBoard
To keep examples from being "about spherical int in a vacuum", we'll continue with a teaching context: we have a list of tasks. We are not building an application architecture (that's a separate topic); we just keep data in main() and work with it.
#include <string>
enum class Status { Todo, Done };
struct Task {
int id{};
std::string title;
Status status{Status::Todo};
};
Later in different examples we'll filter tasks by status, create views and see where they can become dangling.
2. Scenario #1: view built on a temporary object
The most common newbie "surprise": we built a view from a temporary container, the container disappears at the end of the expression, and the view remains.
Look carefully: here the container is created temporarily right in the expression.
#include <iostream>
#include <ranges>
#include <vector>
int main() {
auto done_view = std::vector<int>{1, 2, 3, 4}
| std::views::filter([](int x) { return x % 2 == 0; });
for (int x : done_view) {
std::cout << x << ' '; // UB: view refers to an already-destroyed vector
}
}
This code may:
- "work" on your machine (which is especially dangerous: you'll believe everything is fine),
- crash,
- print garbage,
- behave differently in Debug/Release.
The reason is simple: the temporary std::vector<int>{...} lives until the end of the full expression (until the ;). After the ; it is destroyed. And done_view — is a variable that lives longer. So done_view points at an already-destroyed vector.
The right mindset here is: if you want a view to live — the owner must live even longer. So first store the container in a variable, then build the view.
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3, 4};
auto evens = v | std::views::filter([](int x) { return x % 2 == 0; });
for (int x : evens) {
std::cout << x << ' '; // 2 4
}
}
4. Scenario #2: view "escaped" from a function
The second classic scenario: we created a view inside a function where the owner is a local variable, and returned the view to the outside. Outside the view lives, but the owner already died.
Imagine a "convenient" function: "give me a view of all done tasks". A newbie very easily writes this:
#include <ranges>
#include <vector>
auto make_done_view_bad() {
std::vector<int> v{1, 2, 3, 4};
return v | std::views::filter([](int x) { return x % 2 == 0; });
}
Looks nice at first sight. But v will be destroyed when exiting the function, and the returned view will become dangling.
The correct variant depends on the goal. If you really want a view (lazy, without copies), then the owner must come from outside: for example, pass the container by reference and return a view that refers to that external container.
#include <ranges>
#include <vector>
auto done_view_of(const std::vector<int>& v) {
return v | std::views::filter([](int x) { return x % 2 == 0; });
}
Now the responsibility is clear: whoever passed the container ensures that the container lives longer than the view.
And here's how to use it safely:
#include <iostream>
#include <ranges>
#include <vector>
auto done_view_of(const std::vector<int>& v) {
return v | std::views::filter([](int x) { return x % 2 == 0; });
}
int main() {
std::vector<int> v{1, 2, 3, 4};
auto evens = done_view_of(v);
for (int x : evens) std::cout << x << ' '; // 2 4
}
5. Scenario #3: the source is alive, but you modified it
Now a subtler situation. The owner container seems to be alive, but we structurally modified it, and because of that the view became unsafe.
For std::vector there are two main "enemies of reference stability":
- push_back() can cause reallocation and move elements to another memory location,
- erase() shifts elements and invalidates iterators and references to elements after the removal point.
Here's an example where everything looks logical: we made a view of even elements, then appended another element to the vector, and then tried to iterate the view.
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3, 4};
auto evens = v | std::views::filter([](int x) { return x % 2 == 0; });
v.push_back(6); // may cause reallocation
for (int x : evens) {
std::cout << x << ' '; // potential UB
}
}
Why "potential"? Because if push_back did not trigger reallocation (for example, capacity was enough), it may "work". But if it did — the view/iterators it relies on become invalid. Again, a lottery.
Same story with erase():
#include <ranges>
#include <vector>
int main() {
std::vector<int> v{10, 20, 30, 40};
auto big = v | std::views::filter([](int x) { return x >= 30; });
v.erase(v.begin()); // shifts elements
(void)big; // big is now unsafe to iterate
}
The key idea: a view is not a "snapshot of data", but a "window into the container right now". If you change the container, the window can crack.
6. Scenario #4: a lambda captured a reference and "outlived" the variable
Another favorite: a view stores a lambda-predicate inside. And the lambda may hold references, if you captured with [&] or [&threshold].
First, the safe variant: capture by value. It's like copying the number threshold into the predicate.
#include <ranges>
#include <vector>
int main() {
std::vector<int> v{5, 10, 15, 20};
int threshold = 12;
auto above = v | std::views::filter([threshold](int x) { return x > threshold; });
(void)above;
}
And now the dangerous variant: capture by reference and let the view outlive the variable.
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector<int> v{5, 10, 15, 20};
auto make_view = [&]() {
int threshold = 12;
return v | std::views::filter([&threshold](int x) { return x > threshold; });
};
auto view = make_view(); // threshold is dead
for (int x : view) {
std::cout << x << '\n'; // UB
}
}
The irony is that the problem here is not even ranges, but the ordinary rule of reference lifetimes. It just appears more often in ranges because predicates are often written "on the fly", and the hand naturally reaches for [&].
7. std::ranges::dangling and the lifetime model
What is std::ranges::dangling
The standard library understands that returning an iterator into a temporary object is almost a guaranteed disaster. Therefore, for some std::ranges algorithms there exists a special "marker" type std::ranges::dangling. It means: "I cannot safely return an iterator because the input range was temporary".
It looks like this in practice:
#include <algorithm>
#include <ranges>
#include <vector>
int main() {
auto it = std::ranges::find(std::vector<int>{1, 2, 3}, 2);
(void)it; // this may be std::ranges::dangling, not a normal iterator
}
Important not to confuse: std::ranges::dangling more often shows up in the topic of "algorithms and their return values". Our pain today, however, is a dangling view, which the compiler often cannot "forbid" (because technically the type is correct), but by lifetime it's already "detached from reality".
So std::ranges::dangling is the standard's attempt to tell you: "I see a problem in advance". But the standard cannot always detect view-dangling at compile time, because the data source and lifetimes often depend on program logic.
Practical lifetime model for views
If you want to stop catching these bugs for real, it's useful to keep a very down-to-earth model in mind: each view has an owner (a container or another owning object), and a view is like a "wire" to that owner. While the owner is in place and didn't "replug" (no dangerous structural changes), the wire works. As soon as the owner disappears or moves, the wire remains but leads to emptiness.
From this come working habits.
If you see a view created from an expression with a temporary container, stop and ask yourself: "Will the container definitely live until I start iterating?" If you see a lambda with [&] inside a view, stop and ask: "Will the variables I'm referencing outlive this view?" If you see code that after creating a view continues to do push_back/erase, stop and ask: "Am I accidentally building a 'window' and then rearranging the house walls?"
As a "rule of thumb" (not a strict rule of the standard, but a coding discipline) you can say: a view works well when it lives shortly and is used near its creation site, and the data owner is stable. The farther a view "travels" from the source and the more container mutations happen around it, the higher the chance you're playing UB roulette.
8. TaskBoard: filtering tasks without shooting yourself in the foot
Let's make a small but illustrative example with tasks. Suppose we have tasks, and we want to show the completed ones.
#include <iostream>
#include <ranges>
#include <vector>
enum class Status { Todo, Done };
struct Task {
int id{};
std::string title;
Status status{Status::Todo};
};
int main() {
std::vector<Task> tasks{
{1, "Read ranges paper", Status::Todo},
{2, "Fix dangling bug", Status::Done},
{3, "Drink tea", Status::Done},
};
auto done = tasks | std::views::filter([](const Task& t) {
return t.status == Status::Done;
});
for (const Task& t : done) {
std::cout << t.id << ": " << t.title << '\n';
// 2: Fix dangling bug
// 3: Drink tea
}
}
Here everything is safe because:
- tasks — the owner — lives for the whole main,
- after creating done we do not call push_back/erase,
- the lambda does not capture anything by reference.
And here's how we shoot ourselves in the foot (almost unnoticed):
#include <ranges>
#include <vector>
auto done_tasks_bad() {
std::vector<Task> tasks{{1, "Local task", Status::Done}};
return tasks | std::views::filter([](const Task& t) { return t.status == Status::Done; });
}
This "nice" return of a view actually returns a problem.
If you really want to move the filter into a function, a lifetime-safe variant looks like this: the container comes from outside, and you return a view that relies on it.
#include <ranges>
#include <vector>
auto done_tasks_of(const std::vector<Task>& tasks) {
return tasks | std::views::filter([](const Task& t) { return t.status == Status::Done; });
}
Here the contract is honest: the caller must keep tasks alive longer than they use the view.
9. Common mistakes
Mistake #1: building a view from a temporary container "in one line".
It's very easy to write auto x = std::vector<int>{...} | views::filter(...), because it looks like a "functional style". But the temporary container will be destroyed at the end of the expression, and the view will remain. If you like the pipe style — first save the container in a variable, then build the view.
Mistake #2: returning a view from a function where the data owner is local.
This is the same dangling, just packaged into a "nice API". A local std::vector or std::string dies when the function exits, and the returned view keeps referring to them. If you return a view, the owner must live outside: usually that means the owner is passed into the function by reference.
Mistake #3: capturing [&] in filter/transform, and then keeping the view longer than the captured variables live.
Capture by reference seems convenient, but it's a "hidden reference" that lives inside the lambda, which lives inside the view. As a result you get a dangling that looks like an "ordinary filter". If the value is small (a threshold, flag, id) — it's often safer to capture by value.
Mistake #4: modifying std::vector after creating a view and thinking the view is a snapshot.
A view is not a copy or a snapshot. If after creating a view you call push_back or erase, you risk invalidating what the view relies on. What's especially unpleasant is that sometimes you "get lucky" and everything works, and then with a different data size it suddenly breaks.
Mistake #5: treating std::ranges::dangling as the solution to all lifetime problems.
std::ranges::dangling is a protective marker mainly for ranges-algorithm results (when you cannot return an iterator to a temporary range). But dangling views can happily compile and give you no "warning type". The only cure here is lifetime discipline and understanding who owns the data.
GO TO FULL VERSION