Fixing std::initializer_list

C++11 introduced std::initializer_list. This is a small class used if you want to initialize some container type with a pre-defined set of elements. It allows very convenient syntax just like plain old C arrays have.

Yet it has a couple of problems. This post will talk about them and how they can be fixed.

Throughout this post we’ll use the following class as an example:

class my_vector
{
public:
    // initializes with count elements each having the given value
    my_vector(std::size_t count, int value);

    // initializes from a pointer range
    my_vector(const int* begin, const int* end);

    
];

I’m deliberately limiting the second interface to pointers, as there would be an ambiguity with the first constructor if we made it a template, so I’d needed some SFINAE there and explain that as well. But as I write this explanation I realize that the SFINAE explanation would have been shorter…

Only the constructors are relevant here. This is a simplified version of std::vector. It provides two main constructors: one to initialize it with a given size and one to initialize it with a pointer range.

If we want to create a vector of given size we’ll use it like so:

my_vector vec(5, -1); // -1 -1 -1 -1 -1

If we want to have the contents of some array, we’ll use it like so:

template <std::size_t N>
my_vector copy(int (&array)[N})
{
    return my_vector(array, array + N);
}

The beautiful syntax in the parameter is a reference to an array of given length. It should have been const but I don’t know where to put the const there and I’m too lazy to look it up. That’s why you shouldn’t use C arrays for anything more advanced.

Simple enough.

But what if we want a vector containing the elements 1, 2 and 3? We have to use an array as temporary storage:

int array[] = {1, 2, 3};
my_vector vec(array, array + 3);

That’s not very nice, so that’s why std::initializer_list was created. Simply add a new constructor:

my_vector(std::initializer_list<int> ilist);

And we can use it like so:

// all are equivalent:
my_vector vec1(std::initializer_list<int>{1, 2, 3});
my_vector vec2({1, 2, 3}); // omit the type name
my_vector vec3{1, 2, 3}; // omit the parenthesis
my_vector vec4 = {1, 2, 3};

This allows the same syntax as with array initialization, std::initializer_list just provides a range defined by two random access iterators, so the constructor can be implemented just like the two pointer constructor.

So what’s the problem with std::initializer_list?

There are a few:

Problem 1): Uniform initialization

Let’s first address the elephant in the room:

C++11 also added another feature - uniform initialization. Uniform initialization on its own is also really cool. It allows a single syntax to initialize everything, prevents most vexing parse and narrowing conversions.

But there are cases in C++ where two unrelated features enhance each other, where the combination is greater than the sum of its parts, where the features enhance each other and open many possibilities. And then there are uniform initialization and std::initializer_list.

The problem is: the new uniform initialization syntax is the same as the one for std::initializer_list! Both uses { and } in a constructor. In particular, this conflicts with two of the 4 initializer list syntaxes above, namely vec2 and vec3.

Let’s change the snippet so that we only have two elements:

my_vector vec1(std::initializer_list<int>{1, 2});
my_vector vec2({1, 2});
my_vector vec3{1, 2};
my_vector vec4 = {1, 2};

The syntax for vec3 is the same as calling a constructor with uniform initialization syntax - and it just so happens that there is a constructor taking two integers: the count + value one. So does it call this one and initializes the vector with one 2 or does it call the initializer list constructor and initializes the vector with 1 and 2?

But there is a similar ambiguity for vec2. Do we call the initializer list constructor or do we use uniform initialization to create a temporary my_vector from the count + value constructor and copy that?

The answer is: if there is a std::initializer_list<T> constructor and it uses the brace syntax with some elements that can somehow be converted to T, it will use the initializer list constructor. If the conversion from an element to T is narrowing, it will still use the initializer list constructor but fail to compile.

This behavior can be used to create the infamous uniform initialization gotcha:

my_vector a(1, 2); // 2
my_vector b{1, 2}; // 1 2

So simply switching everything to uniform initialization changes behavior! This means that uniform initialization is not uniform anymore, if there is a std::initializer_list one has to use parenthesis instead.

But the problems don’t end here.

Problem 2) A braced initializer has no type

You have to read the heading three times.

Even though the core language has been amended for std::initializer_list, the expression {1, 2, 3, 4, 5} does not have the type std::initializer_list<int>. So if you have a template function:

template <typename T>
void do_sth(T t);

And you want to call it with an initializer list:

do_sth({1, 2, 3, 4, 5});

You’ll get an error. This makes generic make function more complicated, because that won’t compile:

auto ptr = std::make_unique<my_vector>({1, 2, 3, 4, 5});

If you want to support that, you have to do more work, i.e. creating an additional overload:

template <typename T, typename ... Args>
foo make_foo(std::initializer_list<T> ilist, Args&&... args);

There are many cases throughout the standard library where this has to be done like std::optional’s in-place constructor.

And don’t get me started on the rules for auto deduction of braced initializers!

Problem 3): std::initializer_list access returns const T&

If you have a std::initializier_list constructor it has to copy the elements, it can’t move it because you’ll only get const T& elements. This means that you can’t use std::initializer_list for moveable elements, and even if you pass temporaries, it is less efficient than possible.

Fixing the uniform initialization problem

Important Update: The solution presented here sadly has issues. The temporary array created by the initializer list only lives as long as the list itself. As such, you must be really careful storing them as members as done here.

All problems can be solved by adding an extra layer of indirection - so can this problem.

The main problem with std::initializer_list is probably the quirks regarding uniform initialization. But this can be solved easily: add an extra layer of indirection, i.e. define your own initializer_list:

#include <initializer_list>

template <typename T>
class initializer_list
{
public:
    initializer_list(std::initializer_list<T> ilist)
    : ilist_(ilist) {}

