Standard C ++ 20: un aperçu des nouvelles fonctionnalités C ++. Partie 3 "Concepts"





Le 25 février, l'auteur du cours "Développeur C ++" à Yandex. Travaux pratiques Georgy Osipov a parlé de la nouvelle étape du langage C ++ - le standard C ++ 20. La conférence donne un aperçu de toutes les principales innovations de la norme, explique comment les appliquer maintenant et comment elles peuvent être utiles.



Lors de la préparation du webinaire, l' objectif était de fournir un aperçu de toutes les fonctionnalités clés de C ++ 20. Par conséquent, le webinaire s'est avéré riche et a duré près de 2,5 heures. Pour votre commodité, nous avons divisé le texte en six parties:



  1. Modules et un bref historique de C ++ .
  2. Opération "vaisseau spatial" .
  3. Concepts.
  4. Gammes.
  5. Coroutines.
  6. Autres fonctionnalités de base et standard de la bibliothèque. Conclusion.


Ceci est la troisième partie, couvrant les concepts et les limitations du C ++ moderne.



Concepts







Motivation



La programmation générique est un avantage clé de C ++. Je ne connais pas toutes les langues, mais je n'ai jamais rien vu de tel à ce niveau.



Cependant, la programmation générique en C ++ présente un énorme inconvénient: les erreurs qui se produisent sont douloureuses. Considérez un programme simple qui trie un vecteur. Jetez un œil au code et dites-moi où se trouve l'erreur:



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





J'ai défini une structure X



avec un champ int



, rempli un vecteur avec des objets de cette structure et j'essaye de le trier.



J'espère que vous avez lu l'exemple et trouvé le bogue. J'annoncerai la réponse: le compilateur pense que l'erreur est dans ... la bibliothèque standard. La sortie de diagnostic fait environ 60 lignes et indique une erreur quelque part dans le fichier d'assistance xutility. Il est presque impossible de lire et de comprendre les diagnostics, mais les programmeurs C ++ le font - après tout, vous devez toujours utiliser des modèles.







Le compilateur montre que l'erreur se trouve dans la bibliothèque standard, mais cela ne signifie pas que vous devez immédiatement écrire au comité de normalisation. En fait, l'erreur est toujours dans notre programme. C'est juste que le compilateur n'est pas assez intelligent pour le comprendre, et il rencontre une erreur lorsqu'il entre dans la bibliothèque standard. La suppression de ce diagnostic entraîne une erreur. Mais ça:



  • compliqué,
  • pas toujours possible en principe.


Formulons le premier problème de la programmation générique en C ++: les erreurs lors de l'utilisation de modèles sont complètement illisibles et sont diagnostiquées non pas là où elles ont été faites, mais dans le modèle.



Un autre problème survient s'il est nécessaire d'utiliser différentes implémentations d'une fonction en fonction des propriétés du type d'argument. Par exemple, je veux écrire une fonction qui vérifie que deux nombres sont suffisamment proches l'un de l'autre. Pour les entiers il suffit de vérifier que les nombres sont égaux, pour les nombres à virgule flottante il suffit de vérifier que la différence est inférieure à quelques ε.



Le problème peut être résolu avec le hack SFINAE en écrivant deux fonctions. Hack utilise std::enable_if



... Il s'agit d'un modèle spécial dans la bibliothèque standard qui contient une erreur si la condition n'est pas remplie. Lors de l'instanciation d'un modèle, le compilateur supprime les déclarations avec une erreur:



#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;
}
      
      





En C ++ 17, un tel programme peut être simplifié en utilisant if constexpr



, bien que cela ne fonctionnera pas dans tous les cas.



Ou un autre exemple: je veux écrire une fonction Print



qui imprime n'importe quoi. Si un conteneur lui a été transmis, il imprimera tous les éléments, sinon le conteneur, il affichera ce qui a été passé. Je vais devoir définir pour tous les conteneurs: vector



, list



, set



et d' autres. Ceci n'est pas pratique et n'est pas universel.



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 n'aidera plus ici. Au contraire, cela vous aidera si vous essayez, mais vous devrez essayer beaucoup, et le code se révélera monstrueux.



Le deuxième problème avec la programmation générique est qu'il est difficile d'écrire différentes implémentations de la même fonction de modèle pour différentes catégories de types.



Les deux problèmes peuvent être facilement résolus si vous ajoutez une seule fonctionnalité au langage: imposer des restrictions sur les paramètres du modèle . Par exemple, exigez que le paramètre basé sur un modèle soit un conteneur ou un objet prenant en charge les comparaisons. Tel est le concept.



Ce que les autres ont



Voyons comment les choses se passent dans d'autres langues. Le seul que je connaisse qui a quelque chose de similaire est Haskell.



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





Ceci est un exemple de classe de type qui nécessite la prise en charge des opérateurs "égal" et "non égal" émettant Bool



. En C ++, la même chose serait faite comme ceci:



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





Si vous ne connaissez pas déjà les concepts, il sera difficile de comprendre ce qui est écrit. Je vais tout expliquer maintenant.



