A few months ago, a user asked on Reddit “How suitable are concepts for use as a replacement for CRTP interfaces?”. The main conclusion of the discussion was that concepts were designed to constrain, not for what CRTP is used.
But what is CRTP used for? CRTP (Curiously Recurring Template Pattern) is a way of providing compile-time polymorphism through inheritance. It’s commonly used to extend functionality of a derived class, using some required implementation details provided by it. The main idea behind CRTP is:
1 | // 1. We have a Base class template that utilizes a Derived template parameter |
Code reuse can be seen as CRTP’s most important feature, as per Daisy Hollman’s “Thoughts on Curiously Recurring Template Pattern” lightning talk, since we can write the base class once, and inherit from it to implement the interface without repeating ourselves.
One real-world example of how it’s done is on the Standard library, and standardized on C++20 (which means CRTP is far from an outdated technique!): std::ranges::view_interface
. With just two public member functions, we can provide a vast interface with CRTP!
But the question is: can we emulate the behavior of CRTP without using inheritance?
Let’s go to the point: the short answer is yes, but there are some caveats. So, let’s explore an example and verify some issues we can encounter:
🔗Our CRTP example
For this article, we’ll utilize a tutorial writer’s favorite: Vectors!
1 | namespace mylib { |
In this example, we have:
- A Vector CRTP base class, that can be used for 2D and 3D vectors, with any member type
- Two classes inheriting from it, demonstrating both behaviors we’ve described:
Vector2f
, with floating point types and 2 elements (2D)Vector3i
, with integer type and 3 elements (3D)
There’s one major issue, though: This example does not compile! (Compiler Explorer link)
While the Vector derived classes are still aggregates, they require initialization of the base class! So, they need to look ugly, and now can compile! (Compiler Explorer link)
1 | int main(){ |
In C++20, it’s possible to utilize designators with aggregate initializations, e.g. Vector2f{.x = 0, .y = 1}
, but, like the previous syntax, it forces the knowledge into the user. So, as library writers, we’re forced into writing our constructors, to make the initial call work:
1 | struct Vector2f : Vector<Vector2f> { |
It works now (as always, Compiler Explorer link), but at what cost? Our types are not aggregates anymore, so the user can’t use designators. They can still be trivial, but we need to declare the correct constructors.(left as an exercise to the reader)
🔗Implementing this behavior with concepts
Due to how concepts work, we need to:
- Define our concept to accept 2D and 3D cases
- Implement the member function as a free function
And our solution:
1 | namespace mylib { |
With this interface, we can compile our main
example from previously, with no changes. And an advantage: our types now follow the rule of zero and are aggregates! (Compiler Explorer Link)
But this example working doesn’t mean we can just retire CRTP. We have some issues, and we’ll explore them individually.
🔗Issue #1: CRTP is opt-in by default, concepts aren’t
Concepts are a great addition to the language, improving generic programming over duck typing or unreadable pools of std::enable_if
s.
Although they constrain the inputs to templates, they do not semantically constrain the program.
For example, we can have a mylib::Point2f
class that we accidentally implement the operations for it (Compiler Explorer link):
1 | namespace mylib { |
This example compiles perfectly, but we have a problem: mathematically, adding two points makes no sense! It’s not a syntactic issue, it’s a semantic one.
🔗Solution: Making a concept opt-in
Making a concept opt-in is fairly trivial. We just need to:
- Create a boolean variable template with default
false
- Add that variable as a requirement in our concept declaration
1 | // 1. We create the variable template |
To opt-in into that concept, we then need to specialize the variable template:
1 | template<> inline constexpr bool is_vector<Vector2f> = true; |
Now, we have what we wanted: Vector classes can be added, while our Point class can’t. (Compiler Explorer Link)
Note: Making a concept opt-in is also not a novelty concept (ha!). It’s used in the Standard, by the Ranges library, for the exact same purpose: avoiding wrong semantic usage of constrained functions. One example is the std::ranges::enable_view
variable template.
🔗Issue #2: Member functions
Have you noticed I only used the +
operator until now? That’s because there’s something we cannot replicate with our concepts solution: we cannot replace CRTP’s member functions.
Suppose we want a norm
function, to determine the length of the vector. With CRTP, we have it both ways: it can be a member function, or it can be a free function.
1 | template<typename Derived> |
With concepts, we cannot inject a member function. So, we need to use the free function:
1 | auto norm(const vector auto& self) noexcept { |
You can see on Compiler explorer: CRTP implementation; Concepts implementation.
🔗Issue #3: Argument-dependent lookup
Suppose our user now wants to create their own class, and utilize our interface. They will obviously use their own
1 | // Declaring a Vector3f class inside another namespace |
Our user can then try to use the interface they just declared valid:
1 | int main(){ |
Then, our user faces error messages from all compilers.
From GCC:
error: 'norm' was not declared in this scope; did you mean 'mylib::norm'?
From Clang:
error: use of undeclared identifier 'norm'; did you mean 'mylib::norm'?
And MSVC:
error C3861: 'norm': identifier not found
This happens because, until now, we’ve been depending on everybody’s favorite: Argument-dependent lookup (ADL). For this issue, we could just follow the compiler’s suggestion and make it work.
But forcing the user of derived class to know where the function is located is bad. What if we want to provide an iterator interface to make our class a range? We can’t change the standard algorithms to use mylib::begin
. We need to fix this.
We can try to have a minimal effort
1 | // We declare this in our library |
It would be fine to do that, except ADL explicitly avoids this kind of operation. So, if our user wants ADL, they need to import each individual function into their namespace:
1 | namespace userlib { |
But, if they forget a single one, the interface is broken. We have no elegant solution for that. All we have are workarounds:
1. Using macros to enable ADL
We can provide the user a macro with every function in our library:
1 |
|
Then, the user can just call it into their namespace:
1 | namespace userlib { |
It works (Compiler Explorer link), but, again, it’s far from elegant.
2. Putting our functions into the global namespace
Yes, our functions are limited to an opt-in concept, so we could try doing that without issues. No, still don’t do that.
🔗So is it all issues? Is there any advantage in using concepts over CRTP?
One of the issues with CRTP we’ve discussed in the beginning was it destroying our aggregate types. This doesn’t happen with concepts, which are non-invasive.
But there are other ways the use of concepts can be beneficial to our interfaces. Let’s see some of them:
🔗Advantage #2: Using our interface with third-party classes
When ADL is not a problem, we can use the CRTP interfaces with any class we want, as long as it satisfies our constraints.
For example, we can do (Compiler Explorer Link):
1 | template<> inline constexpr bool mylib::is_vector<a_c_vector> = true; |
Note: notice I’ve said “when ADL is not a problem”. We don’t want to import our functions into the std::
namespace to get ADL, for instance, as it’s undefined behavior.
🔗Advantage #3: Verifying the full interface at declaration time
In order to understand how concepts can help here, we must first analyze how errors are detected in CRTP:
🔗Limiting CRTP to only accept valid input
The first thing we need to understand is the order our declarations and definitions happen.
1. If we try to instantiate a derived class with a forward-declared base CRTP class, we have an error: Base
is an incomplete type.
1 | template<typename Derived> |
2. If we try to access anything from the derived class in a declaration inside the base, now the error is Derived
is incomplete. At the point of the evaluation, we still haven’t gotten into the first declaration inside the derived class.
1 | template<typename Derived> |
1 | template<typename Derived> |
3. We can declare everything in our base and derived class, and define later. This code is fine:
1 | template<typename Derived> |
4. Inside the definition of functions inside the base class, we can access any property of the derived, even if we define the function inside the class definition:
1 | template<typename Derived> |
5. The body of the functions inside the base class will only be instantiated on the first use. So, if we have an error, e.g. asserting the size of our derived class is 0, it will still compile at this point:
1 | template<typename Derived> |
Only when we use it, it will be instantiated and the code will fail:
1 | int main(){ |
So we have an issue: we can static_assert
all we want, and provide pretty error messages for every situation we can imagine. But the user won’t see it at the moment of opt-in: it may have a bug that only shows up very, very late!
🔗Making a concept yield an error when opting in
In our vector example, our vector
concept was very simple. For instance, Vector3f
is a vector2d
, and we wouldn’t want that. Some other errors that could happen include:
x
,y
andz
aren’t numbersx
,y
andz
aren’t the same typesizeof(T) != sizeof(x)*dimensions
T
isn’t constructible asT{x,y,z}
- etc.
The earlier we can catch these errors, the better. Otherwise, we have the same issue as CRTP. So, let’s redefine our concepts:
1 | template<typename T> |
Now, if we try to use the interface functions on the following class, they should fail (spoiler: they do — Compiler Explorer link):
1 | namespace mylib{ |
I’ve promised we can do better than CRTP, so let’s see how we can do better:
- Create an intermediate concept, that utilizes our
vector2d
andvector3d
concepts, without the need to opt-in - Restrict our opt-in variable
is_vector
with such concept - Redefine our
vector
concept to use onlyis_vector
1 | template<typename T> |
And it works! Well… 2/3 of the time. Current implementation of GCC only checks that the specialization is invalid if we instantiate the specialization. Clang and MSVC yield the error we’d expect: (Compiler Explorer link) (to be more exact, one of them yields a beautiful well-explained error we’d expect, the other one is MSVC).
🔗Comparing both solutions
Here’s a succinct table, comparing both techniques:
Feature | CRTP | Concepts |
---|---|---|
Opt-in | ✔️ | ✔️ |
Operator overloading | ✔️ | ✔️ |
Free functions | ✔️ | ✔️ |
Member functions and conversion operators | ✔️ | ❌ |
Opt-in for third-party classes | ❌ | ✔️ |
Argument-Dependent Lookup (ADL) | ✔️ | ❌/✔️ (it’s bad) |
Non-intrusive aggregate types | ❌ | ✔️ |
Interface verification on declaration | ❌ | ✔️ |
Friendly messages for interface errors | ✔️ with static_assert |
❌/✔️ compiler-dependent |
🔗Conclusion
Concepts can be used as replacements for CRTP, and even provide cleaner interfaces in some aspects. However, not being able to provide member functions turns this entire experiment a useless trivia feature for many users.
You can definitely use this technique if all of these apply to your CRTP implementation:
- Classes are in the same namespace as the concept or you don’t mind importing multiple functions into your namespace
- All of your interface’s functions are free functions or operators that can be implemented as free functions
In most cases, it’s safer to stay with good ol’ CRTP, at least for now.
C++ has had numerous Unified [Function] Call Syntax (UCS/UFCS) proposals, though not a single one has moved forward in the standardization process (See Barry Revzin’s post on the history of UFCS: What is unified function call syntax anyway?). If we ever get UFCS into the language, concepts might be a viable replacement for CRTP in more applications.
If you’ve reached this far, thank you for reading. I hope you’ve learned something new; I sure have. As always, if I’m wrong, don’t hesitate to correct me! You can find me on twitter, on the CppLang Slack, and you can also open an issue on this blog’s repository!