Home > front end >  What does explicitly-defaulted move constructor do?
What does explicitly-defaulted move constructor do?

Time:01-31

I'm new to C and need some help on move constructors. I have some objects that are move-only, every object has different behaviours, but they all have a handle int id, so I tried to model them using inheritance, here's the code

#include <iostream>
#include <vector>

class Base {
  protected:
    int id;

    Base() : id(0) { std::cout << "Base() called " << id << std::endl; }
    virtual ~Base() {}
    Base(const Base&) = delete;
    Base& operator=(const Base&) = delete;
    Base(Base&& other) noexcept = default;
    Base& operator=(Base&& other) noexcept = default;
};

class Foo : public Base {
  public:
    Foo(int id) {
        this->id = id;
        std::cout << "Foo() called " << id << std::endl;
    }
    ~Foo() { std::cout << "~Foo() called " << id << std::endl; }
    
    Foo(const Foo&) = delete;
    Foo& operator=(const Foo&) = delete;
    Foo(Foo&& other) noexcept = default;
    Foo& operator=(Foo&& other) noexcept = default;
};

int main() {
    std::vector<Foo> foos;

    for (int i = 33; i < 35; i  ) {
        auto& foo = foos.emplace_back(i);
    }

    std::cout << "----------------------------" << std::endl;
    return 0;
}

Each derived class has a specific destructor that destroys the object using id (if id is 0 it does nothing), I need to define it for every derived type. In this case, the compiler won't generate implicitly-declared copy/move ctors for me, so I have to explicitly make it move-only to follow the rule of five, but I don't understand what the =default move ctor does.

When the second foo(34) is constructed, vector foos reallocates memory and moves the first foo(33) to the new allocation, however, I saw that both the source and target of this move operation has an id of 33, so after the move, foo(33) is destroyed, leaving an invalid foo object in the vector. In the output below, I also didn't see a third ctor call, so what on earth is foo(33) being swapped with? a null object that somehow has an id of 33? Where does that 33 come from, from copy? but I've explicitly deleted copy ctor.

Base() called 0
Foo() called 33
Base() called 0
Foo() called 34
~Foo() called 33  <---- why 33?
----------------------------
~Foo() called 33
~Foo() called 34

Now if I manually define the move ctor instead:

class Foo : public Base {
  public:
    ......

    // Foo(Foo&& other) noexcept = default;
    // Foo& operator=(Foo&& other) noexcept = default;

    Foo(Foo&& other) noexcept { *this = std::move(other); }
    Foo& operator=(Foo&& other) noexcept {
        if (this != &other) {
            std::swap(id, other.id);
        }
        return *this;
    }
}
Base() called 0
Foo() called 33
Base() called 0
Foo() called 34
Base() called 0  <-- base call
~Foo() called 0  <-- now it's 0
----------------------------
~Foo() called 33
~Foo() called 34

this time it's clearly swapping foo(33) with a base(0) object, after id 0 is destroyed, my foo object is still valid. So what's the difference between the defaulted move ctor and my own move ctor?

As far as I understand, I almost never need to manually define my move ctor body and move assignment operator unless I'm directly allocating memory on the heap. Most of the time I'll only be using raw data types such as int, float, or smart pointers and STL containers that support std::swap natively, so I thought I would be just fine using =default move ctors everywhere and let the compiler does the swap memberwise, which seems to be wrong? Perhaps I should always define my own move ctor for every single class? How can I ensure the swapped object is in a clean null state that can be safely destructed?

CodePudding user response:

What does explicitly-defaulted move constructor do?

It will initialize its member variables with the corresponding member variables in the moved from object after turning them into xvalues. This is the same as if you use std::move in your own implementation:

Base(Base&& other) noexcept : id(std::move(other.id)) {}

Note though that for fundamental types, like int, this is no different from copying the value. If you want the moved from object to have its id set to 0, use std::exchange:

Base(Base&& other) noexcept : id(std::exchange(other.id, 0)) {}

Base& operator=(Base&& other) noexcept {
    if(this != &other) id = std::exchange(other.id, 0);
    return *this;
}

With that, the Foo move constructor and move assignment operator can be defaulted and "do the right thing".


Suggestion: I think Base would benefit from a constructor that can initialize id directly. Example:

Base(int Id) : id(Id) {
    std::cout << "Base() called " << id << std::endl;
}

Base() : Base(0) {} // delegate to the above

Now, the Foo constructor taking an id as an argument could look like this:

Foo(int Id) : Base(Id) {
    std::cout << "Foo() called " << id << std::endl;
}

CodePudding user response:

What does explicitly-defaulted move constructor do?

A compiler generated move constructor moves each sub object.

so after the move, foo(33) is destroyed, leaving an invalid foo object in the vector.

The destruction of the moved-from "foo(33)" has no effect on the moved-to "foo(33)" object that remains in the vector; The shown destructor doesn't do anything that would cause the remaining object to become "invalid".

a null object that somehow has an id of 33?

'Null' is a state of a pointer. It doesn't have a conceptual meaning for a class instance - unless the class models a pointer wrapper, in which case you could consider the class instance to be conceptually null when the pointer that it wraps is null; but that doesn't apply to the example class.

Where does that 33 come from, from copy?

You initialised the int with 33; that's where the 33 comes from. The moved-to object also has 33, because it was moved from the integer that had the value 33.

In the output below, I also didn't see a third ctor call,

You didn't see output from the move constructor, because the defaulted move constructor doesn't output anything.

So what's the difference between the defaulted move ctor and my own move ctor?

The default move constructor initialises all sub objects by moving from each respective sub object of the source. By contrast, your move constructor default initialises the sub objects, and then delegates to the user defined move assignment which swaps id .

Maybe it would help, in order to understand the difference, to see what the default move constructor would look like if written manually:

Foo(Foo&& other) noexcept
    : id{std::move(other.id)} {}

Perhaps I should always define my own move ctor for every single class?

No. Most of the time you should define none of the special member functions. This rule of thumb is know as rule of 0.

But remember that most of the time is not the same as all of the time. Sometimes - although rarely - you do need a user defined destructor and in those cases most of the time you also need user defined copy and move constructors and assignment operators. This is known as rule of 5 (aka rule of 3 before move semantics were introduced in the language).

How can I ensure the swapped object is in a clean null state that can be safely destructed?

The very first step is to specify what "clean null state" means.

If there are states that cannot be destroyed, then typically it's best to specify a class invariant that always holds as a post condition of all member functions, and ensures the safety at all times. Maintaining such invariant typically necessitates user defined special member functions.

  •  Tags:  
  • Related