How it works...

To best explain how C++20 Concepts will aid in template programming, we will start with a simple example of programming an interface in C++ today. Interfaces define a contract between the implementation of an Application Programming Interface (API) and the user of the API and are heavily used in object-oriented programming to abstract away the interface of an API from its implementation details.

Let's start with the following pure virtual interface:

class interface
{
public:
virtual ~interface() = default;
virtual void foo() = 0;
};

The preceding pure virtual interface in C++ defines a foo() function. Clients of this API do not need to know how foo() is implemented. All they care about is the definition of the interface and the function signature of foo() to understand how foo() should behave. Using this interface, we can define an implementation of this interface, as follows:

class A :
public interface
{
public:
void foo() override
{
std::cout << "The answer is: 42 ";
}
};

As shown in the preceding example, we created a class called A that inherits the interface and override the foo() function to give it an implementation. We can do the same thing with another implementation, as follows:

class B :
public interface
{
public:
void foo() override
{
std::cout << "The answer is not: 43 ";
}
};

As shown in the preceding example, the B class provides the interface with an alternative implementation of the interface. Clients of this interface can use the interface as follows:

class client
{
interface &m_i;

public:
client(interface &i) :
m_i{i}
{ }

void bar()
{
m_i.foo();
}
};

The client doesn't actually need to know anything about A or B. It simply includes the definition of the interface and uses the interface to access any specific implementation. We can use this client as follows:

int main(void)
{
A a;
B b;

client c1(a);
client c2(b);

c1.bar();
c2.bar();
}

As shown in the preceding example, we first create instances of both A and B, and we then create two different clients that are given implementations of the interface for both A and B. Finally, we execute the bar() functions for each client, resulting in the following output:

