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, рассчитывайте, что его нужно использовать сразу, и старайтесь проектировать код так, чтобы это было очевидно.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