Class Initialization and Copy Elision

When you initialize objects, C++ provides optimization opportunities to eliminate unnecessary copies. Understanding these optimizations helps you write efficient code.

Copy elision

Copy elision is a compiler optimization that eliminates unnecessary copy operations:

class Heavy
{
public:
    int data[1000]{};

    Heavy()
    {
        std::cout << "Constructor\n";
    }

    Heavy(const Heavy&)
    {
        std::cout << "Copy constructor\n";
    }
};

Heavy createHeavy()
{
    return Heavy{};  // Might expect a copy here
}

int main()
{
    Heavy obj{ createHeavy() };  // And another copy here

    return 0;
}

Without optimization, you'd expect:

  1. Constructor (create temporary)
  2. Copy constructor (return from function)
  3. Copy constructor (initialize obj)

With copy elision, you might see just:

Constructor

The compiler eliminates both copies!

Return Value Optimization (RVO)

Return Value Optimization eliminates the copy when returning objects:

Point makePoint()
{
    return Point{10, 20};  // Constructed directly in caller's memory
}

int main()
{
    Point p{ makePoint() };  // No copy!

    return 0;
}

The object is constructed directly where it will be used, skipping intermediate copies.

Named Return Value Optimization (NRVO)

NRVO optimizes returns of named objects:

Point makePoint(int x, int y)
{
    Point result{x, y};  // Named local variable
    return result;       // NRVO can eliminate the copy
}

The compiler may construct result directly in the caller's memory space.

Guaranteed vs. optional elision

C++17 and later: Certain elisions are guaranteed:

Thing t{ Thing{} };  // Guaranteed: no copy/move
Thing t = Thing{};   // Guaranteed: no copy/move

Thing f();
Thing t{ f() };      // Guaranteed when f() returns prvalue

Other cases: Elision is optional (compiler-dependent):

Thing makeIt()
{
    Thing local{};
    return local;  // NRVO is optional
}

Direct initialization vs. copy initialization

Direct initialization (preferred):

Point p1{5, 10};        // Direct initialization
Point p2{ makePoint() };  // Direct initialization

Copy initialization (can prevent elision):

Point p3 = Point{5, 10};   // Copy initialization (elision likely)
Point p4 = makePoint();    // Copy initialization (elision likely)

While copy elision makes these equivalent in practice, direct initialization is clearer about intent.

When elision doesn't happen

Returning different objects prevents NRVO:

Point makePoint(bool flag)
{
    Point p1{1, 2};
    Point p2{3, 4};

    if (flag)
        return p1;  // Can't optimize: multiple return paths
    else
        return p2;
}

The compiler doesn't know which object to construct in the caller's memory.

Move semantics as fallback

When elision doesn't occur, C++ uses move construction if available:

class Resource
{
public:
    int* data{};

    Resource(int size)
    {
        data = new int[size]{};
    }

    // Copy constructor
    Resource(const Resource& other)
    {
        std::cout << "Copy\n";
        // ... deep copy ...
    }

    // Move constructor
    Resource(Resource&& other) noexcept
    {
        std::cout << "Move\n";
        data = other.data;
        other.data = nullptr;
    }
};

Resource makeResource()
{
    Resource temp{100};
    return temp;  // Move instead of copy if elision doesn't happen
}

Forcing copies (defeating elision)

Sometimes you explicitly need a copy:

Point makePoint()
{
    Point p{5, 10};
    return Point{p};  // Explicit copy, prevents NRVO
}

But this is rarely necessary.

Practical implications

Don't avoid returning by value:

// Good: Return by value, trust optimization
Data createData()
{
    return Data{params};
}

// Unnecessary: Trying to "optimize" what's already optimized
void createData(Data& out)
{
    out = Data{params};
}

Don't use std::move on return:

// Wrong: Prevents RVO!
Thing makeIt()
{
    Thing local{};
    return std::move(local);  // DON'T DO THIS
}

// Right: Let compiler optimize
Thing makeIt()
{
    Thing local{};
    return local;  // Compiler handles this optimally
}

Observing elision

#include <iostream>

class Observer
{
public:
    int id{};

    Observer(int i) : id{ i }
    {
        std::cout << "Construct " << id << '\n';
    }

    Observer(const Observer& other) : id{ other.id }
    {
        std::cout << "Copy " << id << '\n';
    }

    ~Observer()
    {
        std::cout << "Destruct " << id << '\n';
    }
};

Observer makeObserver()
{
    return Observer{42};
}

int main()
{
    std::cout << "Before\n";
    Observer obj{ makeObserver() };
    std::cout << "After\n";

    return 0;
}

Try compiling with different optimization levels to see elision in action.

Best Practice
Trust the compiler and don't try to outsmart copy elision. Return by value for local objects as the compiler optimizes this. Use direct initialization (Type obj{ value }) instead of copy initialization. Don't use std::move on local returns as it prevents RVO. Provide move constructors for large types as they're used when elision can't happen. Test with optimizations off to ensure your copy constructors are correct.

Summary

Copy elision: A compiler optimization that eliminates unnecessary copy and move operations, constructing objects directly in their final destination instead of creating temporaries.

Return Value Optimization (RVO): Eliminates copies when returning temporary objects from functions by constructing the return value directly in the caller's memory space.

Named Return Value Optimization (NRVO): Optimizes returns of named local variables, though this optimization is optional and compiler-dependent unlike guaranteed RVO for temporaries.

Guaranteed elision (C++17+): Certain forms of copy elision are guaranteed by the C++17 standard, particularly when initializing from temporaries or returning temporary objects.

Direct vs. copy initialization: Direct initialization (Type obj{value}) is clearer about intent, while copy initialization (Type obj = value) may invoke copy elision but is less explicit.

When elision doesn't happen: Multiple return paths or returning different objects prevents NRVO. In these cases, move semantics provide a fallback for efficient returns.

Don't use std::move on returns: Applying std::move to local return values prevents RVO and makes code less efficient, not more. Let the compiler handle optimization.

Copy elision is one of C++'s most important optimizations. Understanding when it applies lets you write natural, readable code that returns objects by value without performance concerns. The compiler handles the optimization automatically, so focus on clear code structure rather than manual optimization attempts.