Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to design classes with constructor taking a std::initializer_list?

When classes have a constructor overload taking a std::initializer_list, this overload will take precedence even if other constructor overloads are seemingly a better match. This problem is described in detail in Sutter's GotW#1, part 2, as well as Meyers' Effective Modern C++, Item 7.

The classic example of where this problem manifests itself is when brace-initializing a std::vector:

std::vector<int> vec{1, 2};
// Is this a vector with elements {1, 2}, or a vector with a single element 2?

Both Sutter and Meyers recommend to avoid class designs where a initializer_list constructor overload can cause ambiguities to the programmer.

Sutter:

Guideline: When you design a class, avoid providing a constructor that ambiguously overloads with an initializer_list constructor, so that users won’t need to use ( ) to reach such a hidden constructor.

Meyers:

As a result, it’s best to design your constructors so that the overload called isn’t affected by whether clients use parentheses or braces. In other words, learn from what is now viewed as an error in the design of the std::vector interface, and design your classes to avoid it.

But neither of them describe how vector should have been designed to avoid the problem!

So here's my question: How should have vector been designed to avoid ambiguities with the initializer_list constructor overload (without losing any features)?

like image 938
Emile Cormier Avatar asked Feb 06 '15 20:02

Emile Cormier


2 Answers

I would take the same approach that the standard took with piecewise_construct in pair or defer_lock in unique_lock: using tags on the constructor:

struct n_copies_of_t { };
constexpr n_copies_of_t n_copies_of{};

template <typename T, typename A = std::allocator<T>>
class vector {
public:
    vector(std::initializer_list<T>);
    vector(n_copies_of_t, size_type, const T& = T(), const A& = A());
    // etc.
};

That way:

std::vector<int> v{10, 20}; // vector of 2 elems
std::vector<int> v2(10, 20); // error - not a valid ctor
std::vector<int> v3(n_copies_of, 10, 20); // 10 elements, all with value 20.

Plus, I always forget if it's 10 elements of value 20 or 20 elements of value 10, so the tag helps clarify that.

like image 115
Barry Avatar answered Oct 19 '22 11:10

Barry


For the sake of completeness, one possible way (and not one I advocate) to avoid the ambiguity is to use static factory methods as a means to isolate the initializer_list constructor from the others.

For example:

template <typename T>
class Container
{
public:
    static Container with(size_t count, const T& value)
    {
        return Container(Tag{}, count, value);
    }

    Container(std::initializer_list<T> list) {/*...*/}

private:
    struct Tag{};
    Container(Tag, size_t count, const T& value) {/*...*/}
};

Usage:

auto c1 = Container<int>::with(1, 2); // Container with the single element '2'
auto c2 = Container<int>{1, 2}; // Container with the elements {1, 2}

This static factory approach is reminiscent of how objects are allocated and initialized in Objective-C. The nested Tag struct is used to ensure that the initializer_list overload is not viable.


Alternatively, the initializer_list constructor can be changed to a static factory method, which allows you to keep the other constructor overloads intact:

template <typename T>
class Container
{
public:
    static Container with(std::initializer_list<T> list)
    {
        return Container(Tag{}, list);
    }

    Container(size_t count, const T& value) {/*...*/}

private:
    struct Tag{};
    Container(Tag, std::initializer_list<T> list) {/*...*/}
};

Usage:

auto c1 = Container<int>{1, 2}; // Container with the single element '2'
auto c2 = Container<int>::with({1, 2}); // Container with the elements {1, 2}
like image 28
Emile Cormier Avatar answered Oct 19 '22 11:10

Emile Cormier