JavaRush /Курси /C++ SELF /Вказівник T*: зберігає адресу й може бути nullptr

Вказівник T*: зберігає адресу й може бути nullptr

C++ SELF
Рівень 39 , Лекція 0
Відкрита

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* уже зараз

Щоб упорядкувати дії, корисно мати перед очима просту таблицю. Вона не замінює розуміння, але допомагає не панікувати, коли ви бачите вказівники в коді.

Операція Приклад Зміст
Оголосити вказівник
int* p = nullptr;
«p зберігає адресу int, поки що порожньо»
Взяти адресу
&x
отримати адресу обʼєкта x
Присвоїти адресу
p = &x;
тепер p вказує на x
Скопіювати вказівник
p2 = p1;
копіюємо адресу, а не обʼєкт
Перевірити на порожнечу
p == nullptr
є обʼєкт чи немає
Порівняти «той самий обʼєкт?»
p1 == p2
чи вказують на один обʼєкт

Якщо ви впевнено робите ці речі й розумієте їхній зміст, то ви вже на правильному шляху. Далі буде «доступ за адресою», і там почнеться найцікавіше.

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 — це про порожнечу, а коректність адреси — про життя обʼєкта.

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