본문으로 건너뛰기 C++ Copy Elision | When Copies and Moves Disappear

C++ Copy Elision | When Copies and Moves Disappear

C++ Copy Elision | When Copies and Moves Disappear

이 글의 핵심

Copy elision: RVO, NRVO, C++17 guaranteed elision for prvalues, parameter initialization, and why returning local variables with std::move often hurts.

Introduction

Copy elision removes unnecessary copy/move operations. Since C++17, certain prvalue flows must elide copies/moves as specified by the “guaranteed copy elision” rules.

Kinds of elision

RVO — return of prvalue

Widget create() {
    return Widget();  // prvalue
}

NRVO — named local

Widget create() {
    Widget w;
    return w;  // often elided; not always guaranteed like prvalue elision
}

Function arguments

void process(Widget w);
process(Widget());  // often constructs `w` directly

C++17 and prvalues

Returning prvalues and certain initializations must not introduce extra copy/move in the mandated cases—types may even be non-copyable if only moves/prvalue paths are used legally per standard rules.

Common mistakes

std::move on return of local

Widget bad() {
    Widget w;
    return std::move(w);  // often worse: can inhibit NRVO, forces move
}
Widget good() {
    Widget w;
    return w;
}

Multiple return objects

Returning different locals on different paths can prevent NRVO.

Compiler switches

-fno-elide-constructors (GCC/Clang) disables elision for debugging constructor traces.

Detailed examples with assembly

Example 1: Guaranteed RVO (C++17)

struct Widget {
    int data[100];
    Widget() { std::cout << "Constructed\n"; }
    Widget(const Widget&) { std::cout << "Copied\n"; }
    Widget(Widget&&) { std::cout << "Moved\n"; }
};
Widget create() {
    return Widget();  // Prvalue: guaranteed elision
}
int main() {
    Widget w = create();  // Direct construction in w's storage
}

Output:

Constructed

Assembly (GCC 13, -O2):

; create() receives pointer to return slot
; Constructs Widget directly there
; No copy/move instructions

Example 2: NRVO (not guaranteed)

Widget createNamed() {
    Widget w;
    // ... use w ...
    return w;  // Named: NRVO possible but not mandatory
}
int main() {
    Widget w = createNamed();
}

With NRVO (GCC/Clang -O2):

Constructed

Without NRVO (-fno-elide-constructors):

Constructed
Moved

When elision is blocked

1. Conditional returns

Widget conditional(bool flag) {
    Widget w1, w2;
    return flag ? w1 : w2;  // ❌ NRVO blocked: multiple return objects
}

Fix: Return prvalue or use single object:

Widget conditional(bool flag) {
    if (flag) {
        return Widget(1);  // RVO
    }
    return Widget(2);  // RVO
}

2. Returning parameter

Widget process(Widget w) {
    // ... modify w ...
    return w;  // ❌ No elision: w is a parameter, not local
}

Fix: If modification is needed, accept by value is fine. If not, accept by const reference and return new object.

3. Returning member

struct Container {
    Widget widget_;
    
    Widget get() {
        return widget_;  // ❌ No elision: returning member
    }
};

Fix: Return by reference if ownership stays with container:

const Widget& get() const { return widget_; }
Widget& get() { return widget_; }

The std::move anti-pattern

Why std::move on return is usually wrong

Widget bad() {
    Widget w;
    return std::move(w);  // ❌ Blocks NRVO, forces move
}
Widget good() {
    Widget w;
    return w;  // ✅ NRVO or automatic move
}

Benchmark (GCC 13, -O2, 1M iterations):

VersionTime (ms)Operations
return w; (NRVO)0Zero copies/moves
return std::move(w);451M moves
Exception: When returning a different object:
Widget process(Widget w, bool modify) {
    if (modify) {
        Widget result = transform(w);
        return result;  // NRVO possible
    }
    return std::move(w);  // ✅ OK: w is parameter, move is intentional
}

C++17 guaranteed elision rules

What’s guaranteed

