ядро языка — семантика значенийcore language — value semantics

гарантированное исключение копийguaranteed copy elision

Возврат prvalue из функции больше не создаёт временный объект вообще — конструирование происходит напрямую на месте назначения, move/copy-конструктор не обязан существовать. Это часть формальной семантики языка, а не оптимизация компилятора.Returning a prvalue from a function no longer creates a temporary object at all — construction happens directly at the destination, and a move/copy constructor doesn't need to exist. This is part of the language's formal semantics, not a compiler optimization.

часто используется неявноused implicitly all the time частый вопрос: чем это отличается от NRVOcommon question: how this differs from NRVO

Что этоWhat it is

До C++17 выражение вида return Widget(args); формально создавало временный объект, который затем move- или copy-конструировался в объект результата — компиляторы почти всегда пропускали этот шаг оптимизацией (copy elision), но именно как оптимизацию: move/copy-конструктор обязан был существовать и быть доступен, даже если реально не вызывался. C++17 переопределяет саму семантику prvalue (pure rvalue) — значение вида Widget(args) в контексте инициализации не является «временным объектом, который потом надо переместить», а напрямую описывает, как сконструировать объект результата. Никакого временного объекта не возникает — значит, конструктор перемещения/копирования не нужен вообще, ни для вызова, ни для самого факта существования.Before C++17, an expression like return Widget(args); formally created a temporary object which was then move- or copy-constructed into the result object — compilers almost always skipped this step as an optimization (copy elision), but strictly as an optimization: a move/copy constructor still had to exist and be accessible, even if it was never actually called. C++17 redefines the semantics of a prvalue (pure rvalue) itself — a value like Widget(args), in an initialization context, isn't "a temporary object that must later be moved" — it directly describes how to construct the result object. No temporary object is ever created, so a move/copy constructor isn't needed at all, neither for the call nor merely for existing.

до C++17 — оптимизация, конструктор обязателенbefore C++17 — an optimization, a constructor was mandatory
class NoCopyNoMove {
public:
    NoCopyNoMove(int) {}
    NoCopyNoMove(const NoCopyNoMove&) = delete;
    NoCopyNoMove(NoCopyNoMove&&)      = delete;
};

NoCopyNoMove make() {
    return NoCopyNoMove(42);
    // C++14: ошибка компиляции — move-конструктор нужен// C++14: compile error — a move constructor is required
    // формально, даже если оптимизатор его не вызовет// formally, even though the optimizer would never call it
}
C++17 — часть семантики prvalueC++17 — part of prvalue semantics
class NoCopyNoMove {
public:
    NoCopyNoMove(int) {}
    NoCopyNoMove(const NoCopyNoMove&) = delete;
    NoCopyNoMove(NoCopyNoMove&&)      = delete;
};

NoCopyNoMove make() {
    return NoCopyNoMove(42);
    // C++17: компилируется — Widget(42) это prvalue,// C++17: compiles — Widget(42) is a prvalue,
    // конструируется напрямую в объекте результата вызывающего// constructed directly into the caller's result object
}

Зачем ввелиWhy it was introduced

Проблема была не в производительности (оптимизаторы и так почти всегда убирали копию) — проблема была в том, каким кодам это разрешено компилироваться:The problem wasn't performance (optimizers already elided the copy almost every time) — the problem was which code was even allowed to compile:

01

Move-only типы нельзя было безопасно возвращать во всех случаяхMove-only types couldn't be safely returned in every case

Тип без copy-конструктора (только move) обычно можно было вернуть по значению, полагаясь на то, что компилятор либо применит copy elision, либо вызовет move-конструктор. Но тип, у которого нет вообще ни copy, ни move (например, из-за std::atomic-члена или намеренного = delete на оба), формально не мог быть возвращён по значению как prvalue до C++17 — даже если реально ни одна копия никогда бы не произошла.A type without a copy constructor (move-only) could usually be returned by value, relying on the compiler to either elide the copy or call the move constructor. But a type with neither copy nor move at all (say, due to an std::atomic member, or a deliberate = delete on both) formally couldn't be returned by value as a prvalue before C++17 — even though no copy would ever actually happen.

02

Оптимизация была компилятор-зависимой, а требование к коду — нетThe optimization was compiler-dependent, but the code requirement wasn't

Стандарт разрешал (но не обязывал) copy elision в конкретных ситуациях — на практике все мейнстрим-компиляторы применяли её почти всегда, но формально код, полагавшийся на элизию как на гарантию, был непортируем: без гарантии компилятор был вправе честно вызвать move/copy-конструктор, если бы тот существовал, и отказать в компиляции, если нет.The standard permitted (but did not require) copy elision in specific situations — in practice all mainstream compilers applied it almost always, but code that relied on elision as a guarantee was technically non-portable: without a guarantee, a compiler was entitled to genuinely call the move/copy constructor if one existed, and to reject the code if it didn't.

03

Упрощение ментальной модели: prvalue — не объект, а «рецепт» инициализацииSimplifying the mental model: a prvalue isn't an object, it's an initialization "recipe"

P0135 (WG21) переформулировал категории значений так, что prvalue вообще перестаёт быть «временным объектом» до момента материализации (temporary materialization) — материализация происходит только тогда, когда prvalue реально нужно превратить в glvalue (например, чтобы вызвать метод на нём). При инициализации это не требуется — конструирование адресуется напрямую в целевой объект.P0135 (WG21) reformulated the value categories so that a prvalue is no longer "a temporary object" until the moment of temporary materialization — materialization happens only when a prvalue genuinely needs to become a glvalue (say, to call a method on it). Initialization doesn't require this — construction is addressed directly into the target object.

