Endroits glissants en C ++ 17

image

Ces dernières années, le C ++ a fait des pas de géant et suivre toutes les subtilités et les subtilités du langage peut être très, très difficile. Une nouvelle norme n'est pas loin, cependant, l'introduction de nouvelles tendances n'est pas le processus le plus rapide et le plus simple, donc, même s'il reste un peu de temps avant C ++ 20, je suggère de rafraîchir ou de découvrir des endroits particulièrement «glissants» de la norme actuelle la langue.

Aujourd'hui, je vais vous dire pourquoi si constexpr ne remplace pas les macros, quels sont les «internes» de la liaison structurée et ses «pièges», et est-il vrai que la copie élision fonctionne désormais toujours et que vous pouvez écrire n'importe quel retour sans hésitation.

Si vous n’avez pas peur de vous salir un peu les mains, de plonger dans l’intérieur de votre langue, bienvenue chez Cat.



si constexpr


Commençons par la plus simple - if constexpr vous permet de if constexpr la branche d'expression conditionnelle pour laquelle la condition souhaitée n'est pas remplie même au stade de la compilation.

Il semble que cela remplace la macro #if pour désactiver la logique "extra"? Non. Pas du tout.

Tout d'abord, un tel if a des propriétés qui ne sont pas disponibles pour les macros - à l'intérieur, vous pouvez compter toute expression constexpr qui peut être constexpr en bool . Eh bien, et deuxièmement, le contenu de la branche supprimée doit être syntaxiquement et sémantiquement correct.

En raison de la deuxième exigence, if constexpr ne peut pas être utilisé, par exemple, des fonctions inexistantes (le code dépendant de la plate-forme ne peut pas être explicitement séparé de cette manière) ou mauvaises du point de vue du langage de construction (par exemple, " void T = 0; ").

Quel est l'intérêt d'utiliser if constexpr ? Le point principal est dans les modèles. Il existe une règle spéciale pour eux: la branche supprimée n'est pas instanciée lorsque le modèle est instancié. Cela facilite l'écriture de code qui dépend en quelque sorte des propriétés des types de modèles.

Cependant, dans les modèles, il ne faut pas oublier que le code à l'intérieur des branches doit être correct au moins pour une variante (même purement potentielle) d'instanciation, il est donc tout simplement static_assert(false) d'écrire, par exemple, static_assert(false) à l'intérieur d'une des branches (il est nécessaire que ce static_assert dépendait d'un paramètre dépendant du modèle).

