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 :
- Rectifications et dépréciation ;
- Clarifications sur le langage (aujourd'hui) ;
- Les templates ;
- Les attributs ;
- Simplification ;
- Nouveautés de la bibliothèque standard – Système de fichiers ;
- Nouveautés de la bibliothèque standard – Algorithmes parallèles ;
- Nouveautés de la bibliothèque standard – Utilitaires ;
- 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 :
Sélectionneztruc(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.