Home > Software engineering >  GCC fails to select the expected overloaded operator=() when using an empty initializer list
GCC fails to select the expected overloaded operator=() when using an empty initializer list

Time:02-06

I want to design something like a wrapper class for any type T. All that's required is for my class to support assignment of values. So, consider the following simplified code:

template<typename T>
class my_class {
private:
    T m_value;

public:
    explicit my_class() = default;
    my_class(const my_class&) = delete;
    my_class& operator=(const my_class&) = delete;

    template<typename U = T>
    my_class& operator=(U&& u) {
        m_value = std::forward<U>(u);
        std::cout << "Value assigned" << std::endl;
        return *this;
    }
};

As you can see, the constructor is explicit and copying is disabled. So, what I expect is that any value or initializer list can be assigned to my_class.

Now consider this:

my_class<std::string> foo;
foo = {};

What I expect is that my overloaded operator= will be selected with U defaulting to std::string, since I've made sure to disable copying and to make the constructor of my_class explicit. Using both MSVC and Clang, the result is what I expected, with Value assigned being printed. GCC however refuses to compile with the following error:

<source>:25:10: error: use of deleted function 'my_class<T>& my_class<T>::operator=(const my_class<T>&) [with T = std::__cxx11::basic_string<char>]'
   25 |     foo={};
      |          ^
<source>:13:15: note: declared here
   13 |     my_class& operator=(const my_class&) = delete;
      |               ^~~~~~~~

Why does this happen?

EDIT: Some clarification. Using any non empty initializer list works as expected and compiles:

my_class<std::string> foo;
foo = {'a', 'b', 'c'};

If I try to not use templates at all and define an overloaded = like so

my_class operator=(T t) {
    m_value = std::move(t);
    return *this;
}

Then the error GCC produces is this:

<source>:30:10: error: ambiguous overload for 'operator=' (operand types are 'my_class<std::__cxx11::basic_string<char> >' and '<brace-enclosed initializer list>')
   30 |     foo={};
      |          ^
<source>:19:15: note: candidate: 'my_class<T>& my_class<T>::operator=(const my_class<T>&) [with T = std::__cxx11::basic_string<char>]' (deleted)
   19 |     my_class& operator=(const my_class&) = delete;
      |               ^~~~~~~~
<source>:21:15: note: candidate: 'my_class<T>& my_class<T>::operator=(T) [with T = std::__cxx11::basic_string<char>]'
   21 |     my_class& operator=(T u) {
      |

So now the question becomes, why does GCC consider this to be ambiguous, despite the fact that my_class's constructor is explicit? Both MSVC and Clang compile as expected.

CodePudding user response:

GCC is right.

In determining which overload of operator= will be called, the compiler must determine the implicit conversion sequence required to convert the argument (in this case {}) to the parameter type for each of the possible candidates:

my_class& operator=(const my_class&) = delete;  // (1), copy-assignment operator

my_class& operator=(T&& u);  // (2), instantiated from a template

The compiler must determine the implicit conversion sequence for the conversion from {} to const my_class& and the implicit conversion sequence for the conversion from {} to T&&. If one of these implicit conversion sequences is better than the other (when ranked using the rules in [over.ics.rank]) then that overload is chosen, and the program may then be ill-formed if calling that overload is illegal for some reason. If neither implicit conversion sequence is better than the other, then one of the tie-breaker rules might apply, in particular [over.match.best.general]/2.4, which states that a non-template function should be preferred over a function that was instantiated from a function template.

It turns out that the implicit conversion sequence from {} to const my_class& is just as good as the implicit conversion sequence from {} to any default-constructible class type T (e.g. std::string), which makes sense because both of those implicit conversion sequences are just user-defined conversions involving a default constructor. Per [over.ics.rank]/3.3, when we have two different user-defined conversion sequences that involve different constructors, neither one is considered better than the other. As a result, the non-template (1) is chosen. If (2) is replaced by a non-template, then there is no longer any tie-breaker rule that allows (1) to be chosen over (2) or vice versa, so the overload resolution is ambiguous.

However, you are most likely wondering why (1) is even considered a viable candidate, considering that

  • it is deleted, and
  • copy-initializing the argument type from {} type should not be possible, since it would use the explicit default constructor of my_class.

First, it is well-established that deleted functions are still candidates in overload resolution (other than in certain special cases, which do not apply here), but if the deleted function is chosen by the overload resolution process, the program is ill-formed. In this particular case, (1) is a candidate thanks to [over.match.oper]/3.1

If T1 is a complete class type or a class currently being defined, the set of member candidates is the result of a search for operator@ in the scope of T1; otherwise, the set of member candidates is empty.

(1) and (2) are both found by name lookup of operator= in my_class, so they are both candidates even though (1) is deleted.

Second—and this is more subtle—the fact that the implicit conversion sequence from {} to my_class would use an explicit default constructor is supposed to be ignored during overload resolution. Again, however, if the overload that requires this conversion actually ends up being selected, the program will be ill-formed.

This is because explicit constructors interact with list-initialization differently than how they interact with non-list-initialization. In copy-list-initialization, explicit constructors are still candidates, but if an explicit constructor is chosen, the program is ill-formed. (In copy-non-list-initialization, explicit constructors are excluded from the candidate set.) See [over.match.list].

So the implicit conversion sequence for (1) is an implicit conversion sequence that, if it were to actually be carried out, would be ill-formed. But, as I said above, this is supposed to be ignored during overload resolution: the compiler is still supposed to form that implicit conversion sequence and compare it to the implicit conversion sequence for (2). Only if (1) gets chosen and the compiler actually has to compile the implicit conversion sequence, the program is ill-formed.

See Note 1 in [over.best.ics.general]:

Other properties, such as the lifetime, storage class, alignment, accessibility of the argument, whether the argument is a bit-field, and whether a function is deleted, are ignored. So, although an implicit conversion sequence can be defined for a given argument-parameter pair, the conversion from the argument to the parameter might still be ill-formed in the final analysis.

So for example, if an implicit conversion sequence would use a deleted constructor or a deleted conversion function, this is ignored during overload resolution but "the conversion from the argument to the parameter" would end up "ill-formed in the final analysis" if that overload were to be chosen. Although it doesn't specifically say that the same is true for an implicit conversion sequence that would use an explicit constructor when using an explicit constructor would be ill-formed, this is similar enough to using a deleted function that I think it's the right interpretation, since during copy-list-initialization, explicit constructors effectively behave as if they were deleted constructors.

(Note that the tentatively ready resolution for CWG 2525 will change the wording slightly. It's intended to clarify it, although I'm not sure how well it achieves that.)

  • Related