What Are Move Constructors and Move Assignment?

A move constructor and a move assignment operator are the two special member functions that hand a resource over from one object to another instead of duplicating it. Both take an r-value reference parameter:

SampleTrack(SampleTrack&& other) noexcept;            // move constructor
SampleTrack& operator=(SampleTrack&& other) noexcept; // move assignment

The copy versions take const SampleTrack&, which binds to almost anything. The move versions take SampleTrack&&, which binds only to r-values, and they are not const, because a move has to reach into the source and empty it out.

Key Concept
A copy answers the request "give me one of those as well". A move answers a different request: "take it, I am finished with it". Only the second request lets the destination raid the source, and only an r-value argument makes that request.

Follow the Resource, Not the Object

The clearest way to see what these functions buy you is to stop counting objects and start counting heap blocks. Here is a class that owns one block of double values and announces every block-level event it causes. It has a destructor, a copy constructor and a copy assignment operator, and nothing else.

#include <iostream>

class SampleTrack
{
private:
    double* m_samples{};
    int m_slots{};

public:
    explicit SampleTrack(int slots)
        : m_samples{ new double[slots]{} }
        , m_slots{ slots }
    {
        std::cout << "reserve " << m_slots << '\n';
    }

    ~SampleTrack()
    {
        std::cout << "release " << m_slots << '\n';
        delete[] m_samples;
    }

    SampleTrack(const SampleTrack& other)
        : m_samples{ new double[other.m_slots] }
        , m_slots{ other.m_slots }
    {
        for (int i{ 0 }; i < m_slots; ++i)
            m_samples[i] = other.m_samples[i];

        std::cout << "copy " << m_slots << '\n';
    }

    SampleTrack& operator=(const SampleTrack& other)
    {
        if (this == &other)
            return *this;

        delete[] m_samples;
        m_samples = new double[other.m_slots];
        m_slots = other.m_slots;

        for (int i{ 0 }; i < m_slots; ++i)
            m_samples[i] = other.m_samples[i];

        std::cout << "copy-replace " << m_slots << '\n';
        return *this;
    }
};

SampleTrack rerecord(SampleTrack track)
{
    return track;
}

int main()
{
    SampleTrack session{ 4 };
    SampleTrack archive{ rerecord(session) };

    SampleTrack preview{ 2 };
    preview = SampleTrack{ 6 };

    return 0;
}

Output:

reserve 4
copy 4
copy 4
release 4
reserve 2
reserve 6
copy-replace 6
release 6
release 6
release 4
release 4

Three named objects, and yet the program allocated six blocks. Line by line:

Output line What caused it New block?
reserve 4 session is constructed yes
copy 4 session is copied into rerecord's by-value parameter yes
copy 4 return track; copies the parameter out to initialize archive yes
release 4 the parameter dies once the call expression finishes
reserve 2 preview is constructed yes
reserve 6 the temporary SampleTrack{ 6 } is constructed yes
copy-replace 6 the temporary is deep copied into preview, whose old block is freed first yes
release 6 the temporary dies at the end of the assignment statement
release 6 release 4 release 4 preview, archive and session die in reverse order

Look at the two lines in the middle of that list. The parameter inside rerecord and the temporary SampleTrack{ 6 } are both about to be destroyed. Copying them was pure waste: each allocation exists only long enough for its contents to be duplicated and then thrown away.

Adding the Two Functions

A move does the only sensible thing with a dying object. It takes the pointer, sets the source's pointer to null, and allocates nothing. Add these two functions to SampleTrack and change nothing else:

#include <iostream>

class SampleTrack
{
private:
    double* m_samples{};
    int m_slots{};

public:
    explicit SampleTrack(int slots)
        : m_samples{ new double[slots]{} }
        , m_slots{ slots }
    {
        std::cout << "reserve " << m_slots << '\n';
    }