Exemples:

 void foo() {    //    ,       if constexpr ( os == OS::win ) {        win_api_call(); //         }    else {        some_other_os_call(); //  win      } } 

 template<class T> void foo() {    //    ,    T      if constexpr ( os == OS::win ) {        T::win_api_call(); //  T   ,    win    }    else {        T::some_other_os_call(); //  T   ,         } } 

 template<class T> void foo() {    if constexpr (condition1) {        // ...    }    else if constexpr (condition2) {        // ...    }    else {        // static_assert(false); //          static_assert(trait<T>::value); // ,   ,  trait<T>::value   false    } } 

Choses à retenir


  1. Le code dans toutes les succursales doit être correct.
  2. À l'intérieur des modèles, le contenu des branches supprimées n'est pas instancié.
  3. Le code à l'intérieur de n'importe quelle branche doit être correct pour au moins une variante purement potentielle d'instanciation du modèle.

Reliure structurée




En C ++ 17, un mécanisme assez pratique pour décomposer divers objets de type tuple est apparu, vous permettant de lier de manière pratique et concise leurs éléments internes à des variables nommées:

 //     —    : for (const auto& [key, value] : map) {    std::cout << key << ": " << value << std::endl; } 

Par un objet de type tuple, je veux dire un tel objet pour lequel le nombre d'éléments internes disponibles au moment de la compilation est connu (de "tuple" - une liste ordonnée avec un nombre fixe d'éléments (vecteur)).

Cette définition inclut des types tels que: std::pair , std::tuple , std::array , des tableaux de la forme « T a[N] », ainsi que diverses structures et classes auto-écrites.

Arrêtez ... Pouvez-vous utiliser vos propres structures dans la liaison structurelle? Spoiler: vous le pouvez (bien que parfois vous deviez travailler dur (mais plus à ce sujet ci-dessous)).

Comment ça marche


Le travail de liaison structurelle mérite un article séparé, mais comme nous parlons spécifiquement d'endroits «glissants», je vais essayer d'expliquer brièvement comment tout fonctionne.

La norme fournit la syntaxe suivante pour définir la liaison:

attr (facultatif) cv-auto ref-operator (facultatif) expression [ liste d'identificateurs ];

  • attr - liste d'attributs facultative;
  • cv-auto - auto avec des modificateurs const / volatile possibles;
  • ref-operator - spécificateur de référence facultatif (& ou &&);
  • identifier-list - une liste de noms de nouvelles variables;
  • expression est une expression qui aboutit à un objet de type tuple utilisé pour la liaison (l'expression peut être sous la forme " = expr ", " {expr} " ou " (expr) ").

Il est important de noter que le nombre de noms dans la identifier-list doit correspondre au nombre d'éléments dans l'objet résultant de l' expression .

Tout cela vous permet d'écrire des constructions du formulaire:

 const volatile auto && [a,b,c] = Foo{}; 

Et nous arrivons ici au premier endroit «glissant»: rencontrer une expression de la forme « auto a = expr; ", Vous voulez généralement dire que le type" a "sera calculé par l'expression" expr ", et vous vous attendez à ce que dans l'expression" const auto& [a,b,c] = expr; "La même chose sera faite, seuls les types pour" a,b,c "seront les types const& element correspondants de" expr "...

La vérité est différente: le spécificateur d' cv-auto ref-operator est utilisé pour calculer le type d'une variable invisible, dans laquelle le résultat du calcul de expr est affecté (c'est-à-dire que le compilateur remplace « const auto& [a,b,c] = expr » par « const auto& e = expr ").

Ainsi, une nouvelle entité invisible apparaît (ci-après je l'appellerai {e}), cependant, l'entité est très utile: par exemple, elle peut matérialiser des objets temporaires (par conséquent, vous pouvez les connecter en toute sécurité " const auto& [a,b,c] = Foo {}; ").

Le deuxième endroit glissant découle immédiatement du remplacement effectué par le compilateur: si le type déduit pour {e} n'est pas une référence, le résultat de expr sera copié dans {e}.

Quels types les variables auront-elles dans la identifier-list ? Pour commencer, ce ne seront pas exactement des variables. Oui, ils se comportent comme des variables réelles et ordinaires, mais seulement avec la différence qu’ils se réfèrent à l’intérieur à une entité qui leur est associée, et le decltype partir d’une telle variable de «référence» produira le type d’entité auquel cette variable se réfère:

 std::tuple<int, float> t(1, 2.f); auto& [a, b] = t; // decltype(a) — int, decltype(b) — float ++a; // ,  « »,   t std::cout << std::get<0>(t); //  2 

Les types eux-mêmes sont définis comme suit:

  1. Si {e} est un tableau ( T a[N] ), alors le type sera un - T, les modificateurs cv coïncideront avec ceux du tableau.
  2. Si {e} est de type E et supporte l'interface tuple, les structures sont définies:

     std::tuple_size<E> 

     std::tuple_element<i, E> 

    et fonction:

     get<i>({e}); //  {e}.get<i>() 

    alors le type de chaque variable sera le type std::tuple_element_t<i, E>
  3. Dans d'autres cas, le type de la variable correspondra au type d'élément de structure auquel la liaison est effectuée.

Donc, si très brièvement, les étapes suivantes sont prises avec la liaison structurelle:

  1. Calcul du type et initialisation de l'entité invisible {e} sur la base des modificateurs type expr et cv-ref .
  2. Créez des pseudo-variables et liez-les aux éléments {e}.

Lier structurellement vos classes / structures


Le principal obstacle à la liaison de leurs structures est le manque de réflexion en C ++. Même le compilateur, qui, semble-t-il, doit savoir avec certitude comment telle ou telle structure est organisée à l'intérieur, a du mal: les modificateurs d'accès (public / privé / protégé) et l'héritage compliquent grandement les choses.

En raison de ces difficultés, les restrictions sur l'utilisation de leurs classes sont très strictes (au moins pour l'instant: P1061 , P1096 ):

  1. Tous les champs internes non statiques d'une classe doivent provenir de la même classe de base et doivent être disponibles au moment de l'utilisation.
  2. Ou la classe doit implémenter la «réflexion» (prendre en charge l'interface tuple).

 //  «»  struct A { int a; }; struct B : A {}; struct C : A { int c; }; class D { int d; }; auto [a] = A{}; //  (a -> A::a) auto [a] = B{}; //  (a -> B::A::a) auto [a, c] = C{}; // : a  c    auto [d] = D{}; // : d — private void D::foo() {    auto [d] = *this; //  (d   ) } 

L'implémentation de l'interface tuple vous permet d'utiliser n'importe laquelle de vos classes pour la liaison, mais elle semble un peu lourde et comporte un autre écueil. Prenons immédiatement un exemple:

 //  ,      int   class Foo; template<> struct std::tuple_size<Foo> : std::integral_constant<std::size_t, 1> {}; template<> struct std::tuple_element<0, Foo> { using type = int&; }; class Foo { public: template<std::size_t i> std::tuple_element_t<i, Foo> const& get() const; template<std::size_t i> std::tuple_element_t<i, Foo> & get(); private: int _foo = 0; int& _bar = _foo; }; template<> std::tuple_element_t<0, Foo> const& Foo::get<0>() const { return _bar; } template<> std::tuple_element_t<0, Foo> & Foo::get<0>() { return _bar; } 

Maintenant, nous lions:

 Foo foo; const auto& [f1] = foo; const auto [f2] = foo; auto& [f3] = foo; auto [f4] = foo; 

Et il est temps de réfléchir aux types que nous avons? (Celui qui pourrait répondre tout de suite mérite un délicieux bonbon.)

 decltype(f1); decltype(f2); decltype(f3); decltype(f4); 

Bonne réponse
 decltype(f1); // int& decltype(f2); // int& decltype(f3); // int& decltype(f4); // int& ++f1; //     foo._foo,  {e}    const 


Pourquoi est-ce arrivé? La réponse réside dans la spécialisation par défaut de std::tuple_element :

 template<std::size_t i, class T> struct std::tuple_element<i, const T> { using type = std::add_const_t<std::tuple_element_t<i, T>>; }; 

std::add_const n'ajoute pas const aux types de référence, donc le type de Foo sera toujours int& .

Comment gagner ça? Ajoutez juste une spécialisation pour const Foo :

 template<> struct std::tuple_element<0, const Foo> { using type = const int&; }; 

Ensuite, tous les types seront attendus:

 decltype(f1); // const int& decltype(f2); // const int& decltype(f3); // int& decltype(f4); // int& ++f1; //     

Par ailleurs, le même comportement est vrai pour, par exemple, std::tuple<T&>
- vous pouvez obtenir une référence non constante à l'élément interne, même si l'objet lui-même sera constant.

Choses à retenir


  1. « cv-auto ref » dans « cv-auto ref [a1..an] = expr » fait référence à la variable invisible {e}.
  2. Si le type déduit {e} n'est pas référencé, {e} sera initialisé par copie (avec précaution avec les classes "lourdes").
  3. Les variables decltype sont des liens «implicites» (elles se comportent comme des liens, bien que decltype renvoie un type non référence pour elles (sauf si la variable fait référence à un lien)).
  4. Des précautions doivent être prises lors de l'utilisation de types de référence pour la liaison.

Optimisation de la valeur de retour (rvo, copie élision)




C'était peut-être l'une des fonctionnalités les plus discutées de la norme C ++ 17 (au moins dans mon cercle d'amis). Et en effet: C ++ 11 a apporté la sémantique du mouvement, ce qui a grandement simplifié le transfert de l '"interne" de l'objet et la création de diverses usines, et C ++ 17 en général, semble-t-il, a permis de ne pas penser à comment renvoyer l'objet d'une méthode d'usine , - maintenant tout devrait être sans copier et en général, "bientôt tout fleurira sur Mars" ...

Mais soyons un peu réalistes: l'optimisation de la valeur de retour n'est pas la chose la plus simple à mettre en œuvre. Je recommande fortement de regarder cette présentation de cppcon2018: « Optimisation de la valeur de retour: plus difficile qu'il n'y paraît» d'Arthur O'Dwyer, dans laquelle l'auteur explique pourquoi cela est difficile.

Becquet court:

Il existe une «fente pour la valeur de retour». Cet emplacement est essentiellement juste une place sur la pile qui est allouée par celui qui appelle et passe à l'appelé. Si le code appelé sait exactement quel objet unique sera renvoyé, il peut simplement le créer immédiatement dans cet emplacement directement (à condition que la taille et le type de l'objet et de l'emplacement soient identiques).

Qu'est-ce qui en découle? Prenons-le à part avec des exemples.

Tout ira bien ici - NRVO fonctionnera, l'objet sera construit immédiatement dans le "slot":

 Base foo1() { Base a; return a; } 

Ici, il n'est plus possible de déterminer sans ambiguïté quel objet devrait être le résultat, de sorte que le constructeur de déplacement (c ++ 11) sera implicitement appelé :

 Base foo2(bool c) { Base a,b; if (c) { return a; } return b; } 

Ici, c'est un peu plus compliqué ... Puisque le type de la valeur de retour est différent du type déclaré, vous ne pouvez pas implicitement appeler move , donc le constructeur de copie est appelé par défaut. Pour éviter que cela ne se produise, vous devez appeler explicitement move :

 Base foo3(bool c) { Derived a,b; if (c) { return std::move(a); } return std::move(b); } 

Il semblerait que ce soit la même chose que foo2 , mais l'opérateur ternaire est une chose très particulière ...

 Base foo4(bool c) { Base a, b; return std::move(c ? a : b); } 

Similaire à foo4 , mais également d'un type différent, il faut donc move exactement:

 Base foo5(bool c) { Derived a, b; return std::move(c ? a : b); } 

Comme vous pouvez le voir dans les exemples, il faut encore réfléchir à la manière de redonner du sens même dans des cas apparemment triviaux ... Y a-t-il des moyens de vous simplifier un peu la vie? Oui: clang soutient depuis un certain temps le diagnostic de la nécessité d'appeler explicitement move , et il existe plusieurs propositions ( P1155 , P0527 ) dans la nouvelle norme qui rendront le move explicite moins nécessaire.

Choses à retenir


  1. RVO / NRVO ne fonctionnera que si:
    • on sait sans ambiguïté quel objet unique doit être créé dans le "slot de valeur de retour";
    • les types d'objet et de fonction de retour sont identiques.
  2. S'il y a ambiguïté dans la valeur de retour, alors:
    • si les types de l'objet et de la fonction retournés correspondent, move sera appelé implicitement;
    • sinon, vous devez explicitement appeler move.
  3. Attention à l'opérateur ternaire: il est concis, mais peut nécessiter un déplacement explicite.
  4. Il est préférable d'utiliser des compilateurs avec des diagnostics utiles (ou au moins des analyseurs statiques).

Conclusion


Et pourtant j'aime C ++;)

Source: https://habr.com/ru/post/fr465181/


All Articles