Comparing Variadic Functions: C vs C++

I recently implemented a logging library in C and was reminded of how much simpler variadic functions are in C++.

Consider a common variadic use case: format strings with functions like printf or an equivalent log interface:

void log(const char *fmt, ...) __printf_check(1,2);

Inside the function implementation, you'll see the C variadic constructs:

// Declare a va_list which holds info needed by variadic funtions
va_list argp;

// Access to the variadic arguments for our format string
va_start(argp, fmt);

// Do some work…
{ … }

// End use of variadic arguments
va_end(argp)

At this stage, I have no specific complaints about C variadic functions. However, my frustration arises when I need to pass the variadic arguments to another function. Your function counts explode: you need to provide a variadic function and a secondary implementation that accepts a va_list as input.

You might think this is a strange use case: why exactly am I passing variadic arguments around?

Here are some specific reasons for my logging library to pass around variadic arguments:

  1. I wanted an option to echo log strings to the console using printf
  2. I wanted an upper-level log API that takes in a log level (for verbosity filtering) and internally adds decorations such as a timestamp.
  3. I needed a base-level log function that writes bytes to the log buffer without adding decoration.
    • This is used to provide specialized functions such as log_data, which can nicely format the contents of a block of memory

By providing multiple entry points into the log buffer, my total required function count quickly exploded.

Let's start with my pretty_log implementation:

void pretty_log(int level, const char *fmt, ...)
{
    if(level > log_level_)
    {
        return;
    }

    TickType_t t = xTaskGetTickCount();

    // Add the timestamp to the log buffer
    // This needs a variadic log function
    log("[%lu] ", t);

    // Parse the variadic arguments from the pretty_log input
    va_list argp;
    va_start(argp, fmt);

    // Pass our variadic arguments to the va_list log function
    logv(fmt, argp);

    if(console_output_en_)
    {
        // We'll print timestamp to console in a variadic function
        consolePrintf("[%lu] ", t);

        //But we have a va_list for our pretty_log string
        //so we need a console print that takes a va_list
        console_vprint(fmt, argp);
    }

    va_end(argp);
}

This one function alone implies the need for two versions of our internal log function. Let's see what those look like:

// logv function, which takes in a format string and va_list
void logv(const char *fmt, va_list argp)
{
    // Log the requested string using a va_list version of eprintf
    evprintf(putc_logbuf_, fmt, argp);
}

// Variadic log function
void log(const char *fmt, ...)
{
    va_list argp;
    va_start(argp, fmt);

    // Keep implementation simple - forward to logv
    logv(fmt, argp);

    va_end(argp);
}

We need to treat our consolePrint function similarly and provide two versions.

void consolePrint(const char * fmt, ...);
void console_vprint(const char * fmt, va_list argp);

But it didn't stop with console printing! Check the logv function above: we also need a va_list version of eprintf since we are forwarding on our variadic arguments even further. The madness never ends!

C++ has similar variadic function support, but also provides variadic templates. A variadic template is simply a template that takes an arbitrary number of types. Let's take a quick look at how they work. I've lifted a very helpful example from this StackOverflow article:

template<typename ...T>
void f(T ... args) 
{
    g( args... );        //pattern = args
    h( x(args)... );     //pattern = x(args)
    m( y(args...) );     //pattern = args (as argument to y())
    n( z<T>(args)... );  //pattern = z<T>(args)
}

You'll notice that the template specifier includes an ellipsis (), and so does the function invocation. These indicate a variadic template.

When inside our variadic template function, the ellipsis is used to unpack our parameters. The rule is that the pattern to the left of the is repeated, and the unpacked parameters are separated by commas. The pattern = indicators above show you which form of the pattern will be repeated. Here's a demonstration of the functions using three arguments as input:

g( arg0, arg1, arg2 );           
h( x(arg0), x(arg1), x(arg2) );
m( y(arg0, arg1, arg2) );
n( z<int>(arg0), z<char>(arg1), z<short>(arg2) );

By using C++ variadic templates, we can simply our implementation to require one log function, one eprintf function, and one consolePrint function. Let's look at pretty_log and log using variadic templates:

template <typename ... Args>
void pretty_log(int level, char const * const format, Args const & ... args) noexcept
{
    if(level > log_level_)
    {
        return;
    }

    TickType_t t = xTaskGetTickCount();

    // Add the timestamp to the log buffer
    log("[%lu] ", t);

    //We'll just forward the args
    log(fmt, args…);

    if(console_output_en_)
    {
        consolePrintf("[%lu] ", t);
        consolePrintf(fmt, args…);
    }
}

template <typename ... Args>
void log(char const * const format, Args const & ... args) noexcept
{
    eprintf(putc_logbuf_, args…);
}

Thanks to variadic templates, we can create a more straightforward implementation. I don't know about you, but I certainly love the convenience and look forward to never writing another chain of paired variadic functions!

Further Reading