Macro-free Logging and Tracing in C++20, with concepts and template specialization

Viewed 378

I have been dabbling with new C++20 features such as modules and concepts. One of the inherent properties of the new modules is that they do not leak pre-processor definitions to consumers -- this is both a blessing and a curse because some behaviours, such as logging, in C++ have traditionally been implemented with #define macros so that they can be #defined to nothing in release builds.

My question is simple: how should one go about implementing logging without macros, today, assuming that one still wants to retain behaviours like having the compiler entirely remove logging calls, with no side effects, in release builds?

My endeavour to achieve this exploits lambdas and C++20 concepts to drive template specialization.

#define NOOP /* no operation */

template <typename T>
concept printable = requires (const T & message) {
    std::cout << message;
};

template <typename F>
concept format_factory = std::regular_invocable<F>
&& std::convertible_to<std::invoke_result_t<F>, std::string_view>;

...

#ifdef _DEBUG
private:
    template<std::regular_invocable F>
    static inline const std::invoke_result_t<F> map(F f) {
        return f();
    }

    template<typename T>
    static inline constexpr const T& map(const T& value) {
        return value;
    }

public:
    template<printable T>
    static inline void trace(const T& message) {
        std::cout << message << std::endl;
    }

    template<typename... Args>
    static inline void trace(const std::string_view& format, Args&&... args) {
        std::cout << std::format(format, map(args)...) << std::endl;
    }

    template<format_factory F, typename... Args>
    static inline void trace(const F& format, Args&&... args) {
        std::cout << std::format(format(), map(args)...) << std::endl;
    }

#else
public:
    template<typename... Args>
    static inline constexpr void trace(const Args&... args) { NOOP; }
#endif

The idea is that...

  • any literals and values to be logged can be passed, normally, because, in release builds, the compiler will optimise out any copies or moves since they will not be accessed.
  • anything 'expensive' to be logged can be passed as an accessor lambda which will not be invoked in release builds and, consequently, also be optimised out.

For example, user-code might look like this:

Log::trace("format literal ({}, {})", []() { return "expensive value"; }, "cheap value");

I have tried this with Visual C++ 2022 (preview) and I can confirm that it does work as intended but is it a good idea? How could I make it better?

Remember that this is done because I want to export this from a C++20 module and I can't do that with preprocessor macros as far as I understand.

1 Answers

Modularized approach

The entire approach with macro-definitions in headers in discouraged with modules. Not that it's impossible, but those headers with logging configuration need to be parsed by the compiler for each source file that include them.

I suppose you could add the macro definitions at the command line, e.g. with gcc:

g++ [...] -DDEBUG_LOGGING

And with Visual C++ (from here):

[...] /p:DefineConstants="DEBUG_LOGGING"

Working with headers

"Remember that this is done because I want to export this from a C++20 module and I can't do that with preprocessor macros as far as I understand."

--> If you want to export the logging functionality as a module, then the implementation cannot depend upon configurations of a header file. In C++20 we have two ways of including headers: Inside the global module fragment and inside the module declaration:

module;
#define DEBUG_LOGGING      // or similar configuration
#include "logging.hpp"     // inside global module fragment

export module some_module;
// 1)
#define DEBUG_LOGGING
#include "logging.hpp"     // inside module declaration
// 2)
#define DEBUG_LOGGING
import logging;            // defined is ignored with module unit

Quoting from cppreference:

"#include should not be used in a module unit (outside the global module fragment), because all included declarations and definitions would be considered part of the module. Instead, headers can also be imported with an import declaration: "
And:
"Importing a header file will make accessible all its definitions and declarations. Preprocessor macros are also accessible (because import declarations are recognized by the preprocessor). However, contrary to #include, preprocessing macros defined in the translation unit will not affect the processing of the header file. This may be inconvenient in some cases (some header files uses preprocessing macros as a form of configuration), in which case the usage of global module fragment is needed. "

So the logging header may be included in the global module fragment, but then the compiler needs to process it several times. This is especially troublesome if that header is using a 3rd party library like spdlog or similar, in which case that header-inclusion becomes a recursive nightmare.

Alternatively, the logging header may be included inside the module declaration, but then its definitions will leak outside that module, and the header file may not import any modules at all. That last bit I tried with gcc and got the following error:

log_helper.hpp:3:1: error: post-module-declaration imports must not be from header inclusion

Code sample

We should not depend on header file configuration to declare (and parse) our templated logging functions since that is a horribly slow process, so instead we can feed the configurations to the compiler statically (by instantiating a type). The general idea is to compile the module once, and depend on consumers of the module to generate the appropriate function calls.

Here is the code that you suggested:

template<format_factory F, typename... Args>
static inline void trace(const F& format, Args&&... args) {
    std::cout << std::format(format(), map(args)...) << std::endl;
}

If we call it with trace ("arg={}", my_obj); then the compiler will generate code for that particular overload. However, if we then call it with a different number of arguments, e.g. trace ("arg_1={}, arg_2={}", my_obj1, my_obj2);, then the compiler will have to lex and parse the trace function again (including recursing through dependent templated types), which is a slow process. With a module, we compile once and store that as an abstract syntax tree in binary format, so generating code after that will be much faster.

This is my suggestion for a code sample. I have omitted variadic template expansion to ease reading:

// logging.cpp
export module logging;

export class ILogger
{
public:
    virtual void Trace() = 0;
};

export enum class LogType
{
    null,
    default
};

class NullLogger : public ILogger
{
public:
    void Trace() override {};
};

export class Log
{
public:
    static void Configure(LogType lt) {
        switch(lt) {
            case LogType::null:
                mInstance = std::make_shared<NullLogger>();
                return;
            default:
                break;
        }
    }
    static void Trace() { mInstance->Trace(); }

private:
    std::shared_ptr<ILogger> mInstance;
};

I will have to take a look at optimized assembly (link-time optimizations), to check if the indirection for null logger is optimized away. Likely, some more experienced C++ folks can tell immediately.

An idea for optimization would be to store store a function pointer for mInstance->Trace() to avoid vtable lookups. But again, the compiler might deduce that automatically. I have very limited experience with that particular subject.

Related