Leveraging Zero-Cost Abstractions in C++: Variadic Templates
C++’s strength mostly comes from the zero-cost abstractions it provides. Stroustrup explains what it means in the C++ papers:
In general, C++ implementations obey the zero-overhead principle: What you don’t use, you don’t pay for [Stroustrup, 1994]. And further: What you do use, you couldn’t hand code any better.
This can be achieved because:
C++ maps directly onto hardware. Its basic types (such as char, int, and double) map directly into memory entities (such as bytes, words, and registers), most arithmetic and logical operations provided by processors are available for those types. Pointers, arrays, and references directly reflect the addressing hardware. There is no “abstract”, “virtual” or mathematical model between the C++ programmer’s expressions and the machine’s facilities. This allows relatively simple and very good code generation.
Variadic Functions in C/C++
It’s been possible to implement variadic functions in C and C++ for a
long time by using the standard <stdarg.h> header.
That’s how you can implement an average function that takes multiple
parameters (can be called by average(3, 1.0, 2.0, 4.0)):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
The syntax is a little complicated but it gets the job done. Complicated syntax
is not the only problem with this kind of implementation. It’s necessary to
have at least one fixed parameter (count in this case). The va_start macro
uses this parameter to figure out the start address of the list of arguments.
As it’s not possible to figure out the size of the va_list by only using its
value, the programmer has to provide the length to the function correctly.
Here, the count parameter is used for this purpose. A call like
average(4, 1.0, 2.0) will read more memory than it should and the compiler
isn’t able to warn you about that. Once the length is figured out we can loop
and successively call va_arg(args, double) to read the next double in the
va_list. This is problematic because we’re paying the cost of a loop
((1.0 + 2.0 + 4.0) / 3 would be the ideal zero-overhead way to calculate the
average of the 3 numbers) and we are trusting that the caller provided only
doubles to the va_list (the compiler won’t warn you if you call
average(3, a, "foo", c)).
printf is a very commonly used variadic function. Compilers and library
implementors use non-standard extensions to implement a printf that
doesn’t suffer from the common problems with variadic functions. It means that
printf("%ld\n", avg); will raise a warning (avg is double):
1
| |
A standard implementation of printf is problematic because it relies on the
format string to figure out the length of the va_list and the types of the
arguments. It’s the programmer’s responsibility to ensure that the right number
of parameters is passed and that the format specifiers match the parameters’ types.
Variadic Templates to the Rescue
C++11 introduces variadic templates. Let’s implement the average function
using variadic templates and see how it compares with the va_list version.
The first advantage: we won’t need a count parameter and we’ll be able to
simply call average(a, b, c) to calculate the average of 3 numbers.
First, we need a function to count the number of arguments passed:
1 2 3 4 5 6 7 8 | |
We have to define count for two cases: the zero-argument case and the
one-or-more-arguments case.
typename... Args is how you define a variadic template. Our implementation of
count allows 0 or more type parameters. A different type for each argument
can be provided when calling.
count<int, const char[2], float>(1, "a", 2.0) or simply
count(1, "a", 2.0) will return 3.
When count(a, b, c) is called, the n receives a (consequently T becomes
the type of a) and args of type Args... receives a, b then it
effectively returns 1 + count(b, c). That’s what the 1 + count(args...)
expression expands to.
Similarly, we can define a variadic template function that sums all parameters:
1 2 3 4 5 6 7 8 | |
The sum of zero numbers is 0.0 and the sum of a number n and args numbers
is n + sum(args...).
Now we can easily define the average function using sum and count:
1 2 3 4 | |
This function takes a variadic number of arguments and divides their sum
(n + sum(args...)) by their number (1 + count(args...)).
Defining it like double average(Args... args) { return sum(args...) / count(args...); }
would be problematic as it would lead to division by zero and it doesn’t make
sense to calculate the average of zero numbers after all.
Variadic Templates Allow the C++ Compiler to Complain About Problems
Let’s abuse the function and see how the compiler reacts. Calling average with
no arguments (average()) should not work:
1 2 3 | |
Actually, calling with one argument will work.
Passing a non-numeric value (average(a, "foo")) won’t work:
1 2 3 | |
You can’t sum const char* and double, thus average(a, "foo") will fail.
Variadic Templates Allow the C++ Compiler to Optimize the Generated Code
The generated code is very efficient. Calling average(a, b, c) will generate
the same assembly code (a + b + c) / 3 would.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | |