JavaRush /Курси /C++ SELF /Повернення посилання на локальну змінну

Повернення посилання на локальну змінну

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

1. Проблема: «адреса є, а обʼєкта вже немає»

Якби C++ був фільмом жахів, то dangling reference — це мить, коли герой упевнено відчиняє двері, а там порожнеча… та він однаково робить крок. У коді все так само: у вас є посилання або вказівник, тобто «двері», але за ними вже немає живого обʼєкта.

Важливо вловити інтуїцію: тип T* або T& визначає лише те, «як звертатися», але не гарантує, «що там досі є щось живе». Компілятор часто не може довести, що ви повернули «посилання на небіжчика», тому пропускає код, а далі починається лотерея UB.

Dangling pointer / dangling reference — це вказівник або посилання, які вказують на обʼєкт, час життя якого вже завершився, тобто обʼєкт знищено.

Найчастіша причина цього — повернення посилання або вказівника на локальну змінну функції.

2. Як «помирає» локальна змінна під час return

Тут важливо зрозуміти: return — це не просто «передати значення назовні». Це ще й «зачинити крамницю», тобто вийти з блоку функції. А отже — знищити всі локальні змінні, які жили в цій функції.

Уявіть, що функція — це готельний номер. Локальні змінні — його мешканці. Поки ви всередині функції, мешканці на місці. Щойно ви виходите (return), приходять покоївки, тобто деструктори, і всіх виселяють. А ви водночас намагаєтеся повернути назовні «номер кімнати мешканця» й сподіваєтеся, що він там досі є.

Невелика схема — спрощена, але корисна:

flowchart TD
    A[Вхід у функцію] --> B[Створюються локальні змінні]
    B --> C[Працюємо з ними]
    C --> D[return ...]
    D --> E[Вихід із функції]
    E --> F[Локальні змінні знищено]
    F --> G[Зовні залишилося посилання/вказівник на «порожнє місце»]

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

3. Неправильні return: звідки береться dangling

Антиприклад: повернути T* на локальну змінну

Цей приклад здається «логічним» лише доти, доки ви не усвідомите, що життя змінної вже скінчилося.

#include <iostream>

int* bad_ptr() {
    int x = 42;
    return &x; // dangling: x буде знищено під час виходу з функції
}

int main() {
    int* p = bad_ptr();
    std::cout << *p << '\n'; // UB: може вивести 42, а може щось випадкове, а може й упасти
}

Чому компілятор часто не попереджає? Бо з погляду типів усе чесно: &x — це int*. Формально ви справді повертаєте вказівник на int. Але мова не зобовʼязана вас рятувати, якщо ви повертаєте адресу того, що ось-ось зникне.

Найпідступніше в UB те, що воно «іноді працює». Особливо в режимі налагодження. Особливо «на моїй машині». Особливо до релізу.

Антиприклад: повернути T& (посилання) на локальну змінну

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

#include <iostream>

int& bad_ref() {
    int x = 7;
    return x; // dangling: x буде знищено під час виходу з функції
}

int main() {
    int& r = bad_ref();
    std::cout << r << '\n'; // UB
}

Зверніть увагу на психологічну пастку: посилання виглядає «безпечнішим», адже воно не nullptr. Але це хибне відчуття. Посилання може бути dangling так само, як і вказівник.

Чому «я ж використовую одразу після return» не рятує

Дуже поширена надія новачка звучить так: «Ну я ж одразу розіменую, буквально на наступному рядку, встигну!»

На жаль, «одразу після return» — це вже «після виходу з функції». У мить, коли керування повернулося в код, що викликав функцію, локальні змінні вже знищено.

Тобто ось це:

int* p = bad_ptr();
std::cout << *p << '\n';

не означає «я встиг». Це означає: «я читаю через вказівник на вже знищений обʼєкт».

4. Безпечний варіант за замовчуванням: повертаємо за значенням

Тепер хороша новина: у більшості практичних випадків найпростіший і найправильніший контракт — повертати за значенням.

Почнімо з базового прикладу:

#include <iostream>

int good_value() {
    int x = 42;
    return x; // OK: повертаємо копію значення
}

int main() {
    int v = good_value();
    std::cout << v << '\n'; // 42
}

Тут неважливо, що x — локальна змінна. Ви передаєте назовні значення, а не «посилання на мешканця готелю».

І ще один важливий момент для тих, хто вже хвилюється про продуктивність: у сучасному C++ повернення за значенням часто дуже добре оптимізується (copy elision/NRVO). Але навіть без оптимізацій передусім важлива коректність. Програма, яка «швидко падає», від цього не стає швидкою.

5. Коли повертати посилання або вказівник усе-таки можна

Іноді посилання або вказівник — це хороший інтерфейс. Але тоді ви маєте чітко відповісти на запитання: хто є власником даних і як довго вони живуть.

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

Найзрозуміліший безпечний варіант — повернути посилання на те, що передано параметром за посиланням:

#include <string>

const std::string& pick_name(const std::string& a, const std::string& b, bool first) {
    return first ? a : b; // OK: a і b живуть у коді, що викликає функцію (якщо він їх утримує)
}

Тут безпека не магічна, а контрактна: код, який викликає функцію, зобовʼязаний не знищити a/b, доки користується результатом.

І це слушний момент для головної думки: посилання або вказівник — це «запозичення», а запозичення вимагають дисципліни.