Dans Haskell, ces restrictions sont obligatoires. Si vous ne dites pas qu'il y aura une opération ==



, vous ne pourrez pas l'utiliser. En C ++, les restrictions ne sont pas strictes. Même si vous ne spécifiez pas d'opération dans le concept, elle peut toujours être utilisée - après tout, il n'y avait aucune restriction auparavant, et les nouvelles normes s'efforcent de ne pas violer la compatibilité avec les précédentes.



Exemple



Complétons le code du programme dans lequel vous cherchiez récemment une erreur:



#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());
}
      
      





Ici, nous avons créé un concept IterToComparable



. Il montre que le type T



est un itérateur, et il pointe vers des valeurs qui peuvent être comparées. Le résultat de la comparaison est quelque chose de convertible bool



, par exemple, en lui-même bool



. Une explication détaillée sera fournie un peu plus tard, pour l'instant vous n'avez pas besoin de vous plonger dans ce code.



Soit dit en passant, les restrictions sont faibles. Il ne dit pas qu'un type doit satisfaire toutes les propriétés des itérateurs: par exemple, il n'a pas besoin d'être incrémenté. Ceci est un exemple simple pour démontrer les possibilités.



Le concept a été utilisé à la place d'un mot class



ou typename



dans la construction de c template



. C'était autrefois template<class InputIt>



, mais maintenant le mot class



remplacé par le nom du concept. Par conséquent, le paramètre InputIt



doit satisfaire la contrainte.



Maintenant, lorsque nous essayons de compiler ce programme, l'erreur n'apparaîtra pas dans la bibliothèque standard, mais comme elle devrait l'être - in main



. Et l'erreur est compréhensible, car elle contient toutes les informations nécessaires:



  • Que s'est-il passé? Appel de fonction avec contrainte non satisfaite.
  • Quelle contrainte n'est pas satisfaite? IterToComparable<InputIt>



  • Pourquoi? L'expression n'est ((* a) < (* b))



    pas valide.




La sortie du compilateur est lisible et prend 16 lignes au lieu de 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')
      
      





Ajoutons l'opération de comparaison manquante à la structure, et le programme se compilera sans erreur - le concept est satisfait:



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





De même, vous pouvez améliorer le deuxième exemple, p enable_if



. Ce modèle n'est plus nécessaire. Nous utilisons plutôt le concept standard is_floating_point_v<T>



. On obtient deux fonctions: une pour les nombres à virgule flottante, l'autre pour les autres objets:



#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;
}
      
      





Nous modifions également la fonction d'impression. Si un appel a.begin()



et a.end()



dire, nous supposons ce a



conteneur.



#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;
}
      
      





Encore une fois, ce n'est pas un exemple idéal, car le conteneur n'est pas seulement quelque chose avec begin



et end



, il y a beaucoup plus d'exigences qui lui sont imposées. Mais déjà pas mal.



Il est préférable d'utiliser un concept prêt à l'emploi comme is_floating_point_v



dans l'exemple précédent. Pour un analogue de conteneurs, la bibliothèque standard a également un concept - std::ranges::input_range



. Mais c'est une histoire complètement différente.



Théorie



Il est temps de comprendre quel est le concept. Il n'y a vraiment rien de compliqué ici:



Concept est un nom pour une contrainte.



Nous l'avons réduit à un autre concept, dont la définition est déjà significative, mais cela peut paraître étrange: la



contrainte est une expression standard.



En gros, les conditions ci-dessus "être un itérateur" ou "être un nombre à virgule flottante" - ce sont les restrictions. L'essence même de l'innovation réside précisément dans les limites, et le concept n'est qu'une manière de s'y référer.



La limitation la plus simple est la suivante true



. Tout type lui convient.



template<class T> concept C1 = true;
      
      





Des opérations booléennes et des combinaisons d'autres contraintes sont disponibles pour les contraintes:



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>;
      
      





Vous pouvez utiliser des expressions dans les contraintes et même appeler des fonctions. Mais les fonctions doivent être constexpr - elles sont calculées au moment de la compilation:



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.
}
      
      





Et la liste des possibilités ne s'arrête pas là.



Il existe une fonctionnalité intéressante pour les contraintes: vérifier l'exactitude de l'expression - qu'elle compile sans erreur. Regardez la limitation Addable



. Il est écrit entre parenthèses a + b



. Les conditions de contrainte sont remplies lorsque les valeurs a



et les b



types T



autorisent un tel enregistrement, c'est-à-dire qu'il T



a une certaine opération d'addition:



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





Un exemple plus complexe est l'appel des fonctions swap



et forward



. La contrainte sera exécutée lorsque ce code sera compilé sans erreur:



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));
};
      
      





Un autre type de contrainte est la validation de type:



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





Une contrainte peut exiger non seulement l'exactitude de l'expression, mais aussi que le type de sa valeur corresponde à quelque chose. Ici nous écrivons:



  • expression en accolades,
  • ->,



  • une autre limitation.


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





La limitation dans ce cas - same_as<int>





