Replacing CRTP Static Polymorphism With Concepts
This is a guest post from Matthew Guidry. Matthew works as a software engineer in the CAD industry. He designs libraries and cross platform desktop applications, and is interested in using modern C++ techniques to improve overall software architecture. You can find Matthew online on Twitter @mguid2088.
One of the usages of the CRTP is for implementing static polymorphism. This technique can be used to provide customization points to classes in libraries among other things. Though CRTP is a powerful tool for implementing static interfaces and adding functionality to a class, it has some drawbacks, and we can do better, by using C++20 concepts.
Our Toy Example Using CRTP
We will create some function that takes a polymorphic logger and logs a std::string_view
message to all log levels. For simplicity, our toy logger has no notion of log level filtering or sinks. We will also create our CRTP base class:
template <typename TLoggerImpl> class Logger { public: void LogDebug(std::string_view message) { Impl().DoLogDebug(message); } void LogInfo(std::string_view message) { Impl().DoLogInfo(message); } void LogError(std::string_view message) { Impl().DoLogError(message); } private: TLoggerImpl& Impl() { return static_cast<TLoggerImpl&>(*this); } friend TLoggerImpl; }; template <typename TLoggerImpl> void LogToAll(Logger<TLoggerImpl>& logger, std::string_view message) { logger.LogDebug(message); logger.LogInfo(message); logger.LogError(message); }
Let’s also define a couple derived logger classes which we will call CustomLogger
and TestLogger
:
struct CustomLogger : public Logger<CustomLogger> { void DoLogDebug(std::string_view message) const { std::cout << “[Debug] ” << message << ‘\n’; } void DoLogInfo(std::string_view message) const { std::cout << “[Info] ” << message << ‘\n’; } void DoLogError(std::string_view message) const { std::cout << “[Error] ” << message << ‘\n’; } }; struct TestLogger : public Logger<TestLogger> { void DoLogDebug(std::string_view) const {} void DoLogInfo(std::string_view) const {} void DoLogError(std::string_view) const {} };
Now we can use them as follows:
CustomLogger custom_logger; LogToAll(custom_logger, “Hello World”); TestLogger test_logger; LogToAll(test_logger, “Hello World”);
This code works but suffers from the following issues::
- Methods in the derived class must be named differently from the methods in the base class; if they use the same name, the base class interface will be hidden by methods in the derived class
- There is a level of indirection that is inherent to the CRTP
- It doesn’t clearly express the intention that it is constraining the API of a Logger.
A more pressing issue with the CRTP idiom is that it is yet another idiom. It is a pattern that you must be aware of at all times when trying to understand a piece of code. Just skimming through the Logger
code, it may not be immediately apparent what it is trying to accomplish unless this is something you come across often.
Now that we know the issues, we’ll iteratively refactor our example, using concepts to fix the issues.
Requires Requires Requires….
First, we will remove all of the code from inside Logger
. We are left with this:
template <typename TLoggerImpl> struct Logger {};
What we want to do now is add constraints to TLoggerImpl
. Ignoring concepts, we could do this with an ad-hoc constraint:
template <typename TLoggerImpl> requires requires(TLoggerImpl logger) { logger.LogDebug(std::string_view{}); logger.LogInfo(std::string_view{}); logger.LogError(std::string_view{}); } struct Logger {};
The two requires
keywords have different meanings. The one on the left is a requires-clause which checks (requires) that the requires-expression on the right evaluates to true
.
We also want to expose the functionality from the passed template parameter to Logger
if it satisfies its constraints. To do this, we will allow Logger
to inherit from TLoggerImpl
. So now we have the following:
template <typename TLoggerImpl> requires requires(TLoggerImpl logger) { ... } struct Logger : TLoggerImpl {};
Eliminating Ad-Hoc Constraints
We have created a new problem for ourselves. Using requires requires
feels like, and probably is, a code smell. The requires
expression should be refactored into a concept, so let’s do that. We will call this concept LoggerLike
, which says that anything that satisfies it is like what a Logger
should look like.
template <typename TLoggerImpl> concept LoggerLike = requires(TLoggerImpl log) { log.LogDebug(std::string_view{}); log.LogInfo(std::string_view{}); log.LogError(std::string_view{}); }; template <typename TLoggerImpl> requires LoggerLike<TLoggerImpl> struct Logger : TLoggerImpl {};
Even better still, we can eliminate the requires-clause and use the concept as a type-constraint in the template parameter list like this:
template <LoggerLike TLoggerImpl> struct Logger : TLoggerImpl {};
This is effectively like using the concept as a pure virtual base interface, but here, this is a static interface resolved at compile time. This interface has no functionality on its own; it only defines the methods that its template parameter must implement.
At this point, we should modify our CustomLogger
and TestLogger
classes. We will remove the inheritance and rename their methods to adhere to our concept:
struct CustomLogger { void LogDebug(std::string_view message) const { std::cout << “[Debug] ” << message << ‘\n’; } void LogInfo(std::string_view message) const { std::cout << “[Info] ” << message << ‘\n’; } void LogError(std::string_view message) const { std::cout << “[Error] ” << message << ‘\n’; } }; struct TestLogger { void LogDebug(std::string_view) const {} void LogInfo(std::string_view) const {} void LogError(std::string_view) const {} };
As you may have noticed, we haven’t made any modifications to our LogToAll
function. It still expects a Logger&
:
template <typename TLoggerImpl> void LogToAll(Logger<TLoggerImpl>& logger, std::string_view message) { logger.LogDebug(message); logger.LogInfo(message); logger.LogError(message); }
Let’s create aliases for each of our loggers. In order for this to work, we will also rename our loggers by suffixing them with Impl
(they could also be qualified in a namespace):
struct CustomLoggerImpl { … }; struct TestLoggerImpl { … }; using CustomLogger = Logger<CustomLoggerImpl>; using TestLogger = Logger<TestLoggerImpl>;
Now we can use them the same way we did before:
CustomLogger custom_logger; LogToAll(custom_logger, “Hello World”); TestLogger test_logger; LogToAll(test_logger, “Hello World”);
We’ve now refactored our example to use concepts and it is simpler relative to what we started with:
- We’ve fixed the method naming issue; concepts enforce the method names by design
- We’ve removed some indirection in that we no longer have to implement functionality in the base and derived classes
- Our code is now much more expressive because concepts exist to constrain syntax and semantics; we now know that we are trying to constrain our
Logger
Going Even Further
Is there a way it could be made even simpler? We still have some redundancy here. We are using the Logger
class to enforce our concept instead of using it directly. By this, I mean that our function could be written this way:
template <LoggerLike TLogger> void LogToAll(TLogger& logger, std::string_view message) { logger.LogDebug(message); logger.LogInfo(message); logger.LogError(message); }
This eliminates the need for the Logger
class and type aliases. We can also rename our logger classes back to TestLogger
and CustomLogger
and use them directly. The way we use the classes and functions remains the same:
CustomLogger custom_logger; LogToAll(custom_logger, “Hello World”); TestLogger test_logger; LogToAll(test_logger, “Hello World”);
What this does is move the constraint checking from the point where we create the alias to the point where we pass it to an API that expects the concept. Depending on your use-case you may decide to use one or the other.
Adding Functionality
After switching to concepts, it should be very easy to add functionality to our logger. Quickly imagine that we want to add some tag to all of our logs. Let’s look at our CustomLoggerImpl
class again:
struct CustomLoggerImpl { void LogDebug(std::string_view message) const { std::cout << “[Debug] ” << message << ‘\n’; } void LogInfo(std::string_view message) const { std::cout << “[Info] ” << message << ‘\n’; } void LogError(std::string_view message) const { std::cout << “[Error] ” << message << ‘\n’; } };
All we need to do to add functionality to our CustomLoggerImpl
and any other logger that satisfies LoggerLike
is add it directly to the derived class like so:
template <LoggerLike TLoggerImpl> struct TaggedLogger : TLoggerImpl { TaggedLogger(const std::string& tag) : m_tag(tag) {} void LogDebugTagged(const std::string& message) { const std::string& tagged = “[” + m_tag + "] " + message; static_cast<TLoggerImpl*>(this)->LogDebug(tagged); } ... private: std::string m_tag; }; using TaggedCustomLogger = TaggedLogger<CustomLoggerImpl>;
We can use it like so:
TaggedCustomLogger logger; logger.SetTag(“MyTag”); logger.LogDebugTagged(“Hello World”);
Concepts will change the way we code
The CRTP is one of the good old template tricks that has been with us since C++98, and it has now been transformed with concepts.
Concepts will change the way we write template code. Like templates themselves, which revealed their power over the years, concepts may have interesting techniques awaiting to be discovered.
How do you use concepts to make your template code simpler?
You will also like
- How to Emulate the Spaceship Operator Before C++20 with CRTP
- Variadic CRTP Packs: From Opt-in Skills to Opt-in Skillsets
- Variadic CRTP: An Opt-in for Class Features, at Compile Time
- If you see cut-paste, it is rotate
- The Curiously Recurring Template Pattern (CRTP)
Share this post!