Using C++20's concepts as a CRTP alternative: a viable replacement?

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 1. We have a Base class template that utilizes a Derived template parameter
template<typename Derived>
struct Base {
// 2. We also have a public interface that our derived class wants to provide
auto public_behavior(){
// 3. We can safely cast the this pointer to the Derived class
auto& self = *static_cast<Derived*>(this);

// 4. We then use the required provided behavior from the base class, and give our base behavior
return do_something(self.provided_behavior());
}

};

// 5. We can have a derived class that inherits from Base, using itself as template parameter.
// Notice it uses public inheritance, since we want to provide a public interface
struct MyDerived : Base<MyDerived> {
// 6. And then we provide the interface that the base class requires
auto provided_behavior() { /*...*/ }
};

// 7. We can just invoke the public behavior, and no one using it needs to know it uses CRTP.
MyDerived object;
object.public_behavior();

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
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
36
37
38
39
40
41
42
43
namespace mylib {

// Our CRTP base class, representing a 2D or a 3D vector
template<typename Derived>
struct Vector {
// A basic operation. Our CRTP base class would have many of them, but one is enough.
auto operator+(const Derived& other) const noexcept {
auto& self = *static_cast<const Derived*>(this); // Derived must be const-qualified

// Let's implement an operation that supports two vector sizes
if constexpr(requires{ self.z; }) { // 3D-case, detecting if we have a Z-component
return Derived { self.x + other.x, self.y + other.y, self.z + other.z };
} else { // 2D, and base case
return Derived { self.x + other.x, self.y + other.y };
}
}
};

// We then can declare our trivial Vector structures
struct Vector2f : Vector<Vector2f> {
float x;
float y;
};

struct Vector3i : Vector<Vector3i> {
int x;
int y;
int z;
};
}

// And then, we use our Vector library...
int main(){
mylib::Vector2f v1{1, 0};
mylib::Vector2f v2{0, 1};

mylib::Vector2f v3 = v1 + v2;

mylib::Vector3i v4{1, 0, 0};
mylib::Vector3i v5{0, 1, 1};

mylib::Vector3i v6 = v4 + v5;
}

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
2
3
4
5
6
7
8
9
10
11
int main(){
mylib::Vector2f v1{{}, 1, 0}; // Empty braces initializing the Vector<...> base everywhere!
mylib::Vector2f v2{{}, 0, 1};

mylib::Vector2f v3 = v1 + v2;

mylib::Vector3i v4{{}, 1, 0, 0};
mylib::Vector3i v5{{}, 0, 1, 1};

mylib::Vector3i v6 = v4 + v5;
}

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
2
3
4
5
6
7
8
9
10
11
12
struct Vector2f : Vector<Vector2f> {
constexpr Vector2f(float x, float y) noexcept : x(x), y(y) {}
float x;
float y;
};

struct Vector3i : Vector<Vector3i> {
constexpr Vector3i(int x, int y, int z) noexcept : x(x), y(y), z(z) {}
int x;
int y;
int z;
};

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:

  1. Define our concept to accept 2D and 3D cases
  2. Implement the member function as a free function

And our solution:

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
36
37
38
39
40
41
42
43
44
namespace mylib {

// We can describe our requirements separately...

// ... for 2D...
template<typename T>
concept vector2d = requires(T vec){
{vec.x};
{vec.y};
};

// ... and for 3D...
template<typename T>
concept vector3d = requires(T vec){
{vec.x};
{vec.y};
{vec.z};
};

// ... and then compose our vector (#1)
template<typename T>
concept vector = vector2d<T> || vector3d<T>;

// Our operator is now a free function (#2)
template<vector Vector>
auto operator+(const Vector& self, const Vector& other) noexcept {
if constexpr(vector3d<Vector>) { // We can replace our requires-clause with our concept
return Vector {self.x + other.x, self.y + other.y, self.z + other.z };
} else { // 2D, still the base case
return Vector {self.x + other.x, self.y + other.y };
}
}

struct Vector2f {
float x;
float y;
};

struct Vector3i {
int x;
int y;
int z;
};
}

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_ifs. 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace mylib {

struct Point2f {
float x;
float y;
};

}

int main(){
mylib::Point2f p1 {0, 0};
mylib::Point2f p2 {1, 1};

auto p3 = p1 + p2;
}

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:

  1. Create a boolean variable template with default false
  2. Add that variable as a requirement in our concept declaration
1
2
3
4
5
6
7
// 1. We create the variable template
template<typename T>
inline constexpr bool is_vector = false;

// 2. Then we update our concept to use it
template<typename T>
concept vector = is_vector<T> && (vector2d<T> || vector3d<T>);

To opt-in into that concept, we then need to specialize the variable template:

1
2
template<> inline constexpr bool is_vector<Vector2f> = true;
template<> inline constexpr bool is_vector<Vector3i> = 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template<typename Derived>
struct Vector {
auto operator+(const Derived& other) const noexcept {/* ... */}

// Free function, as a hidden friend
friend auto norm(const Derived& self) noexcept {
auto norm_sq = self.x * self.x + self.y * self.y;
if constexpr(requires{ self.z; }) {
norm_sq += self.z + self.z;
}
return std::sqrt(norm_sq);
}

// Member function
auto norm() const noexcept {
auto& self = *static_cast<const Derived*>(this);
return norm(self); // Slideware notice: can't compile here.
}
};