    ~SampleTrack()
    {
        std::cout << "release " << m_slots << '\n';
        delete[] m_samples;
    }

    SampleTrack(const SampleTrack& other)
        : m_samples{ new double[other.m_slots] }
        , m_slots{ other.m_slots }
    {
        for (int i{ 0 }; i < m_slots; ++i)
            m_samples[i] = other.m_samples[i];

        std::cout << "copy " << m_slots << '\n';
    }

    SampleTrack(SampleTrack&& other) noexcept
        : m_samples{ other.m_samples }
        , m_slots{ other.m_slots }
    {
        other.m_samples = nullptr;
        other.m_slots = 0;

        std::cout << "adopt " << m_slots << '\n';
    }

    SampleTrack& operator=(const SampleTrack& other)
    {
        if (this == &other)
            return *this;

        delete[] m_samples;
        m_samples = new double[other.m_slots];
        m_slots = other.m_slots;

        for (int i{ 0 }; i < m_slots; ++i)
            m_samples[i] = other.m_samples[i];

        std::cout << "copy-replace " << m_slots << '\n';
        return *this;
    }

    SampleTrack& operator=(SampleTrack&& other) noexcept
    {
        if (this == &other)
            return *this;

        delete[] m_samples;

        m_samples = other.m_samples;
        m_slots = other.m_slots;
        other.m_samples = nullptr;
        other.m_slots = 0;

        std::cout << "adopt-replace " << m_slots << '\n';
        return *this;
    }
};

SampleTrack rerecord(SampleTrack track)
{
    return track;
}

int main()
{
    SampleTrack session{ 4 };
    SampleTrack archive{ rerecord(session) };

    SampleTrack preview{ 2 };
    preview = SampleTrack{ 6 };

    return 0;
}

Output:

reserve 4
copy 4
adopt 4
release 0
reserve 2
reserve 6
adopt-replace 6
release 0
release 6
release 4
release 4

Six blocks became four, and main() did not change by a single character. Three things are worth staring at.

The first copy 4 survived. session is a named object that main uses again later, so passing it to rerecord by value still copies. Move semantics never takes anything from an object you might read again.

The second copy 4 became adopt 4. return track; names a function parameter that is about to be destroyed, so the compiler treats it as an r-value and selects the move constructor.

Two release 4 lines became release 0. The moved-from parameter and the moved-from temporary each hold a null pointer and a slot count of zero. Their destructors run exactly as before, find nothing to free, and do nothing. That is the whole trick: the resource was not destroyed early, it changed owner.

Which Function the Compiler Picks

Overload resolution decides, and it decides on the value category of the right-hand expression, not on what the object contains:

Right-hand expression Category Chosen when both pairs exist
a named object, as in preview = session l-value copy assignment
a temporary, as in preview = SampleTrack{ 6 } r-value move assignment
a function's returned prvalue, as in SampleTrack a{ rerecord(b) } r-value nothing runs, the result is constructed in place
a local or parameter named in return treated as an r-value move constructor
any of the above, on a class with no move functions either the copy version, since const& binds to r-values too

The last row matters. A class without move functions is not an error; it just quietly pays for a copy everywhere a move was available. That is precisely what the first program did.

The Obligations of a Correct Move

A move constructor has two jobs and a move assignment operator has four. Take them in order, because the order is what makes the code correct.

Guard against self-assignment. if (this == &other) return *this; comes first in the assignment operator, and it prevents the disaster of the destination freeing the very block it is about to adopt.

Release what the destination already holds. This applies to assignment only. preview owned a block of two slots before preview = SampleTrack{ 6 } ran; without the delete[] m_samples that follows the guard, that block would leak, since nothing else points at it any more.

Take the handle and nothing else. Copy the pointer, copy the size. No allocation, no element loop. This is why a move is cheap regardless of how many samples the block holds.

