c++conversion-operatorref-qualifier

Conversion operator with ref-qualifers: rvalue ref and const lvalue ref overloads ambiguity


While answering another question, I noticed something peculiar about conversion operators when dealing with ref-qualifiers.

Consider the following code:

using P = std::unique_ptr<int>;

struct A {
    P p;
    operator P() && { return std::move(p); }
    operator P const&() const& { return p; }
};

int main() {
    A a;
    P p;
    p = std::move(a);
}

This does not compile because apparently there is ambiguity when selecting the correct operator overload (see the errors in the demo below). It works if I remove the const qualifier on the second overload like this:

operator P const&() & { return p; }

Also, if instead of an assignment I simply construct a P object, it also works:

P p = std::move(a);

However, this only happens for conversion operators. If instead I write a normal member function that does the exact same thing, it compiles just fine.

struct B {
    P p;
    P get() && { return std::move(p); }
    P const& get() const& { return p; }
};

int main() {
    B b;
    P p;
    p = std::move(b).get();
}

Why is that? What's so special about a conversion operator for these overloads to be ambiguous when they aren't on a normal member function?

Full demo

Side note: if instead of std::unique_ptr<int>, I use a custom non-copyable type, nothing changes.

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

Other side note: for some reason, MSVC doesn't say there is an ambiguity, it just selects the wrong overload. Unless I use my custom non-copyable type, in which case it agrees the call is ambiguous. So I guess it has to do with std::unique_ptr::operator=. Not too important, but if you have any idea why, I'd love to know.


Solution

  • When you write p = std::move(a), it is actually p.operator=(std::move(a)). There are two relevant candidates for this function:

    P& operator=(P&&) noexcept;  // (1) Move assign operator
    P& operator=(const P&);      // (2) Copy assign operator
    

    The fact that the second one is deleted isn't considered yet.

    So, the conversion from a A rvalue to something that P&& would accept in (1) calls the user defined conversion function operator P() &&.

    For the second overload, it would call operator const P&() const&.

    Both of these options are user-defined conversion functions, so neither is better in terms of overload resolution, thus an ambiguity.

    But if you remove the const from operator const P&() /*const*/&, it can no longer be called (since std::move(a) isn't an lvalue, so it can't call an lvalue qualified function if they aren't const qualified), and there is no ambiguity since the other choice is removed.

    You can try it yourself for a function not named operator=:

    struct P {
        void f(const P&) = delete;
        void f(P&&) {}
    };
    
    struct A {
        P p;
        operator P() && { return std::move(p); }
        operator P const&() const& { return p; }
    };
    
    int main() {
        A a;
        P p;
        p.f(std::move(a));
    }
    

    For the case of B, std::move(b).get() is either going to be of type P or const P. The overload resolution is done by the call to get(), and an rvalue ref qualified function wins over a const lvalue ref qualified function for an rvalue, std::move(b).get() is going to be an rvalue P, and there are no ambiguities in choosing the move assign operator.


    For P p = std::move(a);, the overload resolution is a bit different: Since it is initializing a P object, it's looking for the best way to convert from std::move(a).
    The candidates are all the constructors of P + all the conversion functions of a.
    operator P() && beats operator P() const& because the conversion being considered is from std::move(a) to A&& or const A& to call the conversion operator, not to P&& or const P& when matching arguments of a constructor.