With concepts, we cannot inject a member function. So, we need to use the free function:

1
2
3
4
5
6
7
auto norm(const vector auto& self) noexcept {
auto norm_sq = self.x * self.x + self.y * self.y;
if constexpr(requires{ self.z; }) {
norm_sq += self.z + self.z;
}
return std::sqrt(norm_sq);
}

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
2
3
4
5
6
7
8
9
10
11
// Declaring a Vector3f class inside another namespace
namespace userlib {
struct Vector3f {
float x;
float y;
float z;
};
}

// Correctly opting-in into our concept
template<> inline constexpr bool mylib::is_vector<userlib::Vector3f> = true;

Our user can then try to use the interface they just declared valid:

1
2
3
4
int main(){
userlib::Vector3f v{1, 0, 1};
float n = norm(v);
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// We declare this in our library
namespace mylib::vector_functions {

using mylib::norm;
using mylib::operator+;

}

namespace userlib {

// The user then imports all functions into their namespace
using namespace mylib::vector_functions;

}

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
2
3
4
namespace userlib {
using mylib::norm;
using mylib::operator+;
}

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
2
3
#define IMPORT_VECTOR_FUNCTIONS() \
using ::mylib::norm; \
using ::mylib::operator+

Then, the user can just call it into their namespace:

1
2
3
4
5
namespace userlib {

IMPORT_VECTOR_FUNCTIONS();

}

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
2
3
4
5
6
template<> inline constexpr bool mylib::is_vector<a_c_vector> = true;

auto do_operation(const a_c_vector& vec, const a_c_vector& vec2){
using namespace mylib;
return norm(vec + vec2);
}

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
2
3
4
5
template<typename Derived>
struct Base;

struct MyDerived : Base<MyDerived> {};

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
2
3
4
5
6
template<typename Derived>
struct Base {
static_assert(sizeof(Derived) != 0); // Error: Derived is incomplete.
};

struct MyDerived : Base<MyDerived> {};
1
2
3
4
5
6
7
8
9
template<typename Derived>
struct Base {
auto something_else()
-> decltype(static_cast<Derived*>(this)->something()); // Still incomplete.
};

struct MyDerived : Base<MyDerived> {
int something();
};

3. We can declare everything in our base and derived class, and define later. This code is fine:

1
2
3
4
5
6
7
8
template<typename Derived>
struct Base {
auto something_else();
};

struct MyDerived : Base<MyDerived> {
int something();
};

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
2
3
4
5
6
7
8
9
10
11
template<typename Derived>
struct Base {
auto something_else(){
static_assert(sizeof(Derived) != 0);
return static_cast<Derived*>(this)->something();
}
};

struct MyDerived : Base<MyDerived> {
int something();
};

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
2
3
4
5
6
7
8
9
10
11
template<typename Derived>
struct Base {
auto something_else(){
static_assert(sizeof(Derived) == 0);
return static_cast<Derived*>(this)->something();
}
};

struct MyDerived : Base<MyDerived> {
int something();
};

Only when we use it, it will be instantiated and the code will fail:

1
2
3
int main(){
MyDerived{}.something_else(); // Fails at this moment
}

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 and z aren’t numbers
  • x, y and z aren’t the same type
  • sizeof(T) != sizeof(x)*dimensions
  • T isn’t constructible as T{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
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
template<typename T>
concept vector2d = requires(T vec){
// We require that vec.x and vec.y are valid expressions
vec.x;
vec.y;

// We require that x is a number (there seems to be no concept for that, so we use a type trait)
requires std::is_arithmetic_v<decltype(vec.x)>;
// And that y is the same type
requires std::same_as<decltype(vec.x), decltype(vec.y)>;
// And that the type is composed by only x and y (assuming no weird alignment stuff)
requires sizeof(vec) == sizeof(vec.x) * 2;

// We require that constructing a T from x and y is a valid expression
T{vec.x, vec.y};
};

// Then we repeat the same logic, but for 3 members
template<typename T>
concept vector3d = requires(T vec){
vec.x;
vec.y;
vec.z;
requires std::is_arithmetic_v<decltype(vec.x)>;
requires std::same_as<decltype(vec.x), decltype(vec.y)>;
requires std::same_as<decltype(vec.x), decltype(vec.z)>;
requires sizeof(vec) == sizeof(vec.x) * 3;
T{vec.x, vec.y, vec.z};
};

Now, if we try to use the interface functions on the following class, they should fail (spoiler: they do — Compiler Explorer link):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
namespace mylib{

struct Vector2_err {
float x;
double y;
};

template<> inline constexpr bool is_vector<Vector2_err> = true;

}

int main(){
mylib::Vector2_err v1{1, 0};
mylib::Vector2_err v2{0, 1};
// Compiles fine up until here

v1 + v2; // Error on instantiation, like CRTP!
}

I’ve promised we can do better than CRTP, so let’s see how we can do better:

  1. Create an intermediate concept, that utilizes our vector2d and vector3d concepts, without the need to opt-in
  2. Restrict our opt-in variable is_vector with such concept
  3. Redefine our vector concept to use only is_vector
1
2
3
4
5
6
7
8
template<typename T>
concept vector_interface = vector2d<T> || vector3d<T>;

template<vector_interface T>
inline constexpr bool is_vector = false;

template<typename T>
concept vector = is_vector<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!