Leave the source empty but valid. Setting other.m_samples to nullptr is not politeness, it is the whole safety argument. The move constructor below is broken precisely because it skips that step:

    // broken: both objects now point at the same block
    SampleTrack(SampleTrack&& other) noexcept
        : m_samples{ other.m_samples }
        , m_slots{ other.m_slots }
    {
        std::cout << "adopt " << m_slots << '\n';
    }

The source is still a real object with a real destructor. When it dies, it runs delete[] m_samples on the block the destination is now using, and the destination is left with a dangling pointer that it will delete a second time when it dies. Nulling the source turns both of those into delete[] nullptr, which is defined to do nothing.

Danger
A move that does not empty the source produces a double delete, not a leak. The program frequently appears to work until the two destructors happen to run in a build where the allocator notices, at which point it aborts far away from the code that caused it.

Both functions should also be marked noexcept. A move that steals a pointer cannot fail, and saying so lets standard library containers use it: std::vector will only move its elements while reallocating if their move constructor promises not to throw, because a throw halfway through would leave the container with no way back.

Best Practice
Mark move constructors and move assignment operators noexcept. Without it, containers silently fall back to copying your elements.

What the Compiler Writes for You

The compiler generates a move constructor and a move assignment operator on its own, but only when all three of these hold:

  • no copy constructor or copy assignment operator is user-declared
  • no move constructor or move assignment operator is user-declared
  • no destructor is user-declared

Any class that manages a resource has a destructor, so no class that needs a move constructor ever gets one for free. When the compiler does generate them, they perform a memberwise move: each member that has a move operation is moved, and every other member is copied.

Warning
A raw pointer member has no move operation, so a memberwise move copies it. The generated move constructor hands the destination a pointer to the same block and leaves the source pointing at it too, which is the double-delete case above. Pointer members have to be moved by hand.

That interaction between the five special members is why they are treated as a set rather than as five independent choices.

Key Concept
The rule of five: if you define or delete any one of the destructor, copy constructor, copy assignment operator, move constructor or move assignment operator, you should define or delete all five. Declaring one of them suppresses the compiler's generation of others, so a partially specified set almost always means some operation silently does the wrong thing.

Returning by Value

return track; in rerecord moved rather than copied even though track is a named l-value. The standard has a special rule for this: when the expression in a return statement names an automatic object local to the function, including a by-value parameter, overload resolution is performed first as if the object were an r-value. The object is about to be destroyed either way, so there is nothing left to protect.

Elision goes further still. When a function returns a prvalue, as SampleTrack{ 6 } is, no constructor call happens at all, and the object is built directly in the caller's storage. In the second trace above, archive is initialized from rerecord(session) without a single line of output, because there is no copy and no move to report. Moving is the fallback for the cases the compiler cannot elide, not a replacement for elision.

Deleting Copy, Deleting Move

Deleting the copy operations gives you a move-only type, which is the right shape for anything whose resource genuinely cannot be shared or duplicated:

    SampleTrack(const SampleTrack& other) = delete;
    SampleTrack& operator=(const SampleTrack& other) = delete;

    SampleTrack(SampleTrack&& other) noexcept;
    SampleTrack& operator=(SampleTrack&& other) noexcept;

With the copy operations deleted, rerecord(session) no longer compiles, because initializing a by-value parameter from a named object needs the deleted copy constructor. Everything that operates on temporaries still works. This is exactly the design of std::unique_ptr, which the next lessons cover.

Deleting the move operations is the direction that surprises people. A deleted function is still a declared function, and it still takes part in overload resolution. Since a return statement prefers the move constructor, a deleted move constructor wins the vote and then rejects the program. The following will not compile:

#include <iostream>
#include <string>
#include <string_view>

class Preset
{
private:
    std::string m_label{};

public:
    explicit Preset(std::string_view label)
        : m_label{ label }
    {
    }

    Preset(const Preset& other) = default;
    Preset& operator=(const Preset& other) = default;

    Preset(Preset&& other) = delete;
    Preset& operator=(Preset&& other) = delete;

