Trying to outsmart a compiler defeats much of the purpose of using one.
-- B. Kernighan & Plauger, "The Elements of Programming Style"
Introduction
One of many wierd constructs in C++ is the ellipsis construct (...). That's a way to specify that there might come a variable amount of extra parameters after the one that you give. Underneath there is no real magic, the parameters that you specify are pushed onto the stack and then inside the function you can traverse the stack. There are some catches with this though. In this little article we'll explore how the ... works and what not to do.
Calling conventions
So in order for the stack traversal to work functions with ellipses are automatically flagged for standard C calling convention and promotion rules. Remember, the C calling convention is that the arguments are pushed onto the stack right to left and the caller cleans up the stack. This might vary though for different platforms, the standard C calling convention on PPC is not that heavy on stack usage as the Intel platform (one might even say it's a proper processor). The fact that the caller cleans up makes it possible to have a variable number for arguments passed down since at the point of calling the function we know how many arguments there are. The order right to left makes less difference, as the va_* family of macros hides the stack traversal anyways for us.
Argument promotions
For all the arguments that are passed down inside the ... the compiler perform the standard conversion sequences:
- lvalue to rvalue
- array to pointer
- function to pointer
After that all the integral types are subject to integral promotions (basically turning them into ints) and the floating point values are subject to their promotions (turning them into doubles). The standard promotion rules are a leftover from the C days, where everything were converted into doubles and ints (and pointers) just so that the compiler might be easier to write. The conversion situation is interesting if we for a moment look at the implementation of the va_arg macros. For example the runtime that ships with visual studio has the following definition:
The macro _INTSIZEOF just aligns the size of the incoming type with the size of int, which on the Win32 platform is 4. Consider what happens if you push two floats on the stack and then use this code sequence to read them:
This will not print 1 and 2. Why? Well, the va_arg macro is really not that intelligent, the programmer really needs to make sure that he/she knows what's happening. The only thing that the va_arg macro guards against is the integral promotions from short and char to int and that the stack is correctly incremented in those cases. But for float, the promotion is to a double, which is 8 bytes to the float's 4 bytes. In order for this to work we need to make sure that the
Some practical results trying to push non int/float/void*
Enough talk about the standard I hear you say. Show us some code! Well, here you go. A very simple program that demonstrates pushing a whole object on the stack to an ... function.
Let's disassemble the ouput from the compiler and see what's really happening under the hood when we call bar():
Notice how the whole Foo object is pushed onto the stack. We could potentially access the whole Foo object inside the bar() function by simply extract the pointer to the first element and then cast it to a Foo*. That requires poking inside the va_arg macros though on your platform and if you switch platform (e.g. PPC below) you're in a whole world of pain :) Also note that since the va_arg macro knows about the agrument promotion rules, if we had packed the Foo structure tightly and made the "max" variable 1 bytes in size, we could not ever access the str part of Foo since the it would be misaligned to the assumed at least 4 bytes for each argument.
You might notice that the calling convention on the PPC stuffs things in registers as opposed on the stack. But wait, how can I get the things out of the Foo object? Not without intimate knowledge of the layout of the Foo object you can't. The neat trick we had on x86 that just takes the pointer to the first element as the Foo object (ok, that was a little fishy) breaks down here. We can't take a reference to a register. And things are already stuffed into registers by the caller.
I think the most common case here that illustrates the above behaviour of passing an object to a ... function is passing strings to printf. As a dutiful C++ programmer you've moved away from const char* and embraced the glory that is std::string, right? Well, that might put you up that brown creek with no paddle. Look at the following code:
Doesn't look to bad, right? No warnings, compiles cleanly and when you run it. Bam. Not the expected behaviour. Why? The whole std::string object was pushed onto the stack, when the format string told printf to expect a pointer to a string. The first member in std::string is interpreted as a pointer and off we go (on the standard library that ships with visual 8 it's actually an iterator that's usually set to NULL). For different STL implementations, the first member of std::string is different things, but very rarely the actual pointer to the string. The correct version looks like:
So what can we do?
The only safe types to push down a function with ellipses are integral types, floating point types and pointers. Everything else, don't do it. And if you're writing custom handlers for ... functions, make sure that you understand the promotion rules for your machine.
In closing
So that was fun. Ellipses have their uses, but they also add a couple of extra things to think about. For example, how do you pick the best match between an ellipsis function and a function with default arguments? Now add a couple of templates and specializations in the mix. My head hurts. Language features are just that, features. Not requirements that you absolutely need to use. Don't get me wrong, I use ellipses as well, but very limited. Usually just for string formatting, which the runtime really should not use anyways except for human readable messages. Handles anyone? Localization? :)
Resource
- ¶ 5.2.2.7 in the standard.