C++17 en détail : clarifications sur le langage

Bartlomiej Filipek

Traducteur

kurtcpp

Voici la nouvelle partie de ma série sur les détails de C++17. Je voudrais aujourd'hui me concentrer sur les fonctionnalités qui clarifient certaines parties complexes du langage telles que l'élision de copie et l'ordre d'évaluation des expressions.

Commentez

- Titre : C++17 en détail : clarifications sur le langage

- Auteur : Bartlomiej Filipek

- Traducteur : kurtcpp

- Parution : 20 octobre 2021

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2021 Bartlomiej Filipek. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. La série

- III. Documents et liens

- IV. Ordre plus strict pour l'évaluation des expressions

- V. Garantie d'élision de copie

- VI. Spécification des exceptions incluse au système de types

- VII. Allocation dynamique de mémoire pour les données suralignées

- VIII. Résumé

I - Introduction

Vous savez tous ceci : C++ est un langage très complexe, et certaines (voire la plupart) de ses parties prêtent fortement à confusion. L'une des raisons de ce manque de clarté pourrait être la liberté de choix laissée aux implémentations et compilateurs – par exemple, pour permettre des optimisations plus agressives ou pour rester compatible avec les versions antérieures (ou avec le C). Parfois, il s'agit simplement d'un manque de temps, d'efforts ou de coopération. C++17 passe en revue certains des « trous » les plus notoires et les traite, ce qui nous permet de mieux comprendre le fonctionnement des choses.

J'aimerais aujourd'hui aborder les thèmes suivants :

- l'ordre d'évaluation ;

- l'élision de copie (optimisation facultative apparemment mise en œuvre sur tous les compilateurs populaires) ;

- les exceptions ;

- les allocations de mémoire pour les données alignées (ou suralignées).

II - La série

Ce billet est le second d'une série traitant des détails des fonctionnalités de C++17.

Voici le plan de cette série :

1. Rectifications et dépréciation ;

