Table of Contents

Currently, as of C++20, there’s no support for so called non-terminal variadic arguments. For example, we cannot write:

template <class ...Args> void func(Args&& ...args, int num=42);
func(10, 20); // error

As you can see, I wanted 10 and 20 to be passed as ...args and 42 as a default value for num. Compilers currently cannot resolve this code.

In this blog post, I’d like to show you a couple of tricks you can implement to avoid this issue. Knowing those techniques might help with things like logging functions where we could have std::source_location at the end of a function declaration.

The std::source_location Case

Last time I showed you a couple of techniques and improvements for logging functions. We discussed __FILE__, __LINE__ macros, how to wrap them in functions that can take variable number of arguments. And later I also introduced std::source_location from C++20. One issue that we might have is that the following code doesn’t compile:

template <typename ...Args>
void log(Args&& ...args, source_location& loc = source_location::current()) { }

log("hello world", 42);

Like the intro’s code, I want to pass a variable number of arguments, but at the same time, “fix” the last one and provide a default value.

Here are the options to consider:

  • Provide function overloads one, two, three parameters (like before C++11).
  • Use a function template, but specify the template parameters: like log<int, double>(42, 100.75);.
  • Use a custom deduction guide.
  • Use a small structure and pass source_location as a parameter to a constructor. Something like Logger().log(...).
  • Use tuples and then the call would be as follows: log(std::make_tuple("hello", 42, 100.076));.
  • Wait for the new C++ Standard where this problem is solved?
  • A different approach with <<?

Let’s review that list now.

1. Function Overloads

It’s probably the most straightforward approach. Why not write two or three function overloads and allow passing 1, 2 or 3 parameters? This was a popular technique before C++11, where variadic arguments weren’t possible.

template <typename T>
void log(T&& arg, source_location& loc = current());
template <typename T, typename U>
void log(T&& t, U&& u, source_location& loc = current());
template <typename T, typename U, typename V>
void log(T&& t, U&& u, V&& v, source_location& loc = current());

While this code might not be the best for a generic library function, it might sometimes be the simplest solution for small projects.

Ok, but let’s try something more complicated.

2. Provide Explicit Argument Types

The main issue with non-terminal variadic arguments is that the compiler cannot resolve and adequately match the arguments.

So why not help it?

What we can do is to write what types we’d like to handle and then it should work:

#include <iostream>
#include <source_location>
#include <string>

template <typename... Ts>
void log(Ts&&... ts, const std::source_location& loc = std::source_location::current()) {
    std::cout << loc.function_name() << " line " << loc.line() << ": ";
        ((std::cout << std::forward<Ts>(ts) << " "), ...);
        std::cout << '\n';
}

int main() {
    log<int, int, std::string>(42, 100, "hello world");
    log<double, std::string>(10.75, "an important parameter");
}

Play @Compiler Explorer

As you can see, I specified all types, and that way, the compiler can properly build the final template specialisation.

And this points us in one direction…

3. Deduction Guides

As you could see from the previous point, if we provide correct arguments, the compiler can resolve it.

In C++17, we have another tool that can help us - deduction guides and class template argument deduction (CTAD).

What we can do is the following:

template <typename... Ts>
struct log {    
    log(Ts&&... ts, std::source_location& loc = std::source_location::current()) {
        std::cout << loc.function_name() << " line " << loc.line() << ": ";
        ((std::cout << std::forward<Ts>(ts) << " "), ...);
        std::cout << '\n';
    }
};

template <typename... Ts>
log(Ts&&...) -> log<Ts...>;

The deduction guide at the bottom tells the compiler to build log<Ts...> when it sees log(Ts...). The main advantage here is that the deduction guide is a layer between our actual variadic constructor with the default argument. That way, the compiler has a simpler job.

And play with the full example below:

#include <iostream>
#include <source_location>
#include <string>

template <typename... Ts>
struct log
{    
    log(Ts&&... ts, const std::source_location& loc = std::source_location::current()) {
        std::cout << loc.function_name() << " line " << loc.line() << ": ";
        ((std::cout << std::forward<Ts>(ts) << " "), ...);
        std::cout << '\n';
    }
};

