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:
- Modules et un bref historique de C ++ .
- Opération "vaisseau spatial" .
- Concepts.
- Gammes.
- Coroutines.
- 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.