Discussion: Monsters on the Move

Execute the code to understand the output and gain insights into constructor behavior and value categories.

Run the code

Now, it’s time to execute the code and observe the output.

Press + to interact
#include <iostream>
struct Monster
{
Monster() = default;
Monster(const Monster &other)
{
std::cout << "Monster copied\n";
}
Monster(Monster &&other)
{
std::cout << "Monster moved\n";
}
};
struct Jormungandr : public Monster
{
Jormungandr() = default;
Jormungandr(const Jormungandr &other) : Monster(other)
{
std::cout << "Jormungandr copied\n";
}
Jormungandr(Jormungandr &&other) : Monster(other)
{
std::cout << "Jormungandr moved\n";
}
};
int main()
{
Jormungandr jormungandr1;
Jormungandr jormungandr2{std::move(jormungandr1)};
}

Understanding the output

When we initialize the second Jormungandr with a moved-from object, the move constructor gets called for Jormungandr but the copy constructor for Monster. Why is that?

As we learned in the Will It Move? puzzle, when we wrap an expression in std::move(), it turns into an rvalue. An important detail here, though, is that value categories are about expressions, not objects. So, nothing happens to the jormungandr1 object itself; it’s just that the jormungandr1 expression is an lvalue, but the std::move(jormungandr1) expression is an rvalue.

It would be simpler if it was called “l-expression” and “r-expression” instead of“lvalue” and “rvalue.”

When we then copy-initialize jormungandr2 with the expression Jormungandr jormungandr2{std::move(jormungandr1)}, overload resolution prefers the move constructor of Jormungandr over its copy constructor since the expression std::move(jormungandr1) is an rvalue, which can be moved from. And yes, we read that right; this form is always called copy-initialization, even if the move constructor happens to be called. There’s no such thing as move-initialization.

Fixing the move constructor

Now, let’s turn our attention to the implementation of the move constructor of Jormungandr. You might already have spotted the bug: we forgot to wrap other in a std::move() like so:

Jormungandr(Jormungandr &&other) : Monster(std::move(other))

The other parameter is an rvalue reference. But the other expression, which we pass to the Monster constructor, is an lvalue! Since the other expression is an lvalue, we can’t use the move constructor, and overload resolution picks the copy constructor instead. However, after wrapping it in std::move(), the expression std::move(other) is now an rvalue, and the move constructor is picked.

Press + to interact
#include <iostream>
struct Monster
{
Monster() = default;
Monster(const Monster &other)
{
std::cout << "Monster copied\n";
}
Monster(Monster &&other)
{
std::cout << "Monster moved\n";
}
};
struct Jormungandr : public Monster
{
Jormungandr() = default;
Jormungandr(const Jormungandr &other) : Monster(other)
{
std::cout << "Jormungandr copied\n";
}
Jormungandr(Jormungandr &&other) : Monster(std::move(other))
{
std::cout << "Jormungandr moved\n";
}
};
int main()
{
Jormungandr jormungandr1;
Jormungandr jormungandr2{std::move(jormungandr1)};
}

The output would then instead be the following:

Monster moved
Jormungandr moved

Question

What would happen if Monster was defined like this instead?

struct Monster
{
    Monster() = default;
    Monster(const Monster &other) 
    {
        std::cout << "Monster copied\n";
    }
};
Show Answer

Verify your answer by executing the following code:

Press + to interact
#include <iostream>
struct Monster
{
Monster() = default;
Monster(const Monster &other)
{
std::cout << "Monster copied\n";
}
};
struct Jormungandr : public Monster
{
Jormungandr() = default;
Jormungandr(const Jormungandr &other) : Monster(other)
{
std::cout << "Jormungandr copied\n";
}
Jormungandr(Jormungandr &&other) : Monster(other)
{
std::cout << "Jormungandr moved\n";
}
};
int main()
{
Jormungandr jormungandr1;
Jormungandr jormungandr2{std::move(jormungandr1)};
}

Level up your interview prep. Join Educative to access 70+ hands-on prep courses.