6. Шпаргалка: які return зазвичай безпечні

Щоб не тримати все в голові, корисно мати просту шпаргалку. Це не догма, але добра стартова точка:

Що повертаємо Зазвичай безпечно? Зміст (контракт)
T
Так «Ось самостійний результат — він ваш»
std::optional<T>
Так «Результат може бути відсутній, але якщо є — він ваш»
T& / const T&
Іноді «Я даю посилання на чийсь обʼєкт; стежте, щоб власник не зник»
T*
Іноді «Адреса може бути nullptr; власник десь назовні»
T* на локальну змінну
Ні «Я даю адресу того, що ось-ось зникне»

7. Приклад із застосунку TaskBook: типовий баг

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

Нехай модель задачі така:

#include <string>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

А самі задачі лежать у векторі:

#include <vector>

std::vector<Task> tasks;

Тепер типова потреба: «Знайти задачу за id». Новачок іноді пише так:

#include <vector>

const Task& find_task_bad(const std::vector<Task>& tasks, int id) {
    Task temp{ id, "не знайдено", false };
    for (const Task& t : tasks) {
        if (t.id == id) return t;
    }
    return temp; // dangling: temp локальна змінна
}

Це виглядає майже правдоподібно: «якщо не знайшов — поверну тимчасову заглушку». Але temp — локальна змінна. Щойно функція завершується, temp знищується. Посилання лишається, а обʼєкта вже немає.

Це саме той баг, який може проявитися «через тиждень», коли ви трохи зміните налаштування оптимізації компілятора, і раптом рядок "не знайдено" перетвориться на набір випадкових символів.

8. Як виправити: два безпечні варіанти

Виправлення: повернути std::optional<Task> за значенням

Якщо задача може не знайтися, дуже природно виразити це через std::optional.

#include <optional>
#include <vector>

std::optional<Task> find_task_value(const std::vector<Task>& tasks, int id) {
    for (const Task& t : tasks) {
        if (t.id == id) return t; // копія Task
    }
    return std::nullopt;
}

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

Виправлення: повернути вказівник на елемент вектора (nullable)

Іноді копіювати Task не хочеться, наприклад, якщо структура велика. Тоді можна повернути вказівник на елемент усередині переданого контейнера:

#include <vector>

const Task* find_task_ptr(const std::vector<Task>& tasks, int id) {
    for (const Task& t : tasks) {
        if (t.id == id) return &t; // адреса елемента, який живе в tasks
    }
    return nullptr;
}

Такий код уже не повертає адресу локальної змінної — і це головне. Але тут зʼявляється контракт: вказівник валідний, доки живе tasks і доки контейнер не змінюють так, що елементи «переїжджають» (про інвалідацію ви вже чули в темі vector). Для сценарію «прочитати й одразу використати» цього зазвичай достатньо.

Приклад використання:

#include <iostream>

void print_task(const Task* t) {
    if (!t) {
        std::cout << "Задачу не знайдено\n";
        return;
    }
    std::cout << t->id << ": " << t->title << '\n';
}

Зверніть увагу, як nullptr змушує нас чесно обробити відсутність результату. Це не «мінус один як магічний id», а нормальний і читабельний контракт.

9. Як швидко запідозрити dangling за кодом

Є просте правило здорової підозри: якщо ви бачите return &x; або return x;, де x — локальна змінна, а тип значення, яке повертається, — посилання або вказівник, у голові має спрацювати сигнал: «перевір час життя».

Іноді корисно буквально запитувати себе: «Де живе цей обʼєкт?»
Якщо відповідь така: «усередині цієї функції», — то повертати на нього посилання або вказівник не можна.

Якщо хочеться швидше знаходити такі місця, допомагає прийом «перейменування для чесності». Наприклад, замість temp назвати will_die_soon. Жарт, звісно… але інколи це справді рятує: мозок перестає обманюватися «нейтральними» іменами.

10. Типові помилки

Помилка № 1: «Поверну посилання на локальну змінну — адже це швидше, ніж копіювати».
Це одна з найдорожчих «оптимізацій» у житті програміста: ви виграєте гіпотетичні наносекунди, а натомість отримуєте UB. Якщо обʼєкт створюється всередині функції, безпечний варіант за замовчуванням — повернути його за значенням. Сучасний C++ часто оптимізує це краще, ніж ваші ручні спроби «заощадити на копіюванні».

Помилка № 2: «Зроблю локальний обʼєкт, а потім поверну &obj — вказівник же просто адреса».
Так, вказівник — це адреса. Але ця адреса може вказувати на памʼять, де обʼєкта вже немає. Памʼять можуть перевикористати, перезаписати, а інколи вона навіть виглядає «ще схожою на старе значення», що робить баг особливо підступним.

Помилка № 3: «Якщо не знайшли — повернемо посилання на тимчасову заглушку».
Підхід «повернути посилання на temp» виглядає красиво на папері, але ламається через час життя. Для сценарію «результату може не бути» краще використовувати std::optional<T>, T* з nullptr або інший явний спосіб виразити відсутність значення — але не посилання на те, що зникне.

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

Помилка № 5: «Раз я повернув const T&, то вже точно ніхто нічого не зламає».
const захищає лише від зміни через це посилання. Воно взагалі ніяк не захищає від знищення обʼєкта. Можна отримати dangling const T& так само легко, як dangling T&. const — це про «не змінювати», а не про «жити вічно».

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