Перейти до змісту

Л3. Копії, спільні дані й адреси в пам’яті

Коротко про лекцію

Розберемо, коли програма створює незалежну копію значення, а коли два імені ведуть до одного об’єкта. Далі введемо адресу, вказівник і час життя об’єкта.

У Л1–Л2 ми вже створювали змінні та змінювали їхні значення. Тепер потрібна точніша модель зберігання даних. Вона підготує цикли, послідовності та майбутні функції.

Практичний сенс. Помилка часто виникає через неправильне припущення про спільність даних. Тому спочатку треба з’ясувати, скільки об’єктів реально існує.

1. Об’єкт має значення і час існування

Під час виконання програма створює об’єкти. Об’єкт має тип, збережене значення та певний час існування.

Наприклад:

int score{70};

Ця інструкція створює об’єкт типу int зі значенням 70. Ім’я score дає доступ до цього об’єкта.

Поки що достатньо такої моделі:

ім’я → об’єкт → значення
score → int    → 70

2. Ініціалізація з іншого int створює копію значення

Такий запис створює новий об’єкт і копіює в нього поточне значення. Розглянемо два оголошення:

int original{10};
int copy{original};

Після виконання обох рядків original і copy є двома окремими об’єктами.

#include <iostream>

int main() {
    int original{10};
    int copy{original};

    copy = 20;

    std::cout << "оригінал: " << original << "\n";
    std::cout << "копія: " << copy << "\n";
}

Зміна copy залишає original без змін. Причина проста: кожне ім’я веде до свого об’єкта.

flowchart TD
    O["original\n10"]
    C["copy\n10"]
    O -->|"копіюємо значення під час створення"| C
    C --> M["copy = 20"]
    M --> R["original = 10\ncopy = 20"]

Ця відмінність стане важливою під час передавання даних у функції.

3. Посилання дає друге ім’я тому самому об’єкту

Іноді потрібен спільний об’єкт. Наприклад, дві частини коду мають працювати з одним значенням.

Для цього C++ має посилання. Посилання — це друге ім’я вже наявного об’єкта.

Термін: посилання

Посилання зв’язується з уже наявним об’єктом. Зміна через звичайне посилання змінює той самий об’єкт.

3.1. Синтаксис посилання

Символ & біля типу позначає посилання:

int& alias{score};

Запис читаємо по частинах:

  1. int& — тип посилання на int;
  2. alias — нове ім’я;
  3. score — об’єкт, з яким пов’язане посилання.
#include <iostream>

int main() {
    int score{70};
    int& alias{score};

    alias = 80;

    std::cout << "оцінка: " << score << "\n";
    std::cout << "посилання: " << alias << "\n";
}

Після alias = 80 значення score теж дорівнює 80. Обидва імені ведуть до одного об’єкта.

flowchart TD
    S["один об’єкт int\n70 → 80"]
    A["score"] --> S
    B["alias"] --> S

3.2. Звичайне присвоєння не переприв’язує посилання

Посилання пов’язується з об’єктом під час створення. Далі запис alias = other; змінює значення спільного об’єкта.

int score{70};
int other{95};
int& alias{score};

alias = other;

Після цих інструкцій score дорівнює 95. Ім’я alias далі веде до score.

4. const-посилання дає доступ лише для читання

Іноді спільний об’єкт треба лише читати. Тоді до типу посилання додають const.

const int& read_only_score{score};

read_only_score веде до того самого score. Через це ім’я змінювати об’єкт заборонено.

#include <iostream>

int main() {
    int score{72};
    const int& read_only_score{score};

    score = 80;

    std::cout << read_only_score << "\n";
}

Програма виведе 80. Посилання бачить актуальне значення спільного об’єкта.

У Л6 цей механізм з’явиться у параметрах функцій. Там const& дозволить читати великі дані без зайвої копії.

5. Об’єкт має адресу в пам’яті

Поки об’єкт існує, він займає певне місце в пам’яті. Позначення цього місця називають адресою.

C++ використовує оператор & перед ім’ям об’єкта, щоб отримати його адресу.

&score

Символ & тепер має дві вже відомі ролі:

Запис Роль &
int& alias{score}; частина типу посилання
&score оператор отримання адреси

Контекст запису показує потрібну роль.

6. Вказівник зберігає адресу

Вказівник — змінна, яка зберігає адресу іншого об’єкта.

Термін: вказівник

Вказівник зберігає адресу іншого об’єкта. Сам вказівник є окремою змінною зі своїм значенням-адресою.

Для зберігання адреси C++ має окремий тип. Змінну такого типу називають вказівником.

Вказівник на int записують так:

int* pointer{&score};

У цьому оголошенні:

  1. int* означає «вказівник на int»;
  2. pointer є ім’ям вказівника;
  3. &score дає адресу об’єкта score.

6.1. Розіменування звертається до об’єкта за адресою

Оператор * перед уже створеним вказівником виконує розіменування. Результатом є доступ до об’єкта за збереженою адресою.

*pointer
#include <iostream>

