1. Занурюємося в T*
Коли ви вперше бачите &x, здається, що питання вже закрите: «адресу отримати можемо — і все, перемога». Але в реальних програмах адреса рідко потрібна лише «на мить». Частіше хочеться зберегти адресу, передати її далі, порівняти, покласти в змінну, сказати: «обʼєкт є / обʼєкта немає» або тримати «посилання на вибраний обʼєкт» як стан. Саме для цього нам і потрібен окремий тип даних: вказівник.
Вказівник можна уявити як папірець з адресою: сам обʼєкт — це «квартира», а вказівник — «адреса на папірці». Папірець може лежати у вас у кишені, ви можете дати його другові, тобто передати у функцію, можете стерти адресу й записати нову. Але папірець не є квартирою — це ключова думка сьогоднішньої лекції.
Що таке T* за змістом: «значення-адреса»
Вказівник — це змінна, яка зберігає адресу. Не значення обʼєкта, не копію обʼєкта, не «маленький обʼєкт усередині», а саме адресу, за якою розташований обʼєкт.
- int x = 10; — змінна x зберігає число 10
- int* p = &x; — змінна p зберігає адресу x
Дуже важливо проговорювати це вголос, коли ви читаєте код. Не «p дорівнює x», а «p вказує на x» або «у p лежить адреса x».
Невелика схема (груба, зате чесна):
Stack (умовно)
+-------------------+
| x: 10 | <-- обʼєкт (значення)
+-------------------+
| p: 0x7ffd... | <-- вказівник (адреса x)
+-------------------+
Адреси на кшталт 0x7ffd... — це лише формат виведення, зазвичай шістнадцятковий. І так, адреси будуть різними за різних запусків програми — це нормально. Адреса — не «ID користувача у вашій базі даних», а технічна координата на час життя обʼєкта.
2. Синтаксис і дисципліна ініціалізації
Як оголосити вказівник T*
Синтаксис має такий вигляд:
int* p = nullptr;
Читається так: «p — вказівник на int».
І тут у новачків зазвичай виникають два цілком природні запитання:
Перше: чому зірочка *? Історично це повʼязано з операцією розіменування, яку ми детально розберемо в наступній лекції. Поки що сприймайте * в оголошенні як «позначку вказівника».
Друге: чого стосується * — типу чи імені? У C++ це частина оголошення, і записи int* p; та int *p; еквівалентні. Раджу обрати стиль, який легше сприймати:
- або int* p (зірочка поруч із типом: «вказівник-на-int»),
- або int *p (зірочка поруч з іменем: «p — вказівник»).
Головне — не змішуйте стилі в одному проєкті, інакше мозок почне витрачати зайві сили на дрібниці замість логіки.
Головне правило дисципліни: вказівник завжди має бути ініціалізований
Найнебезпечніший рядок на початку знайомства з вказівниками — ось такий:
int* p; // погано: p не ініціалізований
Неініціалізований вказівник містить сміттєву адресу. Вона може виглядати як «нормальне число», може навіть інколи «влучати» в якусь памʼять, але це вже територія UB і лотереї «пощастить / не пощастить».
Правильна звичка з самого початку: вказівник завжди в одному з двох станів:
1) або він дорівнює nullptr (тобто «нікуди не вказує»),
2) або зберігає адресу реального обʼєкта, який зараз існує.
Тому початкова ініціалізація найчастіше така:
int* p = nullptr;
Це ніби перевести змінну в стан «поки нікого не вибрано». І це абсолютно нормально.
nullptr: коректний «порожній стан» вказівника
Слово nullptr можна перекласти як «нульовий вказівник». Але на рівні змісту корисніше думати так: nullptr означає «вказівник ні на що не вказує».
Це не помилка. Це нормальна ситуація — як порожній рядок або порожній vector: стан «поки немає значення».
Мініприклад:
#include <iostream>
int main() {
int* p = nullptr;
std::cout << std::boolalpha;
std::cout << (p == nullptr) << '\n'; // true
}
Порівняння з nullptr — це базова перевірка: «обʼєкт є чи його немає».
І ще один важливий момент: у сучасному C++ ми намагаємося використовувати саме nullptr, а не 0 і не NULL. Тому що nullptr — це окреме спеціальне значення для вказівників, і компілятор працює з ним передбачуваніше.
3. Присвоєння, копіювання та порівняння вказівників
Присвоєння адреси: p = &x
Тепер зведімо все разом: є обʼєкт і є вказівник, який зберігає його адресу.
#include <iostream>
int main() {
int x = 10;
int* p = nullptr;
p = &x; // тепер p зберігає адресу x
std::cout << "x = " << x << '\n'; // x = 10
std::cout << "&x = " << &x << '\n'; // &x = 0x...
std::cout << "p = " << p << '\n'; // p = 0x... (та сама адреса)
}
У цьому прикладі важливо побачити: p і &x виводяться однаково — як адреса, бо всередині p саме й лежить &x.
Зверніть увагу: ми поки не чіпаємо те, «що лежить за адресою». Сьогодні ми тренуємо саме ідею: «вказівник зберігає адресу» і «може бути порожнім».
Копіювання вказівника — це копіювання адреси
Одна з найчастіших мисленнєвих пасток: студент бачить p2 = p1 і підсвідомо думає: «скопіювали обʼєкт». Ні. Ми скопіювали папірець з адресою. Тепер два папірці вказують на ту саму квартиру.
#include <iostream>
int main() {
int x = 42;
int* p1 = &x;
int* p2 = p1; // копія адреси
std::cout << std::boolalpha;
std::cout << (p1 == p2) << '\n'; // true
}
Зміст цієї рівності такий: «вони вказують на один і той самий обʼєкт».
Ця властивість водночас дуже потужна і дуже небезпечна. Потужна — бо дає змогу ділитися доступом до одного обʼєкта. Небезпечна — бо можна випадково отримати кілька «точок впливу» на один обʼєкт, забути про це, а потім довго дивуватися, чому щось змінюється «само». Але це вже тема наступної лекції, де ми навчимося читати й записувати за адресою.
Вказівники можна порівнювати: з nullptr і між собою
Порівняння — ще одна базова операція для вказівників. У межах сьогоднішньої лекції нам потрібні лише два види порівнянь.
Перший вид — порівняння з nullptr. Це перевірка: «є обʼєкт чи немає».
#include <iostream>
int main() {
int* p = nullptr;
if (p == nullptr) {
std::cout << "p is empty\n"; // p is empty
}
}
Другий вид — порівняння вказівників між собою. Це перевірка: «той самий обʼєкт чи різні».
#include <iostream>
int main() {
int a = 1;
int b = 2;
int* pa = &a;
int* pb = &b;
std::cout << std::boolalpha;
std::cout << (pa == pb) << '\n'; // false
std::cout << (pa == &a) << '\n'; // true
}
Ми не обговорюємо тут «хто більший» (<, >). Для вказівників такі порівняння існують, але для новачка це майже завжди зайве джерело болю й плутанини. Зараз важливіша чітка логіка: «порожньо / не порожньо» і «той самий обʼєкт / інший обʼєкт».
4. Типізація вказівників: чому int* і double* не змішуються
У C++ адреса — не «просто адреса». Вона завжди несе зміст типу: адреса int — це int*, адреса double — це double*.
Чому це важливо? Тому що тип впливає на те, як мова потім інтерпретуватиме доступ за адресою, тобто розіменування. Якщо тип неправильний, ви фактично просите компілятор виконати потенційно беззмістовну операцію. Тому C++ не дозволяє просто так присвоювати вказівники різних типів один одному.
Приклад (це не повинно компілюватися — і це добре):
int main() {
int x = 1;
int* pi = &x;
// double* pd = pi; // помилка компіляції: різні типи вказівників
}
І тут у новачка може виникнути «геніальна» ідея: «а я приведу тип!». У більшості навчальних і прикладних сценаріїв це погана ідея. Якщо вам хочеться зробити таке приведення, це часто сигнал: «модель даних спроєктовано невдало — зупиніться й подумайте». Приведення вказівників — не тема сьогоднішньої лекції, і ми свідомо туди не заглиблюємося.
5. Навчальний сценарій: «вибраний користувач» як вказівник
Щоб вказівники не здавалися магією заради магії, привʼяжімо їх до простої задачі з реального коду: зберігати «поточного вибраного користувача» як стан.
Уявімо, що у нас є struct User (ми вже вміємо працювати зі struct з попередніх занять), а в main ми інколи вибираємо користувача, а інколи «скидаємо вибір». Для цього зручно мати змінну «може бути користувач, а може не бути».
Саме тут nullptr виглядає особливо природно.
#include <iostream>
#include <string>
struct User {
std::string name;
int age{};
};
int main() {
User alice{"Alice", 20};
User bob{"Bob", 30};
User* selected = nullptr; // поки нікого не вибрано
selected = &alice; // вибрали Alice
std::cout << (selected == &alice) << '\n'; // 1 (true)
selected = nullptr; // скинули вибір
std::cout << (selected == nullptr) << '\n'; // 1 (true)
selected = &bob; // вибрали Bob
std::cout << (selected == &bob) << '\n'; // 1 (true)
}
Зверніть увагу: ми поки не читаємо поля через вказівник. Ми просто використовуємо його як «маркер вибору» і вчимося підтримувати коректний стан. У наступній лекції ми зробимо ще один крок: навчимося читати й змінювати обʼєкт за адресою.
6. Що можна робити з T* уже зараз
Щоб упорядкувати дії, корисно мати перед очима просту таблицю. Вона не замінює розуміння, але допомагає не панікувати, коли ви бачите вказівники в коді.
| Операція | Приклад | Зміст |
|---|---|---|
| Оголосити вказівник | |
«p зберігає адресу int, поки що порожньо» |
| Взяти адресу | |
отримати адресу обʼєкта x |
| Присвоїти адресу | |
тепер p вказує на x |
| Скопіювати вказівник | |
копіюємо адресу, а не обʼєкт |
| Перевірити на порожнечу | |
є обʼєкт чи немає |
| Порівняти «той самий обʼєкт?» | |
чи вказують на один обʼєкт |
Якщо ви впевнено робите ці речі й розумієте їхній зміст, то ви вже на правильному шляху. Далі буде «доступ за адресою», і там почнеться найцікавіше.
7. Підготовка до наступної лекції: розіменування
Зазвичай мозок одразу питає: «ну гаразд, адреса — а як отримати значення?». Відповідь: через розіменування *p. Але розіменування — це окрема тема наступної лекції, бо там зʼявляється головне правило безпеки: розіменовувати можна лише валідний вказівник.
Щоб акуратно показати, куди ми рухаємося, ось приклад, де розіменування навмисно закоментовано:
#include <iostream>
int main() {
int x = 5;
int* p = &x;
std::cout << p << '\n'; // 0x... (адреса)
// std::cout << *p << '\n'; // 5 (розіменування — наступна лекція)
}
Поки що досить запамʼятати: p — це адреса, а *p — це вже «те, що лежить за адресою». І між ними — ціла прірва відповідальності.
8. Типові помилки під час роботи з T* і nullptr
Помилка № 1: неініціалізований вказівник (int* p;).
Це одна з найнебезпечніших речей, які може зробити новачок, бо така помилка не обовʼязково дасть про себе знати одразу. Такий p зберігає «сміттєву адресу», і будь-яка спроба використати його пізніше може призвести до UB. Правильна звичка — одразу ініціалізувати: int* p = nullptr; або int* p = &x;.
Помилка № 2: використання 0 або NULL замість nullptr.
Історично в C і старому C++ часто писали 0 або NULL. У сучасному C++ це гірше читається і може призводити до неоднозначностей під час перевантаження. Якщо ви хочете позначити «порожній вказівник», пишіть nullptr і заощадите собі нерви в майбутніх темах.
Помилка № 3: очікування, що p2 = p1 копіює обʼєкт.
Присвоювання вказівників копіює лише адресу. У результаті дві змінні починають вказувати на один і той самий обʼєкт, і це може неочікувано «звʼязати» різні частини програми. Корисна звичка — подумки промовляти: «я копіюю координати, а не дані».
Помилка № 4: плутанина з типом вказівника (int* проти double*).
Адреса в C++ типізована. Якщо ви намагаєтеся присвоїти int* у double*, компілятор видає помилку не зі шкідливості, а тому що інакше ви легко отримаєте беззмістовну інтерпретацію памʼяті. На початку шляху варто взагалі уникати приведень вказівників і ставитися до помилки компілятора як до підказки: «ви намагаєтеся змішати різні сутності».
Помилка № 5: «перевірив на nullptr один раз і заспокоївся назавжди».
Перевірка p != nullptr каже лише: «вказівник не порожній». Вона ще не гарантує, що адреса завжди буде валідною в будь-який момент часу. Тема «валідність адреси в часі» (lifetime) і «висячі вказівники» — окрема розмова, і ми ще до неї дійдемо. Зараз важливо хоча б не плутати поняття: nullptr — це про порожнечу, а коректність адреси — про життя обʼєкта.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