JavaRush /Курси /C++ SELF /Розіменування *

Розіменування * p і доступ p -> field

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

1. Базова ідея: p — це «де», *p — це «що»

Коли ви вперше бачите вказівники, цілком природно запитати: «Навіщо зберігати адресу, якщо я хочу працювати з даними?» Адреса сама по собі зазвичай не дуже цікава — вона потрібна як маршрут до даних. Розіменування — це наче прийти за адресою з навігатора й відчинити потрібні двері: у вас є координати (p), і ви хочете потрапити всередину квартири (*p).

Головна думка сьогоднішньої лекції дуже проста:
p — це «де», *p — це «що лежить за цією адресою».
А коли обʼєкт — це struct, ми хочемо не просто «зайти до квартири», а ще й «відкрити конкретну шафку» — тут і зʼявляється оператор ->.

p і *p: адреса й обʼєкт

Почнімо з найпростішого. Маємо змінну x. Вона зберігає число. У неї є адреса. Ми можемо взяти цю адресу, зберегти її у вказівнику, а потім через вказівник знову дістатися до x.

Нижче — невелика таблиця, яку корисно тримати в голові: вона рятує від половини типових помилок із вказівниками.

Вираз Приклад «Що це?» Тип Що означає за змістом
обʼєкт (значення)
x
дані
int
саме число
адреса обʼєкта
&x
координата в памʼяті
int*
де лежить x
вказівник (змінна)
p
змінна з адресою
int*
адреса, збережена в p
розіменування
*p
дані за адресою
int
обʼєкт, на який вказує p

Тепер розгляньмо маленький приклад: просто надрукуймо значення, адресу і значення «через адресу».

#include <iostream>

int main() {
    int x = 42;
    int* p = &x;

    std::cout << "x  = " << x << '\n';   // x  = 42
    std::cout << "&x = " << &x << '\n';  // &x = 0x.... (адреса буде іншою)
    std::cout << "*p = " << *p << '\n';  // *p = 42
}

Зверніть увагу на важливий момент для розуміння: p — це не «особливий доступ до x». p — це просто змінна, у якій зберігається число-адреса. Магія починається лише в момент *p.

2. Читання і запис через *p

*p для читання

Коли ми кажемо «розіменувати вказівник», то виконуємо операцію *p й отримуємо доступ до обʼєкта, який лежить за адресою p. На практиці найчастіше перший крок — просто прочитати значення, щоб переконатися: ми дивимося саме туди, куди хотіли. Під час налагодження це цілком природна перевірка.

Приклад дуже простий: змінюємо x і бачимо, що читання через *p показує поточний стан x.

#include <iostream>

int main() {
    int x = 10;
    int* p = &x;

    std::cout << *p << '\n';  // 10
    x = 25;
    std::cout << *p << '\n';  // 25
}

Тут корисно усвідомити: вираз *p не «зберігає копію». Він щоразу переходить за адресою і звертається до живого обʼєкта.

*p для запису

Тепер найцікавіше: *p можна використовувати не лише праворуч (для читання), а й ліворуч (для запису). Саме це і є «керування обʼєктом за адресою».

Нехай у нас є x = 10, і вказівник p на x. Якщо ми напишемо *p = 99;, то змінимо сам x, тому що *p — це і є x, тільки «дістали його за адресою».

#include <iostream>

int main() {
    int x = 10;
    int* p = &x;

    *p = 99;                 // змінюємо x через вказівник
    std::cout << x << '\n';  // 99
}

Саме тут багато новачків починають підозрювати, що C++ — це не мова, а квест «вгадай, що мав на увазі автор». Але цього разу все чесно: *p — це обʼєкт, тож його можна змінювати.

Часта плутанина: p = &x і *p = x

На цьому етапі варто спеціально проговорити два схожі вирази, які означають зовсім різне. Саме тут часто зʼявляються баги, після яких хочеться сказати: «Воно саме зламалося, чесно».

Порівняймо:

#include <iostream>

