JavaRush /Курсы /C++ SELF /Избегаем хранения указателей на элементы контейнеров

Избегаем хранения указателей на элементы контейнеров

C++ SELF
41 уровень , 2 лекция
Открыта

1. Введение

Когда вы пишете маленькое приложение и у вас есть список сущностей (задачи, заметки, пользователи), логика «я нашёл нужный элемент — запомню адрес — потом быстро к нему обращусь» кажется естественной. Это прямо как в реальной жизни: нашёл нужный дом, записал адрес и думаешь, что дом никуда не денется. В программировании, увы, «переезд дома» — вполне штатная операция, особенно в std::vector.

Представим, что мы делаем консольный мини‑менеджер задач. На прошлых лекциях у нас уже были struct, std::vector, std::optional, функции, ввод/вывод. Продолжим в том же духе и будем развивать одно и то же приложение.

Нам хочется иметь «выбранную задачу», чтобы, например, пометить её выполненной или переименовать. И первая (очень человеческая) мысль — хранить указатель.

2. Антипаттерн: «выбранная задача» как Task*

В этом разделе специально сделаем чуть «неправильно», чтобы увидеть проблему глазами, а не только словами. Мы не будем исполнять опасные строки (закомментируем), потому что наша цель — научиться избегать UB, а не тренировать удачу.

#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; // <-- "удобно же!"
};

Сценарий использования обычно такой: нашли задачу — запомнили её адрес.

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

А в main (или в обработчике команды):

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

И вот здесь у новичка появляется ощущение: «Ну всё, я держу задачу в руках». На самом деле вы держите адрес в буфере vector, и он может стать мусором после обычных операций со списком задач.

3. Что именно ломается: reallocation и erase

Сейчас будет важный момент, который часто недооценивают: указатель может стать невалидным не потому, что вы сделали что-то «экстремальное», а потому что вы сделали самое обычное: добавили элемент или удалили элемент.

Reallocation при росте

std::vector хранит элементы в одном непрерывном куске памяти. Когда места (capacity) не хватает, он выделяет новый буфер (обычно больше), переносит туда элементы и освобождает старый. Старые адреса становятся недействительными.

Наглядная схема:

flowchart LR
    A["vector buffer (old)"] -->|push_back, места нет| B["allocate new buffer"]
    B --> C["move/copy elements"]
    C --> D["free old buffer"]
    D --> E["старые &v[i] больше невалидны"]

Вы можете даже не заметить, что произошёл переезд: программа «иногда работает», а потом внезапно ломается. Именно поэтому в мире C++ есть отдельные обсуждения про «валидность итераторов/указателей» после операций контейнеров — это не придирка, а реальная инженерная боль. Даже в материалах по стандарту встречаются вопросы уровня «как операция влияет на валидность» (например, обсуждение влияния shrink_to_fit на валидность итераторов).

Сдвиг элементов после erase

erase удаляет элемент и сдвигает хвост, чтобы «дырки» не осталось. Значит, элементы после удалённого меняют индекс и адрес.

Схема:

flowchart TD
    A["[10][20][30][40]"] -->|erase 20| B["[10][30][40] + сдвиг"]
    B --> C["адрес бывшего 30 изменился"]

И вот здесь ключевая мысль дня: указатель на элемент vector — это договор “на сейчас”, а не “на жизнь”.

4. Баг в нашем приложении: указатель «выбранная задача» стал висячим

Давайте воспроизведём типичную жизненную ситуацию: мы выбрали задачу, а потом добавили новую. Даже если вы не делали erase, одного push_back иногда достаточно.

#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; // <-- может стать UB, если selected невалиден
}

Теперь связка:

state.selected = FindTaskById(state.tasks, 1);
AddTask(state.tasks, 99, "New task"); // может вызвать reallocation
// MarkSelectedDone(state);           // потенциально UB

Коварство в том, что вы можете «не поймать» баг сразу. Он может проявиться только когда vector решил расшириться именно на этом шаге. Именно такие баги потом рождают легенды: «C++ странный, оно само сломалось». Нет, оно не само — это мы попросили у vector стабильность адресов, которую он не обещал.

