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 вказує на живий обʼєкт | Можна | Але лише якщо ви впевнені, що час життя обʼєкта ще не завершився |
| Неініціалізований | |
Не можна | Усередині — «сміття» (невизначене значення) |
| Висячий (dangling) | обʼєкт знищено, адреса залишилася | Не можна | не рятує |
Перевірка 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) і за потреби шукати знову.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