c++move-constructorstdoptional

Is implicit conversion to std::optional guaranteed to use move constructor?


The following code block illustrates a difference between returning a std::optional via an implicit conversion (i.e. fn) vs. an explicit construction (i.e. fn2). Specifically, the implicit conversion (of an lvalue) invokes the move constructor, whereas the explicit construction invokes the copy constructor.

How is it legal for the move constructor to be used in this situation? Is the use of the move constructor guaranteed in this situation?

#include <iostream>
#include <optional>

class Foo {
public:
    Foo() { std::cout << __PRETTY_FUNCTION__ << '\n'; }
    ~Foo() { std::cout << __PRETTY_FUNCTION__ << '\n'; }
    Foo(const Foo&) { std::cout << __PRETTY_FUNCTION__ << '\n'; }
    Foo& operator=(const Foo&) { std::cout << __PRETTY_FUNCTION__ << '\n'; return *this; }
    Foo(Foo&&) { std::cout << __PRETTY_FUNCTION__ << '\n'; }
    Foo& operator=(Foo&&) { std::cout << __PRETTY_FUNCTION__ << '\n'; return *this; }

    int data[100];
};

std::optional<Foo> fn(const Foo& in)
{
    Foo out = in;
    return out; // invokes Foo's move constructor
}

std::optional<Foo> fn2(const Foo& in)
{
    Foo out = in;
    return std::optional<Foo>(out); // invokes Foo's copy constructor
}

int main()
{
    fn(Foo{});
    fn2(Foo{});
    return 0;
}

Solution

  • How is it legal for the move constructor to be used in this situation?

    To quote cppreference.com on that topic (not an authoritative source but easier to understand):

    The expression is move-eligible if it is a (possibly parenthesized) identifier expression that names a variable of automatic storage duration whose type is a non-volatile object type (since C++11) or a non-volatile rvalue reference to object type (since C++20)

    and that variable is declared in the body or as a parameter of the innermost enclosing function or lambda expression. (since C++11)

    If the expression is move-eligible, overload resolution to select the constructor to use for initialization of the returned value […] is performed twice: first as if expression were an rvalue expression (thus it may select the move constructor) […]

    So, yes, the move conversion is guaranteed in return out because the compiler first tries to match with an rvalue and there is a matching optional(Foo&&) constructor accepting that. It does not match explicit constructors.

    It cannot work in return std::optional<Foo>(out) because here out is still an lvalue and only the constructed optional is a prvalue. Regular copy elision will still avoid the copy or move of the optional itself. The same is true for return { out } and similar constructs, which are not optimized, either.

    It might be important to note that these rules do not remove const-ness despite the fact that regular copy-elision does. What I mean by that is that in the following code, fn3 does not invoke a copy constructor while fn4 copies.

    Foo fn3(const Foo& in)
    {
        const Foo out = in;
        return out; // copy elision does not care about const
    }
    std::optional<Foo> fn4(const Foo& in)
    {
        const Foo out = in;
        return out;
        // tries to invoke optional(const Foo&&)
        // resolves to optional(const Foo&), not optional(Foo&&)
    }