Autrement dit, le type de l'expression x + 1



doit être exactement int



.



Notez que la flèche est suivie de la contrainte et non du type lui-même. Découvrez un autre exemple du concept:



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





Il a deux limites. Le premier indique que:



  • l'expression est *x



    correcte;
  • le type est T::inner



    correct;
  • le type est *x



    converti enT::inner.





Il y a trois exigences en une seule ligne. Le second indique que:



  • l'expression est x * 1



    syntaxiquement correcte;
  • son résultat est converti en T



    .


Toutes les restrictions peuvent être formées en utilisant les méthodes ci-dessus. Ils sont très amusants et agréables, mais vous en auriez rapidement assez et oublieriez si vous ne pouviez pas les utiliser. Et vous pouvez utiliser des contraintes et des concepts pour tout ce qui prend en charge les modèles. Bien entendu, les principales utilisations sont les fonctions et les classes.



Nous avons donc compris comment écrire des contraintes , maintenant je vais vous dire où vous pouvez les écrire .



Une contrainte de fonction peut être écrite à trois endroits différents:



//   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>;
      
      





Et il y a une quatrième façon, qui semble assez magique:



void f(Incrementable auto arg);
      
      





Un modèle implicite est utilisé ici. Jusqu'au C ++ 20, ils n'étaient disponibles que dans les lambdas. Vous pouvez maintenant être utilisé auto



dans toute signature de fonction: void f(auto arg)



. De plus, un auto



nom de concept est autorisé avant cela , comme dans l'exemple. À propos, des modèles explicites sont maintenant disponibles dans lambdas, mais nous en parlerons plus tard.



Une différence importante: quand on écrit requires



, on peut noter n'importe quelle contrainte, et dans d'autres cas, seulement le nom du concept.



Il y a moins de possibilités pour une classe - seulement deux façons. Mais cela suffit amplement:



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





Anton Polukhin, qui a aidé à la préparation de cet article, a remarqué que le mot requires



peut être utilisé non seulement lors de la déclaration de fonctions, de classes et de concepts, mais aussi directement dans le corps d'une fonction ou d'une méthode. Par exemple, cela est pratique si vous écrivez une fonction qui remplit un conteneur d'un type auparavant inconnu:



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

    //   
}
      
      





Cette fonction fonctionnera aussi bien avec les deux vector



, qu'avec list



, et pour la première, la méthode nécessaire dans son cas sera appelée reserve



.



Utile requires



pour static_assert



. De cette façon, vous pouvez vérifier le respect non seulement des conditions ordinaires, mais également l'exactitude du code arbitraire, la présence de méthodes et d'opérations dans les types.



Fait intéressant, un concept peut avoir plusieurs paramètres de modèle. Lorsque vous utilisez le concept, vous devez tout spécifier sauf un - celui dont nous vérifions la contrainte.



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





Le concept a Derived



deux paramètres de modèle. Dans la déclaration, f



j'ai indiqué l'un d'entre eux, et le second - la classe X



, qui est vérifiée. On a demandé au public quel paramètre j'avais indiqué: T



ou U



; cela a-t-il fonctionné Derived<Other, X>



ou Derived<X, Other>



?



La réponse n'est pas évidente: elle l'est Derived<X, Other>



. Lors de la spécification d'un paramètre Other



, nous avons spécifié un deuxième paramètre de modèle. Les résultats du vote ont divergé:



  • bonnes réponses - 8 (61,54%);
  • mauvaises réponses - 5 (38,46%).


Lors de la spécification des paramètres du concept, vous devez tout spécifier sauf le premier, et le premier sera vérifié. J'ai longtemps réfléchi à la raison pour laquelle le Comité avait pris une telle décision, et je vous suggère de réfléchir aussi. Écrivez vos idées dans les commentaires.



Donc, je vous ai dit comment définir de nouveaux concepts, mais ce n'est pas toujours nécessaire - il y en a déjà beaucoup dans la bibliothèque standard. Cette diapositive montre les concepts trouvés dans le fichier d'en-tête <concepts>.







Ce n'est pas tout: il existe des concepts pour tester différents types d'itérateurs dans <iterator>, <ranges> et d'autres bibliothèques.







Statut







Les «concepts» sont partout, mais pas encore complètement dans Visual Studio:



  • GCC. Bien pris en charge depuis la version 10;
  • Bruit. Prise en charge complète de la version 10;
  • Visual Studio. Pris en charge par VS 2019, mais pas entièrement implémenté nécessite.


Conclusion



Lors de la diffusion, nous avons demandé au public s'il aimait cette fonctionnalité. Résultats du sondage:



  • Super fonctionnalité - 50 (92,59%)
  • Alors si caractéristique - 0 (0,00%)
  • Incertain - 4 (7,41%)


L'écrasante majorité de ceux qui ont voté ont apprécié les concepts. Je pense aussi que c'est une fonctionnalité intéressante. Merci au comité!



Les lecteurs de Habr, ainsi que les auditeurs de webinaires, auront l'occasion d'évaluer les innovations.



All Articles