Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

std::optional: Not participating in overload resolution vs. being defined as deleted

I am trying to understand the mechanism behind type traits propagation as described for std::optional in http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0602r4.html. There is a subtle difference in the treatment of copy operations, which shall be conditionally defined as deleted, versus move operations, which shall rather not participate in overload resolution.

What is the reason for that difference, and how would I test the latter? Example:

#include <type_traits>
#include <optional>

struct NonMoveable {
  NonMoveable() = default;
  NonMoveable(NonMoveable const&) = default;
  NonMoveable(NonMoveable&&) = delete;
  NonMoveable& operator=(NonMoveable const&) = default;
  NonMoveable& operator=(NonMoveable&&) = delete;
};

// Inner traits as expected
static_assert(!std::is_move_constructible<NonMoveable>::value);
static_assert(!std::is_move_assignable<NonMoveable>::value);

// The wrapper is moveable, via copy operations participating in
// overload resolution. How to verify that the move operations don't?
static_assert(std::is_move_constructible<std::optional<NonMoveable>>::value);
static_assert(std::is_move_assignable<std::optional<NonMoveable>>::value);

int main(int argc, char* argv[])
{
  NonMoveable a1;
  NonMoveable a2{std::move(a1)}; // Bad, as expected
  std::optional<NonMoveable> b1;
  std::optional<NonMoveable> b2{std::move(b1)}; // Good, see above. But
                                                // useless as a test for
                                                // P0602R4.
  return 0;
}

Bonus Question

Does GCC do the right thing? I have modified the example a bit to get a tiny step closer: https://godbolt.org/z/br1vx1. Here I made the copy operations inaccessible by declaring them private. GCC-10.2 with -std=c++20 now fails the static asserts and complains

error: use of deleted function 'std::optional<NonMoveable>::optional(std::optional<NonMoveable>&&)'

According to Why do C++11-deleted functions participate in overload resolution? the delete is applied after overload resolution, which could indicate that the move constructor participated, despite P0602R4 said it shall not.

On the other hand https://en.cppreference.com/w/cpp/language/overload_resolution states right in the beginning

... If these steps produce more than one candidate function, then overload resolution is performed ...

so overload resolution was skipped, because the move constructor was the only candidate?

like image 470
Michael Steffens Avatar asked Oct 01 '20 10:10

Michael Steffens


1 Answers

std::optional is a red herring; the key is understanding the mechanisms that lead to why these requirements are placed on a library type

There is a subtle difference in the treatment of copy operations, which shall be conditionally defined as deleted, versus move operations, which shall rather not participate in overload resolution.

The under-the-hood requirements (and how to implement these) for std::optional are complex. However, the requirement that move operations shall not participate in overload resolution (for non-movable types) vs copy operations being deleted (for non-copyable types) likely relates to a separate topic;

  • NRVO(1) (a case of copy elision, if you may) and
  • more implicit moves, e.g. choosing move constructors over copy constructors when returning named objects with automatic storage duration from a function.

We can understand this topic by looking at simpler types than std::optional.

(1) Named Returned Value Optimization


TLDR

The move eagerness that is expanding in C++ (more eager moves in C++20) means there are special cases where a move constructor will be chosen over a copy constructor even if the move constructor has been deleted. The only way to avoid these for, say, non-movable types, is to make sure the type has no move constructor nor a move assignment operator, by knowledge of the rule of 5 and what governs whether these are defined implicitly.

The same preference does not exist for copying, and there would be no reasons to favour removing these over deleting them, if this was even possible (2). In other words, the same kinks that exist for favouring moves in overload resolution, that sometimes unexpectedly choose move over copy, is not present for the reverse; copy over move.

(2) There is no such thing as a class without the existence of a copy ctor and a copy assignment operator (although these may be defined as deleted).


Implicit move eagerness

Consider the following types:

struct A {
  A() { std::cout << __PRETTY_FUNCTION__ << "\n"; }
  A(A const &) { std::cout << __PRETTY_FUNCTION__ << "\n"; }
  A &operator=(A const &) {
    std::cout << __PRETTY_FUNCTION__ << "\n";
    return *this;
  }
};

struct B {
  B() { std::cout << __PRETTY_FUNCTION__ << "\n"; }
  B(B const &) { std::cout << __PRETTY_FUNCTION__ << "\n"; }
  B &operator=(B const &) {
    std::cout << __PRETTY_FUNCTION__ << "\n";
    return *this;
  }

  B(B &&) = delete;
  B &operator=(B &&) = delete;
};

Where, A:

  • has user-defined constructors, such that a move constructor and move assigment operator will not be implicitly defined,

and where B, moreover:

  • declares and deletes a move constructor and a move assignment operator; as these are declared and defined as deleted, they will participate in overload resolution.

Before we continue through different standard versions, we define the following functions that we shall return to:

A getA() {
    A a{};
    return a;
}

B getB() {
    B b{};
    return b;
}