As shown in the preceding screenshot, the client is unaware that the interface was defined in two different ways as the client only concerns itself with the interface. This technique is demonstrated in a lot of C++ literature, specifically to implement what is known as the S.O.L.I.D object-oriented design principles. The S.O.L.I.D design principles stand for the following:

  • Single responsibility principle: This ensures that if an object must change, it only changes for one reason (that is, an object doesn't provide more than one responsibility).
  • Open–closed principle: This ensures that an object can be extended without being modified.
  • Liskov substitution principle: This ensures that, when inheritance is used, subclasses implement the behavior of functions they override and not just the function's signature.
  • Interface segregation principle: This ensures that an object has the smallest possible interface so that clients of the object are not forced to depend on APIs they do not use.
  • Dependency inversion principle: This ensures that objects are only dependent on interfaces and not on implementations.

The combination of these principles is designed to ensure that your use of object-oriented programming in C++ is easier to understand and maintain over time. One issue, however, with the existing literature for S.O.L.I.D and C++ is that it advocates for the heavy use of pure virtual interfaces, which come at a cost. Each class must be given an extra virtual table (that is, vTable), and all function calls encounter the extra overhead of virtual function overloading.

One way to solve this is to use static interfaces (something that is not often talked about in existing literature). To best explain how this works, let's start with the definition of our interface, as follows:

#include <iostream>

template<typename DERIVED>
class interface
{
public:
constexpr void foo()
{
static_cast<DERIVED *>(this)->foo_override();
}
};

As shown in the preceding example, we will leverage static polymorphism to implement our interface. The preceding class takes a type called DERIVED and casts an instance of the interface to the DERIVED class, calling a version of the foo function that has been overridden. The implementation of A now looks like this:

class A :
public interface<A>
{
public:
void foo_override()
{
std::cout << "The answer is: 42 ";
}
};

As shown in the preceding example, instead of inheriting the interface, A now inherits an interface of A. When the foo() function from the interface is called, the interface redirects the call to the foo_override() function for A. We can implement B using the same approach:

class B :
public interface<B>
{
public:
void foo_override()
{
std::cout << "The answer is not: 43 ";
}
};

As shown in the preceding example, B is capable of providing its own implementation of the interface. It should be noted that so far in this design pattern, we have yet to use virtual, meaning we have created an interface and implementations of that interface without the need for virtual inheritance, so there is no overhead associated with this design. In fact, the compiler is capable of removing the redirection of the call from foo() to foo_override(), ensuring that the use of abstraction doesn't provide any additional runtime costs compared to the use of pure virtual interfaces.

Clients of A and B can be implemented as follows:

template<typename T>
class client
{
interface<T> &m_i;

public:
client(interface<T> &i) :
m_i{i}
{ }

void bar()
{
m_i.foo();
}
};

As shown in the preceding code snippet, the only difference between the client in this example and the one in the previous example is the fact that this client is a template class. Static polymorphism requires that the type of information about an interface is known at compile time. This tends to be fine in most designs as the use of pure virtual interfaces earlier was not because we wanted the ability to perform runtime polymorphism and type erasure, but instead to ensure that clients only adhere to interfaces and not implementations. In both cases, the implementation of each client is static and known at compile time.

To use the client, we can use some C++17 class type deduction to ensure that our main() function remains unchanged, as follows:

int main(void)
{
A a;
B b;

client c1(a);
client c2(b);

c1.bar();
c2.bar();
}

Executing the preceding example results in the following:

As shown in the preceding screenshot, the code executes the same. The only difference between the two approaches is the fact that one uses pure virtual inheritance, which comes with a runtime cost, while the second approach uses static polymorphism, which comes with a human cost. Specifically, the preceding example for most beginners is difficult to understand. In large projects with nested dependencies, the use of static polymorphism can be extremely difficult to understand and read.

Another issue with the preceding example is the fact that the compiler does not have enough information about the interface and clients of that interface to provide a reasonable error message when the wrong type is given. Check out this example:

int main(void)
{
client c(std::cout);
}

This results in the following compiler error:

/home/user/book/chapter13/recipe01.cpp: In function ‘int main()’:
/home/user/book/chapter13/recipe01.cpp:187:23: error: class template argument deduction failed:
187 | client c(std::cout);
| ^
/home/user/book/chapter13/recipe01.cpp:187:23: error: no matching function for call to ‘client(std::ostream&)’
/home/user/book/chapter13/recipe01.cpp:175:5: note: candidate: ‘template<class T> client(interface<T>&)-> client<T>’
175 | client(interface<T> &i) :
| ^~~~~~

...

The preceding error message is hardly useful, especially for a beginner. To overcome these issues, C++20 Concepts promises to provide a cleaner implementation of template programming moving forward. To best explain this, let's look at how we would implement the interface using C++20 Concepts:

template <typename T>
concept interface = requires(T t)
{
{ t.foo() } -> void;
};

As shown in the preceding example, we have defined a C++20 Concept called interface. Given a type T, this concept requires that T provides a function called foo() that takes no input and returns no output. We can then define A as follows:

class A
{
public:
void foo()
{
std::cout << "The answer is: 42 ";
}
};

As shown in the preceding code snippet, A no longer needs to leverage inheritance at all. It simply provides a foo() function given a normal C++ class definition. B is implemented the same way:

class B
{
public:
void foo()
{
std::cout << "The answer is not: 43 ";
}
};

Once again, inheritance is no longer needed. Clients of this interface are implemented as in the following:

template<interface T>
class client
{
T &m_i;

public:
client(T &i) :
m_i{i}
{ }

void bar()
{
m_i.foo();
}
};

As shown in the preceding example, we have defined a class that takes a template type T and calls its foo() function. In our static polymorphic example earlier, we could have implemented the client the exact same way. The problem with that approach is that the client would have no way of determining whether the type T adhered to the interface. Static asserts combined with SFINAE, such as std::is_base_of(), could be leveraged to solve this issue, but every object that depends on the interface would have to include this logic. With C++20 concepts, however, this simplicity can be achieved without the need for inheritance or any complicated template tricks such as SFINAE. So, let's see what we can use instead of the following:

template<typename T>

The following can be used instead:

template<interface T>

The problem with C++ today with template programming is the fact that the typename keyword tells the compiler nothing about the type itself. SFINAE provides a means to solve this by defining certain characteristics about a type at a huge human cost as SFINAE is even more complicated to understand, and the resulting compiler errors when things go wrong are anything but useful. C++20 Concepts addresses all of these issues by defining the properties of a type, called a Concept, and then uses that concept in place of typename, providing the compiler with all of the information it needs to determine whether a given type adheres to the concept. When something goes wrong, the compiler can provide a simple error message about what the provided type is missing.

C++20 Concepts is an exciting new feature coming soon that promises to completely change how we program with C++ templates, reducing the overall human costs of working with templates at the expense of a more complicated compiler and C++ specification.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset