C ++ 20 Standard: An overview of new C ++ features. Part 3 "Concepts"





On February 25, the author of the course "C ++ Developer" in Yandex.Practical work Georgy Osipov spoke about the new stage of the C ++ language - the C ++ 20 Standard. The lecture provides an overview of all the main innovations of the Standard, tells how to apply them now and how they can be useful.



When preparing the webinar, the goal was to provide an overview of all the key features of C ++ 20. Therefore, the webinar turned out to be intense and lasted for almost 2.5 hours. For your convenience, we have divided the text into six parts:



  1. Modules and a brief history of C ++ .
  2. Operation "spaceship" .
  3. Concepts.
  4. Ranges.
  5. Coroutines.
  6. Other core and standard library features. Conclusion.


This is the third part, covering the concepts and limitations in modern C ++.



Concepts







Motivation



Generic programming is a key advantage of C ++. I do not know all languages, but I have never seen anything like it at this level.



However, generic programming in C ++ has a huge disadvantage: errors occurring are painful. Consider a simple program that sorts a vector. Take a look at the code and tell me where the error is:



#include <vector>
#include <algorithm>
struct X {
    int a;
};
int main() {
    std::vector<X> v = { {10}, {9}, {11} };
    //  
    std::sort(v.begin(), v.end());
}
      
      





I have defined a structure X



with one field int



, filled a vector with objects of that structure and am trying to sort it.



I hope you read the example and found a bug. I will announce the answer: the compiler thinks that the error is in ... the standard library. The diagnostic output is approximately 60 lines long and indicates an error somewhere inside the xutility helper file. It is almost impossible to read and understand the diagnostics, but C ++ programmers do it - after all, you still need to use templates.







The compiler shows that the error is in the standard library, but this does not mean that you need to immediately write to the Standardization Committee. In fact, the error is still in our program. It's just that the compiler isn't smart enough to figure it out, and it runs into an error when it goes into the standard library. Unraveling this diagnostics leads to an error. But this:



  • complicated,
  • not always possible in principle.


Let's formulate the first problem of generic programming in C ++: errors when using templates are completely unreadable and are diagnosed not where they were made, but in the template.



Another problem arises if there is a need to use different implementations of a function depending on the properties of the argument type. For example, I want to write a function that checks that two numbers are close enough to each other. For integers it is enough to check that the numbers are equal, for floating point numbers it is enough to check that the difference is less than some Ξ΅.



The problem can be solved with the SFINAE hack by writing two functions. Hack uses std::enable_if



... This is a special template in the standard library that contains an error if the condition is not met. When instantiating a template, the compiler throws away declarations with an error:



#include <type_traits>

template <class T>
T Abs(T x) {
    return x >= 0 ? x : -x;
}

//      
template<class T>
std::enable_if_t<std::is_floating_point_v<T>, bool>
AreClose(T a, T b) {
    return Abs(a - b) < static_cast<T>(0.000001);
}

//    
template<class T>
std::enable_if_t<!std::is_floating_point_v<T>, bool> 
AreClose(T a, T b) {
    return a == b;
}
      
      





In C ++ 17, such a program can be simplified using if constexpr



, although this will not work in all cases.



Or another example: I want to write a function Print



that prints anything. If a container was passed to it, it will print all the elements, if not the container, it will print what was passed. I'll have to define it for all containers: vector



, list



, set



and others. This is inconvenient and not universal.



template<class T>
void Print(std::ostream& out, const std::vector<T>& v) {
    for (const auto& elem : v) {
        out << elem << std::endl;
    }
}

//      map, set, list, 
// deque, array…

template<class T>
void Print(std::ostream& out, const T& v) {
    out << v;
}
      
      





SFINAE won't help here anymore. Rather, it will help if you try, but you will have to try a lot, and the code will turn out to be monstrous.



The second problem with generic programming is that it is difficult to write different implementations of the same template function for different categories of types.



Both problems can be easily solved if you add only one feature to the language - to impose restrictions on template parameters . For example, require the templated parameter to be a container or object that supports comparisons. This is the concept.