Как это работает под капотомHow it works under the hood

Ключевое разграничение — три разных случая, которые часто путают между собой:The key distinction is between three different cases that are frequently confused with each other:

// (1) return prvalue-выражения напрямую — ГАРАНТИРОВАНО C++17// (1) returning a prvalue expression directly — GUARANTEED in C++17
Widget make1() {
    return Widget(1, 2);  // prvalue — конструируется прямо в объекте результата вызывающего,// a prvalue — constructed straight into the caller's result object,
}                        // move-конструктор Widget не обязан существовать// Widget's move constructor doesn't need to exist

Widget w1 = Widget(3, 4);  // (3) copy elision при инициализации — тоже ГАРАНТИРОВАНО C++17// (3) copy elision on initialization — also GUARANTEED in C++17
                             // ровно один объект w1, никакого временного// exactly one object w1, no temporary involved

// (2) NRVO для именованной локальной переменной — НЕ гарантировано, всё ещё оптимизация// (2) NRVO for a named local variable — NOT guaranteed, still an optimization
Widget make2() {
    Widget w;             // именованный локальный объект// a named local object
    w.configure();
    return w;              // компилятор МОЖЕТ применить NRVO (обычно применяет),// the compiler MAY apply NRVO (usually does),
}                        // но формально это по-прежнему move — конструктор нужен// but formally this is still a move — the constructor is required

Разница между (1) и (2) — не косметическая. В (1) выражение Widget(1, 2) само по себе является prvalue: у него никогда не было отдельного адреса, отдельного от объекта результата, до которого его можно было бы «переместить» — компилятор просто обязан сконструировать объект результата, используя аргументы 1, 2. В (2) w — это полноценный именованный объект с собственным адресом на стеке функции; возврат w формально требует переноса его состояния в объект результата вызывающего, и стандарт лишь разрешает компилятору пропустить этот перенос (NRVO), не обязывая. Отсюда и правило: move-конструктор всё ещё должен существовать и быть доступным для (2), даже если реально не вызывается почти никогда.The difference between (1) and (2) isn't cosmetic. In (1), the expression Widget(1, 2) is itself a prvalue — it never had a separate address, distinct from the result object, from which it could be "moved" — the compiler is simply required to construct the result object using the arguments 1, 2. In (2), w is a full named object with its own address on the function's stack; returning w formally requires transferring its state into the caller's result object, and the standard merely permits the compiler to skip that transfer (NRVO), without requiring it. Hence the rule: the move constructor must still exist and be accessible for case (2), even though it's almost never actually invoked in practice.

ГраблиGotchas

Путаница «гарантированный copy elision = NRVO гарантирован» — частый вопрос на собеседованииConfusing "guaranteed copy elision" with "NRVO is guaranteed" — a common interview question

C++17 гарантирует отсутствие временного объекта только для prvalue (случаи 1 и 3 выше) — возврат выражения-конструктора напрямую или инициализация через временное выражение. NRVO — возврат уже существующей именованной локальной переменной — остаётся опциональной оптимизацией и в C++17, и позже: move-конструктор обязан существовать и быть доступным (не = delete, не приватным), даже если ни один мейнстрим-компилятор его в итоге не вызовет.C++17 guarantees the absence of a temporary only for prvalues (cases 1 and 3 above) — returning a constructor expression directly, or initializing from a temporary expression. NRVO — returning an already-existing named local variable — remains an optional optimization in C++17 and beyond: the move constructor must still exist and be accessible (not = delete, not private), even if no mainstream compiler ever ends up calling it.

Явный move-конструктор всё ещё нужен для NRVO-случаяAn explicit move constructor is still required for the NRVO case

Тип, у которого специально удалён move-конструктор (= delete), не сможет компилироваться в функции вида make2() из примера выше — компилятор не обязан применять NRVO, а значит обязан проверить доступность move-конструктора «на всякий случай». Это единственная причина, по которой move-only и move-deleted типы ведут себя по-разному в паттернах (1)/(3) и (2).A type whose move constructor is deliberately deleted (= delete) won't compile inside a function like make2() from the example above — the compiler isn't required to apply NRVO, so it must check that the move constructor is accessible "just in case." This is the single reason move-only and move-deleted types behave differently between patterns (1)/(3) and (2).

Гарантия не отменяет самого понятия move — она отменяет временный объектThe guarantee doesn't eliminate the concept of move — it eliminates the temporary object

Гарантированный copy elision не значит «move теперь бесплатный всегда» — он значит «в конкретно этих ситуациях move/copy вообще не требуется, потому что объект один, а не два». Move-конструкторы по-прежнему нужны почти везде в реальном коде (контейнеры, NRVO, явный std::move) — эта фича закрывает конкретный узкий, но важный формальный пробел, а не заменяет move-семантику целиком.Guaranteed copy elision doesn't mean "move is now free everywhere" — it means "in these specific situations, move/copy isn't required at all, because there's one object, not two." Move constructors are still needed almost everywhere in real code (containers, NRVO, explicit std::move) — this feature closes one narrow but important formal gap, it doesn't replace move semantics as a whole.