int main() {
    int score{70};
    int* pointer{&score};

    std::cout << *pointer << "\n";
    *pointer = 90;
    std::cout << score << "\n";
}

Перший * у int* pointer належить типу. Вираз *pointer розіменовує вже створений вказівник.

flowchart TD
    P["вказівник pointer\nзберігає адресу"] --> S["score\n70 → 90"]
    D["*pointer\nдоступ за адресою"] --> S

7. nullptr позначає відсутність адреси об’єкта

Вказівник іноді ще не має адреси потрібного об’єкта. Для такого стану C++ має спеціальне значення nullptr.

int* pointer{nullptr};

nullptr означає, що вказівник зараз не веде до об’єкта. Такий вказівник розіменовувати не можна.

Межа: nullptr

nullptr позначає відсутність адреси об’єкта. Перевірка на nullptr потрібна перед розіменуванням вказівника, який може бути порожнім.

#include <iostream>

int main() {
    int* pointer{nullptr};

    if (pointer == nullptr) {
        std::cout << "Адреси об'єкта немає\n";
    }
}

Перевірка стосується самого вказівника. Розіменування виконуємо лише за наявності коректної адреси живого об’єкта.

8. Вказівник можна спрямувати на інший об’єкт

Посилання зберігає зв’язок із початковим об’єктом. Вказівник є окремою змінною, тому його значення-адресу можна змінити.

#include <iostream>

int main() {
    int first{10};
    int second{20};
    int* pointer{&first};

    // Перенаправлення змінює адресу у pointer, а не значення first.
    pointer = &second;
    *pointer = 25;

    std::cout << "перший: " << first << "\n";
    std::cout << "другий: " << second << "\n";
}

Після pointer = &second вказівник зберігає адресу second. Тому *pointer = 25 змінює second.

9. Час життя визначає, чи об’єкт ще існує

Кожний локальний об’єкт створюється у певний момент. Пізніше його існування завершується.

Проміжок між цими подіями називають часом життя об’єкта.

Термін: час життя

Час життя — проміжок, у якому об’єкт уже створений і ще не знищений. Доступ до об’єкта коректний лише всередині цього проміжку.

Фігурні дужки можуть утворювати внутрішній блок. Локальний об’єкт такого блока знищується під час виходу з нього.

#include <iostream>

int main() {
    int* pointer{nullptr};

    {
        int local{42};
        // Адреса придатна лише поки local існує всередині цього блока.
        pointer = &local;
        std::cout << "Усередині блока: " << *pointer << "\n";
    }

    // Після завершення блока pointer лишається з адресою, але об’єкта local уже немає.
    std::cout << "Після блока вказівник не можна розіменовувати\n";
}

У внутрішньому блоці local існує. Тому адреса в pointer придатна для розіменування.

Після закривальної } час життя local завершився. Значення адреси може залишитися у pointer, але живого об’єкта за цією адресою вже немає.

sequenceDiagram
    participant M as main
    participant L as local
    Note over M,L: вхід у внутрішній блок
    M->>L: створення local = 42
    Note over M,L: pointer зберігає адресу local
    L-->>M: вихід із блока, local знищено
    Note over M: стару адресу не можна розіменовувати

10. Висячий вказівник веде до об’єкта, час життя якого завершився

Вказівник із адресою вже знищеного об’єкта називають висячим вказівником. Аналогічна проблема можлива і з посиланням.

Розіменування висячого вказівника створює невизначену поведінку. Стандарт C++ не задає надійного результату такої програми.

Терміни: висячий вказівник і невизначена поведінка

Висячий вказівник зберігає адресу об’єкта, час життя якого завершився. Доступ через таку адресу має невизначену поведінку, тому результат запуску не можна використовувати як доказ коректності.

Розглянемо фрагмент:

int* pointer{nullptr};

{
    int local{42};
    pointer = &local;
}

// *pointer використовувати не можна: local уже не існує.

Коментар позначає заборонений наступний крок. Такий код не треба «перевіряти запуском», бо коректність уже порушена моделлю часу життя.

Для аналізу достатньо двох питань:

  1. який об’єкт стоїть за адресою або посиланням;
  2. чи живий цей об’єкт у момент доступу.

pointer != nullptr відповідає лише на питання про відсутність нульової адреси. Ця перевірка сама по собі не доводить, що об’єкт ще живий.

11. Вибір моделі залежить від потрібного зв’язку

Після цієї лекції можна користуватися таким орієнтиром:

Потреба Початковий вибір
потрібний окремий об’єкт із тим самим значенням копія
потрібне друге ім’я живого об’єкта посилання &
спільний об’єкт треба лише читати const&
треба зберігати адресу, яка може бути відсутня вказівник * і nullptr

Сирий вказівник у цій лекції лише зберігає адресу вже наявного об’єкта. Керування створеними ресурсами розглянемо в Л9 через RAII.

У наступній лекції повернемося до зміни стану. Там новий стан виникатиме багато разів під час виконання циклу.

12. Інтерактивна самоперевірка лекції

Самоперевірка вимагає відрізняти копії від спільного об’єкта. Також треба читати &, *, nullptr і знаходити помилки часу життя.