int main() {
    int x = 5;
    int y = 100;

    int* p = &x;  // p зберігає адресу x
    p = &y;       // тепер p зберігає адресу y (ми ПЕРЕНАПРАВИЛИ вказівник)

    *p = 7;       // тепер змінюємо y, тому що p вказує на y
    std::cout << x << '\n';  // 5
    std::cout << y << '\n';  // 7
}

Зміст такий:
p = &y; змінює адресу, збережену у вказівнику (куди вказуємо).
*p = 7; змінює дані за адресою (що лежить там, куди вказуємо).

Якщо коротко: один рядок змінює «стрілку», другий — «мішень».

3. nullptr і перевірка перед розіменуванням

Зараз буде правило, яке звучить трохи нудно, зате економить багато часу:
Якщо вказівник може бути nullptr, то розіменовувати його можна лише після перевірки.

Перевірку зазвичай записують так: if (p != nullptr).

#include <iostream>

int main() {
    int* p = nullptr;

    if (p != nullptr) {
        std::cout << *p << '\n';
    } else {
        std::cout << "p is null\n";  // p is null
    }
}

Чому так суворо? Тому що nullptr — це не адреса обʼєкта. Це «порожній стан». Спроба зробити *nullptr — це як спроба відчинити двері, яких не існує: не просто помилка, а справжня лотерея із серії «а що сьогодні зламаємо».

Окремий момент про стиль: у сучасному C++ прийнято використовувати саме nullptr, а не 0 або NULL. І це не просто примха — саме така форма давно усталилася в текстах стандарту.

4. Вказівник на struct: оператор ->

Чому зʼявляється ->, якщо вже є *p

Коли ми працюємо зі структурою, у нас є поля. Для звичайного обʼєкта до поля звертаємося через крапку: obj.field. Але якщо маємо вказівник p, то p — це не обʼєкт, а адреса. Отже, крапка не підходить: адреса не має поля age (у неї взагалі немає полів, вона лише «координата»).

Щоб звернутися до поля структури через вказівник, використовують оператор ->:

  • p->field читається як «візьми обʼєкт за адресою p і візьми його поле field».

Зробімо мінімальний приклад зі структурою User.

#include <iostream>
#include <string>

struct User {
    std::string name;
    int age{};
};

int main() {
    User u{"Bob", 30};
    User* p = &u;

    std::cout << p->name << '\n';  // Bob
    p->age += 1;
    std::cout << u.age << '\n';    // 31
}

Це дуже важливий приклад: зміна через p->age змінює початковий обʼєкт u, тому що p вказує саме на нього.

p->field і (*p).field

Тепер — момент, який часто виглядає як «заклинання латиною», хоча насправді тут усе цілком логічно.

p->field — це скорочення для (*p).field.

Тобто спочатку ми розіменовуємо p, отримуємо обʼєкт, а потім беремо в нього поле через крапку.

Але тут є нюанс: оператори мають пріоритет. Оператор . (крапка) має вищий пріоритет, тому вираз *p.field означає не те, що ви хочете. Зазвичай він або взагалі не компілюється, або працює не так, як ви очікуєте.

Правильно так:

(*p).age — спочатку розіменували p, потім взяли age
p->age — те саме, але читати легше

Покажімо цю еквівалентність прямо в коді.

#include <iostream>
#include <string>

struct User {
    std::string name;
    int age{};
};

int main() {
    User u{"Ann", 20};
    User* p = &u;

    std::cout << p->age << '\n';     // 20
    std::cout << (*p).age << '\n';   // 20
}

Чому оператор -> такий популярний? Тому що (*p).age виглядає так, ніби ви випадково зачепили клавіатуру ліктем — і воно спрацювало. А p->age читається майже як англійське «pointer to age».

5. Мінісхема: що відбувається при *p і p->field

Нижче — схема, щоб трохи відпочити від суцільного тексту й перемкнутися на образи. Уявімо, що p вказує на u.

flowchart LR
    P[p: User*] -->|містить адресу| U[User u]
    U --> NAME[name]
    U --> AGE[age]

    P2["*p"] -->|це| U
    P3["p->age"] -->|це| AGE

Саме так і корисно думати: *p — це «сам обʼєкт», p->age — «конкретне поле обʼєкта».