    const std::string& label() const { return m_label; }
};

Preset warmestPreset()
{
    Preset chosen{ "tape saturation" };
    return chosen;
}

int main()
{
    Preset current{ warmestPreset() };
    std::cout << current.label() << '\n';

    return 0;
}

The compiler reports:

s.cpp: In function 'Preset warmestPreset()':
s.cpp:28:12: error: use of deleted function 'Preset::Preset(Preset&&)'
   28 |     return chosen;
      |            ^~~~~~
s.cpp:19:5: note: declared here
   19 |     Preset(Preset&& other) = delete;
      |     ^~~~~~

The copy constructor was perfectly usable and never got a chance. If you want a type that copies but never moves, delete nothing: leave the move operations undeclared and every copy overload will be selected in their place.

Swapping Instead of Assigning (Advanced)

Both move functions can be written as a swap. The destination ends up with the source's resource, which was the goal, and the source ends up holding whatever the destination had, which its destructor will clean up anyway.

The obvious implementation does not work. std::swap is itself implemented with one move construction and two move assignments, so calling std::swap(*this, other) from inside your move assignment operator calls your move assignment operator again, and the recursion only ends when the stack does. Swap the members instead of the objects:

    friend void swapTracks(SampleTrack& first, SampleTrack& second) noexcept
    {
        double* samples{ first.m_samples };
        int slots{ first.m_slots };

        first.m_samples = second.m_samples;
        first.m_slots = second.m_slots;

        second.m_samples = samples;
        second.m_slots = slots;
    }

double* and int have no move operations of their own, so nothing here can call back into SampleTrack.

Measuring the Difference

The trace programs above use four-slot and six-slot blocks, where the difference between copying and adopting is invisible. Scale the block up and it stops being invisible. This program runs one pipeline forty times over a million integers: refill a buffer, produce a doubled version of it, and keep the result. The only difference between the two timed loops is whether the result reaches stored through a copy assignment or a move assignment.

#include <chrono>
#include <iostream>

class Tally
{
private:
    int* m_bins{};
    int m_binCount{};

public:
    explicit Tally(int binCount)
        : m_bins{ new int[binCount] }
        , m_binCount{ binCount }
    {
    }

    ~Tally()
    {
        delete[] m_bins;
    }

    Tally(const Tally& other)
        : m_bins{ new int[other.m_binCount] }
        , m_binCount{ other.m_binCount }
    {
        for (int i{ 0 }; i < m_binCount; ++i)
            m_bins[i] = other.m_bins[i];
    }

    Tally& operator=(const Tally& other)
    {
        if (this == &other)
            return *this;

        delete[] m_bins;
        m_bins = new int[other.m_binCount];
        m_binCount = other.m_binCount;

        for (int i{ 0 }; i < m_binCount; ++i)
            m_bins[i] = other.m_bins[i];

        return *this;
    }

    Tally(Tally&& other) noexcept
        : m_bins{ other.m_bins }
        , m_binCount{ other.m_binCount }
    {
        other.m_bins = nullptr;
        other.m_binCount = 0;
    }

    Tally& operator=(Tally&& other) noexcept
    {
        if (this == &other)
            return *this;

        delete[] m_bins;
        m_bins = other.m_bins;
        m_binCount = other.m_binCount;
        other.m_bins = nullptr;
        other.m_binCount = 0;

        return *this;
    }

    int binCount() const { return m_binCount; }
    int& operator[](int bin) { return m_bins[bin]; }
    int operator[](int bin) const { return m_bins[bin]; }
};

Tally doubled(const Tally& source)
{
    Tally result{ source.binCount() };

    for (int i{ 0 }; i < source.binCount(); ++i)
        result[i] = source[i] * 2;

    return result;
}