5. Правило проекта: не храним Task* на элементы vector как состояние

В этом разделе сформулируем правило не как «страшилку», а как практичный инженерный договор с самим собой: мы хотим, чтобы приложение было предсказуемым, а значит, не должно зависеть от «удачно/неудачно перераспределилась память».

Правило звучит так: в долгоживущем состоянии приложенияAppState, в полях структур состояния, между командами/итерациями цикла) мы не храним сырые указатели или ссылки на элементы std::vector. Если очень нужно обратиться к элементу, мы либо находим его заново, либо храним устойчивый «маркер», по которому можно безопасно переполучить доступ.

Важно: это правило не говорит «никогда не берите адрес элемента». Брать можно. Хранить надолго — нет. Если вы взяли Task* и тут же использовали, пока точно не меняли контейнер, это нормально. Проблема начинается, когда указатель переживает изменения контейнера.

6. Замена №1: хранить индекс вместо указателя

Самый простой «маркер» — индекс. Он переживает reallocation (потому что индекс — число), но требует дисциплины: проверять границы и помнить, что erase меняет индексы.

Сделаем состояние так:

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

struct AppState {
    std::vector<Task> tasks;
    std::optional<std::size_t> selectedIndex; // вместо Task*
};

Выбор задачи теперь делается так: нашли индекс — сохранили.

#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;
}

Использование:

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

    const std::size_t i = *state.selectedIndex;
    if (i >= state.tasks.size()) return; // защита от выхода за границы

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

Здесь уже трудно «случайно» получить висячий указатель от reallocation: мы каждый раз заново обращаемся к tasks[i] по актуальному буферу.

7. Но индекс тоже не «личность»: что делать, если был erase

С индексом есть тонкий момент. Он не ломается от reallocation, но может начать указывать на «другой элемент» после удаления чего-то раньше по списку. Это не UB, но это логическая ошибка: вы выбрали задачу “Купить молоко”, а после удаления первой задачи вдруг «выбранной» стала другая.

Поэтому индекс — хороший выбор, если вы контролируете сценарии удаления или готовы аккуратно обновлять selection при erase. Обычно для учебного проекта мы выбираем один из двух подходов.

Первый подход — после любой операции, которая может поменять порядок/индексы (например, после erase), мы сбрасываем выбор. Это не самый «умный» UX, зато очень честный и безопасный. Второй подход — хранить не индекс, а идентификатор (id) и переполучать индекс по нему.

Начнём с простого и честного: сбрасывать выбор.

#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; // сброс: индексы могли сдвинуться
}

Да, это выглядит «грубо», но это уже взрослое инженерное решение: лучше потерять выбор, чем тихо выбрать не то.

8. Замена №2: хранить id и переполучать доступ

Когда элементу нужен смысловой «паспорт» (идентичность), индекс становится плохим кандидатом: он про позицию, а не про сущность. Тогда мы храним selectedId и при необходимости каждый раз находим актуальный элемент.

Состояние:

#include <optional>

struct AppState {
    std::vector<Task> tasks;
    std::optional<int> selectedId; // "выбрана задача с таким id"
};

Выбор:

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

Доступ «на сейчас»:

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

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

Использование:

#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;
}

Ключевой момент: мы возвращаем указатель как временный доступ, но не сохраняем его в состоянии. То есть указатель живёт «внутри команды», а не «между командами».

Таблица-подсказка: что хранить вместо T*

В этом разделе полезно один раз зафиксировать, как выбирать представление «ссылки на элемент» без реальных ссылок и указателей в состоянии. Это похоже на выбор способа записать адрес доставки: можно записать «координаты на карте» (индекс), а можно записать «номер заказа» (id) и уже потом уточнить координаты.

