JavaRush /Курси /C++ SELF /Повернення за значенням і copy elision (NRVO)

Повернення за значенням і copy elision (NRVO)

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

1. Повернення за значенням: чому це нормально

Коли ви лише починаєте, фраза «повернути обʼєкт за значенням» звучить приблизно як «винести холодильник із квартири через двері й не подряпати одвірок». Інтуїтивно це лякає: здається, що буде дорого, що копіювання неминуче і що краще повернути T* або T&, щоб «не тягати важке». На практиці в сучасному C++ такий підхід часто веде не до швидшого, а до менш безпечного коду.

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

Погляньмо на дуже простий приклад без нашого ресурсу — просто щоб швидко пригадати відчуття:

#include <string>

std::string make_title() {
    return "Task Tracker"; // повертаємо за значенням
}

Так, std::string — не маленький тип, але ви постійно повертаєте рядки за значенням, і світ від цього не руйнується. Причина саме в оптимізаціях, про які сьогодні поговоримо: copy elision і NRVO.

2. Що відбувається під час return: copy, move або elision

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

З погляду попередніх лекцій, де ми розглядали типи з copy/move-операціями, під час повернення за значенням можливі три сценарії. Іноді буде копіювання, якщо move недоступний. Іноді — переміщення, якщо copy дорогий, а move є. А іноді компілятор просто створить результат одразу там, де він має опинитися, і не буде ні копії, ні move.

Для орієнтиру тримайте в голові таку табличку:

Ситуація під час return Що робить компілятор Як це виглядає по суті
Потрібен окремий незалежний обʼєкт, а move недоступний Копіювання «Створи ще один такий самий обʼєкт»
Є move, і не хочеться копіювати Переміщення «Передай ресурс, а джерело обнули»
Результат можна створити одразу «в місці призначення» Copy elision (усунення копій/переміщень) «Не створюй проміжних обʼєктів узагалі»

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

Copy elision: «нічого зайвого»

У програмуванні «лінивий» компілятор — це компілятор, який не робить зайвої роботи. Copy elision — це ціла родина оптимізацій, коли компілятор усуває копіювання або переміщення, бо бачить: проміжний обʼєкт не потрібен як окрема сутність.

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

Щоб було наочніше, ось дуже спрощена схема:

flowchart LR
    A[Функція] --> B[Локальний обʼєкт]
    B --> C[Копія або переміщення в результат]
    C --> D[Обʼєкт у коді, що викликає функцію]

А під час copy elision компілятор прагне зробити так:

flowchart LR
    A[Функція] --> D[Обʼєкт у коді, що викликає функцію]
    A -->|конструює одразу| D

Історично в стандарті C++ ця поведінка поступово закріплювалася. Зокрема, у C++17 ухвалили зміни, які гарантують усунення копій у низці сценаріїв. Часто це повʼязують із поняттям «guaranteed copy elision».

Важливо: copy elision — це не «трюк для олімпіадників». Це щоденна практика, завдяки якій повертати за значенням стало справді зручно.

NRVO: повертаємо іменовану локальну змінну

Якщо copy elision загалом — це ідея «не роби проміжних копій», то NRVO (Named Return Value Optimization) — найпоширеніший варіант у реальних програмах: ви створюєте локальну змінну, акуратно її заповнюєте, а потім робите return цієї змінної.

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

Ось класичний патерн:

Buffer make_report() {
    Buffer r{128};
    // заповнюємо r даними...
    return r; // кандидат на NRVO
}

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

Практичне правило просте: пишіть код так, ніби можливі і копія, і move, і elision, але правильність програми від цього не змінюється.

Міні-експеримент: «чому мої copy/move не викликаються?»

Іноді ви додаєте в copy/move-конструктори повідомлення в консоль, щоб побачити, скільки разів «щось копіюється», а потім… майже нічого не бачите. І думаєте: «Компілятор ігнорує мій код?»

Ні, не ігнорує. Він просто не зобовʼязаний створювати проміжні обʼєкти, а отже — не зобовʼязаний викликати ваші copy/move там, де їх вдалося усунути. Це нормальна ситуація. Саме тому не можна проєктувати програму так, щоб «побічні ефекти копіювання» були важливою частиною логіки.

