Motivated by Haskell’s type system I have been learning the C++20 feature, concepts. Concepts are to C++ what type classes are to Haskell (conceptually at least). In a nutshell, concepts allow us to declaratively describe a type based off of requirements that the type must satisfy. Often this is paired with C++ type_traits that aid in this description.
Why is this cool? Well, it allows us to constrain polymorphic types elegantly. This leads to better error messages, safer code, and overall tighter C++ (in my opinion). In my short example I am going to go through how to ensure that a template argument derives some notion of printing to the terminal. That is, I want to make sure any template argument passed into my container has an overloaded std::ostream operator
<<
. This might not be the most compelling example of concepts but I found it nifty and those who aren't experts may also.
Let’s do it!
//this is a container
template <typename T>
struct Container {
using value_type = T; //type alias
T val; //storage
void show() {
std::cout << val;
}
}
So we start with a simple struct Container
with a type alias value_type
and a show
function that simply prints the val
member to the screen. This works as it should for the following substitutions for type T
.
int main() {
Container<std::string> t{"Concepts rule!"};
t.show(); //prints "Concepts rule!" to the terminal
return 0;
}
int main() {
Container<int> t{69};
t.show(); //prints 69 to the terminal
return 0;
}
But as I am sure you know, this is not fine and dandy for all types passed as template arguments. Consider the example of passing in a user defined type to Container
.
template<typename T>
struct Box { /* implementation code ... */ };
int main() {
Container<Box<int>> t{}; //this is fine
t.show(); //compiler error!
return 0;
This blows up in our face when we substitute a template argument for something that does not define the <<
operator. Not at the time of template instantiation but because we call t.show()
.
We get an error message that says,
`no match for 'operator<<' (operand types are 'std::ostream' {aka 'std::basic_ostream<char>'} and 'Bar<int>'
This is obvious, right? The error message clearly says that the compiler has no idea what it means to apply the infix operator <<
with your user defined type Bar<int>
as an argument.
Well, let’s say that we want to add the constraint that any type that does not implement a notion of printing to the terminal is incorrect. In our current example, if we never call show
, the compiler error won’t arise! This is bad. We want to ensure that at template instantiation we are not allowed to pass in a type that does not comply to our constraint.
This is different than a generic compiler error based on a language mechanism like operator overloading. We are declaring the “acceptable types” of Container
.
This is where concepts come in. Concepts allow us to express ideas related to types such that a template parameter has semantic meaning. We are constraining polymorphic types.
Lets make a concept called Showable
template<typename T>
concept Showable = requires(T& t, std::ostream& os) {
{os << t} -> std::same_as<std::ostream&>;
};
Let’s un-pack this. We introduce the concept
keyword that creates a new set of types called Showable
. The requires syntax used in this example is in the form of requires(parameter list...){requirement sequence...}
. The parameters do nothing else other than help us describe requirements in the requirements sequence. The requires clause reads as, given a T&
and a std::ostream&
the expression {os << t}
returns std::ostream&
.
Putting the whole thing together should read as, Showable
takes a type T such that the operation of os << t
where os is of type std::ostream&
and t is of type T&
is valid and returns a value of std::ostream&
.
Our new concept creates a predicate that can be used in place of typename
for our templated code. This predicate fails if a type passed into our template does not satisfy the above requirements. Failure in this context is much closer to what we want semantically.
From here we change our Container
template struct to
template <Showable T> // change typename to our concept Showable
struct Container {
using value_type = T;
T val;
void show() {
std::cout << val;
}
};
int main() {
Container<Box<int>> t; // where Box<T> does not implement an overloaded <<
return 0;
}
error: constraints not satisfied for class template 'Container' [with T = Box<int>]
Container<Box<int>> t;
^~~~~~~~~~~~~~~~~~~
note: because 'Box<int>' does not satisfy 'Showable'
template <Showable T>
Yay! We just wrote a concept! We said something about the type of types allowed as an argument to a user defined type….meta. This was a contrived example but it doesn’t take much convincing to see that this C++ feature is bad ass and super powerful.
Here is the semantically “equivalent” code in Haskell (in respect to constraining polymorphic types)
data Container a = Container a -- a simple data constructor
show' :: Show(a) => a -> String -- redefinition of show using the standard lib show function
show' = show
main = do
putStrLn $ show' (Container "this isn't c++?!")
Where Show(a)
constrains the polymorphic type applied to our show'
function. In the above code our type Container
does not derive from the type class Show
which means that the above code is ill formed and results in the compiler error below
* No instance for (Show (Container String))
arising from a use of show
We fix this issue with the following change
data Container a = Container a deriving(Show) -- our data constructor now derives Show
show' :: Show(a) => a -> String -- redefinition of show using the standard lib show function
show' = show
main = do
putStrLn $ show' (Container "this isn't c++?!")
Our constraint is met and now produces the output we were expecting :)
Container "this isn't c++?!"
Top comments (1)
nit: there is a small typo in the example provided. The explanation says ‘Bar’ but the example code has ‘struct Box’