Search code examples
c++overload-resolutionc++23std-variant

unexpected results in selecting conversion operator overload?


This question is based on previous SO discussion (which was affected by non-compliant compilers). So I'm using the latest c++23 released versions of gcc/clang/MSVC.

Here is a simple test with conversion operator overloading, and passing it to a variant:

struct C
{
    template <typename T> operator T () {return 0.5;}
    operator int () {return 1;}
    operator std::string () { return "";}
};

int main ()
{
    C c;
    std::cout << std::variant<int, double, std::string>{c}.index() << "\n"; // selects template
    std::cout << std::variant<int, std::string, double>{c}.index() << "\n"; // selects template
    // demand to use string, MSVC fails
    std::cout << std::variant<int, std::string, double>{std::in_place_index<1>, c}.index() << "\n";
}

I expected either the code to fail, because of ambiguity, or select type int if variant does index deduction in declaration order. But it always selects the template operator, no matter the order the types are mentioned in variant. Are compilers correct? What's the explanation? Or perhaps there an UB. Also is that a bug in MSVC failing to compile the last line?

Code Explorer Demo is here


Solution

  • You are expecting to call the constructor of the form

    template< class T >
    constexpr variant( T&& t ) noexcept(/*...*/);
    

    to construct the std::variant objects. But that is not happening. This constructor is not viable in any of the cases, because T would be deduced to C. But the constructor is excluded from overload resolution if it would be ambiguous which element type should be constructed from the T, which is now C. That ambiguity is exactly what is happening in your construction: Imaging an overloaded function F with the overloads

    void F(int);
    void F(double);
    void F(std::string);
    

    calling F(std::forward<T>(t)) where T is C would be ill-formed because overload resolution is ambiguous as all three candidates are viable, but with different choices for the conversion function in the argument.

    What happens instead is that you choose the move constructor of std::variant</*...*/>. This constructor is viable, because the templated conversion function of C can be used to convert c to std::variant</*...*/> by deducing T to that type.

    Then, inside the templated conversion function you copy-initialize the std::variant</*...*/> instance returned by the conversion function from the operand of the return statement. The type of the operand is double, so the double element type of the variant will be constructed.


    MSVC is also correct to reject the last case: The constructor that you are attempting to use is excluded from overload resolution unless std::string is constructible from c, i.e. unless a declaration of the form

    std::string _(c);
    

    would be well-formed.

    However, std::string also has multiple constructors, for example:

    basic_string( const basic_string& other );
    basic_string( basic_string&& other ) noexcept;
    explicit basic_string( const Allocator& alloc );
    basic_string( const CharT* s, const Allocator& alloc = Allocator() );
    basic_string( std::initializer_list<CharT> ilist,
              const Allocator& alloc = Allocator() );
    basic_string( std::nullptr_t ) = delete;
    

    The first two are viable with your std::string conversion function. The other ones are viable with your templated conversion function. Therefore, again, you have multiple overloads with conversion sequences that are considered indistinguishable and nothing else in the them making one better candidate than the other. The overload resolution for the construction would be ill-formed.

    There is also one constructor for std::string of the form

    template< class StringViewLike >
    explicit basic_string( const StringViewLike& t,
                           const Allocator& alloc = Allocator() );
    

    This one would be preferred over the others if it is not excluded from overload resolution, because StringViewLike can be deduced to C, so that the conversion sequence will be the identity conversion sequence. The question is then, whether or not the constructor is excluded from the overload resolution. It participates in overload resolution only if std::is_convertible_v<const StringViewLike&, std::basic_string_view<CharT, Traits>> is true and std::is_convertible_v<const StringViewLike&, const CharT*> is false.

    In your case the second condition is certainly true, because the templated conversion operator (uniquely) achieves the conversion. Therefore this constructor does not participate and can't make the construction of the std::string unambiguous.


    GCC and Clang are technically wrong to not diagnose the ill-formed last call as such, however they have a good reason to differ from the standard's requirements.

    In the declaration of the form

    std::string _(c);
    

    where c can be converted to std::string via conversion function there shouldn't be any reason to consider the constructors of std::string, which would, even if the move constructor was chosen unambiguously, always cause a redundant move. Instead the conversion function can directly initialize _ without involvement of a move constructor.

    This is the currently still open CWG issue 2327. GCC and Clang seem to implement a resolution of this issue that would prefer the conversion function over constructors.


    I guess the take-away from this is to avoid conversion functions when possible, especially templated ones.