Search code examples
c++loggingspdlog

Abstraction for logger objects (currently spdlog)


I'm developing a C++ library that uses some type of information logging using spdlog. The library can be used by a CLI tool, or directly by the end-user. I would like to be able to pass an empty interface implementation in cases where I don't care about the logging (for example for unit tests or whenever I don't want to include the spdlog stuff and do not care about logging).

The only solution that comes to my mind is to create a wrapper around spdlog functions (info/error/warn/debug). I tried doing that but since these are templated variadic functions that I'd have to make virtual to be able to override them, it's just not possible.

Could you guide me if my reasoning about the problem is wrong (passing around logger object to different modules), and if there is any other solution to such problem?

I'm open to using other logging libraries, it's just I cant find the one that would be easy to wrap.

EDIT: Setting the SPDLOG_LEVEL will not solve my problem as spdlog will still be a dependency in my code. Ideally I'd like to depend only on my interface and pass a valid spdlog wrapper or an empty implementation when needed. This would also be beneficial in situations when I'd like to change the logging library.


Solution

  • This seems to be a clash between compile-time polymorphism (template code) and run-time polymorphism (virtual functions), for which there is no simple solution.

    Problem

    To elaborate on that, the typical log function takes a variable number of parameters of various types. This allows logging any type of data, the typical requirement is only that it has a conversion to a string defined. In order to implement that, the C++ tool of choice is to use templates. The mechanism for that is that the compiler looks at the types of the parameters and then creates code depending on those types.

    Now, if you want to exchange the logger library at run-time, you need to use regular, virtual-based polymorphism. This means you write an abstract baseclass which defines the interface and according concrete classes implementing that interface. The problem here is that there is an infinite number of possible member functions you'd have to provide, so that is clearly not feasible.

    Alternative Polymorphic Sink

    If you look at spdlog, you will find a log_it_() method. This is the central point to which a prepared record is passed. All log methods converge at that single spot which does the actual work. One approach would be to make this single function virtual, which would allow writing different implementations for it.

    Notes:

    • It's unlikely that you could easily hook into other loggers with that. Point is, you couldn't even use it with spdlog, since it requires you to access and modify its internals first.
    • There is a runtime overhead from the virtual function call and the additional indirection.
    • A null logger still has the overhead of processing the different parameters into a record, which may well involve dynamic allocation.

    Alternative Type Erasure

    If you define a set of templates similar to spdlog's which take parameters and then store them as std::any, you could then pass them through a single virtual function, which you could then use to plug in different logging libraries.

    Notes:

    • The code to extract the types and forward them to according loggers is non-trivial and large. Also, you will not be able to cover 100% of all cases that you could by accessing the logging library directly.
    • std::any also has allocation overhead. How it compares to the Polymorphic Sink approach above isn't clear.
    • A null logger would still have the overhead of the conversion to std::any.

    Mimicking The spdlog Macros

    The macros like SPDLOG_TRACE() can be replaced with YOURPROJECT_TRACE(), which is then defined in a way that it forwards to spdlog, some other log or that it does nothing. Depending on build options, that should allow you to completely remove the dependency on spdlog code.

    Notes:

    • This is a compile-time decision. If you want to disable logging for unit testing, you still have to recompile the code, too.
    • This has no performance overhead when not logging.
    • This has a code smell, because it uses macros. Also, the logger could be seen as global or singleton which is considered an antipattern.

    Using A Set Of Facades

    These facades would each implement one logging library. They would all have a set of templated log functions taking different parameter types, which they would then forward to the according logging libraries. These facades all share a common interface, but they don't have a common abstract baseclass defining this interface. This is also known as "duck typing".

    Notes:

    • The code using these loggers could get them injected since they are real objects carrying actual state.
    • Code using these loggers would not need to consider the concrete logging library the facade uses.
    • Changing the underlying logging library would require changing the facade which would then require recompilation of any code using it. As long as it's just for unit testing, that seems reasonable.
    • Other than with classes sharing an abstract base class, there is no guarantee that the different facades can be exchanged by one another (Liskov Substitution Principle). This can be ensured using automated tests though.
    • There is probably little to no performance overhead because the forwarding code in the facade can be compiled and optimized inline.
    • You can not switch implementations at runtime. You can only configure the selected logging library underneath.