Black Magic using Initializer_list and pack expansion

381 views Asked by At

To expand flexible function parameters, there is a method using std::initializer_list. However I couldn't understand it. Can anyone explain this in an understandable way?

template<typename T, typename... Args>
auto print(T value, Args... args) {
    std::cout << value << std::endl;
    return std::initializer_list<T>{([&] {
        std::cout << args << std::endl;
    }(), value)...};
}
1

There are 1 answers

5
Nicol Bolas On

This is a very confused way of doing things, but C++14 requires that we do something similar. I'll explain the limitations and why it gets done this way (though there are clearer ways to do it).

The goal of this code is to repeatedly print out each parameter it is given on a separate line. Since the function is a variadic template, it needs to use a pack expansion on the expression std::cout << args << std::endl.

Your first thought might be (std::cout << args << std::endl) ...;. However, that's not actually a valid thing you can do in C++14. In fact, you can only perform a pack expansion in the context of a comma-delimited sequence of values in C++14, such as a list of arguments to a function or whatever. You can't just expand a pack as a naked statement.

Well, one place that you can expand a pack into is a braced-init-list (the {} for initializing objects). However, {(std::cout << args << std::endl) ...}; doesn't work either. There's nothing wrong with the expansion; the problem is the braced-init-list itself. Grammatically, a braced-init-list can only appear when you are initializing an object. And a naked {} as a statement doesn't initialize anything. So you can't use it there.

So you have to use the {} to initialize something. The typical idiom for this is to initialize an empty array. For example:

int unused[] = {0, ((std::cout << args << std::endl), 0)...};

The initial 0, is needed in case args is empty; you can't initialize an unsized array with no elements. The trailing , 0 in the expansion expression is part of a comma expression.

In C++, the expression (1, 2) means "evaluate the expression 1, then discard its value, evaluate the expression 2, and use that as the result of the total expression." So using that in the pack expansion means `output one argument, discard the result, and use 0 as the result of the expression. So each pack expansion is just a really fancy way of saying "0", as far as the result of the expression is concerned.

In the end, unused just stores a bunch of zeros. We use a side-effect of the initialization of unused as a way to force C++ to unpack a pack expansion.

In the code you show, the user decided to use the braced-init-list to directly initialize an initializer_list<T>. That's valid as well, and it has the minor benefit of working on an empty args. The problem is that the user then returns this object. This is bad because nobody can actually use that return value.

initializer_lists don't own the objects they reference. A temporary array is created at the cite of the braced-init-list which holds those objects; the initializer_list just points into that array. The temporary array will be destroyed at the end of the return statement, so the caller will get an initializer_list that points into a bunch of destroyed objects. Also, it invokes the copy constructor of T a bunch of times needlessly, since nobody can use the return value.

So this is a confused and bad example of a common idiom. It would be better to remove the return and just make it a void function.

C++17 just lets us do this directly without having to initialize an array:

((std::cout << args << std::endl), ...);

This is a fold expression over the comma operator. It will invoke each sub-expression first to last on the values in args.