Contents

How to deal with variadic templates and default function arguments?

I’ve recently started to write my own logging library which I encourage you to check out. It’s called libsl. I’ve decided to use fmt::format for log message formatting and expose the most basic interface for logging messages:

1
2
3
void log(const Logger::Level& level,
    const std::string& msg,
    std::source_location sl = std::source_location::current());

The premise here is that the user is responsible for providing a pre-formatted message. Additionally, the log function call collects the source location of where it was called so, this information can be used in the log message as well. You’d use the library the following way:

1
2
3
log(Logger::Level::Info, "this is a simple message");
...
log(Logger::Level::Error, fmt::format("a = {}, b = {}, c = {}", a, b, c));

This is all cool but I decided to improve the interface a bit to be able to do something like so:

1
logfmt(Logger::Level::Info, "a = {}, b = {}, c = {}", a, b, c);

As a result, the fmt::format will be called internally. This is where the problems start and the story begins.

Variadic templates

One way to declare such interface would be through a variadic function template:

1
2
3
4
template <typename ... ArgsT>
void logfmt(const Logger::Level& level, std::string fmtStr, ArgsT&&... args) {
    ...
}

What about the std::source_location? It can’t precede the argument pack, because then, the automatic template deduction will fail. Consider the following:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Logger {
public:
    enum class Level {
        Info,
        Error
    };

    template <typename ... ArgsT>
    void log(const Logger::Level& l,
            std::string fmtStr,
            std::source_location sl = std::source_location::current(),
            ArgsT&& ... args) {
    }
};

int main (int argc, char *argv[])
{
    Logger logger;
    int a = 123;
    float b = 3.14;
    std::string c = "hello";
    logger.log(Logger::Level::Info, "a = {}, b = {}, c = {}", a, b, c);
    return 0;
}

This won’t build (as expected):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
variadic.cpp: In function ‘int main(int, char**)’:
variadic.cpp:27:15: error: no matching function for call to ‘Logger::log(Logger::Level, const char [23], int&, float&, std::string&)’
   27 |     logger.log(Logger::Level::Info, "a = {}, b = {}, c = {}", a, b, c);
      |     ~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
variadic.cpp:13:10: note: candidate: ‘template<class ... ArgsT> void Logger::log(const Level&, std::string, std::source_location, ArgsT&& ...)’
   13 |     void log(const Logger::Level& l,
      |          ^~~
variadic.cpp:13:10: note:   template argument deduction/substitution failed:
variadic.cpp:27:63: note:   cannot convert ‘a’ (type ‘int’) to type ‘std::source_location’
   27 |     logger.log(Logger::Level::Info, "a = {}, b = {}, c = {}", a, b, c);

The opposite is not possible either:

1
2
3
4
5
6
7
template <typename ... ArgsT>
void log(
        std::source_location sl,
        const Logger::Level& l,
        std::string fmtStr,
        ArgsT&& ... args) {
}

The caller would have to provide the source_location manually every time, which kind of defeats the point of having such API in the first place.

Solutions?

There are a couple of solutions, each with its own set of pros and cons.

Preprocessor

The most obvious one is a macro.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
template <typename ... ArgsT>
void logApi(const Logger::Level& l,
        std::string fmtStr,
        std::source_location sl,
        ArgsT&& ... args) {
}

#define log(level, fmtStr, ...) \
logApi(level, fmtStr, std::source_location::current(), __VA_ARGS__)

logger.log(Logger::Level::Info, "a = {}, b = {}, c = {}", a, b, c);

Although this works, this feels very wrong. Therefore I’m not gone deem this approach as acceptable.

Custom deduction guides

The function could be converted to a function object - that would allow to resolve automatic template arguments deduction problem by specifying a set of deduction guides:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Logger {
public:
    enum class Level {
        Info,
        Error
    };

    template <typename ... ArgsT>
    void logApi(const Logger::Level& l,
            std::string fmtStr,
            std::source_location sl,
            ArgsT&& ... args) {
    }
};

template <typename ... ArgsT>
struct log {
	log(Logger& l,
	    const Logger::Level& level,
        std::string fmtStr,
        ArgsT... args,
        std::source_location sl = std::source_location::current()) {
        l.logApi(level, fmtStr, sl, std::forward<ArgsT>(args)...);
    }
};

template <typename ... ArgsT>
log(Logger&, const Logger::Level&, const char*, ArgsT...) -> log<ArgsT...>;

Logger logger;
int a = 123;
float b = 3.14;
std::string c = "hello";
log(logger, Logger::Level::Info, "a = {}, b = {}, c = {}", a, b, c);

That again, works but requires an intermediate object to be used by the client which feels… weird.

Implicit type conversion

std::source_location could be captured as a “side effect” of implicit conversion. This solution is still a bit hacky but much better than any of the prior ones. To do that, a wrapper object is needed:

1
2
3
4
5
6
7
8
9
struct SlWrapper {
    Logger::Level level;
    std::source_location sl;

    SlWrapper(Logger::Level level,
        std::source_location sl = std::source_location::current())
    {
    }
};

I’m exploiting an implicit conversion rules (which allow to convert one of the constructor arguments) to convert Logger::Level to SlWrapper. At the same time, the std::source_location is captured. The compiler is allowed to construct SlWrapper solely from Logger::Level:

1
2
SlWrapper w = Logger::Level::Info;
// SlWrapper contains source location as well now

Thanks to implicit conversion, the logfmt can be implemented the following way:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
struct SlWrapper {
    Logger::Level level;
    std::source_location sl;

    SlWrapper(Logger::Level level, std::source_location sl = std::source_location::current()) :
        level{level},
        sl{sl}
    {
    }
};

void log(const Logger::Level& level,
        std::string msg,
        std::source_location sl = std::source_location::current()) {
    // ...
}

template <typename ... ArgsT>
void logfmt(SlWrapper wrapper,
        std::string fmtStr,
        ArgsT&& ... args) {
    log(wrapper.level,
            fmt::format(fmt::runtime(fmtStr), std::forward<ArgsT>(args)...),
            wrapper.sl);
}

int main (int argc, char *argv[])
{
    Logger logger;
    int a = 123;
    float b = 3.14;
    std::string c = "hello";
    logger.logfmt(Logger::Level::Info, "a = {}, b = {}, c = {}", a, b, c);
    return 0;
}

This is a perfect compromise between complexity and usability allowing to achieve the desired API. Have a look on the implementation within my logging library for more details.