int main()
{
    constexpr int binCount{ 1'000'000 };
    constexpr int rounds{ 40 };

    Tally stored{ 1 };
    Tally source{ binCount };

    const auto copyStarted{ std::chrono::steady_clock::now() };
    for (int round{ 0 }; round < rounds; ++round)
    {
        for (int i{ 0 }; i < binCount; ++i)
            source[i] = i + round;

        Tally produced{ doubled(source) };
        stored = produced;
    }
    const std::chrono::duration<double> copySpent{ std::chrono::steady_clock::now() - copyStarted };

    std::cout << "copy pipeline: " << copySpent.count() << " s (last bin " << stored[binCount - 1] << ")\n";

    const auto moveStarted{ std::chrono::steady_clock::now() };
    for (int round{ 0 }; round < rounds; ++round)
    {
        for (int i{ 0 }; i < binCount; ++i)
            source[i] = i + round;

        stored = doubled(source);
    }
    const std::chrono::duration<double> moveSpent{ std::chrono::steady_clock::now() - moveStarted };

    std::cout << "move pipeline: " << moveSpent.count() << " s (last bin " << stored[binCount - 1] << ")\n";

    return 0;
}

Count the writes before you read the numbers. Per round the copying loop writes a million integers into source, a million more into the doubled result, and a million more into stored. The moving loop does the first two and then hands over a pointer. One of every three million-element writes disappears, so roughly a third of the memory traffic disappears with it.

The clock agrees. One run on the platform's executor reported 0.0230 seconds for the copy pipeline against 0.0162 for the move pipeline, a saving just under a third; repeated runs on a shared machine drift between about a fifth and a third. What does not drift is the direction, and the gap widens as the block grows, because the copy is proportional to the element count while the move is a fixed handful of assignments.

Looking Forward

Everything so far has depended on an r-value turning up naturally: a temporary, a returned prvalue, a parameter named in return. Often you have a named object you are genuinely finished with and want to move out of anyway. The next lesson introduces std::move, the cast that lets you say so explicitly, and the rules about what you may do with an object afterwards. The lessons after it apply all of this to std::unique_ptr, a move-only owner built on exactly the pattern in this lesson.

Key Terminology

Term Meaning
Move constructor A constructor taking T&& that initializes a new object by taking the source's resource
Move assignment operator An operator= taking T&& that releases the destination's resource, then takes the source's
Memberwise move What a compiler-generated move does: move each member that can be moved, copy the rest
Moved-from object The source after a move: valid, destructible, and holding nothing you should read
Rule of five If one of the destructor, copy constructor, copy assignment, move constructor or move assignment is defined or deleted, all five should be
Copy elision The compiler constructing a result directly in its destination, so neither a copy nor a move runs

Summary

Move functions take T&&, copy functions take const T&. The move parameter is non-const because the operation has to empty the source, and it binds only to r-values, which is what restricts moving to objects that are about to be destroyed.

A move allocates nothing. It copies a handle and a size, whatever the resource's size, which is why the saving grows with the resource and vanishes for a class that owns only scalars.

Move assignment has four obligations in order: guard against self-assignment, release the destination's existing resource, take the source's handle, and null out the source. Skipping the second leaks; skipping the fourth double deletes.

Value category selects the overload. Named objects copy, temporaries and returned prvalues move, and a class with no move functions copies in every case because const& also binds to r-values.

Objects named in a return statement are moved. Automatic locals and by-value parameters are treated as r-values there, and when the returned expression is a prvalue the compiler elides both the copy and the move entirely.

The compiler only generates move functions for classes that do not need them. Any user-declared destructor, copy operation or move operation suppresses generation, and the generated version copies raw pointer members rather than moving them.

Deleting is not the same as omitting. A deleted move constructor still wins overload resolution for a return statement and then rejects the program, so a copyable but non-movable type is made by leaving the move operations undeclared.

Best Practice
Give every class that owns a resource all five special members, mark the two move functions noexcept, and leave the moved-from object empty rather than merely unused.