C / FUNCTIONS
Variadic functions with stdarg.h
Write functions that take a variable number of arguments with stdarg.h, and read them correctly with va_arg despite default argument promotions.
What you will learn
- Write a variadic function using va_list, va_start, va_arg and va_end
- Pick a stopping rule: leading count, format string, or a cast NULL sentinel
- Ask va_arg for the promoted type: int for char/short, double for float
- Forward arguments by writing a va_list variant, the way printf pairs with vprintf
Understanding Variadic functions with stdarg.h
The ... at the end of a parameter list tells the compiler that any number of further arguments may follow, and that it should stop type-checking them. Nothing about those arguments is transmitted to the function: not how many there are, not how big each one is, not what type it had at the call site. stdarg.h gives you a cursor, va_list, that walks the extra arguments in the order they were written, and va_arg(ap, T) means read an object of type T here and move the cursor forward by that much. The type in va_arg is your assertion, not a question, which is why a wrong type is not an error but silently corrupts every read after it.
Arguments matched against ... cannot be converted to a declared parameter type, because there is no declared type, so the compiler applies the default argument promotions instead: float becomes double, and char, short, _Bool and their unsigned relatives become int. A char argument therefore has to be read with va_arg(ap, int) and a float with va_arg(ap, double), even though the call site plainly wrote a char or a float. Everything else, including int, long, double, pointers and structs, is passed unchanged, so the type you name must match exactly, and long is not int.
Since the function learns nothing about the argument count, it needs an out-of-band agreement with the caller: a leading count, a format string that describes each argument in turn, or a sentinel value that marks the end. The va_start and va_end pair brackets the traversal, and va_end must run on every return path, because on some ABIs va_start sets up bookkeeping that va_end tears down. A va_list is single-pass and is left indeterminate once another function such as vprintf has consumed it, so a second walk needs va_copy, and forwarding your own ... requires a function that accepts a va_list, since you cannot expand a va_list back into a call.
<stdarg.h>
<stdio.h>
int sum_ints(int count, ...)
{
va_list ap;
int total = 0;
va_start(ap, count);
for (int i = 0; i < count; i++)
total += va_arg(ap, int); /* assert: the next argument is an int */
va_end(ap);
return total;
}
double average(int count, ...)
{
va_list ap;
double total = 0.0;
va_start(ap, count);
for (int i = 0; i < count; i++)
total += va_arg(ap, double); /* float arguments arrive as double */
va_end(ap);
return count > 0 ? total / count : 0.0;
}
int main(void)
{
printf("sum_ints = %d\n", sum_ints(4, 10, 20, 30, 40));
printf("average = %.2f\n", average(3, 1.5, 2.5, 6.0));
return 0;
}
A variadic function receives promoted arguments with no record of their number or type, so va_arg does not discover a type, it asserts one.
Worked examples
Forwarding to vprintf
A logging wrapper that hands its whole argument list to the printf machinery instead of trying to rebuild the call.
<stdarg.h>
<stdio.h>
void log_line(const char *tag, const char *fmt, ...)
{
va_list ap;
printf("[%s] ", tag);
va_start(ap, fmt);
vprintf(fmt, ap);
va_end(ap);
putchar('\n');
}
int main(void)
{
log_line("info", "listening on port %d", 8080);
log_line("warn", "retry %d of %d after %s", 2, 5, "timeout");
return 0;
}
Example explained
Line 1va_start(ap, fmt) anchors the cursor just past fmt, the last named parameter, so vprintf's first conversion reads 8080.
Line 2vprintf accepts the va_list itself; there is no way to splice a va_list back into printf's ..., which is why every printf-family function has a v-twin.
Line 3vprintf consumes ap and leaves it indeterminate, so va_end is the only legal thing left to do with it in this function.
Line 4putchar('\n') sits outside the va_start/va_end pair because it never touches the argument list.
A sentinel-terminated list
Ending the argument list with a null pointer instead of a count, and why the sentinel must be cast.
<stdarg.h>
<stdio.h>
void print_path(const char *first, ...)
{
va_list ap;
const char *part = first;
va_start(ap, first);
while (part != NULL) {
fputs(part, stdout);
part = va_arg(ap, const char *);
if (part != NULL)
putchar('/');
}
va_end(ap);
putchar('\n');
}
int main(void)
{
print_path("usr", "local", "share", (const char *)NULL);
print_path("home", (const char *)NULL);
return 0;
}
Example explained
Line 1first is a named parameter, so it is not part of the ... list and is never fetched with va_arg.
Line 2The loop reads one pointer ahead of what it prints, which is how it knows whether a separating slash is still needed.
Line 3main passes (const char *)NULL rather than bare NULL, because a plain 0 may be sent as a 4-byte int while va_arg reads 8 bytes as a pointer.
Line 4The sentinel is the entire contract: a caller who forgets it makes the loop walk past the end of the argument list.
What the promotions do
A char, a short and a float passed through ... and read back with the types they were actually promoted to.
<stdarg.h>
<stdio.h>
void show_promoted(int n, ...)
{
va_list ap;
va_start(ap, n);
printf("char -> %d\n", va_arg(ap, int)); /* not char */
printf("short -> %d\n", va_arg(ap, int)); /* not short */
printf("float -> %.2f\n", va_arg(ap, double)); /* not float */
va_end(ap);
}
int main(void)
{
char c = 'A';
short s = -3;
float f = 1.5f;
show_promoted(3, c, s, f);
return 0;
}
Example explained
Line 1c is a char at the call site but is promoted to int before it is passed, so va_arg(ap, int) is the correct read and 'A' prints as 65.
Line 2-3 arrives as an int as well, since promotion to int is value-preserving; asking for va_arg(ap, short) would be undefined behaviour, not a harmless truncation.
Line 31.5f is promoted to double, so reading a double recovers the exact value, while va_arg(ap, float) would consume the wrong number of bytes.
Line 4Each printf deliberately contains a single va_arg call: two va_arg calls in one expression have unspecified evaluation order and could be matched to the wrong conversions.
Important notes
Until C23 a variadic function must have at least one named parameter and va_start needs it as the second argument; C23 permits void f(...) and va_start(ap), but older compilers reject both.
Nothing behind the ... is type-checked, so keep the prototype in scope and, on GCC or Clang, mark printf-style wrappers with __attribute__((format(printf, fmt_index, first_vararg_index))) to get -Wformat warnings at the call sites.
Common mistakes
Writing va_arg(ap, float) or va_arg(ap, char) because the call site passed a float or char: the value is garbage and the cursor is left misaligned, so every later argument is wrong too.
Terminating a sentinel list with bare NULL or 0: it can be passed as a 4-byte int while va_arg reads a full pointer, so the loop runs off the end of the arguments and crashes.
Letting the count or the format string disagree with what was actually passed, as in printf(user_input): va_arg cheerfully reads stack memory that was never an argument.
Try it yourself
Change, predict, then run
Write int max_of(int count, ...) that returns the largest of count int arguments and confirm that max_of(5, 3, 91, -4, 17, 60) gives 91. Then make count == 0 return INT_MIN and make sure va_end still runs on that path.
Open the C workspaceCheck your understanding
A function declared void f(int n, ...) is called as f(1, 3.5f), and its body reads the argument with va_arg(ap, float). What is the result?
- It works, but with reduced precision, because a float occupies only four bytes
- The compiler stored a double, and va_arg converts it back to float for you
- It is undefined behaviour: the argument arrived as a double, so only va_arg(ap, double) is valid
- It works, because the ... in the prototype records that a float was passed
Show answer
Arguments matched against ... undergo the default argument promotions, so 3.5f is converted to double at the call site; va_arg(ap, float) then tells the cursor to read a float-sized, float-formatted object out of a double, giving a meaningless value and leaving the cursor in the wrong place for later reads. Option 1 is tempting because the compiler really does perform the float-to-double promotion, but va_arg has no record of the original type, so nothing converts it back: the promotion is one-way and the callee must ask for double.