You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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:
For this article, we’ll utilize a tutorial writer’s favorite: Vectors!
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)
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:
structVector3i : 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)
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.
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):
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.
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.
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.
Our user can then try to use the interface they just declared valid:
1 2 3 4
intmain(){ 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
namespace mylib::vector_functions {
using mylib::norm; using mylib::operator+;
}
namespace userlib {
usingnamespace 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:
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.
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.
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:
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> structBase;
structMyDerived : 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.
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:
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:
Only when we use it, it will be instantiated and the code will fail:
1 2 3
intmain(){ MyDerived{}.something_else(); }
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!
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:
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).
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
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!
via Joel Filho https://joelfilho.com
December 1, 2023 at 02:04PM
The text was updated successfully, but these errors were encountered:
Using C++20's concepts as a CRTP alternative: a viable replacement?
https://ift.tt/fn5rAP0
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:
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:
For this article, we’ll utilize a tutorial writer’s favorite: Vectors!
In this example, we have:
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)
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: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:
And our solution:
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.
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):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:
false
To opt-in into that concept, we then need to specialize the variable template:
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.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.With concepts, we cannot inject a member function. So, we need to use the free function:
You can see on Compiler explorer: CRTP implementation; Concepts implementation.
Suppose our user now wants to create their own class, and utilize our interface. They will obviously use their own
Our user can then try to use the interface they just declared valid:
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
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:
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:
Then, the user can just call it into their namespace:
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.
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):
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.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.3. We can declare everything in our base and derived class, and define later. This code is fine:
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:
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:
Only when we use it, it will be instantiated and the code will fail:
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 avector2d
, 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}
The earlier we can catch these errors, the better. Otherwise, we have the same issue as CRTP. So, let’s redefine our concepts:
Now, if we try to use the interface functions on the following class, they should fail (spoiler: they do — Compiler Explorer link):
I’ve promised we can do better than CRTP, so let’s see how we can do better:
vector2d
andvector3d
concepts, without the need to opt-inis_vector
with such conceptvector
concept to use onlyis_vector
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).
Here’s a succinct table, comparing both techniques:
static_assert
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:
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!
via Joel Filho https://joelfilho.com
December 1, 2023 at 02:04PM
The text was updated successfully, but these errors were encountered: