CodeGym /Courses /C++ SELF /Avoid storing pointers to container elements

Avoid storing pointers to container elements

C++ SELF
Level 41, Lesson 2
Available

1. Introduction

When you write a small application and have a list of entities (tasks, notes, users), the logic "I found the element — I will remember its address — later I'll access it quickly" feels natural. It's just like in real life: you found a house, wrote down the address and assume the house won't move. In programming, unfortunately, "the house moved" is a perfectly ordinary operation, especially for std::vector.

Imagine we're building a tiny console task manager. In previous lectures we already had struct, std::vector, std::optional, functions, IO. We'll continue in the same spirit and evolve the same application.

We want to have a "selected task" so that, for example, we can mark it done or rename it. The first (very human) thought is to store a pointer.

2. Anti-pattern: "selected task" as Task*

In this section we will intentionally do things a bit "wrong" so we can see the problem with our own eyes, not just hear about it. We will not execute dangerous lines (they will be commented out), because our goal is to learn to avoid UB, not to tempt fate.

#include <iostream>
#include <string>
#include <vector>

struct Task {
    int id{};
    std::string title;
    bool done{false};
};

struct AppState {
    std::vector<Task> tasks;
    Task* selected = nullptr; // <-- convenient, right!
};

The typical usage scenario is like this: you found the task — you remembered its address.

Task* FindTaskById(std::vector<Task>& tasks, int id) {
    for (Task& t : tasks) {
        if (t.id == id) return &t;
    }
    return nullptr;
}

And in main (or in a command handler):

state.selected = FindTaskById(state.tasks, 42);
if (state.selected) {
    std::cout << "Selected: " << state.selected->title << "\n";
}

And here's where a beginner gets the feeling: "Okay, I hold the task". In fact you hold an address in the vector buffer, and it can become garbage after ordinary operations on the task list.

3. What exactly breaks: reallocation and erase

Now comes an important point that is often underestimated: a pointer can become invalid not because you did something "extreme", but because you did the most ordinary thing: added or removed an element.

Reallocation on growth

std::vector stores elements in one contiguous chunk of memory. When capacity is insufficient, it allocates a new buffer (usually larger), moves elements there and frees the old one. Old addresses become invalid.

Illustration:

flowchart LR
    A["vector buffer (old)"] -->|push_back, no space| B["allocate new buffer"]
    B --> C["move/copy elements"]
    C --> D["free old buffer"]
    D --> E["old &v[i] are no longer valid"]

You may not even notice the relocation: the program "sometimes works" and then suddenly breaks. That is why in the C++ world there are whole discussions about "iterator/pointer validity" after container operations — it's not nitpicking but a real engineering pain. Even in standard documents you can find discussions like "how does shrink_to_fit affect iterator validity".

Shifting elements after erase

erase removes the element and shifts the tail so there is no "hole". That means elements after the removed one change index and address.

Diagram:

flowchart TD
    A["[10][20][30][40]"] -->|erase 20| B["[10][30][40] + shift"]
    B --> C["address of former 30 changed"]

And here is the key idea of the day: a pointer to a vector element is a "for now" agreement, not "for life".

4. Bug in our app: selected task pointer became dangling

Let's reproduce a typical real-world scenario: we selected a task, then added a new one. Even if you did not call erase, a single push_back can be enough.

#include <vector>
#include <string>

void AddTask(std::vector<Task>& tasks, int id, std::string title) {
    tasks.push_back(Task{id, std::move(title), false});
}

void MarkSelectedDone(AppState& state) {
    if (!state.selected) return;
    state.selected->done = true; // <-- may become UB if selected is invalid
}

Now the sequence:

state.selected = FindTaskById(state.tasks, 1);
AddTask(state.tasks, 99, "New task"); // may trigger reallocation
// MarkSelectedDone(state);           // potential UB

The insidious part is that you may not catch the bug immediately. It may manifest only when the vector decides to expand on this exact step. Bugs like this create myths: "C++ is weird, it broke by itself". No — it didn't break by itself; we asked the vector for address stability that it never guaranteed.

