JavaRush /Курси /C++ SELF /Змінні в памʼяті: адреси, посилання та вказівники

Змінні в памʼяті: адреси, посилання та вказівники

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

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 вказівник

Сутність Що це Може бути «порожньою»? Можна перенаправити? Як отримати значення
int x
сам обʼєкт
x
int& r = x
друге імʼя обʼєкта
x
ні ні
r
int* p = &x
окремий обʼєкт, зберігає адресу
x
так (
nullptr
)
так
*p

Якщо хочеться коротко, «в одному реченні»:

Посилання виглядає як змінна й веде до одного й того самого обʼєкта, а вказівник — це окрема змінна-адреса, яку потрібно перевіряти та розіменовувати.

Область видимості й «висячі» адреси: коли адреса стає небезпечною

Ви вже знаєте, що змінна живе в межах блоку { ... }. Тепер додамо важливий наслідок: адреса коректна лише доти, доки живий обʼєкт, на який вона вказує.

Найпростіший приклад «висячого вказівника»:

#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: висячий вказівник після виходу з блоку.
Якщо ви взяли адресу змінної, яка живе всередині { ... }, то після закривальної } обʼєкт знищено, а адреса стає «висячою». Це не «рідкісна теорія», а цілком реальна причина дивних багів. Гарна звичка: не зберігати адреси на «внутрішні» змінні довше, ніж живе їхній блок, і особливо не використовувати такі адреси після виходу з блоку.

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