Templates are an extremely powerful – and terrifying – element of C++ programs. I say “terrifying” – not because templates are particularly hard to use (normally), or even particularly complex to write (normally) – but because when things go wrong the compiler’s output is a tsunami of techno-word-salad that can overwhelm even the experienced programmer.
The problem with generic code is that it isn’t completely generic. That is, generic code cannot be expected to work on every possible type we could substitute. The generic code typically places constraints on the substituted type, which may be in the form of type characteristics, type semantics or behaviours. Unfortunately, there is no way to find out what those constraints are until you fail to meet them; and that usually happens at instantiation time, far away from your code and deep inside someone else’s hard-to-decipher library code.
The idea of Concepts has been around for many years; and arguably they trace their roots right back to the very earliest days of C++. Now in C++17 we are able to use and exploit their power in code.
Concepts allow us to express constraints on template types with the goals of making generic code
- Easier to use
- Easier to debug
- Easier to write
In this pair of articles we’ll look at the basics of Concepts, their syntax and usage. To be open up-front: this article is designed to get you started, not to make you an expert on Concepts or generic code.
Not-so-generic code
Let’s start with some generic code. In this case a fixed-size First-In-First-Out (FIFO) buffer. If you’re new to generic (template) code, or would like a quick refresher, have a look here.
For simplicity I’ve built it as an adapter around std::array; and for brevity I’m omitting all error handling. (In other words, this is demonstration-quality, not production-quality code)
template <typename T, size_t sz> class Buffer { public: void push(const T& in_val); T pop(); bool empty() const; private: using Container = std::array<T, sz>; using Iterator = typename Container::iterator; Container buffer { }; Iterator write { std::begin(buffer) }; Iterator read { std::begin(buffer) }; size_t num_items { 0 }; };
For the moment, I’ll ignore the implementation of the member functions. We can now create Buffers of different types in client code.
#include "Buffer.h" int main() { Buffer<int, 16> int_buf { }; Buffer<double, 8> dbl_buf { }; int_buf.push(100); // etc... }
What about if I try to use my Buffer with one of my user-defined types?
// Some Abstract Data Type // class ADT { public: ADT(int init); void op_A() const; void op_B(); void op_C(); private: // Attributes... }; int main() { Buffer<ADT, 16> adt_buffer { }; // Should be fine...? }
If you’re familiar with template code you may be able to guess what the problem is here. That’s useful, since the error diagnostic emitted by the compiler isn’t particularly enlightening
src/main.cpp:39:27:error: could not convert '<brace-enclosed initializer list>()' from '<brace-enclosed initializer list>' to 'ADT' Container buffer { }; ^ src/main.cpp:39:27: error: could not convert '<brace-enclosed initializer list>()' from '<brace-enclosed initializer list>' to 'ADT' src/main.cpp:39:27: error: could not convert '<brace-enclosed initializer list>()' from '<brace-enclosed initializer list>' to 'ADT' src/main.cpp:39:27: error: could not convert '<brace-enclosed initializer list>()' from '<brace-enclosed initializer list>' to 'ADT' src/main.cpp:39:27: error: could not convert '<brace-enclosed initializer list>()' from '<brace-enclosed initializer list>' to 'ADT' ... (this goes on for quite a while...)
For those not so familiar, what’s happening is the compiler attempting to construct a std::array of ADT objects. In the absence of any other information it is attempting to call the default constructor of the ADT class. Our design for the ADT omits a default constructor (there’s only a non-default constructor).
It would be so much more helpful if the compiler could emit a more meaningful diagnostic for us, giving us more of a clue as to what’s gone wrong (and whose fault it is!). This is where template requirements come in.
Requirements
In order to be used with our Buffer class any template parameter must be capable of being default-constructed. That is, it must be possible to construct an object without passing any parameters. Thus, we must have a default constructor or default values for any constructor parameters.
We can add this additional constraint onto the template class as a Requirement. Requirements are compile-time Boolean expressions. The template will only be instantiated if all the requirements placed upon it are true.
(You’ll need to enable concepts on most compilers. On gcc you must use the compiler flag ‘-fconcepts’)
To help us there is a useful type trait provided by the Standard Library – std::is_default_constructible – which will return true (at compile-time) if supplied type can be default-constructed.
#include <type_traits> template <typename T, size_t sz> requires std::is_default_constructible<T>::value class Buffer { public: void push(const T& in_val); T pop(); bool empty() const; private: using Container = std::array<T, sz>; using Iterator = typename Container::iterator; Container buffer { }; Iterator write { std::begin(buffer) }; Iterator read { std::begin(buffer) }; size_t num_items { 0 }; };
(Note: there is an alias for the type trait – is_default_constructible_v<T> – which could be used to simplify code. I’ll stick to the ‘full’ versions in this article)
Our error message is now a lot more palatable:
int main() { Buffer<ADT, 16> adt_buffer { }; }
src/main.cpp:169:19: error: template constraint failure Buffer<ADT, 16> buffer { }; ^ src/main.cpp:169:19: note: constraints not satisfied src/main.cpp:169:19: note: 'std::is_default_constructible::value' evaluated to false
Adding a default constructor to my ADT class makes the errors disappear. So everything’s fine, then?
Well, not quite.
Let’s visit the implementations of the Buffer’s member functions
template <typename T, size_t sz> void Buffer<T, sz>::push(const T& in_val) { if (num_items == sz) throw std::out_of_range { "Buffer full!" }; *write = in_val; // <= Insert by copy ++num_items; ++write; if (write == std::end(buffer)) write = std::begin(buffer); } template <typename T, size_t sz> T Buffer<T, sz>::pop() { if (num_items == 0) throw std::out_of_range { "Buffer empty!" }; auto temp = *read; // <= Extract by copy --num_items; ++read; if (read == std::end(buffer)) read = std::begin(buffer); return temp; }
(Once again, remember this is not meant to be production-level code. I know there are plenty of things we could do to make this code more flexible!)
If we decide for some (presumably very good) design reason to make our user-defined ADT class non-copyable we have a problem.
// Some Abstract Data Type // class ADT { public: ADT(int init); void op_A() const; void op_B(); void op_C(); // ADTs are non-copyable // ADT(const ADT&) = delete; ADT& operator=(const ADT&) = delete; private: // Attributes... }; int main() { Buffer<ADT, 16> adt_buffer { }; // OK adt_buffer.push(ADT { }); }
src/main.cpp: In instantiation of 'void Buffer<T, sz>::push(const T&) [with T = ADT; long unsigned int sz = 16]': src/main.cpp:175:24: required from here src/main.cpp:49:12: error: use of deleted function 'ADT& ADT::operator=(const ADT&)' *write = in_val; ~~~~~~~^~~~~~~~ src/main.cpp:86:10: note: declared here ADT& operator=(const ADT&) = delete; ^~~~~~~~
In this instance it’s relatively straightforward to diagnose the error. In production code, however, templates can be based on other templates, which can be based on other (ever-more obscure) templates; and so on. There’s no guarantee the error message will be in any way decipherable to the average programmer.
Futhermore, if you attempt to retrieve any ADT objects from the Buffer you’ll be greeted with a similar diagnostic, this time listing the copy constructor as the culprit.
For our Buffer template class, then, the requirement to support default-construction is not sufficient – we also need to add the requirement to support both copy-construction and copy-assignment.
template <typename T, size_t sz> requires std::is_default_constructible<T>::value && std::is_copy_assignable<T>::value && std::is_copy_constructible<T>::value class Buffer { public: void push(const T& in_val); T pop(); bool empty() const; private: using Container = std::array<T, sz>; using Iterator = typename Container::iterator; Container buffer { }; Iterator write { std::begin(buffer) }; Iterator read { std::begin(buffer) }; size_t num_items { 0 }; };
(Note the use of logical AND to combine each of the requirements. We could, of course, build a complex set of requirements with a combination of logical operators.)
Our error diagnostic becomes
src/concepts.cpp: In function 'int main()': src/concepts.cpp:297:19: error: template constraint failure Buffer<ADT, 16> adt_buffer { }; // OK ^ src/concepts.cpp:297:19: note: constraints not satisfied src/concepts.cpp:297:19: note: 'std::is_copy_assignable::value' evaluated to false src/concepts.cpp:297:19: note: 'std::is_copy_constructible::value' evaluated to false src/concepts.cpp:299:16: error: request for member 'push' in 'adt_buffer', which is of non-class type 'int' adt_buffer.push(ADT { }); ^~~~
A good point to pause
I think it’s reasonable to state that very little template code is truly, completely generic; that is, it would work with any type. In many (if not most) cases there will be some requirement on the capabilities of the template parameter. Usually, these requirements are tacit – implicit constraints that are only found (by the client!) when the template is instantiated.
Adding explicit requirements to template parameters has two benefits
- It forces the template designer to consider – and document! – the constraints imposed on the template parameters.
- It provides useful diagnostic information for template users; expressing where their code is deficient.
I would argue, then, it is good practice to always document the requirements on your parameters when you write template code.
However, explicitly listing every requirement on your parameters can quickly become exhausting. And, very commonly, requirements come in ‘sets’ that define abstract characteristics of types (for example, the characteristic of being ‘copyable’ in our example). This is where the idea of Concepts comes in. And that is what we will explore in part 2 of this article.
- Practice makes perfect, part 3 – Idiomatic kata - February 27, 2020
- Practice makes perfect, part 2– foundation kata - February 13, 2020
- Practice makes perfect, part 1 – Code kata - January 30, 2020
Glennan is an embedded systems and software engineer with over 20 years experience, mostly in high-integrity systems for the defence and aerospace industry.
He specialises in C++, UML, software modelling, Systems Engineering and process development.