JavaRush /Курсы /C++ SELF /Опасности указателей: неинициализированный и висячий

Опасности указателей: неинициализированный и висячий

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

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 == nullptr
Нельзя Это нормальное состояние «нет объекта»
Валидный p указывает на живой объект Можно Но только если вы уверены во времени жизни
Неинициализированный
int* p;
Нельзя Внутри «мусор» (неопределённое значение)
Висячий (dangling) объект умер, адрес остался Нельзя
p != nullptr
не спасает

Проверка 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) и при необходимости искать заново.

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