5. Project rule: do not store Task* to vector elements as state

In this section we will formulate the rule not as a scare tactic but as a practical engineering agreement with ourselves: we want the app to be predictable, so it must not depend on "luckily/unluckily the memory was reallocated".

The rule is: in long-lived application state (in AppState, in state struct fields, between commands/iterations) we do not store raw pointers or references to elements of std::vector. If you need to access an element, either find it again each time or store a stable "marker" that safely allows re-acquiring access.

Important: this rule does not say "never take the address of an element". You may. Storing it for a short time while you immediately use it and without mutating the container is fine. The problem starts when a pointer lives across container-modifying operations.

6. Replacement #1: store an index instead of a pointer

The simplest "marker" is an index. It survives reallocation (because an index is a number) but requires discipline: check bounds and remember that erase changes indices.

Make the state like this:

#include <cstddef>
#include <optional>
#include <vector>

struct AppState {
    std::vector<Task> tasks;
    std::optional<std::size_t> selectedIndex; // instead of Task*
};

Selecting a task now means finding an index and saving it.

#include <optional>
#include <cstddef>
#include <vector>

std::optional<std::size_t> FindTaskIndexById(const std::vector<Task>& tasks, int id) {
    for (std::size_t i = 0; i < tasks.size(); ++i) {
        if (tasks[i].id == id) return i;
    }
    return std::nullopt;
}

Usage:

void MarkSelectedDone(AppState& state) {
    if (!state.selectedIndex) return;

    const std::size_t i = *state.selectedIndex;
    if (i >= state.tasks.size()) return; // guard against out-of-bounds

    state.tasks[i].done = true;
}

Here it is harder to "accidentally" get a dangling pointer from reallocation: we access tasks[i] on the current buffer each time.

7. But an index is not an "identity": what if an erase happened

There is a subtle point with an index. It doesn't break from reallocation, but it may start pointing to a "different element" after removal of an earlier entry. This is not UB, but it is a logical bug: you selected the task "Buy milk", and after deleting the first task suddenly another task becomes selected.

Therefore an index is a good choice if you control deletion scenarios or are ready to carefully update selection on erase. Usually for a learning project we pick one of two approaches.

First approach — after any operation that may change positions/indices (for example, after erase) we clear the selection. It's not the smartest UX, but it's honest and safe. Second approach — store an identifier (id) instead of an index and re-resolve the index by id.

Let's start with the simple and honest option: clear the selection.

#include <vector>

void RemoveTaskByIndex(AppState& state, std::size_t idx) {
    if (idx >= state.tasks.size()) return;

    state.tasks.erase(state.tasks.begin() + static_cast<std::ptrdiff_t>(idx));
    state.selectedIndex = std::nullopt; // clear: indices may have shifted
}

Yes, it looks "rough", but it's a mature engineering decision: better to lose the selection than to silently select the wrong thing.

8. Replacement #2: store id and re-acquire access

When an element needs a semantic "passport" (identity), an index becomes a poor candidate: it's about position, not entity. Then we store selectedId and each time find the actual element by that id.

State:

#include <optional>

struct AppState {
    std::vector<Task> tasks;
    std::optional<int> selectedId; // "task with this id is selected"
};

Selecting:

void SelectTask(AppState& state, int id) {
    state.selectedId = id;
}

Access "for now":

Task* GetSelectedTaskPtr(AppState& state) {
    if (!state.selectedId) return nullptr;

    for (Task& t : state.tasks) {
        if (t.id == *state.selectedId) return &t;
    }
    return nullptr;
}

Usage:

#include <iostream>

void MarkSelectedDone(AppState& state) {
    Task* t = GetSelectedTaskPtr(state);
    if (!t) {
        std::cout << "No selected task\n"; // No selected task
        return;
    }
    t->done = true;
}

The key point: we return a pointer as a temporary access, but do not store it in state. The pointer lives "inside the command", not "between commands".

Cheat sheet: what to store instead of T*

In this section it's useful to record once how to choose a representation of a "reference to an element" without keeping real references or pointers in state. It's similar to choosing how to write a delivery address: you can write "map coordinates" (index) or write an "order number" (id) and resolve coordinates later.

