Trois fonctionnalités simples de C++17 pour simplifier votre code

Jonathan Boccara

Traducteur

kurtcpp

C++17 amène son lot de fonctionnalités au langage C++. Penchons-nous sur trois d'entre elles qui facilitent l'écriture du code et le rendent plus concis, intuitif et juste.

Nous commencerons par les « bindings structurés ». Ils ont été introduits pour permettre à une instruction unique de définir plusieurs variables de types différents. Les bindings structurés s'appliquent à de nombreuses situations et nous verrons plusieurs cas où ils peuvent rendre le code plus concis et simple.

Nous verrons ensuite la « déduction d'arguments de template », qui nous permet d'omettre les arguments de template que nous spécifions habituellement, alors qu'ils ne sont pas vraiment nécessaires.

Puis nous terminerons par la « sélection avec initialisation », qui nous assure un meilleur contrôle sur la portée des objets et nous permet de définir les valeurs à l'endroit approprié.

Démarrons par les bindings structurés.

Commentez

- Titre : Trois fonctionnalités simples de C++17 pour simplifier votre code

- Auteur : Jonathan Boccara

- Traducteur : kurtcpp

- Parution : 19 avril 2020

- Public visé :

- 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 ® 2020 jft. 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. Bindings structurés

- I-A. De C++11 à C++17

- I-B. Renvoyer des objets composés

- I-C. Parcourir une collection composée

- I-D. Initialisation directe

- II. Déduction d'arguments de template

- III. Sélection avec initialisation

- IV. C++17 aide à simplifier le code

- V. Références

- VI. Remerciements

I - Bindings structurés

Les bindings structurés (ou liaisons structurées) nous permettent de définir plusieurs objets à la fois, et ce de manière plus naturelle que dans les précédentes versions de C++.

I-A - De C++11 à C++17

En soi, ce concept n'est pas nouveau. Auparavant, il a été toujours été possible de renvoyer des valeurs multiples depuis une fonction puis d'y accéder grâce à std::tie.

Examinons cette fonction :

std::tuple<char, int, bool> mytuple()
{
char a = 'a';
int i = 123;
bool b = true;
return std::make_tuple(a, i, b);
}

Ceci renvoie trois variables, chacune d'un type différent. Avant C++17, pour accéder à ces variables depuis une fonction appelante, il aurait fallu quelque chose comme :

char a;
int i;
bool b;

std::tie(a, i, b) = mytuple();

Avec cette démarche, il aurait fallu définir les variables préalablement à leur usage et connaître leur type à l'avance.

Mais avec les bindings structurés, nous pouvons réaliser ceci simplement comme suit :

auto [a, i, b] = mytuple();

Cette syntaxe est bien plus commode et plus en accord avec le style C++ moderne, consistant à utiliser auto presque à chaque fois que c'est possible.

Alors, que peut-on utiliser avec une initialisation par un binding structuré ? Pratiquement tout ce qui est un type composé – struct, pair et tuple. Voyons plusieurs cas où cela peut servir.

I-B - Renvoyer des objets composés

Ceci est la manière simple d'affecter en une seule opération chaque partie d'un type composé (struct, pair, etc.) à différentes variables et de faire en sorte que les types corrects soient assignés automatiquement.
Voyons donc un exemple. Si nous insérons dans un objet map, le résultat est un objet std::pair :

std::map<char,int> mymap;
auto mapret = mymap.insert(std::pair('a', 100));

Et si quiconque se demande pourquoi les types ne sont pas explicitement indiqués à pair, la réponse est « déduction des arguments de template » en C++17. Lisez la suite !

Donc, pour déterminer si l'insertion a réussi ou non, nous pourrions extraire l'information du résultat renvoyé par la méthode insert.

Le problème de ce code est que le lecteur a besoin de chercher la signification de .second, ne serait-ce que mentalement. Mais avec les bindings structurés, nous obtenons :

auto [itelem, success] = mymap.insert(std::pair('a', 100));
if (!success) {
// échec de l'insertion
}