2. Clarifications sur le langage (aujourd'hui) ;

3. Les templates ;

4. Les attributs ;

5. Simplification ;

6. Nouveautés de la bibliothèque standard – Système de fichiers ;

7. Nouveautés de la bibliothèque standard – Algorithmes parallèles ;

8. Nouveautés de la bibliothèque standard – Utilitaires ;

9. Conclusion, bonus – avec un livre électronique offert !

III - Documents et liens

Pour rappel : tout d'abord, si vous voulez creuser le standard par vous-même, vous pouvez en lire la dernière version ici :

N4659, 2017-03-21, Working Draft, Standard for Programming Language C++ – ce lien apparaît également sur isocpp.org.

Vous pouvez également trouver ma liste de descriptions concises de toutes les fonctionnalités du langage C++17 :

Téléchargez un exemplaire gratuit de mon antisèche C++17 !

C'est une fiche de référence tenant sur une page, en PDF.

Il y a également une conférence de Bryce Lelbach : C++Now 2017: C++17 Features.

IV - Ordre plus strict pour l'évaluation des expressions

Ce point-ci est compliqué, donc merci de me corriger si je me trompe, et dites-moi si vous avez d'autres exemples, et de meilleures explications. J'ai tenté de me faire confirmer certains détails sur Slack ou Twitter, j'espère ne pas écrire de bêtises ici ! Essayons donc.

C++ ne spécifie aucun ordre d'évaluation pour les paramètres de fonctions. Point.

Par exemple, c'est pourquoi make_unique n'apporte pas simplement du confort syntaxique, mais garantit également la cohérence de la mémoire :

- avec make_unique :

truc(make_unique<T>(), autreFonction());

- et avec un new explicite :

truc(unique_ptr<T>(new T), autreFonction());

Dans le code ci-dessus, nous savons qu'il est garanti que new T s'exécutera avant la construction de l’unique_ptr, mais c'est tout. Par exemple, new T pourrait s'exécuter en premier, suivi de autreFonction(), puis du constructeur d’unique_ptr.
Donc, si jamais autreFonction() émettait une exception, new T engendrerait une fuite de mémoire (puisque le pointeur unique ne serait pas encore créé). Lorsque vous utilisez make_unique, cette fuite n'est plus possible, même si l'ordre d'exécution est imprédictible.
D'autres problématiques similaires sont décrites sur : GotW #56: Exception-Safe Function Calls.

Puisque la proposition a été adoptée, l'ordre d'évaluation devrait désormais être « pratique ».

Voici quelques exemples :

- Dans f(a, b, c), l'ordre d'évaluation de a, b et c n'est toujours pas spécifié, mais on doit terminer d'évaluer complètement chaque paramètre avant de passer au suivant. Ceci est particulièrement important pour les expressions complexes.

- Sauf erreur de ma part, cela résout le problème opposant make_unique et unique_ptr<T>(new T), puisque tout argument de fonction doit être entièrement évalué avant qu'un autre ne le soit.

- Le chaînage d'appels de fonctions(1) fonctionne déjà de gauche à droite, mais l'ordre d'évaluation des expressions internes peut varier, comme montré ici : C++11 - Does this code from “The C++ Programming Language” 4th edition section 36.3.6 have well-defined behavior? - Stack Overflow. Pour être correct : « Les expressions sont ordonnées les unes par rapport aux autres de façon indéterminée » (voir Sequence Point ambiguity, undefined behavior?).

- Dorénavant, avec C++17, en présence de telles expressions internes, le chaînage d'appels de fonctions se déroulera comme attendu, ce qui signifie que ces expressions seront évaluées de gauche à droite. Ce sera le cas pour a(expA).b(expB).c(expC) : expA sera évaluée avant l'appel de b, etc.

- Dans le cas de l'utilisation d'un opérateur surchargé, l'ordre d'évaluation est celui que le standard associe usuellement à cet opérateur :

- Ainsi, pour std::cout << a() << b() << c(), l'ordre d'évaluation sera : a, b, puis c.

Selon les spécifications :

les expressions suivantes sont évaluées dans l'ordre a, puis b, puis c :
1. a.b ;2. a->b ;3. a->*b ;4. a(b1, b2, b3) ;5. b @= a ;6. a[b] ;7. a << b ;8. a >> b.

Et la partie la plus importante de la spécification est probablement :

L'initialisation d'un paramètre (qui inclut tous les calculs de valeurs associés et les effets de bord) est ordonnée de façon indéterminée par rapport à tous les autres paramètres.

StackOverflow: what are the evaluation order guarantees introduced. by C++17?

Pour plus de détails, consultez P0145R3 et P0400R0. Pris en charge à partir de MSVC 2017 : 15.7, GCC 7.0, et Clang 4.0.

V - Garantie d'élision de copie

Actuellement, le standard autorise l'élision dans des cas tels que :

- quand un objet temporaire est utilisé pour initialiser un autre objet (notamment l'objet renvoyé par une fonction, ou l'objet exception créé par une instruction throw) ;

- quand une variable est renvoyée ou lancée (par throw) alors que sa portée se termine ;

- quand une exception est attrapée par valeur.

Mais c'est au compilateur ou à l'implémentation qu'il revient d'élider ou non. En pratique, la définition de chaque constructeur est requise. Parfois, l'élision peut avoir lieu seulement dans les versions release, destinées à être publiées (et donc optimisées), alors que les versions de débogage (sans aucune optimisation) n'élideront rien.

Avec C++17, nous obtenons des règles plus claires quant à l'application de l'élision, et les constructeurs peuvent ainsi être complètement omis.

En quoi cela pourrait-il être utile ?

- Pour permettre de renvoyer des objets qui ne sont ni déplaçables ni copiables – car nous pouvons désormais omettre leurs constructeurs de déplacement et de copie. Ceci est utile avec le motif de conception « fabrique » (ou « factory », en anglais).

- Pour améliorer la portabilité du code et prendre en charge le « renvoi par valeur », plutôt qu'utiliser des paramètres de sortie.

Exemple :

// conformément à la spécification P0135R0
struct NonDeplacable
{
NonDeplacable(int);
// pas de constructeur de copie ni de déplacement
NonDeplacable(const NonDeplacable&) = delete;
NonDeplacable(NonDeplacable&&) = delete;

std::array<int, 1024> arr;
};

NonDeplacable fabriquer()
{
return NonDeplacable(42);
}

// construire l'objet
auto grosObjetNonDeplacable = fabriquer();

Le code ci-dessus ne se compilerait pas en C++14, puisque les constructeurs de copie et de déplacement sont supprimés. Mais en C++17, ces constructeurs ne sont pas requis – car l'objet grosObjectNonDeplacable sera directement construit.

Définir les règles de l'élision de copie n'est pas facile, mais les auteurs de la proposition ont suggéré des nouveaux types simplifiés de catégories de valeurs :

- glvalue : une glvalue est une expression dont l'évaluation conduit à l'emplacement d'un objet, d'un champ de bits ou d'une fonction.

- prvalue : une prvalue est une expression dont l'évaluation initialise un objet, un champ de bits ou un opérande d'un opérateur, comme spécifié par le contexte dans lequel elle apparaît.

En résumé : les prvalues réalisent l'initialisation, les glvalues produisent des emplacements.

Malheureusement, en C++17, nous n'obtiendrons l'élision de copie que pour les objets temporaires, pas pour l'optimisation de valeur de retour nommée(2). Cela ne couvre donc que le premier point, pas l'optimisation de renvoi de valeur nommée. Peut-être que C++20 poursuivra et ajoutera plus de règles à ce sujet ?

Pour plus de détails : P0135R0, MSVC 2017 : 15.6. GCC : 7.0, Clang : 4.0.

VI - Spécification des exceptions incluse au système de types

Auparavant, la spécification des exceptions pour une fonction n'appartenait pas au type de cette fonction, mais elle en fera désormais partie.

Nous obtiendrons donc une erreur dans le cas suivant :

void (*p)();
void (**pp)() noexcept = &p; // erreur : on ne peut pas convertir
// en pointeur vers une fonction noexcept

struct S {
typedef void (*p)();
operator p();
};
void (*q)() noexcept = S(); // erreur : on ne peut pas convertir
// en pointeur vers noexcept

L'une des raisons d'ajouter cette fonctionnalité est qu'elle permet de meilleures optimisations. Dans notre exemple, cela peut se produire lorsque vous avez la garantie qu'une fonction est noexcept.

De plus, en C++17, la spécification des exceptions a été épurée : Removing Deprecated Exception Specifications from C++17 – les « spécifications dynamiques d'exceptions » ont été retirées. De fait, vous pouvez uniquement utiliser le spécificateurnoexcept (page en anglais) pour déclarer si une fonction pourrait lever des exceptions ou non.

Pour plus de détails : P0012R1, MSVC 2017 : 15.5, GCC 7.0, Clang 4.0.

VII - Allocation dynamique de mémoire pour les données suralignées

Lorsque vous utilisez SIMD(3) ou lorsque vous faites face à d'autres contraintes concernant l'agencement de la mémoire, vous pouvez avoir besoin d'aligner vos objets de manière spécifique. Par exemple, pour utiliser SSE(4), il vous faut un alignement sur 16 octets (pour AVX 256, il vous faudrait un alignement sur 32 octets). Vous définiriez par conséquent un vector4 comme suit :

class alignas(16) vec4
{
float x, y, z, w;
};
auto pVectors = new vec4[1000];

N.B. : le spécificateuralignas est disponible depuis C++11.

En C++11 ou 14, vous n'avez aucune garantie quant à l'alignement de la mémoire. En conséquence, vous devez souvent recourir à des combinaisons spéciales telles que _aligned_malloc/_aligned_free pour vous assurer que l'alignement est préservé. Cette démarche n'est pas aussi satisfaisante, puisqu'elle ne fonctionne pas avec les pointeurs intelligents de C++ et rend de surcroît visibles dans le code les allocations et libérations de mémoire (alors que nous devrions cesser d'utiliser des new et delete bruts, selon les C++ Core Guidelines).

