ядро языка — семантика значенийcore language — value semantics
Возврат 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.
До 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.
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
}
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
}
Проблема была не в производительности (оптимизаторы и так почти всегда убирали копию) — проблема была в том, каким кодам это разрешено компилироваться:The problem wasn't performance (optimizers already elided the copy almost every time) — the problem was which code was even allowed to compile:
Тип без 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.
Стандарт разрешал (но не обязывал) 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.
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.
Ключевое разграничение — три разных случая, которые часто путают между собой: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.
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-конструктор (= 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).
Гарантированный 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.