Якщо вам хочеться побачити, що відбувається, можна ненадовго додати дуже простий лог у конструктори. Але ставтеся до нього як до навчального ліхтарика, а не як до точного вимірювального приладу.

Чому return std::move(x) найчастіше не потрібен

Це місце — чемпіон за кількістю «автоматичних помилок зі звички».

Коли ви вже дізналися про std::move, рука так і тягнеться написати:

return std::move(r);

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

Чому так? Тому що std::move(r) — це явний сигнал: «Вважай r джерелом переміщення». А компілятор і без цього вміє розглядати локальний обʼєкт під час return як потенційне джерело move, якщо elision не спрацювала. Але головне ось що: додаючи std::move, ви можете завадити NRVO, бо вираз, що повертається, перестає бути «просто імʼям локальної змінної» і стає «виразом зі std::move».

Майже завжди пишіть return r;, якщо r — локальна змінна результату, і не додавайте std::move «для прискорення».

Якщо колись вам знадобиться свідомо написати std::move у return, ви зробите це не «на автоматі», а тому, що точно розумієте, чому NRVO тут не застосовується і чого саме ви хочете досягти. На рівні курсу це радше рідкісний випадок.

3. Практика на Buffer: повертаємо з функції й не боїмося

Далі продовжимо наш навчальний консольний міні-проєкт: умовний Task Tracker, який друкує звіт. Щоб повʼязати тему з попередніми лекціями, звіт будуватимемо не через std::string (у реальному світі так і варто було б зробити), а через наш навчальний тип, що володіє ресурсом, — Buffer.

Припустімо, що Buffer у нас уже є з лекцій про Rule of Five і move-операції: він зберігає size і data, коректно копіюється (глибоке копіювання) і переміщується (перенесення вказівника + nullptr у джерелі). Тут я покажу лише невеликі фрагменти, потрібні саме для теми повернення за значенням.

Додамо простий метод запису символів. Для простоти — без складної логіки форматування:

#include <cstddef>

struct Buffer {
    std::size_t size{};
    char* data{nullptr};

    // ... конструктори/деструктор/copy/move вже реалізовано раніше

    void set(std::size_t i, char ch) {
        if (i < size) data[i] = ch;
    }
};

Повертаємо локальний результат (кандидат на NRVO)

Тепер напишімо функцію, яка повертає буфер за значенням: вона створює локальний обʼєкт і повертає його. Це один із найтиповіших сценаріїв для NRVO:

#include <cstddef>

Buffer make_banner() {
    Buffer b{6};       // припустімо, виділяє масив char[6]
    b.set(0, 'H');
    b.set(1, 'i');
    b.set(2, '!');
    return b;          // кандидат на NRVO
}

Навіть якщо NRVO раптом не спрацює, у нас є move-конструктор, і компілятор зможе повернути результат через переміщення. А якщо NRVO спрацює, не буде взагалі ні копії, ні move, і це абсолютно нормально.

Copy elision «у чистому вигляді»: return Buffer{...};

Є ще прозоріший варіант: не створювати іменовану змінну, а одразу повернути тимчасовий обʼєкт:

Buffer make_small() {
    return Buffer{3};
}

У такому вигляді компілятор має максимально простий вибір: «Навіщо створювати тимчасовий обʼєкт, щоб потім кудись його переносити? Давай я одразу побудую результат на місці». Саме так ситуація з copy elision зазвичай виглядає найочевидніше на інтуїтивному рівні.

Історично стандарт посилював і уточнював такі випадки усунення копій. У чернетках і редакторських звітах можна натрапити на прямі згадки про роботу над «mandatory copy elision» і додавання перехресних посилань із цього приводу.

Кілька return у функції: чому NRVO іноді не застосовують

Тепер зробімо приклад, схожий на реальний код: у нас є розгалуження. Наприклад, якщо завдань мало — один шаблон звіту, а якщо багато — інший.

Buffer make_report_template(bool many_tasks) {
    Buffer a{4};
    Buffer b{4};

    if (many_tasks) return a;
    return b;
}

Тут тонкість у тому, що функція повертає то a, то b. Через це компілятору складніше, а іноді й неможливо, застосувати NRVO до «однієї конкретної змінної», бо кандидатів кілька. У такій ситуації часто використовують переміщення, якщо воно є, бо це все одно дешево порівняно з глибоким копіюванням.

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