What you want to express Bad idea Working idea Why
"A specific item of the list is selected right now" store T* in AppState store std::optional<std::size_t> index does not break on reallocation
"An item is selected and it should remain the same even if the list changes" T*/size_t without extra logic store std::optional<Id> identity is separated from position
"There may be no selected item" size_t = -1 std::optional<std::size_t> / std::optional<Id> absence is visible in the type
"You need to give access to the element outside" return T& without guarantees return T* (nullable) and use immediately pointer signals "may be absent" and "do not store me long"

Small glue logic of the app

Now we will assemble a small fragment that you can mentally plug into your CLI command loop. There will be no complex input — only a demonstration of relations.

#include <iostream>
#include <optional>
#include <string>
#include <vector>

struct Task {
    int id{};
    std::string title;
    bool done{false};
};

struct AppState {
    std::vector<Task> tasks;
    std::optional<int> selectedId;
};

Task* FindTaskById(std::vector<Task>& tasks, int id) {
    for (Task& t : tasks) if (t.id == id) return &t;
    return nullptr;
}

Task* GetSelectedTask(AppState& state) {
    if (!state.selectedId) return nullptr;
    return FindTaskById(state.tasks, *state.selectedId);
}

And example "commands":

void PrintSelected(const AppState& state) {
    if (!state.selectedId) {
        std::cout << "Selected: none\n"; // Selected: none
        return;
    }
    std::cout << "Selected id: " << *state.selectedId << "\n"; // e.g.: Selected id: 2
}

void MarkSelectedDone(AppState& state) {
    Task* t = GetSelectedTask(state);
    if (!t) return;
    t->done = true;
}

Note the style: we print "selected id" rather than an address. The address is not important to the user; the entity is.

Important nuance: raw pointers are dangerous here not because of delete

When a beginner hears "avoid raw pointers", they often think only about new/delete and memory leaks. Today's problem is of a different class: a pointer can become dangling even without new and without delete in your code. The container manages memory itself, reallocates buffers, shifts elements. Your saved address meanwhile becomes a "ticket for a train that already left".

And that's why the rule reads exactly like that: "do not store pointers to container elements". It's about validity, not ownership.

9. Common mistakes

Mistake #1: "I store const Task*, so it's safe".
const forbids modifying the object through the pointer, but it does not guarantee that the object stays at the same address. After push_back or erase your const Task* can just as easily become dangling. const is about write permissions, not lifetime or address stability.

Mistake #2: Keep a pointer "for a short time" and accidentally do a push_back in between.
Often it looks like this: you found Task* p, then "in between" you added a new task (for example, logic "if empty — add template"), and then you used p again. The problem is that push_back may trigger reallocation not always but sometimes, so the bug is intermittent and especially nasty to debug.

Mistake #3: Switch to an index but forget bounds checks.
An index is not magic. It's convenient because it doesn't depend on address, but you must use it carefully: i < tasks.size() should be right next to the use, not "we checked it somewhere earlier". Especially if the index is stored in state and may become stale after deletions.

Mistake #4: Use an index and ignore erase, ending up "selecting the wrong element".
This is not UB, the program doesn't crash, but behavior becomes odd: after removing something from the start of the list some other task becomes selected. If you use an index as a long-lived marker you must either update it on deletions, clear the selection, or store id and re-find.

Mistake #5: Return Task&/Task* to the outside and allow callers to hold it.
Even if you don't store pointers in AppState, you can accidentally leak this problem through the API: a function returns Task*, external code stores it, then you add a task — and everything breaks. A good rule: if you return a pointer to a vector element, expect it to be used immediately, and design the API so that this is obvious.

1
Task
C++ SELF, level 41, lesson 2
Locked
Task index
Task index
1
Task
C++ SELF, level 41, lesson 2
Locked
Selected number
Selected number
1
Task
C++ SELF, level 41, lesson 2
Locked
Removal and reset
Removal and reset
1
Task
C++ SELF, level 41, lesson 2
Locked
Selection by id
Selection by id
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION