C++ concepts is a revolutionary idea aimed at building better and safer abstractions at the code level. Generics in C++ allows to express an abstract operation (maybe an algorithm) to different types (classes). Templates allowed abstraction of operations such as sort which can be used to sort a vector, an array or any object with RandomAccessIterator properties.
But it was evident that templates in C++ could easily be misused and the error messages of the compiler were cryptic. Because, when a developer uses generics to use the operation with an unintended data structure / data type, the compiler would throw an error at the point an illegal operation is performed. Surprisingly, not when the error occurred in the first place. Let me explain with an example.
Example with a Buffer in C++
In Machine Learning, we use Tensor objects with underlying buffers. A buffer is responsible for allocating memory and providing a pointer. A Tensor object just keeps track of the pointer and its dimensions (height, width, num of channels) and acts as a container. The reason is the container should be separate from underlying memory management.
So, our buffer would look like,
template <typename T>
class Buffer {
public:
Buffer() = default;
~Buffer() = default;
}
But this could lead to a multitude of problems. Did the original developer intend the type be a Plain Old Data (POD) type? A POD is either a scalar or class type (class or union with standard layout). For example, a class with a virtual function will not fit the criteria. The developer might use malloc from C library to allocate space for the primary data type. But what happens when a non-POD type is used by a developer who joins the team later?
Clearly, there isn’t an efficient way for the Compiler to detect illegal usages. This is where C++20 introduces Concepts to define the behavior of template parameter. A concept is a predicate (statement with true / false value), and is a named set of requirements.
Concepts can be used to constrain template parameters used in function definitions or class definitions. A constraint is a sequence of logical operations that specifies requirements on template arguments. These requirements could be in the form of conjunctions, disjunctions and atomic constraints.
Now, we can re-express the Buffer definition as follows.
#ifndef __BUFFER_H__
#define __BUFFER_H__
#include <memory>
#include <type_traits>
// we restrict template arguments to trivial
template <typename T>
requires (std::is_trivial<T>::value)
class Buffer {
public:
Buffer() = default; // default constructor
T* allocate(size_t size); // allocate memory and ret ptr
~Buffer() = default; // default destructor
};
// need to redeclare constraints
// otherwise the compiler throws
// "clause differs in template redeclaration"
template <typename T>
requires (std::is_trivial<T>::value)
T* Buffer<T>::allocate(size_t size) {
T* ptr = static_cast<T*>(std::malloc(sizeof(T) * size));
return ptr;
};
#endif
Let’s test this!
Testing using Clang version 10.0.0
At the time of writing, very few compiler versions support C++20 Concepts. GCC and Clang are ahead of the game. If you are interested in trying out, I would suggest making a git clone of LLVM project and building llvm and clang in the release mode.
clang -std=c++2a -o main main.cc
Now, our main.cc file would look like as follows. “buffer.h” will contain our Buffer class definition.
#include <stdio.h>
#include "buffer.h"
int main() {
Buffer<float> buffer;
float *ptr = buffer.allocate(10);
std::free(ptr);
return 0;
}
You’ll see that the above example compiles and runs without any issues. Now, let’s break it using a non-trivial type. Let’s define a ComplexType for complex numbers in a file called non_trivial_type.h. The type is non-trivial because we define our own constructor.
#ifndef __NON_TRIIVAL_TYPE_H__
#define __NON_TRIIVAL_TYPE_H__
class ComplexType {
private:
uint64_t imaginary_part = 0;
uint64_t real_part = 0;
public:
ComplexType(uint64_t im, uint64_t re);
~ComplexType() = default;
};
ComplexType::ComplexType(uint64_t im, uint64_t re) {
this->imaginary_part = im;
this->real_part = re;
}
#endif
Now, let’s try the main function, but this time with our ComplexType.
#include <stdio.h>
#include "buffer.h"
#include "non_trivial_type.h"
int main() {
Buffer<ComplexType> buffer;
ComplexType *ptr = buffer.allocate(10);
std::free(ptr);
return 0;
}
If you try to compile the program, you will get an error saying,
$ clang -std=c++2a -o main main.cc
main.cc:7:3: error: constraints not satisfied for class template
'Buffer' [with T = ComplexType]
Buffer<ComplexType> buffer;
^~~~~~~~~~~~~~~~~~~
./buffer.h:8:15: note: because 'std::is_trivial<ComplexType>::value'
evaluated to false
requires (std::is_trivial<T>::value)
^
1 error generated.
If you did not use Concepts in Buffer class definition, you can compile without encountering any issues and also run the program! You will never know that the programmer intended the Buffer only for POD types (trivial). How scary is that!
Surely, without Concepts, when something breaks in the future, it would need many hours of debugging to figure out where it went wrong.
Hurdles of Using C++ Concepts
With C++ concepts, the learning curve keeps on increasing. It takes a considerable amount of time for a developer to get used to the syntax and learning Object Oriented principles on top of it will become extremely difficult.
Personally, I would like to avoid redeclaring constraints on my template arguments (Even in our example, we had to use requires (std::is_trivial::value) a several times). This becomes a nightmare when there are multiple constraints in conjunction / disjunction. C++ proposes to use separate Concept definitions in cases like this.
However, concepts will definitely help to avoid misuse of template arguments. As there are various hacks that are possible because of templates and this loop hole allowed for behaviors not intended by the original developer. Now, we can be hopeful and Concepts are only limited by the imagination of the developer.
All the examples are available on github.