Что вы хотите выразить Плохая идея Рабочая идея Почему
«Выбран конкретный элемент списка прямо сейчас» хранить T* в AppState хранить std::optional<std::size_t> индекс не ломается от reallocation
«Выбран элемент, и он должен оставаться тем же, даже если список меняется» T*/size_t без доп. логики хранить std::optional<Id> идентичность отделена от позиции
«Может не быть выбранного элемента» size_t = -1 std::optional<std::size_t> / std::optional<Id> отсутствие видно в типе
«Нужно отдать доступ к элементу наружу» возвращать T& без гарантий вернуть T* (nullable) и использовать сразу pointer подчёркивает “может не быть” и “не храни меня надолго”

Мини‑склейка логики приложения

Сейчас соберём небольшой фрагмент, который можно мысленно встроить в ваш CLI-цикл команд. Здесь не будет сложного ввода — только демонстрация взаимосвязей.

#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);
}

И пример «команд»:

void PrintSelected(const AppState& state) {
    if (!state.selectedId) {
        std::cout << "Selected: none\n"; // Selected: none
        return;
    }
    std::cout << "Selected id: " << *state.selectedId << "\n"; // например: Selected id: 2
}

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

Обратите внимание на стиль: мы печатаем “selected id”, а не печатаем адрес. Адрес в пользовательском смысле не важен; важна сущность.

Важный нюанс: «сырые указатели» опасны тут не из-за delete

Когда новичок слышит «избегайте сырых указателей», он часто думает только про new/delete и утечки памяти. Сегодняшняя проблема другого класса: указатель может стать висячим даже без new и без delete в вашем коде. Контейнер сам управляет памятью, сам перевыделяет буфер, сам сдвигает элементы. А ваш сохранённый адрес тем временем превращается в “билет на поезд, который уже ушёл”.

И вот почему правило звучит именно так: «не хранить указатели на элементы контейнеров». Это про валидность, а не про владение.

9. Типичные ошибки

Ошибка №1: “Я храню const Task*, значит безопасно”.
const запрещает менять объект через указатель, но он не обещает, что объект остаётся по тому же адресу. После push_back или erase ваш const Task* так же легко превращается в висячий. const — это про права на запись, а не про гарантию времени жизни и стабильности адреса.

Ошибка №2: Сохранить указатель “ненадолго”, а потом случайно сделать push_back.
Часто это выглядит так: вы нашли Task* p, потом “между делом” добавили новую задачу (например, логика «если пусто — добавить шаблон»), а потом снова использовали p. Проблема в том, что push_back может вызвать reallocation не всегда, а иногда, поэтому ошибка получается плавающей и особенно неприятной в отладке.

Ошибка №3: Перейти на индекс, но забыть проверки границ.
Индекс — не магия. Он удобен тем, что не зависит от адреса, но обращаться по нему нужно аккуратно: i < tasks.size() должно быть рядом с использованием, а не “где-то когда-то мы проверяли”. Особенно если индекс хранится как состояние и мог устареть после удаления элементов.

Ошибка №4: Перейти на индекс и не учесть erase, получив “выбор не того элемента”.
Это не UB, программа не падает, но поведение становится странным: после удаления чего-то в начале списка выбранной оказывается другая задача. Если вы используете индекс как долгоживущий маркер, нужно либо обновлять его при удалениях, либо сбрасывать выбор, либо хранить id и перепоиск.

Ошибка №5: Вернуть наружу Task&/Task* и позволить вызывающему коду хранить его.
Даже если вы сами в AppState указатели не храните, можно случайно “протечь” этой проблемой через API: функция вернула Task*, внешний код сохранил, потом вы добавили задачу — и всё. Хорошее правило: если возвращаете указатель на элемент vector, рассчитывайте, что его нужно использовать сразу, и старайтесь проектировать код так, чтобы это было очевидно.

1
Задача
C++ SELF, 41 уровень, 2 лекция
Недоступна
Индекс заявки
Индекс заявки
1
Задача
C++ SELF, 41 уровень, 2 лекция
Недоступна
Выбранное число
Выбранное число
1
Задача
C++ SELF, 41 уровень, 2 лекция
Недоступна
Удаление и сброс
Удаление и сброс
1
Задача
C++ SELF, 41 уровень, 2 лекция
Недоступна
Выбор по id
Выбор по id
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