A case study in analyzing C++ compiler errors: why is the compiler trying to copy my move-only object?

Recently a coworker came across a C++ compiler error message that seemed baffling, as they sometimes tend to be.

We figured it out together, and in the hope of perhaps saving some others form being stuck on it too long, I thought I’d describe it.

The code pattern that triggers the error can be distilled down into the following:

#include <utility>  // for std::move

// A type that's move-only.
struct MoveOnly {
  MoveOnly() = default;
  
  // copy constructor deleted
  MoveOnly(const MoveOnly&) = delete;  
  
  // move constructor defaulted or defined
  MoveOnly(MoveOnly&&) = default;      
};

// A class with a MoveOnly field.
struct S {
  MoveOnly field;
};

// A function that tries to move objects of type S
// in a few contexts.
void foo() {
  S obj;
  // move it into a lambda
  [obj = std::move(obj)]() {    
    // move it again inside the lambda
    S moved = std::move(obj);   
  }();
}

The error is:

test.cpp: In lambda function:
test.cpp:19:28: error: use of deleted function ‘S::S(const S&)’
   19 |     S moved = std::move(obj);
      |                            ^
test.cpp:11:8: note: ‘S::S(const S&)’ is implicitly deleted because the default definition would be ill-formed:
   11 | struct S {
      |        ^
test.cpp:11:8: error: use of deleted function ‘MoveOnly::MoveOnly(const MoveOnly&)’
test.cpp:6:3: note: declared here
    6 |   MoveOnly(const MoveOnly&) = delete;
      |   ^~~~~~~~

The reason the error is baffling is that we’re trying to move an object, but getting an error about a copy constructor being deleted. The natural reaction is: “Silly compiler. Of course the copy constructor is deleted; that’s by design. Why aren’t you using the move constructor?”

The first thing to remember here is that deleting a function using = delete does not affect overload resolution. Deleted functions are candidates in overload resolution just like non-deleted functions, and if a deleted function is chosen by overload resolution, you get a hard error.

Any time you see an error of the form “use of deleted function F“, it means overload resolution has already determined that F is the best candidate.

In this case, the error suggests S’s copy constructor is a better candidate than S’s move constructor, for the initialization S moved = std::move(obj);. Why might that be?

To reason about the overload resolution process, we need to know the type of the argument, std::move(obj). In turn, to reason about that, we need to know the type of obj.

That’s easy, right? The type of obj is S. It’s right there: S obj;.

Not quite! There are actually two variables named obj here. S obj; declares one in the local scope of foo(), and the capture obj = std::move(obj) declares a second one, which becomes a field of the closure type the compiler generates for the lambda expression. Let’s rename this second variable to make things clearer and avoid the shadowing:

// A function that tries to move objects of type S in a few contexts.
void foo() {
  S obj;
  // move it into a lambda
  [capturedObj = std::move(obj)]() {    
    // move it again inside the lambda
    S moved = std::move(capturedObj);
  }();
}

We can see more clearly now, that in std::move(capturedObj) we are referring to the captured variable, not the original.

So what is the type of capturedObj? Surely, it’s the same as the type of obj, i.e. S?

The type of the closure type’s field is indeed S, but there’s an important subtlety here: by default, the closure type’s call operator is const. The lambda’s body becomes the body of the closure’s call operator, so inside it, since we’re in a const method, the type of capturedObj is const S!

At this point, people usually ask, “If the type of capturedObj is const S, why didn’t I get a different compiler error about trying to std::move() a const object?”

The answer to this is that std::move() is somewhat unfortunately named. It doesn’t actually move the object, it just casts it to a type that will match the move constructor.

Indeed, if we look at the standard library’s implementation of std::move(), it’s something like this:

template <typename T>
typename std::remove_reference<T>::type&& move(T&& t)
{ 
    return static_cast<typename std::remove_reference<T>::type&&>(t); 
}

As you can see, all it does is cast its argument to an rvalue reference type.

So what happens if we call std::move() on a const object? Let’s substitute T = const S into that return type to see what we get: const S&&. It works, we just get an rvalue reference to a const.

Thus, const S&& is the argument type that gets used as the input to choosing a constructor for S, and this is why the copy constructor is chosen (the move constructor is not a match at all, because binding a const S&& argument to an S&& parameter would violate const-correctness).

An interesting question to ask is, why is std::move() written in a way that it compiles when passed a const argument? The answer is that it’s meant to be usable in generic code, where you don’t know the type of the argument, and want it to behave on a best-effort basis: move if it can, otherwise copy. Perhaps there is room in the language for another utility function, std::must_move() or something like that, which only compiles if the argument is actually movable.

Finally, how do we solve our error? The root of our problem is that capturedObj is const because the lambda’s call operator is const. We can get around this by declaring the lambda as mutable:

void foo() {
  S obj;
  [capturedObj = std::move(obj)]() mutable {
    S moved = std::move(capturedObj);
  }();
}

which makes the lambda’s call operator not be const, and all is well.

1 thought on “A case study in analyzing C++ compiler errors: why is the compiler trying to copy my move-only object?

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s