Why it matters
Some functions need to accept a different number of values each time. A print function might print two values or ten. A function that adds numbers might take three values or thirty. Writing a separate function for each count is not practical. C gave programmers a feature called variable arguments, written with ... in the parameter list. C++ keeps that old feature and adds a newer one, variadic templates, which the compiler checks against type rules.
The old C feature has no type checks at all. A mistake in the types of the values compiles without a warning and produces a meaningless result. The newer C++ feature checks each value's type when the program is compiled. A third option, a std::vector or a std::initializer_list, is often the simplest. This lesson shows all three and says when each one fits.
The lesson on generic functions earlier in this module introduced templates. This lesson uses that knowledge and adds the part that accepts a list of arguments.
Words used in this lesson
- Variable arguments: a function that can be called with a different number of arguments each time.
- Template: a pattern that the compiler fills in with real types when it sees a call.
typenamemarks a type name that the caller chooses. - Parameter pack: a list of arguments whose length and types are chosen at each call. It is written as
Values... values. The three dots after the type name mean the parameter stands for many arguments under one name. - Fold expression: a short way to combine every item in a pack with one operator, such as
+. - Compile time: the time when the compiler reads your code. Run time is when the program runs. An error found at compile time is reported before the program starts.
- Undefined behaviour: a situation in which the language standard makes no promise about the result. The program may print a wrong value, crash, or appear to work.
Step 1: the old C way
C-style variable arguments use three helper tools from the <cstdarg> header. va_list holds the extra arguments. va_start begins reading them. va_arg reads the next argument as a type you name. The function must know how many values to read, so the caller passes a count first.
#include <cstdarg>
#include <iostream>
// C-style variable arguments: the caller passes a count, then the values.
int sum_ints(int count, ...) {
va_list args;
va_start(args, count);
int total = 0;
for (int i = 0; i < count; ++i) {
total += va_arg(args, int); // read the next value as an int
}
va_end(args);
return total;
}
int main() {
std::cout << "three ints: " << sum_ints(3, 10, 20, 30) << '\n';
std::cout << "one double given as an int: " << sum_ints(2, 10, 2.5) << '\n';
}Output:
three ints: 60
one double given as an int: -1167498694The first line is right: 10 + 20 + 30 is 60. The second line is wrong, and the reason is the important part. The caller passed 2.5, which is a double, a number stored in a different format from an int. The function does not know that. It asks for an int with va_arg(args, int), and reads the bits stored for 2.5 as if they were an int. The result has no meaning. The number -1167498694 is what this compiler printed on the machine where this lesson was checked. Another compiler may print a different value.
This is undefined behaviour. The compiler gave no warning under -Wall, and the program ran to the end. Nothing in the language checks the types, and nothing checks that the count matches the number of values. If you pass sum_ints(3, 10, 20), the third read takes whatever bits happen to sit after the last argument. Both mistakes look like working code.
For this reason the C style is not used in new C++ code. The standard library still contains some C-style functions, such as std::printf, which read their format at run time. New functions should use one of the other two tools in this lesson.
Step 2: a template with a parameter pack
The C++ version accepts any number of values, and the compiler checks each value's type. Here is a small function that adds any number of values:
#include <iostream>
// Adds any number of values. The compiler writes one version for each set of types used.
template <typename... Values>
auto sum_all(Values... values) {
return (0 + ... + values); // fold expression: 0 + v1 + v2 + ...
}
int main() {
std::cout << sum_all() << '\n';
std::cout << sum_all(10, 20, 30) << '\n';
std::cout << sum_all(2.5, 1, 0.5) << '\n';
}Output:
0
60
4Take the header one piece at a time. template <typename... Values> says the function has a list of type names, called Values. Each call chooses the types. The three dots after typename mean the list can be empty or can hold many entries. Next, Values... values declares the parameter pack. It is one name that stands for all the arguments. The auto return type tells the compiler to work out the return type from the body. For the second call, the values are all int, so the result is an int. For the third call, the values include a double, so the result is a double, and the output is 4.
The line return (0 + ... + values); is a fold expression. It writes the operator once between every item of the pack. For three values a, b and c, it expands to ((0 + a) + b) + c. The 0 at the start is the starting value. It is what makes the empty call sum_all() work. With no values, the result is just 0.
Each distinct set of argument types gets its own generated function. The compiler sees sum_all(10, 20, 30) and writes a version that takes three int values. It sees sum_all(2.5, 1, 0.5) and writes another version that takes a double, an int and another double. Each generated version is checked when it is written. A wrong type in a call stops the compile with a message. This is the opposite of the C version, where a wrong type runs silently.
Step 3: a pack that prints
The pack can be used with other operators. The next example prints each argument with a space between them. It shows two new tools: sizeof..., which counts the items in the pack, and a fold over the comma operator.
#include <cstddef>
#include <iostream>
#include <string>
// Prints each argument, separated by single spaces, then a newline.
template <typename... Args>
void print_line(const Args&... args) {
std::size_t index = 0;
((std::cout << args << (++index < sizeof...(Args) ? " " : "")), ...);
std::cout << '\n';
}
int main() {
print_line("total", 42, 3.5, 'c', true);
print_line(std::string("Ada"), "scored", 92);
print_line();
}Output:
total 42 3.5 c 1
Ada scored 92
The third line of output is empty, because the last call has no arguments. The function still prints the newline at the end.
Here is what the fold does. The pattern (expression, ...) repeats the expression once for each item in the pack, joined by the comma operator. The comma operator runs its left side and then its right side. For each argument, the code writes the argument, then a space unless it is the last one. The counter index goes up once per argument. sizeof...(Args) gives the number of arguments in the pack, and the compiler knows it.
The parameter type is const Args&.... Each argument is passed by const reference, which is the rule from the read-only parameters lesson. No argument is copied, so a long string costs nothing extra to pass. The mixed types need no extra code. A string literal, an int, a double, a char and a bool each go through the same << operator.
The bool value prints as 1. std::cout prints true as 1 unless you set std::boolalpha. This is a fact about output, not about packs, but it is the kind of detail that shows up when you print mixed types.
Step 4: what a pack cannot do
A parameter pack is fixed at each call site. You write the arguments in the call, and the compiler builds the function from them. You cannot take a number from the user, or the length of a file, and turn it into a call with that many arguments. A loop cannot build a pack either. The values must be written out in the call, or come from a container, which Step 5 covers.
The next program shows this limit. It passes a std::vector to sum_all. The author probably meant to add up the readings in the vector.
#include <iostream>
#include <vector>
template <typename... Values>
auto sum_all(Values... values) {
return (0 + ... + values);
}
int main() {
std::vector<int> readings{4, 8, 15};
std::cout << sum_all(readings) << '\n';
}The compiler reports the error below. The full message is long, because it lists the 14 candidate operators the compiler tried. Read the first line first.
pack_misuse.cpp:6:15: error: no match for 'operator+' (operand types are 'int' and 'std::vector<int>')The pack holds one std::vector<int>, not three numbers. The fold tries to add 0 and the vector, and no + exists between them. The fix is to pass the vector as one argument, to a function that takes a vector and loops over it. That is the next step.
A fold without a starting value fails when the pack is empty. This version has no 0:
template <typename... Values>
auto sum_all_no_init(Values... values) {
return (... + values); // no starting value: fails when no arguments are given
}With no arguments, sum_all_no_init() gives:
empty_fold.cpp:3:17: error: fold of empty expansion over operator+
3 | return (... + values); // no starting value: fails when no arguments are givenWith one or more arguments, the same function works. Only the empty pack fails. The starting value 0 in the earlier sum_all was there for this case. A fold without a starting value needs at least one item, so a call with no items is an error at compile time.
Step 5: turning a pack into a container
A pack can be copied into a vector, so the rest of the function works with one object. The braces in std::vector<int>{values...} expand the pack into a list of values, separated by commas:
#include <iostream>
#include <vector>
template <typename... Values>
std::vector<int> make_list(Values... values) {
return std::vector<int>{values...}; // expands to {v1, v2, ...}
}
int main() {
std::vector<int> a = make_list(4, 8, 15);
std::cout << a.size() << ' ' << a[1] << '\n';
std::vector<int> b = make_list();
std::cout << b.size() << '\n';
std::vector<int> c = make_list(2, 1);
std::cout << c.size() << '\n';
}Output:
3 8
0
2The call with no values gives an empty vector, because braces with nothing inside make an empty list. The vector stores int values, so each argument must fit in an int.
If an argument does not fit, the compiler warns. Passing make_list(2.5, 1) in the same program compiles and prints this warning, shown here in part:
pack_to_vector.cpp:6:29: warning: narrowing conversion of ‘values#0’ from ‘double’ to ‘int’ [-Wnarrowing]A narrowing conversion is one that can lose information. The 2.5 becomes 2, and the .5 is thrown away. The compiler warned and still built the program, so the warning is easy to miss. Read every warning that -Wall prints.
Step 6: when a plain array or vector is safer
A pack is the right tool when the list is short, written in the source code, and mixes types. Printing a few labelled values is a good example. For a list whose length is decided while the program runs, a vector is safer. The next program shows a vector and an std::initializer_list side by side:
#include <cstddef>
#include <initializer_list>
#include <iostream>
#include <vector>
// The count comes from the data, so a vector is the right tool.
int sum_vector(const std::vector<int>& values) {
int total = 0;
for (int v : values) {
total += v;
}
return total;
}
// A fixed list of values written at the call site.
int sum_list(std::initializer_list<int> values) {
int total = 0;
for (int v : values) {
total += v;
}
return total;
}
int main() {
std::vector<int> readings;
for (int i = 1; i <= 5; ++i) {
readings.push_back(i * i); // 1, 4, 9, 16, 25: how many is decided at run time
}
std::cout << "vector: " << sum_vector(readings) << '\n';
std::cout << "list: " << sum_list({1, 2, 3}) << '\n';
std::cout << "size: " << readings.size() << '\n';
}Output:
vector: 55
list: 6
size: 5The vector version handles any count. The loop fills it with five squares, and sum_vector does not need to know that number. The same function would work with fifty values read from a file. The std::initializer_list version takes a list written in braces, {1, 2, 3}. It is a clear way to pass a short fixed list of values of one type. It does not create a new function for each count.
Here is a comparison of the three tools:
| Tool | When the count is known | Types checked |
|---|---|---|
C ... | Run time, from the first argument | No |
| Variadic template | Compile time, at each call | Yes, at compile time |
std::vector or std::initializer_list | Run time (vector) or at the call (list) | Yes |
Choose a vector when the values come from input, a file, or a loop. Choose a variadic template when you want a function that takes a mix of types at fixed call sites, such as a print or log function. Avoid the C style in new code, because the compiler cannot check it.
There is one more cost to a variadic template. Each distinct set of argument types makes the compiler write a new copy of the function. A program that calls sum_all with ints, doubles and strings gets three copies. A vector argument gives one function no matter how many values it holds. Template errors are also longer, as the pack misuse example showed.
Common mistakes
Passing a double where C-style code expects an int. The sum_ints(2, 10, 2.5) example above compiles and runs, and prints a meaningless number. The fix is to use a template, which checks the type, or to pass ints only.
Getting the count wrong in C-style code. sum_ints(3, 10, 20) says there are three values but gives only two. The third read takes whatever is in memory next. The result is undefined behaviour.
Folding an empty pack without a starting value. (... + values) fails when the function is called with no arguments, with the message fold of empty expansion over operator+. Use the form (0 + ... + values) when the empty call should be allowed.
Passing a container to a pack. sum_all(readings) treats the whole vector as one value. The compiler reports no match for 'operator+'. Give the function a vector parameter instead.
Expecting a loop to create a pack. A pack is written in the call. A for loop cannot make sum_all receive ten values. Put the values in a vector and pass the vector.
Taking pack arguments by value when they are large. print_line(const Args&... args) avoids copying each argument. A version written as print_line(Args... args) copies every string passed in. For large objects, use const&, as the read-only parameters lesson describes.
Practice
Each exercise is followed by its answer. Try the exercise before you read the answer.
Exercise 1. Write a template count_args that returns how many arguments it was called with. For example, count_args(1, "a", 2.0) returns 3.
Show the answer
template <typename... T>
int count_args(T...) {
return static_cast<int>(sizeof...(T));
}The parameter list has no names, because the function never reads the values. sizeof...(T) gives the length of the pack, which the compiler knows at compile time. The static_cast turns the std::size_t result into the int the function returns.
Exercise 2. Write a template all_true that returns true only when every argument is true in a boolean sense. Make all_true() with no arguments return true.
Show the answer
template <typename... T>
bool all_true(T... values) {
return (true && ... && values);
}The starting value is true. A fold with a starting value works for an empty pack, so all_true() gives true. In a run of this function, all_true(true, 1, 2.0) printed 1, all_true(true, false) printed 0, and all_true() printed 1. Those mean true, false and true.
Exercise 3. The C-style sum_ints function is unsafe when callers mix types. Replace it with a function that takes a std::vector<int> and returns the sum. Then write the code that sums the numbers from 0 to 100 without listing every number.
Show the answer
#include <iostream>
#include <vector>
int sum_vector(const std::vector<int>& values) {
int total = 0;
for (int v : values) {
total += v;
}
return total;
}
int main() {
std::vector<int> numbers;
for (int i = 0; i <= 100; ++i) {
numbers.push_back(i);
}
std::cout << sum_vector(numbers) << '\n'; // 5050
}A loop builds the vector, which a pack could not do. The vector can hold any count, and the compiler checks that each value is an int. The program prints 5050, the sum of 0 to 100.
Exercise 4. Predict the output of this call, then decide whether it compiles: print_line(1, 'x', std::string("end"));, using the print_line function from Step 3.
Show the answer
The output is 1 x end, followed by a newline. The call compiles, because print_line accepts any mix of printable types. The 1 is an int, the 'x' is a char, and the string is a std::string. Each one goes through <<, and the function puts one space between them.
Exercise 5. Explain why the print_line function takes const Args&... args and not Args... args.
Show the answer
With const Args&..., each argument is passed by reference, so no argument is copied. A long string passed to the function is not duplicated. With Args..., each argument would be copied into the function. The output would be the same, but the copies cost time for large arguments. The const also promises that the function only prints the arguments and does not change them.
Summary
- The C-style
...feature reads arguments without any type checks, so a wrong type gives a meaningless number and no warning. - A variadic template takes a parameter pack, and the compiler checks each type at the call.
- A fold expression such as
(0 + ... + values)combines every item in a pack with one operator, and the starting value makes an empty pack legal. - A pack is fixed at each call site, so a count decided while the program runs needs a vector.
- A
std::vectoror astd::initializer_listis often the simplest and safest way to pass a list of values of one type. - Pass pack arguments as
const Args&...when they may be large, so they are not copied.