Search code examples
c++variadic-templatesc++17initializer-listtype-deduction

Using std::initializer_list constructor without creating ambiguities?


I have a class called Shape, which can be initialized from any iterable, and a class called Array, which simply contains a Shape. However, I'm getting a compile error I can't explain when I try to initialize an Array:

class Shape
{
public:
    template<typename Iterator>
    Shape(Iterator first, Iterator last)
        : m_shape(first, last) {}

    template <typename Iterable>
    Shape(const Iterable& shape)
        : Shape(shape.begin(), shape.end()) {}

    template<typename T>
    Shape(std::initializer_list<T> shape)
        : Shape(shape.begin(), shape.end()) {}

private:
    std::vector<std::size_t> m_shape;
};

class Array
{
public:
    Array(const Shape& shape)
        : m_shape(shape) {}
private:
    Shape m_shape;
};

int main() {
    Shape s{0};       // ok
    Array a1({1, 2}); // ok
    Array a2({0});    // error
}

The compilation error appears on the second constructor of Shape:

prog.cxx:35:16:   required from here
prog.cxx:14:23: error: request for member ‘begin’ in ‘shape’, which is of non-class type ‘const int’
         : Shape(shape.begin(), shape.end()) {}
                 ~~~~~~^~~~~
prog.cxx:14:38: error: request for member ‘end’ in ‘shape’, which is of non-class type ‘const int’
         : Shape(shape.begin(), shape.end()) {}
                                ~~~~~~^~~

I don't understand what is happening here. Why is the Iterable constructor called instead of the initializer_list<T> constructor? What's the difference between the Shape constructor with {0} and the Array constructor?


Solution

  • The code is ill-formed, but not for the reason gcc claims it is. When you write:

    Array a2({0});
    

    We do overload resolution over all the constructors of Array using the initializer {0}.

    Option #1 is:

    Array(Shape const& );
    

    on which we would recurse into attempting to copy-initialize Shape with {0} which ends up invoking the std::initializer_list<int> constructor template due to preferential treatment of std::initializer_list during list-initialization.

    However, that's just one option. Option #2 is:

    Array(Array&& );
    

    The implicit move constructor. To check if that's a candidate, we see if we can initialize Array with {0}, which basically starts over again. In this next layer, we see if we can initialize Shape with 0 (since we're one layer removed), and we can - that's your accept-all-the-things constructor template. This does involve two user-defined conversion sequences, but that's ok for list-initialization.

    So we have two options:

    • Option #1: {0} --> Shape
    • Option #2: 0 --> Shape --> Array

    Neither is better than the other, so the call is ambiguous.


    The easy fix is to add a constraint to your constructor template such that it actually is a range. This is generally good practice anyway, since you don't want is_constructible_v<Shape, int> to be true...