    const T* begin() const noexcept
    {
        return ilist_.begin();
    }

    const T* end() const noexcept
    {
        return ilist_.end();
    }

    std::size_t size() const noexcept
    {
        return ilist_.size();
    }

private:
    std::initializer_list<T> ilist_;
};

This is just a wrapper over std::initializer_list. But if we change the my_vector initializer list constructor so that it uses this type, this fixes the problem:

my_vector a(5, 0);
my_vector b{5, 0};
my_vector c({5, 0});
my_vector d{ {5, 0} }; // need space there, otherwise jekyll expands it...

a will call the count + value constructor as usual. But b will also call it! This is because there is no constructor taking std::initializer_list, so the regular rules apply. c is actually a compilation error because it could either mean c(initializer_list{5, 0}) or c(my_vector{5, 0}). Only d will use the initializer_list constructor, because due to the extra braces the std::initializer_list preference kicks in resolving the ambiguity.

Now we have an initializer list that is not greedy regarding uniform initialization. If you say the syntax with the double braces is ugly, no problem, this is still legal:

my_vector e = {5, 0};

And that’s the syntax I’d want to use when initializing a container with elements - it’s the same as the array one.

You can’t use that syntax unfortunately.

Fixing template deduction

Our new initializer_list hasn’t changed the type of the expression {…} though, it still doesn’t work properly with generic functions. And there really isn’t something we can do about it as we cannot change the type of a literal.

Well, we can make a user-defined literal but there is no version for braced initializers. I recently saw discussion about it, basically allowing {…}_suffix, but it didn’t go much further.

Because we don’t have C++17’s class template argument deduction already, and initializer_list<int>{12, 35, 53} is somewhat ugly we’re left with either a generic make function or extra work for the library implementer.

A make function could look like this:

namespace detail
{
    template <typename T, typename ... Args>
    T get_list_t(int, std::initializer_list<T>);

    struct error
    {
        template <typename ... Args>
        error(Args&&...) {}
    };

    template <typename ... Args>
    error get_list_t(short, error);
}

template <typename ... Args>
auto make_list(Args&&... args)
{
    using value_type = decltype(detail::get_list_t(0, {std::forward<Args>(args)...}));
    static_assert(!std::is_same<value_type, detail::error>::value,
                  "make_list() called without common type");
    return initializer_list<value_type>{std::forward<Args>(args)...};
}

The make_list() function itself just determines the value type for the list and returns it by using the std::initializer_list constructor of initializer_list.

The smart part here is determining the value type, I’ve leveraged that to std::initializer_list itself. The first detail::get_list_t overload when called with 0, {args...} deduces an argument for T and returns a T. If it is not possible to deduce a T (because of conflicting types), the second overload is selected - it has a less priority because it requires converting the int literal 0 to short, a common trick. Its second type is error, which can be created from any set of types, and it returns that.

Now we can just decltype() the return type of the selected function and static_assert() that it is not error.

Allowing move semantics

We still cannot use the initializer_list if we want to move things. While we could easily support a list where all the elements are rvalues, it is by design a homogeneous container and cannot store both lvalue references and rvalue references, so we would not be able to mix it.

We need a second layer of indirection to abstract that away.

So let’s make an initializer_list storing some wrapper over a T, which internally all store a pointer to T, but remembers whether it has been given an rvalue, so you can either call get() or get_rvalue() depending on that information in your code:

template <typename T>
class wrapper
{
public:
    wrapper(const T& value)
    : ptr_(&value), move_(false) {}

    wrapper(T&& value)
    : ptr_(&value), move_(true) {}

    const T& get() const
    {
        return *ptr_;
    }

    T&& get_rvalue() const
    {
        assert(move_);
        // const_cast safe, we know it was not declared const
        return std::move(*const_cast<T*>(ptr_));
    }

    bool is_rvalue() const
    {
        return move_;
    }

private:
    const T* ptr_;
    bool move_;
};

We would use it like so:

template <typename T>
void assign(T& val, const wrapper<T>& ref)
{
    if (ref.is_rvalue())
        val = ref.get_rvalue();
    else
        val = ref.get();
}

template <typename T>
void create(void* mem, const wrapper<T>& ref)
{
    if (ref.is_rvalue())
        ::new(mem) T(ref.get_rvalue());
    else
        ::new(mem) T(ref.get());
}

Then we change our initializer_list implementation so it stores a std::initializer_list<wrapper<T>> instead of T directly, and change the make_list() so that it wraps each argument in a wrapper.

This has no or even less overhead than using std::initializer_list directly and also allows move semantics.

Allowing move semantics - take 2

While the initializer_list using the wrapper works great, the compiler is not able to eliminate the conditional to check whether the current element is an lvalue or rvalue, even though that information is known at compile-time.

And even for std::initializer_list (and inlining) it can’t unroll the loop even though the number of elements is known at compile-time.

Luckily C++11 also added a feature to pass an arbitrary number of objects to a function: variadic templates. If you want a truly generic initializer list, use a variadic template and static_assert() or SFINAE that the type matches; you can even use the same syntax as for std::initializer_list thanks to uniform initialization.

Granted, the implementation is not a simple for loop but you might be able to do it with pack expansion. But the compiler is then able to fully optimize everything.

Conclusion

std::initializer_list does not work nicely with uniform initialization, template arguments or move semantics.

While we can fix all of those issues by simpling wrapping the std::initializer_list, wrapping each T and providing a generic make function, this is still not quite perfect.

However, writing a constructor accepting a variadic number of arguments allows the same syntax and completely bypasses these problems. So the next time you want a std::initializer_list constructor, consider writing one with a variadic number of arguments.

This blog post was written for my old blog design and ported over. If there are any issues, please let me know.