Практичний висновок простий: якщо ви хочете писати в стилі, де NRVO спрацьовує частіше, зазвичай створюють одну змінну результату, заповнюють її залежно від умов, а повертають завжди ту саму.

Наприклад, спрощено:

Buffer make_report_template2(bool many_tasks) {
    Buffer r{4};
    if (many_tasks) r.set(0, 'M');
    else r.set(0, 'S');
    return r; // знову кандидат на NRVO
}

Вбудовуємо в консольний застосунок: друкуємо результат

Тепер поєднаємо все в один невеликий фрагмент застосунку. Нехай Task Tracker, поки що дуже примітивний, друкує банер і повідомляє, що звіт «зібрано».

Для простоти додамо функцію друку: вона виводить перші кілька символів буфера. Це не ідеальна робота з рядками, але зараз нам важлива саме механіка володіння та повернення.

#include <cstddef>
#include <iostream>

void print_prefix(const Buffer& b, std::size_t n) {
    for (std::size_t i = 0; i < n && i < b.size; ++i) {
        std::cout << b.data[i];
    }
    std::cout << '\n';
}

Тепер main:

#include <iostream>

int main() {
    Buffer banner = make_banner();      // повертаємо за значенням
    print_prefix(banner, 3);            // Hi! (і перехід на новий рядок)

    Buffer small = make_small();        // return Buffer{3};
    std::cout << small.size << '\n';    // 3

    return 0;
}

Зверніть увагу на одну важливу деталь дизайну: ми ніде не повертаємо «внутрішні дані» буфера, не повертаємо char* і не повертаємо посилання на локальний обʼєкт. Ми просто повертаємо обʼєкт-власник за значенням — а це саме той сценарій, на який і розрахований сучасний C++.

4. Продуктивність без паніки: пишемо просто

На цьому етапі багатьом хочеться точно знати, чи спрацює NRVO у конкретному рядку. На практиці корисніше виробити іншу звичку: писати код так, щоб він був коректний за будь-якого сценарію — copy, move або elision, — а потім уже розуміти загальну картину.

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

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

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

Помилка № 1: «Я поверну посилання, щоб не копіювати».
Новачки іноді замінюють T make() на const T& make(), бо «посилання ж нічого не копіює». Проблема в тому, що локальна змінна всередині make() знищується під час виходу з функції. Якщо повернути посилання на неї, результат стане висячим посиланням. Навіть якщо «поки що працює», це дуже крихке рішення. Повернення за значенням якраз і розвʼязує цю проблему коректно.

Помилка № 2: звичка писати return std::move(x); «для прискорення».
Звучить логічно: «ну раз move швидкий, то давайте примусово зробимо move». Але в сучасному C++ return x; і так може призвести до переміщення, якщо не спрацює elision, а std::move у return інколи заважає NRVO. Тому за замовчуванням краще писати просто return x;, особливо коли x — локальний результат.

Помилка № 3: намагатися оцінювати логіку програми за кількістю викликів copy/move.
Якщо ви зробили висновок «програма працює правильно, бо я бачив два Move ctor у консолі», то одного дня з іншою версією компілятора або з іншими прапорцями компіляції ви побачите нуль — і почнете панікувати. Copy elision має право усунути ці виклики. Правильність програми не повинна залежати від побічних ефектів копіювання чи переміщення.

Помилка № 4: писати код, який «випадково» заважає оптимізаціям, а потім звинувачувати C++.
Коли код перетворюється на набір мікротрюків — «тут std::move, там std::move, тут ще одна тимчасова змінна заради іншої тимчасової змінної», — компілятору складніше побачити просту картину. У підсумку ви отримуєте і складніший код, і зовсім не факт, що швидший. На рівні сучасного C++ частіше виграє ясний код: один обʼєкт результату, зрозуміле заповнення, return result;.

Помилка № 5: намагатися «відігратися» за рахунок вказівників і ручного керування часом життя.
Після знайомства з темою продуктивності дехто починає повертати new T(...) і думати, що так «точно без копій». Насправді ви отримуєте ручне керування памʼяттю, ризик витоків, неочевидний контракт володіння і потребу в delete. Повернення за значенням плюс коректні copy/move-операції дають і безпеку, і хорошу продуктивність — особливо разом із copy elision.

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