C++14

Now, in C++14 an implementation was allowed to implement copy(/move) elision for certain scenarios; citing [class.copy]/31 from N4140 (C++14 + editorial fixes) [emphasis mine]:

When certain criteria are met, an implementation is allowed to omit the copy/move construction of a class object, even if the constructor selected for the copy/move operation and/or the destructor for the object have side effects. [...]

This elision of copy/move operations, called copy elision, is permitted in the following circumstances:

  • in a return statement in a function with a class return type, when the expression is the name of a non-volatile automatic object (other than a function or catch-clause parameter) with the same cv-unqualified type as the function return type, the copy/move operation can be omitted by constructing the automatic object directly into the function's return value
  • [...]

and, from [class.copy]/32 [emphasis mine]:

When the criteria for elision of a copy/move operation are met, but not for an exception-declaration, and the object to be copied is designated by an lvalue, or when the expression in a return statement is a (possibly parenthesized) id-expression that names an object with automatic storage duration declared in the body or parameter-declaration-clause of the innermost enclosing function or lambda-expression, overload resolution to select the constructor for the copy is first performed as if the object were designated by an rvalue.

But [class.temporary]/1 still placed the same semantic restrictions on an elided copy of an object as if the copy had actually not been elided [emphasis mine]

[..] Even when the creation of the temporary object is unevaluated (Clause [expr]) or otherwise avoided ([class.copy]), all the semantic restrictions shall be respected as if the temporary object had been created and later destroyed.

Such that, even for a situation where copy elision was eligible (and performed), the conversion sequences from, say, a named object viable for NRVO, would need to go through overload resolution to find (possibly elided) converting constructors, and would start with a pass through overload resolution as if the object were designated by an rvalue. This means, that in C++14, the following was well-formed

auto aa{getA()};  // OK, and copy most likely elided.

whereas the following was ill-formed:

auto bb{getB()};  // error: use of deleted function 'B::B(B&&)'    

as overload resolution would find the declared but deleted move constructor of B during the step of considering b in return b; in getB() as an rvalue. For A, no move constructor exists, meaning overload resolution for a in return a; in getA() with a as an rvalue would fail, and whereafter overload resolution without this kink would succeed in finding the copy constructor of A (which would subsequently be elided).

C++17

Now, in C++17 copy elision was made stronger by the concept of delayed (end entirely elided) materialization of temporaries, particularly adding [class.temporary]/3 [emphasis mine]:

When an object of class type X is passed to or returned from a function, if each copy constructor, move constructor, and destructor of X is either trivial or deleted, and X has at least one non-deleted copy or move constructor, implementations are permitted to create a temporary object to hold the function parameter or result object. The temporary object is constructed from the function argument or return value, respectively, and the function's parameter or return object is initialized as if by using the non-deleted trivial constructor to copy the temporary (even if that constructor is inaccessible or would not be selected by overload resolution to perform a copy or move of the object).

This makes a large difference, as copy elision can now be performed for getB() without passing through the special rules of return value overload resolution (which previously picked the deleted move constructor), such that both of these are well-formed in C++17:

auto aa(getA());  // OK, copy elided.
auto bb(getB());  // OK, copy elided.

C++20

C++20 implements P1825R0 which allows even more implicit moves, expanding the cases where move construction or assignment may take place even when one would, at first glance, expect a copy construction/assignment (possible elided).


Summary

The quite complex rules with regard to move eagerness (over copying) can have some unexpected effects, and if a designer wants to make sure a type will not run into a corner case where a deleted move constructor or move assignment operator takes precedence in overload resolution over an non-deleted copy constructor or copy assignment operator, it is better to make sure that there are no move ctor/assignment operator available for overload resolution to find (for these cases), as compared to declaring them and defining them as explicitly-deleted. This argument does not apply for the move ctor/copy assignment operator however, as:

  • the standard contains no similar copy-eagerness (over move), and
  • there is no such thing as a class without a copy constructor or copy assignment operator, and removing these from overload resolution is basically(3) only possible in C++20 using requires-clauses.

As an example (and probably a GCC regression bug) of the difficulty of getting these rules right for a non-language lawyer, GCC trunk currently rejects the following program for C++20 (DEMO):

// B as above
B getB() {
    B b{};
    return b;
}

with the error message

 error: use of deleted function 'B::B(B&&)'

In this case, one would expect a copy (possibly elided) to be chosen above in case B had deleted its move ctor. When in doubt, make sure the move ctor and assignment operator don't participate (i.e., exist) in overload resolution.

(3) One could declare a deleted assignment operator overloaded with both const- and ref-qualifiers, say const A& operator=(const A&) const && = delete;, which would very seldom be a viable candidate during overload solution (assignment to const rvalue), and which would guarantee the non-existence of the other non-const and &-qualified overloads that would otherwise likely to be valid overload candidates.

like image 100
dfrib Avatar answered Sep 28 '22 10:09

dfrib