What others have



Let's see how things are in other languages. The only one I know of that has something similar is Haskell.



class Eq a where
	(==) :: a -> a -> Bool
	(/=) :: a -> a -> Bool
      
      





This is an example of a type class that requires support for the "equal" and "not equal" operators emitting Bool



. In C ++, the same would be done like this:



template<typename T>
concept Eq =
    requires(T a, T b) {
        { a == b } -> std::convertible_to<bool>;
        { a != b } -> std::convertible_to<bool>;
    };
      
      





If you are not already familiar with the concepts, it will be difficult to understand what is written. I'll explain everything now.



In Haskell, these restrictions are required. If you do not say that there will be an operation ==



, then you will not be able to use it. In C ++, the restrictions are not strict. Even if you do not write an operation in the concept, you can still use it - after all, there were no restrictions at all before, and new standards tend not to violate compatibility with the previous ones.



Example



Let's supplement the code of the program in which you were recently looking for an error:



#include <vector>
#include <algorithm>
#include <concepts>

template<class T>
concept IterToComparable = 
    requires(T a, T b) {
        {*a < *b} -> std::convertible_to<bool>;
    };
    
//    IterToComparable   class
template<IterToComparable InputIt>
void SortDefaultComparator(InputIt begin, InputIt end) {
    std::sort(begin, end);
}

struct X {
    int a;
};

int main() {
    std::vector<X> v = { {10}, {9}, {11} };
    SortDefaultComparator(v.begin(), v.end());
}
      
      





Here we have created a concept IterToComparable



. It shows that the type T



is an iterator, and it points to values ​​that can be compared. The result of the comparison is something convertible to bool



, for example, itself bool



. A detailed explanation will be provided a bit later, for now you don't need to delve into this code.



By the way, the restrictions are weak. It does not say that a type must satisfy all the properties of iterators: for example, it does not need to be incremented. This is a simple example to demonstrate the possibilities.



The concept was used instead of a word class



or typename



in the construction of c template



. It used to be template<class InputIt>



, but now the word class



replaced with the name of the concept. Hence, the parameter InputIt



must satisfy the constraint.



Now, when we try to compile this program, the error will pop up not in the standard library, but as it should be - in main



. And the error is understandable, since it contains all the necessary information:



  • What happened? Function call with unfulfilled constraint.
  • Which constraint is not satisfied? IterToComparable<InputIt>



  • Why? The expression is ((* a) < (* b))



    invalid.




The compiler output is readable and takes 16 lines instead of 60.



main.cpp: In function 'int main()':
main.cpp:24:45: error: **use of function** 'void SortDefaultComparator(InputIt, InputIt) [with InputIt = __gnu_cxx::__normal_iterator<X*, std::vector<X> >]' **with unsatisfied constraints**
   24 |     SortDefaultComparator(v.begin(), v.end());
      |                                             ^
