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 — это как раз семейство оптимизаций, когда компилятор устраняет (elide) копирование/перемещение, потому что видит: промежуточный объект не нужен как отдельная сущность.
Самая полезная модель для новичка такая: объект результата может быть создан сразу в памяти вызывающего кода, а функция как будто «строит» этот объект прямо там. То есть не «создали локальный → скопировали наружу», а «сразу построили наружный».
Чтобы было визуально, вот схема (очень упрощённо):
flowchart LR
A[Функция] --> B[Локальный объект]
B --> C[Копия/Move в результат]
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, корректно копируется (deep copy) и перемещается (перенос указателя + 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 к «одной конкретной переменной», потому что кандидатов несколько. В такой ситуации часто будет использовано перемещение (если оно есть), потому что это всё равно дёшево по сравнению с deep copy.
И вот это место важно понять без мистики: NRVO не “ломается” — просто шаблон кода другой. Компилятор не обязан угадывать ваши намерения. Он видит две разные переменные, и оптимизация становится менее очевидной.
Практическое следствие простое: если вы хотите максимально “NRVO‑friendly” стиль, чаще пишут так: создают одну переменную результата и заполняют её в зависимости от условий, а возвращают одну и ту же.
Например (упрощённо):
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), а затем понимать общую картину.
Если функция возвращает объект‑владельца по значению, у вас должно быть выполнено два условия. Во-первых, сам тип должен быть корректно реализован: deep copy там, где нужна копируемость, 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.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