// 1. Prvalue initialization
Widget w = Widget();  // ✅ Guaranteed
// 2. Prvalue return
Widget create() { return Widget(); }  // ✅ Guaranteed
Widget w = create();
// 3. Prvalue function argument
void process(Widget w);
process(Widget());  // ✅ Guaranteed
// 4. Temporary materialization
const Widget& ref = Widget();  // ✅ Guaranteed (lifetime extended)

What’s NOT guaranteed (but often happens)

// NRVO: Named return value optimization
Widget create() {
    Widget w;
    return w;  // ⚠️ Not guaranteed, but usually optimized
}
// Multiple return paths
Widget conditional(bool flag) {
    Widget w1, w2;
    return flag ? w1 : w2;  // ⚠️ Not guaranteed
}

Debugging elision

Method 1: Constructor logging

struct Widget {
    static int ctorCount, copyCount, moveCount;
    
    Widget() { ++ctorCount; }
    Widget(const Widget&) { ++copyCount; }
    Widget(Widget&&) noexcept { ++moveCount; }
    
    static void reset() {
        ctorCount = copyCount = moveCount = 0;
    }
    
    static void report() {
        std::cout << "Ctor: " << ctorCount 
                  << ", Copy: " << copyCount 
                  << ", Move: " << moveCount << "\n";
    }
};
int Widget::ctorCount = 0;
int Widget::copyCount = 0;
int Widget::moveCount = 0;
// Test
Widget::reset();
Widget w = create();
Widget::report();  // Should show: Ctor: 1, Copy: 0, Move: 0

Method 2: Compiler flags

# Disable elision to see all copies/moves
g++ -std=c++17 -fno-elide-constructors test.cpp
# Enable verbose optimization reports
g++ -std=c++17 -O2 -fopt-info-vec-optimized test.cpp

Method 3: Static analysis

// Use concepts to enforce move-only types
template<typename T>
concept MoveOnlyElisionSafe = std::movable<T> && !std::copyable<T>;
template<MoveOnlyElisionSafe T>
T create() {
    return T();  // Must use elision or move (no copy available)
}

Interaction with move semantics

Automatic move on return

C++11+ automatically treats returned locals as rvalues:

Widget create() {
    Widget w;
    return w;  // Implicitly treated as return std::move(w) if NRVO fails
}

Priority:

  1. Try NRVO (zero operations)
  2. If NRVO fails, use implicit move
  3. If move unavailable, use copy

Real-world impact

Example: Factory pattern

Before (C++03):

class Factory {
public:
    static std::unique_ptr<Widget> create() {
        return std::unique_ptr<Widget>(new Widget());
    }
};

After (C++17):

class Factory {
public:
    static Widget create() {
        return Widget();  // Guaranteed elision, no heap allocation needed
    }
};

Example: Builder pattern

class WidgetBuilder {
    Widget widget_;
    
public:
    WidgetBuilder& setSize(int size) {
        widget_.size = size;
        return *this;
    }
    
    WidgetBuilder& setColor(Color c) {
        widget_.color = c;
        return *this;
    }
    
    Widget build() && {
        return std::move(widget_);  // ✅ OK: builder is rvalue, move is explicit
    }
};
// Usage
Widget w = WidgetBuilder()
    .setSize(100)
    .setColor(Color::Red)
    .build();  // One construction, one move (or elision)

Compiler support

CompilerC++17 guaranteed elisionNRVO
GCC7+3+ (partial), 5+ (good)
Clang4+3+
MSVC2017 15.5+2015+
Note: NRVO has been supported for decades, but C++17 made prvalue elision mandatory.

Keywords

C++, copy elision, RVO, NRVO, C++17, optimization, move semantics, prvalue, guaranteed elision


Frequently Asked Questions (FAQ)

Q. When would I use this in practice?

A. Copy elision: RVO, NRVO, C++17 guaranteed elision for prvalues, parameter initialization, and why returning local variab…

Q. What should I read before this?

A. Follow the previous article or related articles links at the bottom of each post to learn in sequence. See the C++ series index for the full picture.

Q. Where can I study this more deeply?

A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.


Other articles related to this topic.


Keywords Covered in This Article (Related Search Terms)

This article covers C++, copy-elision, optimization, C++17.