1. Змінна як обʼєкт у памʼяті: значення й адреса
Уявіть памʼять компʼютера як величезну лінійку з маленьких комірок по 1 байту:
flowchart TD
subgraph Row1["Адреси"]
direction LR
R1000["1000"]
R1001["1001"]
R1002["1002"]
R1003["1003"]
R1004["1004"]
R1005["1005"]
end
subgraph Row2["Дані (по 1 байту)"]
direction LR
B1000["[байт]"]
B1001["[байт]"]
B1002["[байт]"]
B1003["[байт]"]
B1004["[байт]"]
B1005["[байт]"]
end
Будь-яка змінна займає кілька байтів поспіль:
- char зазвичай — 1 байт,
- int часто — 4 байти,
- double часто — 8 байтів,
- std::string має складну внутрішню будову, але для нас важливо одне: це теж обʼєкт у памʼяті.
У кожного обʼєкта в памʼяті є адреса — «координата», з якої цей обʼєкт починається.
Адреса — це не значення
Припустімо, у вас є такий код:
int balance = 42;
У balance є:
- значення: 42
- адреса: щось на кшталт 0x7ffeefbff5ac (точне число не важливе)
І це дві різні сутності. Справді різні.
2. Оператор взяття адреси &
C++ дає змогу отримати адресу змінної — номер комірки памʼяті, де вона зберігається. Для цього використовують оператор &, який читається так:
&x — «адреса змінної x»
Візьмімо змінну з нашого мінізастосунку «Копілка» — баланс — і подивімося на неї очима памʼяті:
#include <iostream>
int main() {
int balance = 42;
std::cout << "balance = " << balance << '\n';
std::cout << "&balance = " << &balance << '\n';
}
Адресу зазвичай виводять як 0x... (шістнадцяткове число). Під час наступного запуску програми вона може бути іншою — це нормально. Операційна система часто «переставляє» обʼєкти в памʼяті, зокрема задля безпеки.
Який тип у &balance?
Якщо balance має тип int, то &balance має тип int*. Тобто «адреса int» — це значення типу «вказівник на int».
3. Розмір обʼєкта: sizeof
Оскільки тип визначає, скільки памʼяті потрібно, корисно вміти це перевіряти. Для цього існує оператор sizeof.
#include <iostream>
int main() {
int x = 0;
double y = 0.0;
std::cout << "sizeof(x) = " << sizeof(x) << '\n';
std::cout << "sizeof(y) = " << sizeof(y) << '\n';
}
Найчастіше ви побачите щось на кшталт:
sizeof(x) = 4
sizeof(y) = 8
Це не «магія компілятора», а просто факт: тип визначає, скільки байтів виділяється під обʼєкт.
І ще одна цікава деталь: розмір вказівника часто однаковий (наприклад, 8 байтів на 64‑бітних системах), навіть якщо він «вказує» і на int, і на double. Вказівник зберігає адресу, а адреси в межах однієї платформи мають однаковий розмір.
#include <iostream>
int main() {
int x = 1;
int* px = &x;
std::cout << "sizeof(int) = " << sizeof(int) << '\n';
std::cout << "sizeof(px) = " << sizeof(px) << '\n';
}
4. Що можна адресувати, а що — ні
Адресу можна взяти лише в того, що реально існує як окремий обʼєкт.
У змінної адресу взяти можна:
int x = 10;
std::cout << &x << '\n';
А ось у «проміжного результату обчислення» адресу взяти не можна:
int x = 10;
std::cout << &(x + 1) << '\n'; // не компілюється
На цьому етапі достатньо простого правила: отримати адресу можна лише для «справжньої» змінної або обʼєкта, але не для «результату формули».
5. Посилання T&: «друге імʼя» обʼєкта
За допомогою & також можна створювати змінні-посилання. Посилання — це псевдонім, тобто друге імʼя вже наявного обʼєкта.
#include <iostream>
int main() {
int balance = 100;
int& refBalance = balance; // refBalance — друге ім’я balance
refBalance = refBalance + 50; // змінюємо через посилання
std::cout << balance << '\n'; // 150
}
Змінна refBalance посилається на ту саму адресу памʼяті, що й balance.
Важливо зрозуміти суть: ми не створювали копію. Обʼєкт один, імен — два.
Перевіряємо, що обʼєкт один: адреси збігаються
Щоб переконатися, що дві змінні вказують на одну ділянку памʼяті, достатньо скористатися оператором &, з яким ви вже знайомі. Приклад:
#include <iostream>
int main() {
int balance = 100;
int& refBalance = balance;
std::cout << &balance << '\n';
std::cout << &refBalance << '\n'; // та сама адреса
}
Якщо адреса однакова, то й обʼєкт один і той самий.
Правила посилань (коротко, але суворо)
Змінну-посилання потрібно ініціалізувати одразу. Тобто так не можна:
int& r; // помилка: посилання не може бути “порожнім”
Посилання не можна «перепризначити» іншому обʼєкту. Саме тут часто виникає плутанина — і це нормально:
#include <iostream>
int main() {
int a = 1;
int b = 2;
int& r = a; // r — це a
r = b; // це НЕ “переприв’язати r”
// це “присвоїти a значення b”
std::cout << a << '\n'; // 2
}
Тобто r = b; — це звичайне присвоювання, оскільки r поводиться як «те саме, що a».
На цьому етапі корисно запамʼятати просту думку: посилання завжди посилається на реальний обʼєкт.
Не плутайте & у типі та & як оператор
Це два різні значення одного символу:
- int& ref = balance; — & частина типу (посилання)
- &balance — операція «взяти адресу»
C++ тут поводиться як той друг, який використовує один і той самий мем у різних ситуаціях: «ну ви ж зрозуміли з контексту». І справді: тут усе вирішує контекст.
6. Вказівник T*: змінна, що зберігає адресу
Вказівник — це не посилання, а окрема змінна. Її значення — адреса іншого обʼєкта.
#include <iostream>
int main() {
int balance = 100;
int* pBalance = &balance; // pBalance зберігає адресу balance
std::cout << "pBalance = " << pBalance << '\n'; // адреса
std::cout << "*pBalance = " << *pBalance << '\n'; // значення за адресою
}
Тут важливо чітко розрізняти три сутності:
- balance — обʼєкт із числом
- &balance — адреса обʼєкта
- pBalance — обʼєкт, що зберігає адресу (тобто pBalance == &balance)
Розіменування *p
Оператор * у виразі означає «взяти значення за адресою».
#include <iostream>
int main() {
int balance = 100;
int* pBalance = &balance;
*pBalance = *pBalance + 50; // змінюємо balance через вказівник
std::cout << balance << '\n'; // 150
}
Вказівник теж змінна (у нього теж є адреса)
Іноді це допомагає краще «приземлити» картину:
#include <iostream>
int main() {
int balance = 100;
int* pBalance = &balance;
std::cout << "&balance = " << &balance << '\n';
std::cout << "pBalance = " << pBalance << '\n';
std::cout << "&pBalance = " << &pBalance << '\n';
}
&pBalance — це адреса самого вказівника в памʼяті.
nullptr: вказівник «у нікуди»
Вказівник може мати спеціальне значення nullptr — «ні на що не вказує».
#include <iostream>
int main() {
int* p = nullptr;
if (p != nullptr) {
std::cout << *p << '\n'; // безпечно лише якщо p коректний
} else {
std::cout << "Вказівник дорівнює nullptr\n";
}
}
На цьому етапі запамʼятайте просте правило безпеки — майже як правило дорожнього руху: розіменовувати *p можна лише тоді, коли p != nullptr і вказує на живий обʼєкт.
Вказівник можна перепризначати
На відміну від посилання, вказівник можна «перенаправити» на інший обʼєкт:
#include <iostream>
int main() {
int a = 1;
int b = 2;
int* p = &a;
std::cout << *p << '\n'; // 1
p = &b; // тепер p вказує на b
std::cout << *p << '\n'; // 2
}
7. Корисні нюанси
Бонус: -> для вказівників на обʼєкти
Якщо у вас є вказівник на обʼєкт і ви хочете викликати метод, то маєте два рівноцінні варіанти:
- (*p).method() — довше, зате наочно
- p->method() — коротко й зручно
#include <iostream>
#include <string>
int main() {
std::string owner = "Alex";
std::string* pOwner = &owner;
std::cout << (*pOwner).size() << '\n';
std::cout << pOwner->size() << '\n'; // те саме
}
Змінна vs посилання vs вказівник
| Сутність | Що це | Може бути «порожньою»? | Можна перенаправити? | Як отримати значення |
|---|---|---|---|---|
|
сам обʼєкт | — | — | |
|
друге імʼя обʼєкта |
ні | ні | |
|
окремий обʼєкт, зберігає адресу |
так () |
так | |
Якщо хочеться коротко, «в одному реченні»:
Посилання виглядає як змінна й веде до одного й того самого обʼєкта, а вказівник — це окрема змінна-адреса, яку потрібно перевіряти та розіменовувати.
Область видимості й «висячі» адреси: коли адреса стає небезпечною
Ви вже знаєте, що змінна живе в межах блоку { ... }. Тепер додамо важливий наслідок: адреса коректна лише доти, доки живий обʼєкт, на який вона вказує.
Найпростіший приклад «висячого вказівника»:
#include <iostream>
int main() {
int* p = nullptr;
{
int temp = 123;
p = &temp;
std::cout << *p << '\n'; // 123 — тут усе добре
}
// temp уже не існує, а p зберігає стару адресу.
std::cout << *p << '\n'; // НЕБЕЗПЕЧНО (не робіть так)
}
Чому це так підступно? Тому що програма інколи «ніби працює», інколи падає, а інколи починає друкувати нісенітницю. Це одна з причин, чому вказівники вважають «небезпечними»: вони дають багато свободи, а свобода потребує дисципліни.
Схожа історія можлива й з посиланнями (посилання теж може стати «висячим»), але вказівники частіше потрапляють у такі пастки просто тому, що їх можна зберігати окремо й перепризначати.
8. Вбудовуємо тему в наш мінізастосунок «Копілка»: режим інспектора
Ми почали «Копілку» в попередніх лекціях: читаємо поповнення й витрати, оновлюємо баланс. Сьогодні додамо режим «інспектора»: покажемо, що баланс — це не абстрактне число, а обʼєкт з адресою, і що його можна змінювати різними способами.
Баланс і його адреса
#include <iostream>
int main() {
int balance = 0;
std::cout << "balance = " << balance << '\n';
std::cout << "&balance = " << &balance << '\n';
}
Посилання на баланс: змінюємо «ніби напряму»
#include <iostream>
int main() {
int balance = 0;
int& refBalance = balance;
refBalance = refBalance + 10; // поповнили через посилання
std::cout << balance << '\n'; // 10
}
Вказівник на баланс: змінюємо через *
#include <iostream>
int main() {
int balance = 0;
int* pBalance = &balance;
*pBalance = *pBalance + 25; // поповнили через вказівник
std::cout << balance << '\n'; // 25
}
Мінісценарій: поповнення, витрати й «один і той самий обʼєкт»
Тут ми вже збираємо маленький «живий» фрагмент: вводимо два числа, оновлюємо баланс, але робимо це через посилання та вказівник, щоб звикнути до думки, що до одного обʼєкта можна звертатися по-різному.
#include <iostream>
int main() {
int balance = 0;
int& refBalance = balance;
int* pBalance = &balance;
int deposit = 0, spend = 0;
std::cin >> deposit >> spend;
refBalance = refBalance + deposit; // через посилання
*pBalance = *pBalance - spend; // через вказівник
std::cout << "balance = " << balance << '\n';
}
Якщо ви зараз подумали: «це дивно, навіщо так робити, можна ж просто balance = balance + deposit;» — чудово! Це означає, що ви розумієте: це навчальний експеримент, а не стиль для реального проєкту.
Сенс цієї вправи в іншому: ви вчитеся бачити, що refBalance і *pBalance працюють із тим самим обʼєктом.
9. Типові помилки
Помилка № 1: неініціалізований вказівник.
Якщо написати int* p; і не надати йому значення, то всередині буде «сміттєва адреса». Іноді програма одразу впаде під час *p, іноді почне псувати памʼять, а іноді — і це найнебезпечніший варіант — «майже працюватиме». На рівні звички це лікується просто: будь-який вказівник або одразу отримує коректну адресу, або nullptr.
Помилка № 2: розіменування nullptr.
nullptr — це не «нульовий обʼєкт», а ситуація, коли «обʼєкта взагалі немає». Розіменування *p за умови p == nullptr — погана ідея, яка зазвичай завершується аварійно. Тому перевірка if (p != nullptr) перед розіменуванням — не параноя, а базова гігієна.
Помилка № 3: плутанина * і & в оголошенні та у виразі.
int* p — зірочка — частина типу («вказівник»).
*p — зірочка — дія («взяти значення за адресою»).
Так само int& r — амперсанд — частина типу («посилання»), а &x — операція «взяти адресу». Один і той самий символ, два значення — класика C++: мова економить символи, а ви потім витрачаєте нервові клітини. З часом це починає читатися автоматично.
Помилка № 4: очікування, що посилання можна «перепривʼязати».
Якщо у вас int& r = a;, то r назавжди повʼязане з a. Рядок r = b; не змінює, «куди дивиться посилання». Він просто змінює значення a. Якщо вам потрібна саме можливість перемикатися між обʼєктами, це вже сценарій для вказівника.
Помилка № 5: висячий вказівник після виходу з блоку.
Якщо ви взяли адресу змінної, яка живе всередині { ... }, то після закривальної } обʼєкт знищено, а адреса стає «висячою». Це не «рідкісна теорія», а цілком реальна причина дивних багів. Гарна звичка: не зберігати адреси на «внутрішні» змінні довше, ніж живе їхній блок, і особливо не використовувати такі адреси після виходу з блоку.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