1. Базова ідея: p — це «де», *p — це «що»
Коли ви вперше бачите вказівники, цілком природно запитати: «Навіщо зберігати адресу, якщо я хочу працювати з даними?» Адреса сама по собі зазвичай не дуже цікава — вона потрібна як маршрут до даних. Розіменування — це наче прийти за адресою з навігатора й відчинити потрібні двері: у вас є координати (p), і ви хочете потрапити всередину квартири (*p).
Головна думка сьогоднішньої лекції дуже проста:
p — це «де», *p — це «що лежить за цією адресою».
А коли обʼєкт — це struct, ми хочемо не просто «зайти до квартири», а ще й «відкрити конкретну шафку» — тут і зʼявляється оператор ->.
p і *p: адреса й обʼєкт
Почнімо з найпростішого. Маємо змінну x. Вона зберігає число. У неї є адреса. Ми можемо взяти цю адресу, зберегти її у вказівнику, а потім через вказівник знову дістатися до x.
Нижче — невелика таблиця, яку корисно тримати в голові: вона рятує від половини типових помилок із вказівниками.
| Вираз | Приклад | «Що це?» | Тип | Що означає за змістом |
|---|---|---|---|---|
| обʼєкт (значення) | |
дані | |
саме число |
| адреса обʼєкта | |
координата в памʼяті | |
де лежить x |
| вказівник (змінна) | |
змінна з адресою | |
адреса, збережена в p |
| розіменування | |
дані за адресою | |
обʼєкт, на який вказує 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 захищає лише від порожнього стану. Але вона не доводить, що обʼєкт за адресою живий, не знищений і взагалі існує. Сьогодні ми це лише фіксуємо як застереження, а детально розберемо вже в темі про безпеку вказівників і час життя обʼєктів.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