1. Введение
Указатели кажутся честным инструментом: «вот адрес, вот доступ, всё прозрачно». Но у них есть суперсила, которая одновременно является проклятием: они позволяют обойти многие защиты языка. Если с std::vector или std::string вы часто получаете хотя бы понятный симптом (исключение в .at(), пустую строку, корректные инварианты), то с указателем вы можете попасть в ситуацию «всё вроде работало… пока не перестало».
Ключевая проблема в том, что язык (и операционная система) часто не могут мгновенно доказать, что адрес «плохой». Вы разыменовали невалидный указатель — и это может проявиться как угодно: от падения программы до «поменялась переменная в другом месте», от странного вывода до успешного прохождения тестов… сегодня. Вот почему тема безопасности указателей — это не теория, а бытовая гигиена вроде «мыть руки» (да, занудно, но жить помогает).
Неинициализированный указатель: «мусорный адрес»
Самая частая ошибка новичка выглядит невинно: вы объявили указатель, а дальше «как-нибудь» его используете. Проблема в том, что неинициализированная переменная типа T* содержит неопределённое значение — в разговорной форме «мусор». И этот «мусор» может оказаться чем угодно: адресом в середине вашей программы, нулём, адресом «куда-то в никуда». Это и есть прямой путь к неопределённому поведению и очень странным багам, особенно если разыменование спрятано глубоко в функции.
Посмотрите на этот пример — он компилируется, и именно этим он опасен:
#include <iostream>
int main() {
int* p; // НЕ инициализирован!
// std::cout << *p << '\n'; // UB: разыменование "мусорного" адреса (закомментировано)
std::cout << "p = " << p << '\n'; // тоже бессмысленно: значение случайное
}
Здесь важно уловить мысль: «ну я же не разыменовываю, я просто печатаю» — уже тревожный звоночек. Вы не контролируете, что внутри p.
Правильная привычка на всю жизнь: указатель всегда должен быть в одном из двух понятных состояний:
1) nullptr — «никого не указываю»
2) адрес живого объекта — «указываю на конкретный объект»
И вот так это выглядит:
#include <iostream>
int main() {
int* p = nullptr; // безопасный старт
std::cout << std::boolalpha;
std::cout << (p == nullptr) << '\n'; // true
}
Почему мы так любим именно nullptr, а не 0? Потому что nullptr — это отдельное, современное и типобезопасное «нулевое указательное значение», и в стандартной библиотеке давно закреплён стиль «используем nullptr, а не 0».
2. Висячий указатель: адрес пережил объект
Если с неинициализированным указателем всё более-менее понятно («забыл присвоить»), то висячий указатель коварнее. Вы могли правильно взять адрес, правильно его сохранить, даже успешно разыменовать — но позже объект уничтожился, а адрес остался. И указатель теперь указывает на место, где объект раньше жил, но больше не живёт. Это как сохранить номер места в кинотеатре после того, как кинотеатр снесли и построили там супермаркет: координаты те же, а смысл уже другой.
Классический сценарий — выход из области видимости:
#include <iostream>
int main() {
int* p = nullptr;
{
int x = 10;
p = &x;
std::cout << *p << '\n'; // 10 (пока x жив)
}
// x уничтожен => p стал dangling
// std::cout << *p << '\n'; // UB (закомментировано)
}
И вот очень важный момент: p не становится nullptr автоматически. Он по-прежнему хранит «какой-то адрес». То есть проверка p != nullptr пройдёт, но разыменование всё равно опасно.
Для наглядности — маленькая схема «что происходит»:
flowchart TD
A["Создали x (локальная переменная)"] --> B["Взяли адрес &x и положили в p"]
B --> C["Вышли из блока { }"]
C --> D["x уничтожен (lifetime закончился)"]
D --> E["p всё ещё хранит старый адрес"]
E --> F["*p -> UB (висячий указатель)"]
Главный практический вывод: когда вы видите указатель в коде, вы обязаны уметь ответить на вопрос: «А объект, на который он указывает, ещё жив?» Если вы не можете ответить — это уже риск.
Висячие указатели из контейнеров
Теперь более «современная» версия проблемы, которая встречается в реальных программах постоянно. Вы берёте адрес элемента std::vector (например, &v[0]), сохраняете его в T*, а потом меняете вектор: добавляете элементы, удаляете, иногда сортируете. И после этого ваш указатель может стать невалидным, потому что vector имеет право поменять внутреннее размещение элементов в памяти.
Пример (опасная строка закомментирована специально):
#include <vector>
int main() {
std::vector<int> v{1, 2, 3};
int* p = &v[0]; // адрес элемента
v.push_back(4); // вектор может переразместить элементы
// *p = 10; // потенциально UB: p мог стать невалидным (закомментировано)
}
Тут важно не уходить в детали реализации, но держать простую модель: у vector элементы живут «кучкой» подряд, и при росте этой кучки он иногда переезжает на новое место. Переезд означает: старые адреса элементов больше не гарантированно валидны.
Что делать новичку, чтобы не попасть в ловушку? Самая практичная стратегия — считать указатели на элементы контейнера временными, использовать их «здесь и сейчас», и не хранить дольше, чем один небольшой участок кода. Если вам нужно «долго помнить элемент», обычно помнят не адрес, а индекс или id (например, поле id в вашей struct), и затем заново находят нужный элемент.
4. Минимальные правила безопасности
Сейчас будет самая полезная часть лекции: набор правил, который можно буквально держать как внутреннюю памятку. Они не требуют знания умных слов, владения new/delete (мы их сегодня не трогаем) или умения читать стандарт на ночь (хотя некоторые так и делают — но это уже хобби на грани).
Сформулирую правила так, чтобы их можно было применять в каждом файле, где вы видите T*.
Почему проверки на nullptr недостаточно
Очень хочется написать правило «если p != nullptr, то можно *p». Это человеческое желание — упростить мир до одного if-а. Но у указателей есть несколько разных неприятных состояний, и только одно из них лечится проверкой на nullptr.
Давайте честно разложим «состояния указателя» в таблицу:
| Состояние указателя | Как выглядит | Можно ли делать *p? | Комментарий |
|---|---|---|---|
| Пустой | |
Нельзя | Это нормальное состояние «нет объекта» |
| Валидный | p указывает на живой объект | Можно | Но только если вы уверены во времени жизни |
| Неинициализированный | |
Нельзя | Внутри «мусор» (неопределённое значение) |
| Висячий (dangling) | объект умер, адрес остался | Нельзя | не спасает |
Проверка p != nullptr защищает только от первого случая («пусто»). А вот от «мусора» и dangling она не защищает вообще.
Отдельно подчеркну: даже если вы уверены, что «я же где-то точно присваивал p = &x», это ещё не значит, что x до сих пор существует. Время жизни объекта — отдельная сущность, и указатель её не продлевает.
Всегда инициализируй указатель
Вместо «объявил — потом присвою» делаем «объявил — сразу nullptr или адрес». Это убивает целый класс багов одним движением. Более того, современный стиль C++ поощряет именно nullptr, а не 0, чтобы нулевое значение было именно указательным, а не «каким-то числом, которое тоже подошло».
int* p = nullptr; // хорошо
Nullable-указатель разыменовывай только после проверки
Если по контракту p может быть nullptr, то if (p == nullptr) — не «лишняя проверка», а часть логики.
#include <iostream>
int main() {
int* p = nullptr;
if (p != nullptr) {
std::cout << *p << '\n';
} else {
std::cout << "no value\n"; // no value
}
}
Не возвращай указатель на локальную переменную
Это настолько частый сценарий, что его стоит увидеть глазами. Вот так делать нельзя:
#include <string>
int* bad() {
int x = 5;
return &x; // dangling сразу после выхода из функции
}
Даже если компилятор предупредит (а он иногда предупреждает), логика всё равно неправильная: x умрёт на выходе из bad().
Не храни указатель на элемент vector, если потом меняешь vector
Если вы сохранили T* на элемент контейнера, считайте, что это «фото на память», актуальное только пока контейнер не менялся. Добавили/удалили — лучше заново получить адрес.
5. Встраиваем правила в мини‑приложение ContactBook
Чтобы это не осталось теорией, давайте привяжем всё к одному контексту. Представим, что мы пишем крошечную «адресную книгу» в консоли: у нас есть Contact, а контакты лежат в std::vector<Contact>. Мы хотим уметь находить контакт и менять его телефон.
Начнём с модели:
#include <string>
struct Contact {
std::string name;
std::string phone;
};
Теперь напишем функцию поиска, которая возвращает указатель на найденный контакт или nullptr, если не нашли. Это хороший и очень типичный контракт: «может отсутствовать».
#include <vector>
#include <string>
Contact* find_contact(std::vector<Contact>& contacts, const std::string& name) {
for (auto& c : contacts) {
if (c.name == name) return &c;
}
return nullptr;
}
Обратите внимание: мы возвращаем адрес элемента вектора, а не локальной переменной. Значит, проблема «возврат адреса локального объекта» здесь не возникает. Но появляется другая: этот указатель валиден, пока мы не сделали действий, которые могут «сломать» адреса элементов.
Использование — только через проверку:
#include <iostream>
#include <vector>
int main() {
std::vector<Contact> contacts{{"Ann", "111"}, {"Bob", "222"}};
Contact* p = find_contact(contacts, "Bob");
if (p != nullptr) {
p->phone = "999";
std::cout << p->name << ": " << p->phone << '\n'; // Bob: 999
}
}
Теперь ключевой момент безопасности: нельзя делать так, чтобы p пережил изменения contacts. Например, такой код выглядит логично, но потенциально опасен:
#include <vector>
int main() {
std::vector<Contact> contacts{{"Ann", "111"}};
Contact* p = find_contact(contacts, "Ann");
contacts.push_back({"Kate", "333"}); // контейнер мог переразместиться
// p->phone = "000"; // потенциально UB (закомментировано)
}
Как сделать безопаснее на нашем текущем уровне? Самый простой вариант — не хранить указатель, а хранить «ключ» (имя или id) и при необходимости заново искать. Да, это «лишний проход», но для учебного приложения и начинающего уровня это честный и понятный компромисс: сначала корректность, потом оптимизация.
И ещё один важный стиль: если параметр по контракту обязательный (например, функция всегда должна получить валидный адрес), то вместо молчаливого if (p == nullptr) return; полезнее явно закрепить контракт через assert. У нас уже был assert в курсе как предохранитель для инвариантов.
#include <cassert>
void set_phone(Contact* c, const std::string& new_phone) {
assert(c != nullptr);
c->phone = new_phone;
}
Так вы не «лечите» ошибку молча, а ловите нарушение контракта сразу.
Отдельно замечу, что в стандарте и вокруг него тема «ошибочного чтения неинициализированных значений» — настолько серьёзная, что её регулярно уточняют и исправляют формулировки, потому что это один из главных источников неуловимых багов. Это хорошо согласуется с нашей практической моралью: лучше не создавать неинициализированные состояния вообще.
6. Типичные ошибки
Ошибка №1: int* p; «ну я потом присвою».
Это ошибка не из-за того, что вы «плохой программист», а из-за того, что мозг любит откладывать. Проблема в том, что «потом» может не наступить на одной ветке if, в одном раннем return, в одном исключительном сценарии. Привычка = nullptr в момент объявления почти полностью лечит этот класс ошибок.
Ошибка №2: вера в магическую проверку p != nullptr.
Эта проверка хорошая, но она не отвечает на вопрос «объект жив?». Она отвечает только на вопрос «похоже, внутри не ноль». Висячий указатель прекрасно проходит такую проверку, а потом ломает программу на разыменовании.
Ошибка №3: «сохранил адрес — значит, объект мой».
Указатель не даёт владения и не продлевает жизнь объекта. Если объект был локальным и вышел из области видимости, адрес превращается в опасный мусор. Если объект был элементом vector, то после изменения контейнера адрес мог стать невалидным. В обоих случаях указатель выглядит «нормально» (не nullptr), но доверять ему нельзя.
Ошибка №4: возвращать T* на локальную переменную из функции.
Это один из самых быстрых способов сделать dangling «мгновенно»: функция завершилась — локальные переменные уничтожены — указатель уже висячий, даже если вы ещё не успели моргнуть. Если вам нужно вернуть результат, возвращайте значение, либо записывайте в объект вызывающего кода через out‑параметр (то, что мы делали в прошлой лекции), но не адрес локального.
Ошибка №5: держать указатель на элемент контейнера «на будущее».
Очень хочется сделать «нашёл контакт — сохранил Contact* current — и дальше работаю». Но как только вы добавили/удалили элемент в std::vector, вы потенциально сделали current невалидным. На нашем уровне лучше привыкать к стратегии «получил указатель — использовал сразу — забыл», а если нужно «помнить», то помнить ключ (имя/id) и при необходимости искать заново.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