template <typename... Ts>
log(Ts&&...) -> log<Ts...>;

int main() {
    log(42, 100, "hello world");
    log(10.75, "an important parameter");
}

Play @Compiler Explorer

This example also showed us how to move from a function to a separate struct and leverage constructor. There might be an issue when you need to return something from such a logging function, though.

What we can do is take this approach and expand. See below.

4. Using a Constructor

How about using only the constructor to take the source location and then expose a separate log function?

Have a look:

#include <iostream>
#include <string_view>
#include <source_location>
#include <fmt/core.h>

struct Logger {
    Logger(std::source_location l = std::source_location::current()) : loc(std::move(l)) { }
    
    template <typename ...Args>
    void debug(std::string_view format, Args&& ...args) {
	    std::cout << fmt::format("{}({}) ", loc.file_name(), loc.line())
                  << fmt::format(format, std::forward<Args>(args)...) << '\n';
    }
    
private:
    std::source_location loc;    
};
 
int main() {
    std::cout << sizeof(std::source_location) << '\n';
    Logger().debug("{}, {}", "hello", "world");
    Logger().debug("{}, {}", 10, 42);
}

Play at @Compiler Explorer

As you can see, I used a constructor for the default argument and then there’s another regular function that takes care of the variadic list. With a regular member function, you can also return values if required.

5. Use a Tuple

For completeness I also need to mention one technique. What we can do is to wrap all variadic arguments into std::tuple:

#include <iostream>
#include <source_location>
#include <string>
#include <tuple>

template <typename... Ts>
void log(std::tuple<Ts...> tup, const std::source_location& loc = std::source_location::current()) {
    std::cout << loc.function_name() << " line " << loc.line() << ": ";
    std::apply([](auto&&... args) {
        ((std::cout << args << ' '), ...);
    }, tup);
    std::cout << '\n';
}

int main() {
    log(std::make_tuple(42, 100, "hello world"));
    log(std::make_tuple(10.75, "an important parameter"));
}

As you can see, we need to use std::apply, which “translates” tuple into a list of arguments.

6. A Stream Object

So far, we discussed regular functions or an option to “convert” it to a separate struct/class. But there’s another approach.

In one article on Arthur O’Dwyer’s blog - How to replace __FILE__ with source_location in a logging macro. He proposes to use a stream object and then pass arguments through << operators.

NewDebugStream nds;
nds << "Hello world! " << 42 << "\n";

7. Wait for C++23 or Later?

As you can imagine, there must be a paper and a proposal to fix that in C++.

The ISO committee considered the proposal P0478, but it was rejected. There are some other ideas - for example, see Non-terminal variadic template parameters | cor3ntin but without the final “materialisations”.

It looks like we need to wait a few years and a few papers to solve this issue. But since it’s not urgent, and there are other solutions, maybe it’s best not to make C++ even more complicated.

Summary

The fundamental theorem of software engineering (FTSE) (see @wiki):

“We can solve any problem by introducing an extra level of indirection.”

The above phrase perfectly describes what I showed in this blog post :) Since C++ doesn’t support non-terminal variadic arguments, we need another layer to solve it.

Here’s a summary of all techniques:

Technique Pros Issues
Several Overloads Simple limited number of parameters, looks not “modern”.
Explicit template arguments Simple You have to keep the list of types and values in sync.
Deduction Guide No need to mention types, it looks like a function call. Requires C++17 support, more complicated to implement. Creates a separate object, rather than a simple function call (but maybe it will be optimised by the compiler?). It cannot easily return values from the constructor.
Struct + Constructor + function No need to mention types, but allows to return values from the logging member function. Creates a separate object with a state, longer syntax.
Wrap into a tuple Relatively easy Looks strange? Need to add <tuple> header.
Stream object A completely new approach, looks easy and similar to std::cout << calls. More function calls, needs a separate “global” object defined.

And what’s your favourite option?

Also, have a look at our other article, which tackles a similar issue from another perspective. How to Pass a Variadic Pack as the First Argument of a Function in C++ - C++ Stories.

As a source for the techniques, I use this SO question: c++ - How to use source_location in a variadic template function? - Stack Overflow and also from comments that I got under the initial post on logging - see @disqus.