C++17 comble ce manque en introduisant, pour l'allocation de mémoire, des fonctions supplémentaires qui acceptent un paramètre pour l'alignement :

void* operator new(size_t, align_val_t);
void* operator new[](size_t, align_val_t);
void operator delete(void*, align_val_t);
void operator delete[](void*, align_val_t);
void operator delete(void*, size_t, align_val_t);
void operator delete[](void*, size_t, align_val_t);

Vous pouvez à présent allouer ce tableau vec4 comme suit :

auto pVectors = new vec4[1000];

Le code ne change pas, mais ceci appellera par magie :

operator new[](sizeof(vec4), align_val_t(alignof(vec4)))

En d'autres termes, new a désormais conscience de l'alignement de l'objet.

Pour plus de détails, consultez P0035R4. MSVC 2017 : 15.5, GCC : 7.0, Clang : 4.0.

VIII - Résumé

Nous nous sommes aujourd'hui concentrés sur quatre domaines dans lesquels les spécifications de C++ sont à présent plus claires. Désormais :

- nous avons des critères pour considérer que l'élision de copie aura lieu ;

- l’ordre d'exécution de certaines opérations est bien défini ;

- l'opérateur new est conscient de l'alignement d'un type ;

- les exceptions sont intégrées aux déclarations de fonctions.

Quelles clarifications choisiriez-vous pour le langage ?

Quels autres manques fallait-il combler ?

La prochaine fois, nous traiterons des nouveautés pour les templates et la programmation générique. Tenez-vous au courant !

Une fois de plus, n'oubliez pas de récupérer ma fiche de référence du langage C++17.

(1)NdT : on dit que les appels de fonctions sont chaînés lorsque chacun renvoie l'objet sur lequel s'applique l'appel suivant.

(2)NdT : cette notion est aussi connue sous le nom de « NRVO » (« Named Return Value Optimization »).

(3)NdT : SIMD est une architecture visant à exécuter une même instruction simultanément sur plusieurs données.

(4)NdT : SSE est un jeu d'instructions étendu, utilisé dans le cadre de SIMD.