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 став висячим
    // std::cout << *p << '\n'; // UB (закоментовано)
}

І ось дуже важливий момент: p не стає nullptr автоматично. Він, як і раніше, зберігає «якусь адресу». Тобто перевірка p != nullptr пройде, але розіменування все одно залишиться небезпечним.

Для наочності — маленька схема того, що відбувається:

flowchart TD
    A["Створили x (локальна змінна)"] --> B["Взяли адресу &x і зберегли в p"]
    B --> C["Вийшли з блока { }"]
    C --> D["x знищено (час життя завершився)"]
    D --> E["p усе ще зберігає стару адресу"]
    E --> F["*p -> UB (висячий вказівник)"]

Головний практичний висновок: коли ви бачите вказівник у коді, маєте вміти відповісти на запитання: «А обʼєкт, на який він указує, ще живий?» Якщо ви не можете відповісти, це вже ризик.

Висячі вказівники з контейнерів

Тепер — більш «сучасна» версія проблеми, яка в реальних програмах трапляється постійно. Ви берете адресу елемента std::vector (наприклад, &v[0]), зберігаєте її в T*, а потім змінюєте вектор: додаєте елементи, видаляєте їх, інколи сортуєте. Після цього ваш вказівник може стати невалідним, тому що std::vector має право змінити внутрішнє розміщення елементів у памʼяті.

Приклад: небезпечний рядок спеціально закоментовано.

#include <vector>

int main() {
    std::vector<int> v{1, 2, 3};

    int* p = &v[0];   // адреса елемента
    v.push_back(4);   // вектор може перерозмістити елементи

    // *p = 10;        // потенційно UB: p міг стати невалідним (закоментовано)
}

Тут не варто занурюватися в деталі реалізації — достатньо тримати в голові просту модель: у std::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; // добре

Вказівник, що може дорівнювати nullptr, розіменовуйте лише після перевірки

Якщо за контрактом p може бути nullptr, то if (p == nullptr) — не «зайва перевірка», а частина логіки.

#include <iostream>

int main() {
    int* p = nullptr;

    if (p != nullptr) {
        std::cout << *p << '\n';
    } else {
        std::cout << "немає значення\n"; // немає значення
    }
}

Не повертайте вказівник на локальну змінну

Це настільки поширений сценарій, що його варто побачити на власні очі. Ось так робити не можна:

#include <string>

int* bad() {
    int x = 5;
    return &x; // висячий вказівник одразу після виходу з функції
}

Навіть якщо компілятор попередить вас про це (а він інколи попереджає), логіка все одно неправильна: x зникне під час виходу з bad().

Не зберігайте вказівник на елемент std::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: «зберіг адресу — значить, обʼєкт мій».
Вказівник не дає володіння й не подовжує життя обʼєкта. Якщо обʼєкт був локальним і вийшов з області видимості, адреса перетворюється на небезпечне сміття. Якщо обʼєкт був елементом std::vector, то після зміни контейнера адреса могла стати невалідною. В обох випадках вказівник виглядає «нормально» (не nullptr), але довіряти йому не можна.

Помилка № 4: повертати T* на локальну змінну з функції.
Це один із найшвидших способів зробити dangling «миттєво»: функція завершилася — локальні змінні знищено — вказівник уже висячий, навіть якщо ви ще не встигли кліпнути. Якщо вам потрібно повернути результат, повертайте значення або записуйте його в обʼєкт коду, що викликає, через out-параметр (саме це ми робили в минулій лекції), але не адресу локального обʼєкта.

Помилка № 5: тримати вказівник на елемент контейнера «на майбутнє».
Дуже хочеться зробити «знайшов контакт — зберіг Contact* current — і далі працюю». Але щойно ви додали або видалили елемент у std::vector, ви потенційно зробили current невалідним. На нашому рівні краще звикати до стратегії «отримав вказівник — використав одразу — забув», а якщо потрібно «памʼятати», то памʼятати ключ (імʼя/id) і за потреби шукати знову.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