6. Приклад: «Нотатки» і редагування через вказівник

Щоб не здавалося, ніби вказівники — це просто «так написано в підручнику», додаймо маленький шматочок логіки до нашого умовного консольного застосунку «Нотатки» (або «Список завдань»). Ми не будемо робити нічого великого: просто покажемо, як вказівник може означати «поточний вибраний обʼєкт» і як через -> можна змінювати поля.

Нехай у нас є така модель:

#include <string>

struct Note {
    int id{};
    std::string title;
    bool pinned{};
};

Тепер уявімо, що ми «вибрали» конкретну нотатку n і хочемо змінити поле pinned через вказівник.

#include <iostream>
#include <string>

struct Note {
    int id{};
    std::string title;
    bool pinned{};
};

int main() {
    Note n{1, "Buy milk", false};
    Note* selected = &n;

    selected->pinned = true;
    std::cout << n.pinned << '\n'; // 1 (true друкується як 1)
}

Так, виведення true як 1 — це нормально. Ми згодом звикнемо до std::boolalpha, але зараз нам важливо інше: значення поля справді змінилося.

Тепер трохи цікавіше: покажімо, що можна змінювати і рядок, і булеве поле, і все це зберігається в початковому обʼєкті.

#include <iostream>
#include <string>

struct Note {
    int id{};
    std::string title;
    bool pinned{};
};

int main() {
    Note n{2, "Read C++ book", false};
    Note* selected = &n;

    selected->title += " (chapter pointers)";
    selected->pinned = !selected->pinned;

    std::cout << n.title << '\n';  // Read C++ book (chapter pointers)
    std::cout << n.pinned << '\n'; // 1
}

Зміст цього фрагмента для нашого застосунку такий: ми можемо зберігати «поточний вибраний обʼєкт» як вказівник і працювати з ним однаково, незалежно від того, де його створено, доки обʼєкт існує.

Сьогодні ми не заглиблюємося в те, як саме вибирати нотатку з контейнера і як гарантувати, що адреса не стане недійсною, — це окрема тема про безпеку та час життя, і до неї ми ще дійдемо. Зараз нам достатньо відпрацювати «механіку рук»: *p і p->field.

7. Типові помилки під час розіменування і ->

Помилка № 1: розіменування nullptr.
Найчастіший сценарій: вказівник оголосили, поставили nullptr «про всяк випадок», а потім у якомусь місці забули, що він може бути порожнім. У результаті зʼявляється *p або p->field без перевірки. Лікується це дисципліною: якщо вказівник за контрактом може бути порожнім, перед кожним розіменуванням має бути if із перевіркою p != nullptr або інша явна гарантія.

Помилка № 2: плутанина між «змінюю вказівник» і «змінюю обʼєкт».
p = &x; — ви змінюєте адресу у вказівнику. *p = 10; — ви змінюєте дані за адресою. Якщо це переплутати, програма починає поводитися так, ніби її поведінка залежить від настрою і фази місяця. Рятує звичка проговорювати: «я зараз рухаю стрілку чи змінюю ціль?».

Помилка № 3: спроба писати p.field для вказівника.
Це зазвичай трапляється за інерцією: «я ж завжди писав через крапку». Але крапка працює лише з обʼєктом, а не з адресою. Для вказівника або p->field, або (*p).field. Якщо ви бачите крапку поруч зі змінною-вказівником — це майже завжди баг.

Помилка № 4: забуті дужки в (*p).field.
Запис *p.field майже ніколи не означає того, що хотів автор. Правильно: (*p).field. На практиці простіше одразу писати p->field, і проблема зникає разом із зайвими дужками та зайвим сивим волоссям.

Помилка № 5: віра в те, що «якщо вказівник не порожній, то все безпечно».
Перевірка p != nullptr захищає лише від порожнього стану. Але вона не доводить, що обʼєкт за адресою живий, не знищений і взагалі існує. Сьогодні ми це лише фіксуємо як застереження, а детально розберемо вже в темі про безпеку вказівників і час життя обʼєктів.

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