main.cpp:12:6: note: declared here
   12 | void SortDefaultComparator(InputIt begin, InputIt end) {
      |      ^~~~~~~~~~~~~~~~~~~~~
main.cpp:12:6: note: constraints not satisfied
main.cpp: In instantiation of 'void SortDefaultComparator(InputIt, InputIt) [with InputIt = __gnu_cxx::__normal_iterator<X*, std::vector<X> >]':
main.cpp:24:45:   required from here
main.cpp:6:9:   **required for the satisfaction of 'IterToComparable<InputIt>'** [with InputIt = __gnu_cxx::__normal_iterator<X*, std::vector<X, std::allocator<X> > >]
main.cpp:7:5:   in requirements with 'T a', 'T b' [with T = __gnu_cxx::__normal_iterator<X*, std::vector<X, std::allocator<X> > >]
main.cpp:8:13: note: the required **expression '((* a) < (* b))' is invalid**, because
    8 |         {*a < *b} -> std::convertible_to<bool>;
      |          ~~~^~~~
main.cpp:8:13: error: no match for 'operator<' (operand types are 'X' and 'X')
      
      





Let's add the missing comparison operation to the structure, and the program will compile without errors - the concept is satisfied:



struct X {
    auto operator<=>(const X&) const = default;
    int a;
};
      
      





Similarly, you can improve the second example, p enable_if



. This template is no longer needed. We use the standard concept instead is_floating_point_v<T>



. We get two functions: one for floating point numbers, the other for other objects:



#include <type_traits>

template <class T>
T Abs(T x) {
    return x >= 0 ? x : -x;
}

//      
template<class T>
requires(std::is_floating_point_v<T>)
bool AreClose(T a, T b) {
    return Abs(a - b) < static_cast<T>(0.000001);
}

//    
template<class T>
bool AreClose(T a, T b) {
    return a == b;
}
      
      





We also modify the print function. If a call a.begin()



and a.end()



say, we assume that a



container.



#include <iostream>
#include <vector>

template<class T>
concept HasBeginEnd = 
    requires(T a) {
        a.begin();
        a.end();
    };

template<HasBeginEnd T>
void Print(std::ostream& out, const T& v) {
    for (const auto& elem : v) {
        out << elem << std::endl;
    }
}

template<class T>
void Print(std::ostream& out, const T& v) {
    out << v;
}
      
      





Again, this is not an ideal example, since the container is not just something with begin



and end



, there are many more requirements imposed on it. But already not bad.



It is best to use a ready-made concept like is_floating_point_v



in the previous example. For an analogue of containers, the standard library also has a concept - std::ranges::input_range



. But that's a completely different story.



Theory



It's time to understand what the concept is. There is really nothing complicated here:



Concept is a name for a constraint.



We have reduced it to another concept, the definition of which is already meaningful, but it may seem strange:



A constraint is a boilerplate boolean expression.



Roughly speaking, the above conditions "be an iterator" or "be a floating point number" - these are the restrictions. The whole essence of innovation lies precisely in the limitations, and the concept is just a way to refer to them.



The simplest limitation is this true



. Any type suits him.



template<class T> concept C1 = true;
      
      





Boolean operations and combinations of other constraints are available for constraints:



template <class T>
concept Integral = std::is_integral<T>::value;

template <class T>
concept SignedIntegral = Integral<T> &&
                         std::is_signed<T>::value;
template <class T>
concept UnsignedIntegral = Integral<T> &&
                           !SignedIntegral<T>;
      
      





You can use expressions in constraints and even call functions. But the functions must be constexpr - they are computed at compile time:



template<typename T>
constexpr bool get_value() { return T::value; }
 
template<typename T>
    requires (sizeof(T) > 1 && get_value<T>())
void f(T); // #1
 
void f(int); // #2
 
void g() {
    f('A'); //  #2.
}
      
      





And the list of possibilities does not end there.



There is a great feature for constraints: verifying that the expression is correct - that it compiles without errors. Look at the limitation Addable



. It is written in brackets a + b



. The constraint conditions are met when the values a



and b



types T



allow such a record, that is, it T



has a certain addition operation:



template<class T>
concept Addable =
requires (T a, T b) {
    a + b;
};
      
      





A more complex example is calling functions swap



and forward



. The constraint will be executed when this code compiles without errors:



template<class T, class U = T>
concept Swappable = requires(T&& t, U&& u) {
    swap(std::forward<T>(t), std::forward<U>(u));
    swap(std::forward<U>(u), std::forward<T>(t));
};
      
      





Another type of constraint is type validation:



template<class T> using Ref = T&;
template<class T> concept C =
requires {
    typename T::inner; 
    typename S<T>;     
    typename Ref<T>;   
};
      
      





A constraint may require not only the correctness of the expression, but also that the type of its value corresponds to something. Here we write:



  • expression in curly braces,
  • ->,



  • another limitation.


template<class T> concept C1 =
requires(T x) {
    {x + 1} -> std::same_as<int>;
};
      
      





The limitation in this case - same_as<int>





That is, the type of the expression x + 1



must be exactly int



.



Note that the arrow is followed by the constraint, not the type itself. Check out another example of the concept:



template<class T> concept C2 =
requires(T x) {
    {*x} -> std::convertible_to<typename T::inner>;
    {x * 1} -> std::convertible_to<T>;
};
      
      





It has two limitations. The first indicates that:



  • the expression is *x



    correct;
  • the type is T::inner



    correct;
  • type is *x



    converted toT::inner.





There are three requirements in one line. The second indicates that:



  • the expression is x * 1



    syntactically correct;
  • its result is converted to T



    .


Any restrictions can be formed using the above methods. They are very fun and enjoyable, but you would quickly get enough of them and forget if you couldn't use them. And you can use constraints and concepts for anything that supports templates. Of course, the main uses are functions and classes.



So, we have figured out how to write constraints , now I will tell you where you can write them .



A function constraint can be written in three different places:



//   class  typename   .
//   .
template<Incrementable T>
void f(T arg);

//    requires.       
//     .
//    .
template<class T>
requires Incrementable<T>
void f(T arg);

template<class T>
void f(T arg) requires Incrementable<T>;
      
      





And there is a fourth way, which looks quite magical:



void f(Incrementable auto arg);
      
      





An implicit template is used here. Until C ++ 20, they were only available in lambdas. You can now be used auto



in any function signature: void f(auto arg)



. Moreover, the auto



name of the concept is allowed before this , as in the example. By the way, explicit templates are now available in lambdas, but more on that later.



An important difference: when we write requires



, we can write down any constraint, and in other cases, only the name of the concept.



There are fewer possibilities for a class - only two ways. But this is quite enough:



template<Incrementable T>
class X {};
template<class T>
requires Incrementable<T>
class Y {};
      
      





Anton Polukhin, who helped with the preparation of this article, noticed that the word requires



can be used not only when declaring functions, classes and concepts, but also right in the body of a function or method. For example, it comes in handy if you write a function that fills a container of a type unknown in advance:



template<class T> 
void ReadAndFill(T& container, int size) { 
    if constexpr (requires {container.reserve(size); }) { 
        container.reserve(size); 
    }

    //   
}
      
      





This function will work equally well with both vector



, and with list



, and for the first, the method needed in its case will be called reserve



.



Useful requires



for static_assert



. This way, you can check the fulfillment of not only ordinary conditions, but also the correctness of arbitrary code, the presence of methods and operations in types.



Interestingly, a concept can have multiple template parameters. When using the concept, you need to specify everything except one - the one that we are checking for the constraint.



template<class T, class U>
concept Derived = std::is_base_of<U, T>::value;
 
template<Derived<Other> X>
void f(X arg);
      
      





The concept has Derived



two template parameters. In the declaration, f



I indicated one of them, and the second - the class X



, which is checked. The audience was asked which parameter I indicated: T



or U



; did it work Derived<Other, X>



or Derived<X, Other>



?



The answer is not obvious: it is Derived<X, Other>



. When specifying a parameter Other



, we specified a second template parameter. The voting results diverged:



  • correct answers - 8 (61.54%);
  • wrong answers - 5 (38.46%).


When specifying the parameters of the concept, you need to specify everything except the first, and the first will be checked. I thought for a long time why the Committee made such a decision, and I suggest that you think too. Write your ideas in the comments.



So, I told you how to define new concepts, but this is not always necessary - there are already plenty of them in the standard library. This slide shows the concepts found in the <concepts> header file.







That's not all: there are concepts for testing different types of iterators in <iterator>, <ranges>, and other libraries.







Status







"Concepts" are everywhere, but not completely in Visual Studio yet:



  • GCC. Well supported since version 10;
  • Clang. Full support in version 10;
  • Visual Studio. Supported by VS 2019, but not fully implemented requires.


Conclusion



During the broadcast, we asked the audience if they liked this feature. Survey results:



  • Super feature - 50 (92.59%)
  • So so feature - 0 (0.00%)
  • Unclear - 4 (7.41%)


The overwhelming majority of those who voted appreciated the concepts. I also think this is a cool feature. Thanks to the Committee!



Habr's readers, as well as webinar listeners, will be given the opportunity to evaluate the innovations.



All Articles