itelem est l'itérateur vers l'élément et success est du type bool (true indique le succès de l'insertion). Les types des variables sont automatiquement déduits des affectations de valeurs – ce qui a beaucoup plus de sens, à la lecture du code.

Prenons un peu d'avance sur la dernière section, puisque C++17 permet désormais les sélections avec initialisation. Nous pourrions donc (et nous le ferions probablement) écrire ceci ainsi :

if (auto [itelem, success] = mymap.insert(std::pair('a', 100)); success) {
// succès de l'insertion
}

Mais nous reviendrons sur ceci dans un moment.

I-C - Parcourir une collection composée

Les bindings structurés fonctionnent également avec une boucle for sur une collection. Donc, si nous revenons à notre précédente définition de mymap : avant C++17, nous l'aurions parcourue avec un bout de code de ce genre :

for (const auto& entry : mymap) {
// traiter entry.first en tant que clé
// traiter entry.second en tant que valeur
}

Ou peut-être, pour être plus explicite :

for (const auto& entry : mymap) {
auto& key = entry.first;
auto& value = entry.second;
// manipuler entry
}

Mais les bindings structurés nous permettent d'écrire ceci de façon plus directe :

for (const auto&[key, value] : mymap) {
// manipuler entry par le biais de key et value
}

Il est plus explicite d'utiliser les variables key et value que seulement entry.first et entry.second et cela ne nécessite pas pour autant de définitions supplémentaires.

I-D - Initialisation directe

Cependant, puisque les bindings structurés peuvent initialiser des variables à partir d'objets tuple, pair, etc., est-il aussi possible d'effectuer une initialisation directe de cette façon ?

Oui, c'est possible. Examinons cette fonction :

auto a = 'a';
auto i = 123;
auto b = true;

Elle définit les variables a de type char avec la valeur initiale 'a', i de type int avec la valeur initiale 123 et b de type bool avec la valeur initiale true.

Avec les bindings structurés, ceci peut s'écrire ainsi :

auto [a, i, b] = tuple('a', 123, true); // types inutiles pour le tuple !

Ceci définira les variables a, i et b de la même façon que si les définitions distinctes ci-dessus avaient été utilisées.

Est-ce une vraie amélioration par rapport aux définitions précédentes ? Pour sûr, nous avons réalisé en une seule ligne ce qui aurait pu en prendre trois, mais pourquoi voudrions-nous faire ceci ?

Voyons le code suivant :

{
istringstream iss(head);
for (string name; getline(iss, name); )
// manipuler name
}

iss et name sont tous deux utilisés uniquement dans le bloc for, cependant iss doit être déclaré en dehors de l'instruction for et à l'intérieur de son propre bloc afin de limiter sa portée autant que possible.

C'est étrange, car iss appartient à la boucle for.

L'initialisation de variables multiples de même type a toujours été possible. Par exemple :

for (int i = 0, j = 100; i < 42; ++i, --j) {
// utiliser i et j
}

Mais ce que nous souhaiterions écrire – sans le pouvoir – est :

for (int i = 0, char ch = ' '; i < 42; ++i) { // ne compile pas
// utiliser i et ch
}

Avec les bindings structurés, nous pouvons écrire :

for (auto[iss, name] = pair(istringstream(head), string {}); getline(iss, name); ) {
// traiter name
}

et :

for (auto[i, ch] = pair(0U, ' '); i < 42; ++i) { // 0U fait que i est un entier non signé
// utiliser i et ch
}

Ceci permet aux variables iss et name (ainsi qu'à i et ch) d'être définies dans la portée de l'instruction for, comme nous le souhaitons, mais aussi à leur type d'être déterminé automatiquement.

Il en va de même avec les instructions if et switch, qui acceptent désormais, en C++17, l'initialisation facultative dans la sélection (voir plus bas). Par exemple :

if (auto [a, b] = myfunc(); a < b) {
// traitement utilisant a et b
}

Rappelez-vous que l'on ne peut pas tout faire avec les bindings structurés, et que tenter de les faire passer dans toutes les situations peut rendre le code plus alambiqué. Voyons l'exemple suivant :

if (auto [box, bit] = std::pair(std::stoul(p), boxes.begin()); (bit = boxes.find(box)) != boxes.end()){
// traiter le if en utilisant les variables box et bit
}

Ici, la variable box est définie avec le type unsigned long et a une valeur initiale renvoyée par stoul(p). stoul(), pour ceux qui ne connaissent pas, est une fonction de <string> qui reçoit un objet std::string en premier argument (elle accepte aussi d'autres arguments optionnels, notamment une base) et lit dans le contenu de celui-ci un nombre entier dans la base spécifiée (10 par défaut), qui est ensuite renvoyé comme une valeur unsigned long.

Le type de la variable bit est celui d'un itérateur pour boxes et sa valeur initiale est celle de .begin() qui permet de déterminer le type pour auto. La valeur de bit lui est assignée dans le code qui teste la condition de l'instruction if. Ceci démontre une contrainte lorsque l'on utilise des bindings structurés de cette manière. Ce que nous voulons vraiment écrire est :

if (const auto [box, bit] = std::pair(std::stoul(p), boxes.find(box)); bit != boxes.end()) { // ceci ne compile pas
// traiter le if en utilisant les variables box et bit
}

Mais nous ne pouvons pas le faire, car une variable déclarée dans une spécification de type auto ne peut pas apparaître dans son propre initialiseur ! Ce qui est assez compréhensible.

En résumé, les avantages de l'utilisation de bindings structurés sont :

- une déclaration unique pour une ou plusieurs variables ;

- les types de ces variables peuvent être différents ;

- ces types peuvent être déduits par un auto unique ;

- ces variables peuvent être initialisées à partir d'un objet de type composé.

L'inconvénient, bien entendu, est qu'un intermédiaire (par exemple std::pair) est utilisé. Cela n'affectera pas nécessairement les performances (ce n'est fait qu'une fois au démarrage de la boucle, de toute façon) puisque la sémantique de déplacement sera utilisée dès que possible – mais souvenez-vous que si un type n'est pas déplaçable (comme std::array, par exemple), les performances pourraient en prendre un coup, selon ce qu'implique l'opération de copie.

Mais n'allez pas optimiser votre code en préjugeant du comportement de votre compilateur ! Si les performances ne sont pas suffisantes, alors utilisez un profiler pour localiser le(s) goulet(s) d'étranglement, autrement, vous perdez du temps de développement. Écrivez simplement le code le plus simple et le plus propre possible.

II - Déduction d'arguments de template

Pour le dire simplement, la déduction d'arguments de template est la capacité des classes templates à déterminer le type des arguments passés aux constructeurs, sans que ce type ne soit explicitement spécifié.

Avant C++17, pour construire une instance de classe template, nous devions explicitement préciser les types de ses arguments (ou bien utiliser une des fonctions d'assistance make_xyz).

Prenons un exemple :

std::pair<int, double> p(2, 4.5);

Ici, p est une instance de la classe pair et elle est initialisée avec les valeurs 2 et 4.5. L'autre manière de réaliser ceci est :

auto p = std::make_pair(2, 4.5);

Chacune de ces démarches a ses inconvénients. Écrire des fonctions make_xyz telles que std::make_pair génère de la confusion ; de plus, c'est artificiel et en désaccord avec la façon dont sont construites les classes qui ne reposent pas sur les templates. Certes, std::make_pair, std::make_tuple et d'autres sont disponibles dans la bibliothèque standard ; cependant, pour les types personnalisés, c'est pire : vous devrez écrire vos propres fonctions make_....

Spécifier des arguments de template comme suit :

auto p = std::pair<int, double>(2, 4.5)

ne devrait pas être nécessaire, puisqu'on peut les déduire à partir des types des arguments – comme c'est toujours le cas avec les fonctions templates.

En C++17, cette obligation d'indiquer les types pour les constructeurs de classes templates a été abolie. Cela signifie que nous pouvons dorénavant écrire :

auto p = std::pair(2, 4.5);

ou bien :

std::pair p(2, 4.5);

qui est la manière dont vous espérez, logiquement, pouvoir définir p !

Revenons donc à la fonction mytuple() de tout à l'heure. En ayant recours à la déduction d'arguments de template (et à auto pour le type de retour de la fonction), nous obtenons :

auto mytuple()
{
char a = 'a';
int i = 123;
bool b = true;
return std::tuple(a, i, b); // aucun type requis
}

Cette manière de coder est bien plus propre, et dans ce cas-ci, nous pourrions même simplifier en :

auto mytuple()
{
return std::tuple('a', 123, true); // déduction automatique du type des arguments
}

Mais ce n'est pas tout. Pour aller plus loin dans cette fonctionnalité, vous pouvez lire la présentation de Simon Brand sur la déduction des arguments de template.

III - Sélection avec initialisation

La sélection avec initialisation rend possible l'initialisation (facultative) d'une variable à l'intérieur des instructions if et switch, à l'instar de ce qui se fait pour les instructions for. Voyez cet exemple :

for (int a = 0; a < 10; ++a) {
// corps du for
}

Ici, la portée est limitée à l'instruction for. Voyons à présent ceci :

{
auto a = getval();
if (a < 10) {
// utiliser a
}
}

Ici, la variable a est utilisée uniquement à l'intérieur de l'instruction if, mais doit être définie en dehors, dans son propre bloc, si nous voulons limiter sa portée. Mais en C++17 ceci peut s'écrire de la manière suivante :

if (auto a = getval(); a < 10) {
// utiliser a
}

Ceci suit la même syntaxe d'initialisation que pour l'instruction for – l'initialisation étant séparée de la sélection par un point-virgule (;). Cette syntaxe d'initialisation peut aussi, sur le même principe, être utilisée avec l'instruction switch. Voici un exemple :

switch (auto ch = getnext(); ch) {
// clauses case requises
}

Ceci aide bien le C++ à gagner en concision, intuitivité et justesse ! Combien d'entre nous ont déjà écrit du code tel que :

int a;
if ((a = getval()) < 10) {
// utiliser a
}
...
// bien plus loin dans le code – a a gardé sa valeur
if (a == b) {
// ...
}

… dans lequel a n'avait pas été correctement initialisé avant le second test if (par erreur), mais n'était pas pour autant signalé par le compilateur, à cause de la définition précédente. En effet, sa portée n'est pas encore terminée ici, puisqu'il n'est pas défini dans un bloc isolé. En C++17, ceci aurait été codé ainsi :

if (auto a = getval(); a < 10) {
// utiliser a
}

... // bien plus loin dans le code – a n'est pas défini ici

if (a == b) {
// ...
}

De cette manière, cela aurait été détecté par le compilateur et signalé comme une erreur. Résoudre une erreur de compilation est bien moins coûteux que résoudre un problème à l'exécution !

IV - C++17 aide à simplifier le code

En résumé, nous avons vu comment les bindings structurés autorisent une unique déclaration qui prend en charge une ou plusieurs variables qui peuvent être de types différents et dont les types sont toujours déduits par un simple auto. Les affectations de ces variables peuvent se faire à partir d'un objet de type composé.

La déduction d'arguments de template nous permet d'éviter l'écriture redondante de paramètres de template et de fonctions utilitaires pour les déduire.

Et la sélection avec initialisation rend homogènes les initialisations dans les instructions if et switch avec celles des instructions for et évite l'écueil des portées de variables trop étendues.

V - Références

Bindings structurés :

http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0144r2.pdf
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0217r3.html

Déduction d'arguments de template :

http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0091r3.html
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0433r2.html

Sélection avec initialisation :

http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0305r1.html

VI - Remerciements

Nous remercions Jonathan Boccara qui nous autorise à traduire ce tutoriel. Nous remercions également kurtcpp pour la traduction en français, lolo78 pour sa relecture technique, et escartefigue pour la relecture orthographique.