a = b = c, une conséquence singulière de l’associativité d’opérateurs

Article original : a = b = c, a strange consequence of operator associativity | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Cas d’étude

Si vous codez en C++ régulièrement, vous avez sans doute déjà rencontré la syntaxe suivante :

class Foo;
Foo * make_Foo();
 
int main()
{
 
    Foo * my_foo;
    if (my_foo = make_Foo())
    {
        // ... On fait de trucs avec le pointeur my_foo
    }
 
    return 0;
}

D’un point de vue sémantique, c’est équivalent à la chose suivante :

class Foo;
Foo * make_Foo();
 
int main()
{
 
    Foo * my_foo = make_Foo();
    if (my_foo)
    {
        // ... On fait de trucs avec le pointeur my_foo
    }
 
    return 0;
}

C’est le sujet d’aujourd’hui : l’assignation au sein d’une expression.

Comment ça marche ?

Quelle est la valeur d’une telle expression ?

Pour le savoir, il suffit de lancer le code suivant :

int main()
{
    int foo = 2;
    std::cout << (foo = 3) << std::endl;
    return 0;
}

La sortie standard nous affiche 3.

On peut donc en déduire que l’expression d’assignation est évaluée comme la variable assignée, après qu’elle le soit1.

Une typo catastrophique

Mettons qu’on a trois variables, a, b et c. Nous voulons que la valeur de a soit true si et seulement si b et c sont égales.

Pour cela, on va écrire ceci :

bool a, b, c;
// ...
a = b == c;

Mais, on n’est pas très loin d’une petite typographie qui peut tout changer :

bool a, b, c;
// ...
a = b = c;

Ce code va compiler et ne vous donnera pas le résultat attendu. Pourquoi ?

L’expression a = b = c sont deux opérations d’assignation au sein d’une même expression. Selon la Table de Précédence des Opérateurs en C++, l’associativité de = est de droite à gauche. Donc l’expression a = b = c est équivalente à a = (b = c).

Puisque (b = c) est évaluée (comme vu précédemment) comme la variable b après assignation, écrire a = b = c; est équivalent à écrire b = c; a = b;.

Si ensuite vous utilisez a en tant que booléen, il sera évalué à true si et seulement si c vaut également true.

Conclusion à propos de a = b = c

Il peut y avoir des cas où cette syntaxe (celle avec les deux = en une seule expression) peut être utile, mais la plupart du temps je la trouve absconse et déroutante.

Aujourd’hui, il n’y a pas de manière efficace de prévenir la typographie (les parenthèses ne vous sauveront pas dans ce cas). Tout ce qu’on peut faire, c’est ouvrir les yeux et utiliser des constantes autant que possible (oui, si b est const, alors la typo est attrapée à la compilation)2.

L’opération d’assignation renvoie une lvalue

Reprenons le a = b = c de tout à l’heure et ajoutons des parenthèses autour de a = b :

int main()
{
    int a = 1, b = 2, c = 3;
 
    (a = b) = c;
 
    std::cout << a << b << c << std::endl;
    return 0;
}

Ça compile et affiche la sortie suivante : 323.

Cela signifie qu’on a assigné à a la valeur de b, puis la valeur de c. L’expression a = b est bien une lvalue.

void foo(int&);
 
int main()
{
    int a = 1, b = 2;
 
    foo(a = b); // Compile parce que `a = b` est une lvalue
    foo(3); // Ne compile pas parce que `3` est une rvalue
 
    return 0;
}

Plus spécifiquement, l’opération d’assignation renvoie une référence vers la variable concernée.

Opération d’assignation pour les types personnalisés

Vous aurez peut-être déjà remarqué que l’opérateur operator= peut, d’après le standard, renvoyer n’importe quel type (je vous renvoie à la section Canonical implementations de operator overloading – cppreference.com pour plus de détails3).

Vous pouvez bien entendu renvoyer une référence vers l’objet assigné :

struct Foo
{
    Foo& operator=(const Foo&) { return *this; }
};
 
int main()
{
    Foo a, b, c;
    a = b = c;
    return 0;
}

Vous pouvez aussi renvoyer une valeur plutôt qu’une référence :

struct Foo
{
    Foo operator=(const Foo&) { return *this; }
};
 
int main()
{
    Foo a, b, c;
    a = b = c; // Fonctionne aussi, mais fait une copie
    return 0;
}

Puisque le résultat est copié, l’assignation b = c devient une rvalue. Du coup, si vous essayez de prendre une référence de cette expression, vous avez une erreur de compilation :

struct Foo
{
    Foo operator=(const Foo& other) 
    { 
        val = other.val; 
        return *this; 
    }
    int val;
};
 
int main()
{
    Foo b = {1}, c = {2};
    Foo & a = b = c; // Ne compile pas parce qu'ici, (b = c) est une rvalue
    return 0;
}

Ce code compilerait si l’operator= renvoyait un Foo& plutôt qu’un Foo.

Vous pouvez également ne rien renvoyer du tout (en utilisant void comme valeur de retour). Dans ce cas, a = b = c ne compile plus du tout :

struct Foo
{
    void operator=(const Foo&) {  }
};
 
int main()
{
    Foo a, b, c;
    a = b = c; // Ne compile pas parce que (b = c) ne renvoie rien
    return 0;
}

Ça peut être une bonne manière de prévenir la syntaxe a = b = c4.

À propos des déclarations

Il y a des cas spécifiques où vous pouvez écrire des déclarations au sein d’une autre instruction (un peu comme les assignations qu’on a vu au début).

Vous pouvez utiliser cette syntaxe spécifique dans la plupart des instructions de contrôle de flux (comme if, while, switch et, bien entendu, for) et dans les appels de fonction.

Par exemple, le tout premier exemple de cet article peut également être écrit comme suit :

class Foo;
Foo * make_Foo();
 
int main()
{
 
    if (Foo * my_foo = make_Foo())
    {
        // ...  On fait de trucs avec le pointeur my_foo
    }
 
    return 0;
}

Cependant, la déclaration elle-même n’est ni une lvalue, ni une rvalue.

Vous ne pouvez pas écrire ceci :

int main()
{
    int a = 1, c = 3;
    a = (int b = c); // Ne compile pas
 
    return 0;
}

Ni cela :

int main()
{
    int b = 2, c = 3;
    (int a = b) = c; // Ne compile pas
 
    return 0;
}

Les instructions où il est possible de faire des déclarations sont indiquées comme de « init-statements » dans le standard, comme ceci :

Conclusion générale

Les syntaxes comme a = b = c et if (a = b) sont intentionnelles et bien définies dans le standard. Cependant, elles sont étrangères à beaucoup de développeurs et développeuses et sont si rarement utilisées qu’elles peuvent être déroutantes.

Des bugs peuvent survenir à cause du fait que le symbole = ressemble beaucoup au digramme ==, soyez donc attentif à ça. Si vous voulez l’éviter avec vos types personnalisés, vous pouvez déclarer l’operator= pour qu’il renvoie void et qu’ainsi la syntaxe a = b = c devienne invalide. Mais ce n’est pas possible avec les types fondamentaux, et c’est une contrainte pas forcément bienvenue.

Merci de votre attention et à la prochaine fois!

Article original : a = b = c, a strange consequence of operator associativity | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addendum

Notes

  1. En réalité, c’est évalué comme une référence à la variable et pas comme la valeur de la variable. Ce sera démontré plus loin dans l’article.
  1. Il est possible d’activer des warnings spécifiques pour prévenir certains cas (par exemple, -Wparentheses peut être utilisé sous GCC pour éviter l’assignation au sein d’un contrôle de flux), mais ça ne couvre pas tous les cas (typiquement, a = b = c n’a pas de warning associé) et parfois vous ne voudrez pas les activer, en fonction de votre affinité avec cette syntaxe.
  1. Le site cppreference.com dit que « par exemple, les opérateurs d’assignation renvoie par référence pour rendre possible la syntaxe a = b = c = d, parce que les opérateurs fondamentaux le permettent. ». Cependant, je n’ai trouvé aucune mention de cette intention spécifique dans la quatrième édition de The C++ Programming Language de Bjarne Stroustrup. Je suspecte que c’est une interprétation libre.
  1. Vous pouvez, comme vous vous en serez peut-être douté, renvoyer n’importe quel type, si vous avez des besoins spécifiques. Le prototype int operator=(const Foo&); (membre de la classe Foo) est valide. Ça peut être pratique, si par exemple vous voulez renvoyer un code d’erreur.
  1. Il y a une différence en terme de pragmatique sur la durée de vie des variables (qui n’est pas le sujet d’aujourd’hui), car dans l’exemple, la variable my_foo ne vit que le temps du bloc if, alors que dans les exemples du début, elle ne vit à travers toute l’étendue du main. Mais puisque c’est techniquement la même chose dans cet exemple particulier (parce qu’il n’y a rien après le bloc if), je n’ai jugé nécessaire de m’étendre sur la question.

Comment quantifier l’inflation de la dette technique ?

Article original : How to quantify technical debt inflation | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Si vous travaillez pour une société de développement logiciel, vous vous êtes sans doute retrouvé dans le cas où vous aviez une dette technique à résoudre, mais où vous n’aviez pas l’approbation de votre hiérarchie pour le faire dans l’immédiat. « On verra ça plus tard », comme ils disent. Mais en bon·ne développeur·se, vous savez deux choses : la dette technique est de plus en plus dure à résoudre au fil du temps, et l’impact d’une dette technique stagnante ajoute un surcoût à chaque ajout de code qui est fait entre-temps.

Quand vous essayez d’argumenter que « la dette technique est coûteuse », vous vous retrouvez souvent face à la question « coûteuse de combien ? ». Mais vous n’avez pas de réponse à cela, car (à l’heure où cet article est rédigé) il n’y a pas de moyen connu pour prédire l’avenir.

C’est ce que j’appelle la problème d’Inflation de la Dette Technique, ou TDI (pour Technical Debt Inflation).

Avertissement : Je ne peux pas vraiment résoudre le sempiternel problème de la TDI dans un article de deux mille mots. Ce que je donne ici est un fruit de réflexion, l’ouverture d’un débat, une ligne d’approche pour vous pousser à mener votre propre réflexion sur le sujet. J’espère que vous pourrez l’apprécier.

Qu’est-ce que la dette technique ?

On peut définir la dette technique comme étant une partie du code qui est mal conçue et qui a besoin d’être réécrite pour être efficace. Elle n’a pas d’impact pour l’utilisateur, mais rend le code plus dur à maintenir et cela complexifie le développement de nouvelles fonctionnalités (on se limitera à cette définition pour cet article).

Souvent, elle apparaît quand on choisit une approche à court terme plutôt qu’une solution sur le long terme. Cela coûte plus de temps d’écrire une solution au long terme sur le moment, mais une version à court terme se repaye toujours dans le futur.

Qu’est-ce que l’inflation de la dette technique ?

Une dette technique est coûteuse de deux manières.

Premièrement, elle est coûteuse à résoudre. Corriger une dette technique prend du temps, et puisqu’elle est invisible du point de vue de l’expérience utilisateur et de la hiérarchie, c’est souvent considéré inutile par ceux-ci.

Secondement, il est coûteux de travailler « proche » d’une dette technique. Elles ont très souvent des impacts autour d’elles, dans la manière de développer de nouvelles fonctionnalités ou de les maintenir. Par exemple, une dette technique peut être une interface contre-intuitive, qui nécessite plus de temps pour s’y habituer qu’une interface ergonomique. Autre exemple, si un module est mal rédigé, toute mise à jour en son sein coûtera sensiblement plus de temps que s’il était bien conçu.

L’inflation de la dette technique est le fait que plus on attend longtemps pour la résoudre, plus ces coûts augmentent.

En effet, plus le temps passe et plus la quantité de code affectée sera grande (puisqu’il y aura de plus en plus de code dépendant de cette dette) et plus il sera difficile de la maintenir.

Quel est l’intérêt de quantifier la dette technique et son inflation ?

Mesurer l’amplitude de l’impact d’une dette technique est compliqué. Il est encore plus dur de justifier une telle évaluation à celles et ceux qui approuveront -ou pas- le travail que ça implique.

Si on arrive à concevoir un modèle qui met des nombres sur la dette technique, il sera plus facile de justifier de la nécessité résoudre celle-ci au plus tôt.

On pourrait avancer des arguments comme « Oui, ça nous coûterait trois jours de corriger cette dette maintenant, mais si on ne le fait pas, dans deux ans elle aura, en tout et pour tout, coûté en moyenne deux heures par semaines par développeuse et développeur, pour un total de cinquante jours à la fin de la deuxième année… ». Cela pourrait aider à mettre en perspective la vision de la hiérarchie.

Mais je dois bien admettre que la chose la plus importante ne sont pas les nombres eux-mêmes, mais les arguments qui se basent dessus.

Comment quantifier la dette technique ?

La dette technique est coûteuse à résoudre. La première étape est d’évaluer combien de temps une dette prendrait à résoudre aujourd’hui. Sans ça, on ne pourra pas évaluer l’inflation de ce coût.

Par chance, c’est souvent assez facile à évaluer. En se basant sur sa propre expérience du code, on est souvent en mesure de donner une estimation du temps nécessaire à la résolution d’une dette.

En général, je conseillerais de multiplier toute évaluation raisonnable par deux ou trois, afin de prendre en compte les impondérables.

Si vous travaillez en équipe, il est sage de baser son estimation sur la personne la plus « lente » de l’équipe, car vous ne serez peut-être pas la personne qui résoudra effectivement la dette, et si vous travaillez plus rapidement que vos collaborateurs, l’évaluation risque d’être faussée.

Une fois cela fait, on peut maintenant évaluer l’inflation de la dette technique.

Comment quantifier l’inflation ?

La chose la plus importante à retenir à propos de l’inflation est qu’elle n’est pas linéaire.

En fait, vu qu’il y a deux axes sur lesquels la dette technique augmente (le surcoût de correction et le surcoût d’utilisation), et qu’ils subissent tous deux l’inflation, alors cette inflation est, a minima, quadratique1. Elle n’est pas proportionnelle au temps, mais au temps au carré.

S’il n’y qu’une chose à retenir de cet article, c’est cela : la TDI est quadratique.

Maintenant, comment évaluer (numériquement) cette inflation ?

Un indicateur simple et utilisable est la taille du code. La taille du code tend à augmenter avec le temps, et si on arrive à avoir un modèle qui extrapole la taille du code dans le futur à partir de son évolution dans le passé, alors on pourra estimer la taille du code dans le futur.

Je vous donne un exemple de méthode d’évaluation de la taille du code dans un des addenda.

À partir de là, il suffit d’appliquer un facteur quadratique sur cette évolution pour obtenir une évaluation de l’inflation de la dette. C’est ce que j’appelle le modèle de Croissance Quadratique (ou Quadratic Expansion dans la langue de Sutter).

Formalisation du modèle de Croissance Quadratique

Soit C0 la taille du code à t0.
Soit C1 la taille du code à t1.
Soit D0 le temps estimé pour résoudre la dette technique à t0.
Soit D1 le temps estimé pour résoudre la dette technique à t1.
Soit I01 le temps perdu à cause de l’impact de la dette technique entre t0 et t1.
Soit Δ01 le total de temps cumulé qu’a coûté la dette technique entre t0 et t1.

C0, C1 et D0 sont des valeurs connues.
D1 et I01 sont des valeurs intermédiaires.
Δ01 et l’objectif du modèle.

D1 = D0 × C1 ÷ C0

I01 = Λ × C1 ÷ C0, où Λ est une constante qu’on appellera “facteur lambda”2.

Δ01 = (I01 × D1) – D0

Δ01 = Λ × D0 × (C1 ÷ C0)2 – D0

Pour simplifier le calcul, supposons Λ = 1 (on cherche à évaluer un ordre de grandeur, pas à avoir une mesure précise), ce qui donne

Δ01 = D0 × ( (C1 ÷ C0)2 – 1 )

Exemple

Vous avez une dette technique conséquente à résoudre. Votre supérieur hésite à la retarder de six (pour des raisons de jalon de livraison). Vous lui dites que ça coûterait du temps en plus de la reporter, et iel vous demande de quantifier à quel point.

À l’heure actuelle, la fonctionnalité concernée est composée de 21,6 milliers de lignes de code. Il y a trois mois, elle était composée de 17,8 milliers de lignes. Le code a donc gonflé de 3,8 milliers de lignes dans cet intervalle. Cependant, votre équipe (composée de quatre développeur·se·s) vient de se voir renforcée avec un nouvel arrivant, ce qui fait un total de cinq devs. Donc au cours des six prochains mois, on peut estimer que le code grossira d’environ 9,5 milliers de lignes supplémentaires (38×2×1,25).

Votre estimez qu’il faudrait une semaine entière (5 jours) pour résoudre la dette technique.

C0 = 21.6k

C1 = 31.1k

D0 = 5 jours

Δ01 = D0 × ( (C1 ÷ C0)2 – 1 ) ≈ 5.4 jours

Conclusion : selon le modèle de croissance quadratique, l’attente coûterait environ cinq jours et demi supplémentaires.

Vous annoncez donc à votre hiérarchie que, compte tenu de la productivité de l’équipe, attendre six fera plus que doubler le temps perdu à résoudre la dette (en incluant sa correction et le temps perdu à la maintenir).

Les limites de ce modèle

Ce modèle a de grosses limitations.

  • Premièrement, le calcul n’est pas transitif. E.g. Δ02 ≠ Δ01 + Δ12. Cela reflète le fait que plus on porte son regard loin (0 → 2), plus le coût de la dette est incertain. Mathématiquement, il faudrait refléter cela avec un intervalle de confiance.
  • Évaluer et extrapoler la taille du code est souvent faisable, mais pas toujours trivial.
  • Surtout, ce modèle n’a jamais été démontré en pratique.

La question à un million d’euros

Il y aurait bien, en théorie, une manière d’évaluer mathématiquement la problématique TDI : en agrégeant des données recueillies sur des centaines de projets à travers les années. Mais ce n’est pas une tâche simple, sinon impossible. En voici les raisons :

  • Cela voudrait dire ingérer dans le code détenu par des sociétés privées.
  • Même en rétrospective, il est complexe d’évaluer l’impact qu’a eu une dette technique.
  • L’étude prendrait des années, sinon des décennies à être complétée, car les impacts d’une dette technique se mesurent dans le temps.

Avec ça en tête, agréger des données réelles dans une étude sérieuse semble impossible. Mais peut-on élaborer un protocole de plus petite échelle qui pourrait nous aider à résoudre la problématique de la TDI ? Ça mérite réflexion.

Conclusion

Je le redis, pour éviter toute ambiguïté : le modèle de Croissance Quadratique est une manière limite, peu précise et non-scientifique d’évaluer l’inflation de la dette technique, mais elle permet d’avoir un ordre de grandeur cohérent en faveur d’une refonte tôtive du code.

J’espère que ce sera le prélude d’étude plus sérieuse sur le problème de la TDI.

Souvenez-vous qu’évaluer le temps « perdu » d’une dette technique existante est loin d’être trivial et qu’un protocole d’évaluation est impossible à petite échelle.

Mais j’espère que ça vous aidera à avoir une idée plus nette du coût concret des dettes techniques.

Merci de votre attention et à la prochaine !

Article original : How to quantify technical debt inflation | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addenda

Comment évaluer la taille du code? Un exemple

Avec Git et un shell Linux, vous pouvez facilement évaluer la taille du votre code.

git ls-files Liste tous les fichiers.

grep -E '\.(cpp|h|hpp)$' est un filtre sur les fichiers de source et d’en-tête.

wc -l compte le nombre de lignes.

Voici la commande complète :

git ls-files | grep -E '\.(c|cpp|h|hpp)$' | xargs -d '\n' wc -l

(NB : xargs permet d’alimenter la sortie dans la commande  wc. L’option -d '\n' est présente pour échapper les espaces dans les noms de fichier)

Alternativement, vous pouvez utiliser wc -m à la place de wc -l pour compter les caractères au lieu des lignes. C’est un peu plus lent et moins intuitif, mais à mon avis une meilleure métrique que le nombre de lignes.

Pour que le résultat soit plus lisible, vous pouvez faire

grep -E '^ *[0-9]+ total$' pour récupérer uniquement la ligne de total.

sed -r 's/^ *([0-9]+) total$/\1/' enlève les informations superflues.

La commande complète est désormais :

git ls-files | grep -E '\.(c|cpp|h|hpp)$' | xargs -d '\n' wc -l | grep -E '^ *[0-9]+ total$' | sed -r 's/^ *([0-9]+) total$/\1/'

Si vous avez plusieurs sous-modules, vous pouvez :

Ajouter --recurse-submodules pour que l’opération soit récursive.

awk '{s+=$1} END {print s}' somme les valeurs de totaux.

Ligne de commande finale :

git ls-files --recurse-submodules | grep -E '\.(c|cpp|h|hpp)$' | xargs -d '\n' wc -l | grep -E '^ *[0-9]+ total$' | sed -r 's/^ *([0-9]+) total$/\1/' | awk '{s+=$1} END {print s}'

Notes

  1. C’est basé sur cette idée : puisqu’il y a deux coûts qui augmentent et que ces deux coûts sont entremêlés, leur combinaison est multiplicative plutôt qu’additive. Cela fait que, d’après moi, l’inflation est quadrative.
  2. Λ représente à quel point le reste du code dépend de la dette technique. Plus l’impact est important, plus Λ sera grande, et donc plus I01 sera grand à son tour. Pour des raisons de simplification, Λ est ici considérée constante.
  3. Est-il seulement possible de concevoir un protocole qui permettrait d’évaluer la justesse de n’importe quel modèle de TDI ? Puisqu’on ne peut réaliser qu’une des deux actions (résoudre la dette dans l’instant ou la résoudre plus tard) il n’y a aucun moyen d’évaluer avec certitude le temps qu’aurait pris l’alternative. De plus, le temps nécessaire pour résoudre une dette dépend des compétences de la personne qui la raison et, il faut bien l’avouer, de la chance. En plus de cela, le modèle est conçu pour inclure un risque, ce qui signifie que l’inflation estimée sera grande pour y inclure ce risque. Il n’y a (à ma connaissance) aucun moyen de vérifier ce genre de représentation abstraite.

Qui est responsable de la mémoire ?

Article original : Who owns the memory? | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Avez-vous déjà entendu parler de « propriété » (en anglais : « ownership« ) de la mémoire, en C++, dans le contexte des pointeurs ?

Quand vous utilisez des pointeurs bruts, ils doivent à un moment ou à un autre être libérés (sinon, vous aurez une fuite mémoire). Mais si ledit pointeur est passé à des fonctions et à travers des fonctionnalités complexes, ou renvoyé par une factory, vous devez savoir qui a la responsabilité de le libérer.

La « propriété » signifie la « responsabilité de nettoyer ». Le propriétaire de la mémoire est celui qui doit libérer le pointeur associé.

La libération peut tantôt être explicite (à travers le mot-clé delete ou la fonction free() dans le cadre des pointeurs bruts) ou rattachée au cycle de vie d’un objet (à travers des pointeurs intelligents — des smart pointers — et la RAII1).

Dans cet article, le terme « pointeur » sera utilisé pour parler à la fois des pointeurs bruts et des pointeurs intelligents.

La problématique de la propriété de mémoire

Ce problème est résolu par l’article suivant : You Can Stop Writing Comments About Pointer Ownership (gpfault.net).

TL;DR : les pointeurs intelligents peuvent remplacer tous les pointeurs bruts, quelle que soit la situation, du coup n’utilisez pas les pointeurs bruts. Les sémantiques de move peuvent permettre de gérer la propriété, et sont vérifiées à la compilation.

L’article est intéressant, mais rate une problématique pourtant importante : que doit-on faire avec les pointeurs déjà existants ? Que doit-on faire quand on est forcé d’utiliser des pointeurs bruts2 ?

Dans la suite de l’article, ce seront les questions auxquelles j’essaierai de répondre.

Pour commencer, quand vous avez une fonctionnalité qui requiert des pointeurs bruts, vous devez vous poser la question: comment cette fonctionnalité se comporte-t-elle au regard de la propriété de mémoire ?

Quand cette question est répondue, on peut distinguer quatre cas :

  • Quand on reçoit un pointeur et qu’on en devient propriétaire.
  • Quand on reçoit un pointeur mais qu’on n’en devient pas propriétaire.
  • Quand on transmet un pointeur mais qu’on n’en devient pas propriétaire.
  • Quand on transmet un pointeur et qu’on en devient propriétaire.

Quand on reçoit un pointeur et qu’on en devient propriétaire

Ce cas est probablement le plus simple. Comme on peut construire un std::unique_ptr ou un std::shared_ptr avec un pointeur brut, tout ce qu’on a à faire c’est de positionner ledit pointeur brut dans un pointeur intelligent et il sera proprement libéré à la fin de son cycle de vie.

Exemple

#include <memory>
#include <iostream>
 
struct Foo
{
    Foo() { std::cout << "Fuite ?" << std::endl; }
    ~Foo() { std::cout << "Pas de fuite" << std::endl; }
};
 
// On n'est pas propriétaire de cette fonction, on ne peut donc pas changer le type renvoyé
Foo * make_Foo()
{
    return new Foo();
}
 
int main()
{
    std::unique_ptr<Foo> foo_ptr(make_Foo());
    // L'instance de Foo est proprement libérée à la fin de la fonction
    return 0;
}

La sortie ressemble à ça:

Fuite ?
Pas de fuite

Quand on reçoit un pointeur mais qu’on n’en devient pas propriétaire

Ce cas est un peu plus complexe. Parfois, pour des raisons particulières, une fonctionnalité vous donne un pointeur que vous ne devez pas libérer.

Dans ce cas-ci, on ne peut pas utiliser un pointeur intelligent (comme dans le premier cas), parce que ce dernier va libérer le pointeur à sa destruction.

Par exemple, dans l’exemple suivant, la classe IntContainer créé un pointeur sur un int et le libère à la fin de son propre cycle de vie :

// On n'est pas propriétaire de cette classe, on ne peut pas la modifier
struct IntContainer
{
    IntContainer(): int_ptr(new int(0)) {}
    ~IntContainer() { delete int_ptr; }
 
    int * get_ptr() { return int_ptr; }
 
    int * int_ptr;
};

Si on essaie d’utiliser un unique_ptr, comme ceci :

int main()
{
    IntContainer int_cont;
    std::unique_ptr<int>(int_cont.get_ptr());
    // Double delete
    return 0;
}

On aura un comportement indéfini. Avec mon compilateur (GCC 11.2), j’ai une exception qui est levée : `free(): double free detected in tcache 2`.

Il y a une solution simple à ce problème. À la place d’utiliser un pointeur, on peut récupérer une référence sur l’objet pointé. De cette manière, on pourra l’utiliser sans risquer de le détruire.

int main()
{
    IntContainer int_cont;
    int & int_ref = *int_cont.get_ptr();
    // On a accès à la valeur de int_ptr via la référence
    return 0;
}

Quand on transmet un pointeur mais qu’on n’en devient pas propriétaire

Certaines librairies ont besoin que vous leur passiez un pointeur brut en paramètre. Dans la plupart des cas, vous gardez la propriété de ces pointeurs, mais le problème de devoir passer un pointeur brut est bien présent.

Il y a deux situations :

  • L’objet à transmettre est une valeur ou une référence.
  • L’objet à transmettre est dans un pointeur intelligent.

Situation 1 : L’objet à transmettre est une valeur ou une référence

Dans cette situation, tout ce que vous avez à faire est d’utiliser l’opérateur & pour passer l’adresse de l’objet à la fonctionnalité qui le demande. Comme elle n’essaiera pas de libérer ce pointeur, rien de néfaste arrivera.

#include <iostream>
 
struct Foo
{
    Foo() { std::cout << "Fuite ?" << std::endl; }
    ~Foo() { std::cout << "Pas de fuite" << std::endl; }
};
 
// Fonction qui nécessite un pointeur brut
void compute(Foo *)
{
    // ...
}
 
int main()
{
    Foo foo;
    // ...
    compute(&foo);
    return 0;
}   

Situation 2 : L’objet à transmettre est dans un pointeur intelligent

Quand tout ce que vous avez est un pointeur intelligent vers l’objet qu’il faut passer à la fonction, vous pouvez utiliser la fonction membre get() pour récupérer le pointeur brut associé au pointeur intelligent. unique_ptr et shared_ptr implémentent tous deux cette fonction3.

#include <memory>
#include <iostream>
 
struct Foo
{
    Foo() { std::cout << "Fuite ?" << std::endl; }
    ~Foo() { std::cout << "Pas de fuite" << std::endl; }
};
 
// Fonction qui nécessite un pointeur brut
void compute(Foo *)
{
    // ...
}
 
int main()
{
    std::unique_ptr<Foo> foo_ptr = std::make_unique<Foo>();
    // ...
    compute(foo_ptr.get());
    return 0;
}

Quand on transmet un pointeur et qu’on en devient propriétaire

Probablement le cas le plus rare d’entre tous4, mais qui peut hypothétiquement exister, d’une fonctionnalité qui demande un pointeur brut et se charge elle-même de le libérer.

Situation 1 : L’objet à transmettre est une valeur ou une référence

Si vous avez l’objet en tant que valeur ou référence, la seule manière d’avoir un pointeur brut qui peut être détruit par autrui est d’appeler new.

Cependant, juste faire un new va copier l’objet est ce n’est pas souhaitable. Comme la propriété est théoriquement passée à la fonctionnalité, on peut faire un std::move sur l’objet pour appeler le constructeur de move (s’il existe) et éventuellement éviter une copie coûteuse.

On a donc juste besoin de faire un new sur l’objet, dans lequel applique move, cela créera le pointeur voulu, qu’on a juste à passer à la fonction.

#include <iostream>
 
struct Foo
{
    Foo() { std::cout << "Fuite?" << std::endl; }
    Foo(const Foo &&) { std::cout << "Constructeur de move" << std::endl; }
    ~Foo() { std::cout << "Pas de fuite" << std::endl; }
};
 
void compute(Foo *foo)
{
    // ...
    delete foo;
}
 
int main()
{
    Foo foo;
    // ...
    compute(new Foo(std::move(foo)));
}

Situation 2 : L’objet à transmettre est dans un pointeur intelligent

La fonction membre get() ne permet pas de transmettre la propriété, donc si on l’utilise pour passer le pointeur brut, la mémoire sera libérée deux fois.

La fonction membre release(), par contre, relâche la propriété en même temps qu’elle renvoie le pointeur brut. C’est ce qu’on voudra utiliser dans cette situation.

#include <iostream>
#include <memory>
 
struct Foo
{
    Foo() { std::cout << "Fuite ?" << std::endl; }
    ~Foo() { std::cout << "Pas de fuite" << std::endl; }
};
 
void compute(Foo *foo)
{
    // ...
    delete foo;
}
 
int main()
{
    std::unique_ptr<Foo> foo_ptr = std::make_unique<Foo>();
    // ...
    compute(foo_ptr.release());
    return 0;
}

Le souci c’est que release() n’est une que membre de unique_ptr, pas de shared_ptr. Les pointeurs « partagés » peuvent avoir de multiples instances qui pointent sur la même ressource, de ce fait ils ne sont pas vraiment propriétaires de la mémoire en premier lieu.

Comment reconnaître l’intention d’une fonctionnalité ?

C’est la question clé quand on fait du refactoring, parce que mal identifier l’intention de la fonctionnalité concernant la propriété de la mémoire va mener soit à des fuites de mémoire, soit à des comportements indéfinis.

En règle générale, la documentation d’une fonctionnalité permet d’obtenir la réponse à cette question.

Comment faire avec les pointeurs alloués par malloc ?

Les cas présentés dans cet article ne concernent que la mémoire qui est allouée avec new et libérée avec delete.

Mais il y a de rares cas où les fonctionnalités utilisées ont recours malloc() et free() à la place.

Quand une fonctionnalité requiert un pointeur brut et que vous avez à le libérer, le problème est inexistant (vous avez le contrôle de l’allocation et de la libération).

Quand une fonctionnalité vous renvoie un pointeur brut (créé par malloc) et que vous ne devez pas le libérer, vous n’avez rien à faire de spécial (vous pouvez utiliser une référence sur l’objet pointé comme indiqué plus haut).

Quand une fonctionnalité requiert un pointeur et que vous ne devez pas le libérer (parce qu’elle utilise free dessus), vous aurez à faire le malloc vous-même. Si vous utilisez un pointeur intelligent, vous devrez malheureusement faire quand un malloc.

Dernièrement, quand une fonctionnalité vous donne un pointeur brut (créé par malloc) et que vous avez à le libérer, ça devient compliqué. La meilleure façon de faire cela est d’utiliser un unique_ptr, avec un « libérateur » personnalisé, en tant que second template du type. En effet, le second template de unique_ptr est un foncteur (c’est-à-dire une classe qui implémente operator()) et qui sera appelé quand on aura besoin de libérer la mémoire. Dans notre cas spécifique, le libérateur dont on a besoin n’a qu’à appeler la fonction free(). Voici un exemple :

#include <memory>
#include <iostream>
 
struct Foo {};
 
// On n'est pas propriétaire de cette fonction, on ne peut pas changer le type renvoyé
Foo * make_Foo()
{
    return reinterpret_cast<Foo*>(malloc(sizeof(Foo)));
}
 
// Ce libérateur est implémenté pour Foo spécifiquement, 
// mais on pourrait écrire un libérateur générique templaté qui appelle free()
struct FooFreer
{
    void operator()(Foo* foo_ptr)
    {
        free(foo_ptr);
    }
};
 
int main()
{
    std::unique_ptr<Foo, FooFreer> foo_ptr(make_Foo());
    // L'instance de Foo est bien libérée à la fin de la fonction
    return 0;
}

Conclusion

Voici un tableau résumant ce qui a été montré ici :

Je reçois un pointeur brutJe transmet un pointeur brut
Je dois le libérerLe garder dans un unique_ptr ou shared_ptrUtiliser l’opérateur & ou la fonction .get()
Je ne dois pas le libérerRécupérer une référence sur l’objetUtiliser l’opérateur new avec un move ou la fonction .release()

Avec ces outils, vous pouvez retirer les pointeurs bruts de votre code en toute sécurité, même si certaines de vos librairies clientes les utilises.

Les solutions proposées sont très simples, mais il est critique d’identifier laquelle utiliser dans chaque situation. Le principal problème avec cette méthode est que la personne qui refactorise le code doit être capable d’identifier la propriété (mais c’est un problème qu’il est impossible d’éviter).

Merci de votre attention et à la prochaine!

Article original : Who owns the memory? | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addendum

Notes

  1. Dans le cas où vous ne le sauriez pas, la RAII est une technique fondamentale en C++ moderne, et qui concerne l’acquisition et la libération de ressources. Souvent, on dit que « telle chose est RAII » pour dire qu’elle nettoie correctement la mémoire et prévient toute forme de fuite, dès qu’elle est détruite de la pile. Par exemple, les pointeurs bruts ne sont pas RAII, car si vous oubliez le delete, il y aura une fuite mémoire. Au contraire, std::string et std::vector sont RAII car il nettoient leur allocation interne dès qu’ils sont libérés de la pile.
  2. Il est parfois difficile pour certains développeurs de comprendre comment on peut être « forcé » de faire telle ou telle chose dans son code. Voici une petite liste de situations à titre d’exemple :
    – Quand on arrive sur un projet existant. On ne peut pas tout refactoriser directement, de son propre chef. Il faut s’adapter et prendre son temps pour faire bouger les choses.
    – Quand on n’est pas propriétaire de certaines partie du code. Sur beaucoup de projets, certaines parties fondamentales du code sont développées par une autre équipe, dans laquelle il est impossible d’ingérer.
    – Quand on doit mettre des priorités sur les fonctionnalités à refactoriser. On ne peut pas tout refaire d’un coup, il faut y aller étape par étape.
    – Quand la hiérarchie managériale fait blocus, faut de budget ou de personnel. Ça peut arriver, et on ne peut pas faire grand chose contre ça.
  3. Aucun cas présenté dans cet article ne fonctionne avec les std::weak_ptr.
  4. En écrivant cet article, je n’ai pu trouver aucun exemple (sur internet ou dans mes souvenirs) d’une fonctionnalité qui requiert un pointeur et le libère elle-même, à votre place.

3 comportements intéressants à propos des conversions en C++

Article original : 3 interesting behaviors of C++ casts | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Cet article est une petite compilation1 de comportement étranges en C++ qui ne sont pas assez conséquents pour mériter un article à part entière.

Utiliser static_cast pour convertir un objet en lui-même peut appeler le constructeur de copie

Quand vous utilisez static_cast, par défaut (c’est-à-dire sans option d’optimisation) cela va appeler le constructeur de conversion de la classe dans laquelle vous essayez de convertir votre objet (si existant).

Par exemple, dans ce code:

class Foo;
class Bar;
 
int main()
{
    Bar bar;
    static_cast<Foo>(bar);
}

L’expression surlignée appellera le constructeur suivant (s’il existe) : Foo(const Bar&).

Jusque là, aucun problème, et il y a une bonne chance pour que vous sachiez déjà cela.

Mais savez-vous ce qui se passe si vous essayez de convertir un objet en lui-même ?

Prenons le code suivant :

struct Foo
{
    Foo(): vi(0), vf(0) {};
    Foo(const Foo & other): vi(other.vi), vf(other.vf) {};
    long vi;
    double vf;
};
 
int main()
{
    Foo foo1, foo2, foo3;
    foo2 = foo1;    
    foo3 = static_cast<Foo>(foo1);
 
    return 0;
}

Et étudions l’assembleur des lignes surlignées :

Ligne 12

mov     rax, QWORD PTR [rbp-32]
mov     rdx, QWORD PTR [rbp-24]
mov     QWORD PTR [rbp-48], rax
mov     QWORD PTR [rbp-40], rdx

Ligne 13

lea     rdx, [rbp-32]
lea     rax, [rbp-16]
mov     rsi, rdx
mov     rdi, rax
call    Foo::Foo(Foo const&) [complete object constructor]
mov     rax, QWORD PTR [rbp-16]
mov     rdx, QWORD PTR [rbp-8]
mov     QWORD PTR [rbp-64], rax
mov     QWORD PTR [rbp-56], rdx

On peut voir que lorsque l’objet foo1 est converti, ça appelle le constructeur de copie de Foo, comme si le constructeur de copie était un « constructeur de conversion du type dans lui-même ».

(Fait avec GCC 11.2 x86-64, Compiler Explorer (godbolt.org))

Bien sûr, ce comportement disparaît dès qu’on active les options d’optimisation.

C’est quelque chose de typiquement inutile à connaître2 et qu’on ne croise vraiment pas souvent dans la vraie vie (je l’ai déjà croisé une fois, mais c’était un malheureux accident).

static_cast peut appeler plusieurs constructeurs de conversion

Parlons de constructeurs de conversion, ils peuvent être transitifs lors de l’utilisation d’un static_cast.

Mettons les classes suivantes :

struct Foo
{  Foo() {};  };
 
struct Bar
{  Bar(const Foo & other) {};  };
 
struct FooBar
{  FooBar(const Bar & other) {};  };
 
struct BarFoo
{  BarFoo(const FooBar & other) {};  };

Nous avons quatre types : Foo, Bar, FooBar et BarFoo. Les constructeurs de conversion nous disent qu’on peut convertir un Foo en Bar, un Bar en FooBar et un FooBar en BarFoo.

Si on essaie d’exécuter le code suivant :

int main()
{
    Foo foo;
    BarFoo barfoo = foo;
    return 0;
}

Il y a une erreur de compilation à la ligne 4 : conversion from 'Foo' to non-scalar type 'BarFoo' requested.

Cependant, si on utilise un static_cast pour faire de foo un FooBar :

int main()
{
    Foo foo;
    BarFoo barfoo = static_cast<FooBar>(foo);
    return 0;
}

Le programme compile.

Si on regarde à l’assembleur qui est généré par la ligne 4 :

lea     rdx, [rbp-3]
lea     rax, [rbp-1]
mov     rsi, rdx
mov     rdi, rax
call    Bar::Bar(Foo const&) [complete object constructor]
lea     rdx, [rbp-1]
lea     rax, [rbp-2]
mov     rsi, rdx
mov     rdi, rax
call    FooBar::FooBar(Bar const&) [complete object constructor]
lea     rdx, [rbp-2]
lea     rax, [rbp-4]
mov     rsi, rdx
mov     rdi, rax
call    BarFoo::BarFoo(FooBar const&) [complete object constructor]

Il n’y a pas moins de 3 conversions générée dans une unique expression.

(Fait avec GCC 11.2 x86-64, Compiler Explorer (godbolt.org))

Un instant !

Vous pourriez vous demander pourquoi je n’ai pas directement appliqué le static_cast pour faire de foo un BarFoo, et que j’en ai seulement fait un FooBar.

Si on essaie de compiler le code suivant :

int main()
{
    Foo foo;
    BarFoo barfoo = static_cast<BarFoo>(foo);
    return 0;
}

On obtient une erreur de compilation :

<source>:16:44: error: no matching function for call to 'BarFoo::BarFoo(Foo&)'

En fait, static_cast n’est pas transitif

Voici ce qui se passe en réalité :

L’expression static_cast<FooBar>(foo) essaie d’appeler le constructeur suivant : FooBar(const Foo&). Or, il n’existe pas, le seul constructeur de conversion qui existe pour FooBar est FooBar(const Bar&). Mais comme Bar possède un constructeur de conversion depuis un Foo, le compilateur convertit implicitement foo en Bar pour pouvoir appeler FooBar(const Bar&).

Ensuite, on essaie d’assigner le FooBar résultant à un BarFoo. Ou, plus précisément, on construit un BarFoo à partir d’un FooBar, ce qui appelle le constructeur BarFoo(const FooBar&).

C’est pour cela qu’il y a une erreur de compilation quand on essaie de convertir directement un foo en BarFoo.

La réalité est que static_cast n’est pas transitif.

Que faire avec cette information ?

Les conversions implicites peuvent apparaître n’importe où. Puisque que static_cast est, selon la pragmatique3, un « appel de fonction » (dans le sens qu’il prend un argument et renvoie une valeur) il donne au compilateur l’opportunité de faire des conversions implicites.

Le comportement des conversions style-C

Ce qu’on appelle « conversions style-C » est l’usage de la syntaxe de conversion C, en C++.

Utiliser des conversions style-C est une mauvaise pratique répandue en C++. Ça aurait dû être un point de l’article Une liste de mauvaises pratiques couramment rencontrées dans le développement de logiciels industriels.

Beaucoup de développeurs C++ ne comprennent pas les comportements spécifiques que les conversions style-C effectuent.

Comment les conversions fonctionnent en C

Si mes souvenirs sont bons, les conversions en C s’utilisent de trois manières différentes.

Premièrement, elles peuvent servir à convertir un type scalaire en un autre

int toto = 42;
printf("%f\n", (double)toto);

Mais ça ne peut que convertir les scalaires. Si on essaye de convertir une struct :

#include <stdio.h>
 
typedef struct Foo
{
    int toto;
    long tata;
} Foo;
 
typedef struct Bar
{
    long toto;
    double tata;
} Bar;
 
 
int main()
{
    Foo foo;
    foo.toto = 42;
    foo.tata = 666;
     
    Bar bar = (Bar)foo;
     
    printf("%l %d", bar.toto, bar.tata);
 
    return 0;
}

On obtient un message d’erreur :

main.c:22:5: error: conversion to non-scalar type requested
   22 |     Bar bar = (Bar)foo;
      | 

(Source : GDB online Debugger | Code, Compile, Run, Debug online C, C++ (onlinegdb.com))

Deuxièmement, on peut utiliser une conversion en C pour réinterpréter un pointeur en un d’un autre type :

#include <stdio.h>
 
typedef struct Foo
{
    int toto;
    long tata;
    int tutu;
} Foo;
 
typedef struct Bar
{
    long toto;
    int tata;
    int tutu;
} Bar;
 
 
int main()
{
    Foo foo;
    foo.toto = 42;
    foo.tata = 666;
    foo.tutu = 1515;
     
    Bar* bar = (Bar*)&foo;
     
    printf("%ld %d %d", bar->toto, bar->tata, bar->tutu);
 
    return 0;
}

Ce code donne la sortie suivante4 :

42 666 0

(Source : GDB online Debugger | Code, Compile, Run, Debug online C, C++ (onlinegdb.com))

Et troisièmement, les conversions en C peuvent permettre d’ajouter ou d’enlever le qualificatif const :

#include <stdio.h>
 
int main()
{
    const int toto = 1;
    int * tata = (int*)(&toto);
    *tata = 42;
     
    printf("%d", toto);
 
    return 0;
}

Ce qui affiche 42.

(Source : GDB online Debugger | Code, Compile, Run, Debug online C, C++ (onlinegdb.com))

Cela marche aussi sur les struct.

On a fait le tour du fonctionnement des conversions en C5.

Comment cela se passe-t-il en C++ ?

Le C++ a ses propres opérateur de conversion (principalement static_castdynamic_castconst_cast, and reinterpret_cast, mais aussi un certain nombre d’autres conversions comme *_pointer_cast, etc.)

Mais le C++ a (originellement) été pensé pour être retro-compatible avec le C. Il fallait donc un moyen d’implémenter des conversions style-C pour qu’elles fonctionnent de manière similaire aux conversions en C, tout cela avec les nouvelles mécaniques de conversion.

Cela fait qu’en C++, quand vous faites une conversion style-C, le compilateur essaie les cinq conversions suivantes, dans l’ordre, s’arrêtant à la première qui fonctionne :

  • const_cast
  • static_cast
  • static_cast suivi de const_cast
  • reinterpret_cast
  • reinterpret_cast suivi de const_cast

Plus de détail sur la page suivante : Explicit type conversion – cppreference.com.

En quoi est-ce mauvais ?

La plupart des développeur C++ s’accordent sur le fait que l’usage des conversions style-C est une très mauvaise pratique. En voici les raisons : ce que le compilateur va faire n’est pas explicite. La conversion style-C va souvent compiler, même s’il y a une erreur, et taire cette erreur. Quand on veut convertir un objet, on veut effectuer spécifique une seule de ces cinq conversions, il est donc bien mieux de la mentionner explicitement plutôt que de laisser le compilateur la déduire. Ainsi, s’il y a un problème, il y a bien plus de chances que le compilateur le détecte et le signale à la compilation. Objectivement, il n’y a aucun avantage à utiliser une conversion style-C.

Voici un argumentaire plus fourni à l’encontre des conversions style-C : Coding Standards, C++ FAQ (isocpp.org).

Conclusion

Les conversions sont des opérations délicates. Elles peuvent être coûteuses (plus qu’on ne le pense parce qu’elles font de la place pour des conversions implicites) et encore aujourd’hui, il y a beaucoup de gens qui utilisent les conversions style-C sans savoir à quel point elles sont mauvaises.

C’est fastidieux, mais il faut apprendre comment les conversions fonctionnent et les spécificités de chacune.

Merci de votre attention et à la prochaine !

Article original : 3 interesting behaviors of C++ casts | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addenda

Liens vers les compilateurs en ligne

Utiliser static_cast pour convertir un objet en lui-même peut appeler le constructeur de copie

static_cast peut appeler plusieurs constructeurs de conversion

Le comportement des conversions style-C

Notes

  1. Calembour volontaire.
  2. Si vous connaissez une situation où c’est effectivement utile, n’hésitez pas à le partager en commentaires.
  3. En linguistique, la pragmatique est l’étude du contexte (complémentaire de la sémantique, l’étude du sens, et nombre d’autres champs d’étude). En terme de langage de programmation, on peut interpréter ça comme la manière pour une fonctionnalité d’interagir avec son entourage dans un contexte donné. Dans notre exemple, static_cast n’est pas un appel de fonction d’un point de vue sémantique, mais agit comme tel auprès de son environnement direct (comme c’est expliqué). La réalité technique est entre les deux : pour les POD ce n’est pas un appel de fonction, mais pour les classes qui définissent un constructeur de copie ça l’est.
  4. Je n’expliquerai pas en détail pourquoi ça affiche 0 au lieu de 1515 pour la valeur de tutu : sachez juste que comme on réinterprète les données en mémoire, tenter de lire un Foo comme si c’était un Bar ne fonctionne pas bien.
  5. Je ne suis pas aussi à l’aise en C qu’en C++. J’ai peut-être oublié un autre usage aux conversions en C. Si c’est le cas, corrigez-moi en commentaires.

Les références constantes ne sont pas toujours vos amies

Article original : Constant references are not always your friends | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Très tôt dans l’apprentissage du C++ moderne, on enseigne que chaque donnée qui n’est pas de taille négligeable1 doit être passée, par défaut, en référence constante :

void my_function(const MyType & arg);

Cela permet d’éviter la copie de ces données dans des situations où elles n’ont pas besoin d’être copiées.

Cette pratique ne s’applique pas avec des besoins plus spécifiques, mais aujourd’hui nous nous attarderons sur les références constantes.

J’ai réalisé que beaucoup de développeur·se·s glorifiaient les références constantes, pensant qu’il s’agissait de la meilleure solution en toute circonstance et qu’elle devait être employée à chaque fois qu’elle pouvait l’être.

Mais sont-elles toujours meilleures que leurs alternatives ? Quels sont les dangers et les pièges qui se cachent derrière elles ?

NB : Dans tout l’article, j’appellerai « références constantes » (ou « const ref » pour faire plus court) ce qui sont, en réalité, des références à une constante. C’est une convention d’appellation qui, même si techniquement incorrecte, est très pratique.

Situation 1 : une const ref en tant que paramètre

Il s’agit d’un cas d’école, où l’emploi d’une const ref est non-optimal.

Prenez cette classe:

struct MyString
{
     // Empty constructor
    MyString()
    { std::cout << "Ctor called" << std::endl; }
     
    // Cast constructor
    MyString(const char * s): saved_string(s) 
    { std::cout << "Cast ctor called" << std::endl; }
     
    std::string saved_string;
};

Il s’agit d’un simple wrapper pour une std::string, qui affiche un message quand un des constructeurs est appelé. On s’en servira pour voir s’il y a des appels indésirables aux constructeurs où s’il y a des conversions implicites3. À partir de maintenant, on considèrera que construire un MyString est une opération lourde et indésirable.

En utilisant une référence constante

Implémentons une fonction qui prend une référence constante à MyString en argument :

void foo(const MyString &)
{
    // ...
}

Et maintenant, appelons là avec, disons, une chaîne de caractères littérale :

int main()
{
    foo("toto");
}

Ça compile, ça fonctionne, et ça affiche le message suivant sur la sortie standard :

Cast ctor called

Le constructeur de conversion est appelé. Pourquoi ?

Le truc, c’est que const MyString & ne peut pas faire directement référence au "toto" qu’on passe à foo(), parce que "toto" est un const char[] et non un MyString. Donc naïvement, ça ne devrait même pas compiler. Or, comme la référence est constante, et donc ne sera pas modifiée au cœur de la fonction, le compilateur estime qu’il peut ainsi copier l’objet pour le convertir dans le bon type et rendre l’appel de fonction valide. Il effectue ainsi une conversion implicite.

Ce n’est pas souhaitable, parce qu’une conversion peut être lourde pour beaucoup de types, et que dans l’inconscient collectif les const ref ne sont pas censées faire de copie quand on les passe en argument. C’est donc le fait que la conversion soit implicite qui est indésirable.

Mais que se passe-t-il si foo() prenait une simple référence plutôt qu’une référence constante ?

En utilisant le mot-clé ‘explicit’

En C++, on peut utiliser le mot-clé explicit pour spécifier qu’un constructeur (ou une fonction de conversion) ne doit pas être utilisée implicitement.

explicit MyString(const char * s): saved_string(s) 
{ std::cout << "Cast ctor called" << std::endl; }

Avec ce mot-clé, on ne peut plus utiliser la fonction foo() avec une chaîne de caractères littérale:

foo("toto"); // Ne compile pas

On doit la convertir explicitement:

foo(static_cast<MyString>("toto")); // Compile

Par contre, il y a un défaut important : on ne peut pas utiliser explicit sur les constructeurs des types de la librairie standard (comme std::string) ou des types des librairies externes. Comment peut-on faire dans ce cas?

En utilisant une référence non-constante

Mettons de côté le mot-clé explicit et considérons que MyString est un type externe et ne peut être modifié.

Ajustons la fonction foo() pour que la référence qu’elle prend en argument ne soit plus constante :

void foo(MyString &)
{
    // ...
}

Que se passe-t-il alors ? Si on essaye d’appeler foo() avec une chaîne de caractères littérale, on aura l’erreur de compilation suivante :

main.cpp: In function 'int main()':
main.cpp:24:9: error: cannot bind non-const lvalue reference of type 'MyString&' to an rvalue of type 'MyString'
   24 |     foo("toto");
      |         ^~~~~~
main.cpp:11:5: note:   after user-defined conversion: 'MyString::MyString(const char*)'
   11 |     MyString(const char * s): saved_string(s)
      |     ^~~~~~~~
main.cpp:17:10: note:   initializing argument 1 of 'void foo(MyString&)'
   17 | void foo(MyString &)
      |          ^~~~~~~~~~

Ici, le compilateur ne peut plus faire de conversion implicite. Parce que la référence n’est pas constante, et donc pourrait être modifiée au sein de la fonction, il ne peut pas la copier pour la convertir.

C’est en fait une bonne chose, parce que cela nous avertit qu’on essaye de faire une conversion et nous demande de le faire explicitement.

Si on veut que le code fonctionne, on doit faire appel au constructeur de cast4 explicitement :

int main()
{
    MyString my_string("toto");
    foo(my_string);
}

Ça compile, et nous affiche la sortie suivante :

Cast ctor called

Mais c’est bien mieux que la première version, parce qu’ici la conversion est explicite. N’importe qui lisant le code comprend que le constructeur est appelé.

Cependant les références non-constantes ont des défauts, le premier étant d’abandonner le qualificatif constant.

En utilisant une spécialisation de template

En dernier lieu, une autre manière d’interdire la conversion implicite est d’utiliser la spécialisation de template :

template<typename T>
void foo(T&) = delete;
 
template<>
void foo(const MyString& bar)
{
    // …
}

Avec ce code, quand vous essayez d’appeler foo() avec n’importe quoi qui n’est pas un MyString, vous allez appeler la surcharge générique de foo(). Cependant, cette fonction est « deletée » et causera une erreur de compilation.

Mais si vous l’appelez avec un MyString, alors c’est la surcharge spécifiée qui sera appelée. De fait, vous avez la garantie qu’aucune conversion implicite ne sera faite.

Conclusion de la situation 1

Parfois, les références constantes peuvent induire des conversions implicites. En fonction du type de l’objet et du contexte, cela peut être indésirable.

Pour éviter cela, explicit est un mot-clé indispensable si vous avez la main sur les constructeurs dudit type.

Sinon, vous pouvez utiliser une référence non-constante ou une spécialisation de template, avec ce que ça implique.

Situation 2 : une const ref en attribut de classe

Penons (de nouveau) un wrapper sur une std::string. Mais ce fois, à la place de conserver un objet, on gardera en mémoire une référence vers l’objet :

struct MyString
{    
    // Cast constructor
    MyString(const std::string & s): saved_string(s) {}
     
    const std::string & saved_string;
};

Utiliser une référence constante au sein d’un objet

Utilisons-là, maintenant, pour voir si ça marche :

int main()
{
    std::string s = "Toto";
    MyString my_string(s);
 
    std::cout << my_string.saved_string << std::endl;
     
    return 0;
}

Avec ce code, on observe ceci sur la sortie standard :

Toto

Ça a donc l’air de fonctionner correctement. Cependant, si on tente de modifier la std::string en dehors de la fonction :

int main()
{
    std::string s = "Toto";
    MyString my_string(s);
 
    s = "Tata";
 
    std::cout << my_string.saved_string << std::endl;
     
    return 0;
}

La sortie n’est plus la même :

Tata

Il semblerait que le fait qu’on a enregistré une référence constante ne signifie pas que la valeur ne peut pas être modifiée. En réalité, cela signifie seulement qu’elle ne peut pas être modifiée par la classe. C’est une grosse différence qui peut être déroutante.

Essayer de réassigner une référence constante

Avec ça en tête, vous voudriez peut-être tenter de réassigner la référence enregistrée, plutôt que de modifier sa valeur.

Mais en C++, on ne peut pas réassigner une référence. Comme c’est précisé dans le wiki IsoCpp : « Can you seseat a reference? No way, » (« Peut-on réassigner une référence ? Aucune chance. ») Source : References, C++ FAQ (isocpp.org).

Faites donc attention, si vous écrivez quelque chose comme ça :

int main()
{
    std::string s = "Toto";
    MyString my_string(s);
 
    std::string s_2 = "Tata";
    my_string.saved_string = s_2;
 
    std::cout << my_string.saved_string << std::endl;
     
    return 0;
}

Cela ne compilera pas, parce que vous ne réassignez pas my_string.saved_string à la référence de s_2, mais vous essayez en fait d’assigner la valeur de s_2 à l’objet auquel my_string.saved_string fait référence, qui est constant du point du vue de MyString.

Si vous essayez de contourner le problème et « déconstifiez » la référence à l’intérieur de MyString, vous aurez ce code-là :

struct MyString
{    
    // Cast constructor
    MyString(std::string & s): saved_string(s) {}
     
    std::string & saved_string;
};
 
int main()
{
    std::string s = "Toto";
    MyString my_string(s);
 
    std::string s_2 = "Tata";
    my_string.saved_string = s_2;
 
    std::cout << my_string.saved_string << std::endl;
     
    return 0;
}

La sortie sera, comme on s’y attend, Tata. Cependant, si on affiche la valeur de s, on aura une petite surprise :

std::cout << s << std::endl;

Vous verrez que c’est Tata qui s’affiche de nouveau !

En effet, comme je l’ai mentionné, en faisant cela vous réassignez la valeur de my_string.saved_string, qui est une référence sur s. Vous réassignez donc la valeur de s ce faisant.

Conclusion de la situation 2

Au final, le mot-clé const pour la variable member const std::string & saved_string; ne signifie pas « saved_string ne sera pas modifiée », mais plutôt « MyString ne peut pas modifier la valeur de saved_string« .

Faites attention, parce que parfois const ne veut pas dire ce que vous pensez qu’il veut dire.

Les types qui devraient toujours être passés par valeur

Utiliser une référence constante est aussi parfois une mauvaise pratique pour certains types spécifiques.

En effet, certains types sont assez petits pour que les passer par valeur ne soit pas spécialement moins optimale que par référence.

Voici quelques types qui sont concernés par cette exception :

  • Les entiers et flottants basiques (int, long, float, etc…)
  • Les pointeurs
  • std::pair<int,int> (ou n’importe quelle paire de petits types).
  • std::span
  • std::string_view
  • … et tous les types qui sont rapides à copier.

Le fait que ces types soient rapides à copier signifie qu’on peut les passer par copie, mais cela ne nous dit pas pourquoi on doit les passer par copie.

Il y a trois raisons à cela. Ces raisons sont détaillées dans un article de Arthur O’Dwyer : Three reasons to pass `std::string_view` by value – Arthur O’Dwyer – Stuff mostly about C++ (quuxplusone.github.io).

Version courte :

  1. Cela élimine une indirection de pointeur dans l’appelé. Passer par référence force l’objet à avoir une adresse. Passer par valeur permet de n’utiliser que les registres.
  2. Cela élimine le ruissellement dans l’appelant. Passer par valeur et utiliser les registres peut parfois ôter le besoin d’avoir recours à la pile d’exécution.
  3. Cela élimine les alias. Le fait de donner une valeur (c’est à dire un objet tout neuf) donne à l’appelé plus d’opportunités d’optimisation.

Conclusion

Il y a deux principaux dangers à utiliser les références constantes :

  • Elles peuvent induire des conversions implicites
  • Quand enregistrées dans une classe, elles peuvent quand même être « modifiées » depuis l’extérieur

Rien n’est intrinsèquement bon ou mauvais — de même, rien n’est intrinsèquement meilleur ou pire.

La plupart du temps, utiliser une const ref pour passer des gros objets en paramètre est meilleur. Mais gardez en tête que ça a ses spécificités et ses limites. Ce faisant, vous éviterez les 1% de situations où les const refs sont en fait contre-productives.

Il y a plusieurs significations sémantiques au mot-clé const. Mais je garde ça pour un autre article.

Merci de votre attention et à la prochaine5 !

Article original : Constant references are not always your friends | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addenda

Les exemple dans Godbolt

Situation 1 : une const ref en tant que paramètre : Compiler Explorer (godbolt.org) et Compiler Explorer (godbolt.org)

Situation 2 : une const ref en attribut de classe : Compiler Explorer (godbolt.org)

Notes

  1. « Taille négligeable » fait référence à, dans ce contexte, des PODs2 qui sont assez petits pour être copiés sans coût — tels les int et les float.
  2. POD signifie « Plain Old Data » ou encore « Bonne Vieille Donnée », et renvoie aux collections passives de champ de valeurs, qui n’utilise aucune fonctionnalité orientée-objet.
  3. MyString est juste un bouche-trou pour des classes plus conséquentes. Il y a des classes, comme std::string, qui sont coûteuses à construire et copier.
  4. Ce que j’appelle « constructeur de cast » est le constructeur avec juste un paramètre. Ce genre de constructeur est celui qui est appelé quand vous faites un static_cast.
  5. La précision scientifique a toujours été un de mes objectifs. Même si je ne l’atteint pas toujours (voire pas souvent), j’essaie de l’atteindre autant qu’il m’en est possible. C’est pourquoi, dorénavant, je n’utiliserai plus l’expression « à la semaine prochaine » puisque, selon les métriques, je publie moyenne deux virgule huit articles par mois.

Les conteneurs : l’embarras du choix

Article original : I don’t know which container to use (and at this point I’m too afraid to ask) | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Quand on parle de conteneurs en C++, c’est std::vector qui remporte la palme de la plus utile (et utilisée) d’entre toutes (juste devant l’occasionnelle std::map pour les fois où on a besoin d’une association clé-valeur1). Ainsi, c’est facile d’oublier que d’autres types de conteneurs existent.

Chaque conteneur a ses forces et ses faiblesses. Si vous avez tendance à oublier quelles elles sont pour chacun, cet article est un bon début.

Avertissement : Tous les conteneurs C++ ne sont pas listés ici, juste ceux qui sont les plus utiles (d’après moi). Si vous voulez aller plus loin, vous trouverez deux liens en addendum qui vous renverront vers des documentations plus complètes.

Comment choisir un conteneur

Critères

Premier™ critère : séquentiel ou associatif

La première question que vous devez vous poser est : est-ce que mon conteneur est séquentiel ou associatif ?

Dans les conteneurs séquentiels, les données sont organisées de manière ordonnée et séquentielle, avec chaque valeur à la suite de la précédente. En mémoire, ce n’est pas toujours contigüe (et souvent ça ne l’est pas), mais en pratique, vous accédez à une valeur avec l’indice de sa position dans le conteneur.

Contrairement aux conteneurs séquentiels, les conteneurs associatifs ne conservent pas les données en tant que séquence, mais en associant la valeur à une clé. À la place d’utiliser un indice pour accéder à la valeur, on utilise la clé.

Critères des conteneurs séquentiels

  • Est-ce que la taille du conteneur est statique ? Si oui, c’est un std::array que vous cherchez (et les autres critère ne sont de toute façon pas importants). Dans tout autre cas de figure, vous devez vous en référer aux autres critères.
  • Est-ce que la taille du conteneur va beaucoup varier ? Ce critère est important pour l’allocation mémoire. Si vous faites beaucoup varier la taille d’un conteneur qui n’est pas prévu pour ça, vous pourrez ressentir des ralentissements et une surutilisation de la mémoire2.
  • Est-ce que l’ordre est important ? Ce critère concerne les structures de données où on ne peut ajouter et retirer des valeurs qu’au début ou à la fin (c’est à dire les structures FIFO et FILO).
  • Est-ce que vous avez besoin d’insérer/supprimer des valeurs aux extrémités du conteneur (c’est-à-dire au début et à la fin) ? Certains conteneurs sont plus efficaces que d’autres pour ces opérations.
  • Est-ce que vous avez besoin d’insérer/supprimer des valeurs au milieu de la structure ? Tout comme le critère précédent, parfois vous devez faire beaucoup d’ajout et de suppressions, mais au milieu de la structure. C’est l’apanage d’autres types de conteneurs.
  • Est-ce que vous avez besoin de trouver le nième élément ? En fonction du conteneur, la recherche n’est pas toujours optimale.
  • Est-ce que vous avez besoin de fusionner des collections ? En fonction du conteneur, la fusion de deux collections n’est pas toujours optimale non plus (certaines ont besoin de réallouer la mémoire et d’autres non).

Critères des conteneurs associatifs

  • Il y a deux sortes de structures associatives : celles qui associent une clé à une valeur (éventuellement de types différents), ce sont les map, et celles pour lesquelles la clé est la valeur, et ce sont les set.
  • Par défaut, les clés sont uniques. Mais il existe une variante pour lesquelles les clés peuvent avoir plusieurs associations. Ce sont les multimap/multiset.
  • La structure interne de ces conteneurs peut être implémentée de deux façons. Par défaut, elles sont ordonnées par clé. Mais elles peuvent aussi être non-ordonnées et utiliser une clé de hachage. Ce sont les versions unordered_ de chacun des conteneurs.

Note : la distinction ordonné/non-ordonné est importante (voire primordiale) pour les conteneurs associatifs. La plupart du temps, les conteneurs associatifs ordonnés sont implémentés sur des arbres binaires équilibrés, tandis que les non-ordonnés sont des tables de hachage. Cela a un impact sur les performances et sur le fait que, pour les tables de hachage, le type de valeur n’a pas besoin d’implémenter un opérateur de comparaison.

La matrice des conteneurs

Voici deux tableaux – un pour les conteneurs séquentiels et un pour les conteneurs associatifs.

Chaque tableau représente quels critères s’appliquent le mieux pour chaque conteneur3.

Conteneurs séquentiels

Conteneurs associatifs

Résumé sous forme de diagramme de flux

Voici un résumé simple à lire, sous forme d’un diagramme de flux, qui permet de choisir quel conteneur choisir en toute circonstance4 : Joe Gibson’s data structure selection flowchart.

Qu’en est-il de la réalité des gens véritables ?

Comme je l’ai mentionné dans l’introduction, dans la vraie vie std::vector et std::map sont la plupart du temps largement suffisants. Avec ça en tête, à quel point est-il utile de chercher le conteneur « optimal » à chaque fois ?

Sémantiquement, chaque conteneur est lié à la manière dont on l’utilise. Quand vous voyez deque, vous pensez « insérer et supprimer aux extrémités », quand vous voyez queue, vous pensez « insérer au début et supprimer à la fin », quand vous voyez list, vous pensez « insérer et supprimer au milieu ».

On voit chaque conteneur en fonction de comment on les utilise, plus que à quel point ils sont efficaces dans telle ou telle situation (ce qui est lié mais pas équivalent). Il y a beaucoup d’opérations qui sont disponibles dans plusieurs conteneurs à la fois. Par exemple, on peut très facilement ajouter et enlever des valeurs à la fin d’un vector, de même que dans un deque (dans les deux cas, on utilise les fonctions membre push_back et pop_back).

Vous avez peut-être envie d’objecter : « Mais ajouter et supprimer des valeurs à la fin d’un vecteur peut être très inefficace ! » En théorie, oui. Mais en pratique, ce n’est le cas que si vous vous trouvez dans une section critique du code. La plupart du temps, si vous êtes dans les 80% du Principe de Pareto, utiliser un vector plutôt qu’un deque n’aura aucun effet sur les performances.

Laissez-moi alors vous poser la question : si un vector fonctionne bien et n’impacte pas les performances, pourquoi utiliser un autre conteneur ?

Le vecteur est une des structures les plus faciles à comprendre grâce à leur similarité avec les bons-vieux-tableaux. Pour la plupart des développeurs, std::vector est le conteneur qu’ils savent le mieux utiliser. On ne devrait pas complexifier le code le plus mondain ni le rendre plus dur à lire, alors que ce n’est pas nécessaire.

Bien sûr, dès que vous avez des besoins spécifiques, vous devez utiliser le conteneur le plus approprié, mais ça n’arrive pas si souvent.

(J’ai pris std::vector comme exemple ici, mais c’est aussi valable pour std::map à propos des conteneurs associatifs)

Et l’optimisation alors ?

L’optimisation doit être un comportement a posteriori, et non a priori.

Quand vous écrivez du code, vous ne devez pas penser aux performances. Vous devez l’écrire de la manière la plus claire possible.

C’est seulement après que vous pouvez penser à l’impact de ce code sur les performances globales. En faisant éventuellement des benchmarks, des analyses statiques et/ou dynamiques pour savoir si il doit être optimisé et selon quels critères d’optimisation (temps d’exécution ? Utilisation de la mémoire ? etc.).

Si le code que vous écrivez n’est pas dans les 20% du Principe de Pareto, vous ne devriez même pas penser aux performances. S’il est dans ces 20%, vous devez y penser après l’avoir écrit.

Une approche plus efficace

Laissez-moi vous présenter le diagramme de flux suivant, dans le même esprit que celui de Joe Gibson, qui résume les propos de la section précédente :

Le principe est simple. Vous avez deux questions à vous poser :

  1. Est-ce que je peux faire ce dont j’ai besoin avec un vector ou une map ?
  2. Est-ce que je suis dans une section critique du code ?

Si vous répondez « Oui » à la première, et « Non » à la deuxième, alors vous devriez utiliser std::vector ou std::map.

Conclusion

Il est plus qu’utile de connaître les différences entre les conteneurs du C++. Cependant, dans la vie quotidienne, il est plus pratique de ne pas trop y penser. C’est une erreur assez commune de sur-réfléchir à des problématiques qui ont des solutions simples. Mais ces connaissances ne sont pas perdues : de temps en temps, vous serez dans une situation assez spécifique qui nécessitera des connaissances avancées sur les conteneurs.

Le Principe de Pareto peut aussi s’appliquer à cela : plus de 80% des outils qu’on connaît sont utiles dans moins de 20% des situations.

Merci de votre attention et à la semaine prochaine !

Article original : I don’t know which container to use (and at this point I’m too afraid to ask) | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addenda

Sources et ressources

Le diagramme de flux de Gibson vient d’un repo GitHub où vous pourrez trouver d’autres informations utiles sur les conteneurs : cpp-cheat-sheet/Data Structures and Algorithms.md at master · gibsjose/cpp-cheat-sheet · GitHub.

Il existe aussi une autre fiche-résumé, plus dense, parlant des conteneurs (et d’autres sujets également) : C++ Cheat Sheets & Infographics | hacking C++ (hackingcpp.com).

Le diagramme de flux pour le choix des structures de données de Joe Gibson


Joe Gibson’s data structure selection flowchart (source: GitHub – gibsjose/cpp-cheat-sheet: C++ Syntax, Data Structures, and Algorithms Cheat Sheet)

Notes

  1. En vérité, tout le monde peut se mettre d’accord sur le fait que std::unordered_map est meilleur que std::map. Mais en pratique, il y a bien plus d’usages indiscriminé de map que de sa contrrepartie.
  2. De plus, ce n’est pas juste la taille globale qui importe, mais aussi la taille maximale locale. Par exemple, si un conteneur ne dépassera pas 20 valeurs dans une section critique du code, on peut pré-allouer ces 20 cases pour éviter toute réallocation sauvage, tout en conservant la flexibilité d’une structure dynamique.
  3. Bien sûr, chaque conteneur peut faire la plupart des opérations indiquées. Mais les tableaux résument lesquels sont les plus efficaces pour chacune.
  4. Malheureusement le diagramme ne présente pas les versions unordered_ des conteneurs associatifs. Mais vous pouvez y palier simplement en vous demandant : « Les valeurs doivent être ordonnées ? Si oui, map/set ; Si non, unordered_map/unordered_set« .

Rétrospective sur le gestionnaire d’erreurs

Article original : Retrospective: The simplest error handler | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Cette semaine nous allons revenir sur le dernier article publié et essaierons d’être critique à son propos. Il y a plusieurs choses à dire et il m’est apparu (grâce à l’intervention de plusieurs internautes) qu’il peut être amélioré.

Si le ErrorHandler ne vous dit rien, je vous renvoie à l’article qui en fait la présentation : Un des gestionnaires d’erreurs les plus simples jamais écrits | Assurer le C++ (wordpress.com).

Le repo où cette fonctionnalité est publiée est toujours disponible et à jour avec les modifications évoquées ici : SenuaChloe/SimplestErrorHandler (github.com).

Version 2 : futile récursivité

En effet, la récursivité était en réalité inutile.

Parfois, quand on déroule un paquet de paramètre, on a besoin de faire une distinction entre le corps de la boucle et le cas de base. Cela arrive quand on fait plus de choses dans le corps que dans le cas de base.

Pour l’ErrorHandler, le cas de base fait exactement la même chose que le reste de la récursivité (plus quelques autres instructions). Du coup, on n’a pas vraiment besoin de la récursivité: il suffit de déployer tout le paquet de paramètre d’un coup en utilisant ce qu’on appelle une fold expression (comme ce qui est exprimé dans le concept) :

template<typename TExceptionType = BasicException, typename ...TArgs>
requires ErrorHandlerTemplatedTypesConstraints<TExceptionType, TArgs...>
void raise_error(const TArgs & ...args)
{
    std::ostringstream oss;
    (oss << ... << args);
    const std::string error_str = oss.str();
    std::cerr << error_str << std::endl;
    throw TExceptionType(error_str);
}

Éviter une récursivité est une bonne pratique en général, quand c’est possible. Les algorithmes récursifs font grossir la pile, ce qu’il est préférable d’éviter pour plusieurs raisons1.

Grâce à cette syntaxe, on peut désormais se passer de fonction auxiliaire (plus besoin de passer un std::ostringstream pour accumuler les flux. Du coup, on n’a plus besoin de fonctions privées. Sans fonction privée, plus besoin de classe, on peut donc utiliser un namespace à la place. L’avantage, c’est qu’il est désormais possible de déclarer le concept dans cet espace de nom (et plus dans l’espace de nom global).

namespace ErrorHandler
{   
    template<typename TExceptionType, typename ...TArgs>
    concept TemplatedTypesConstraints = requires(std::string s, std::ostringstream oss, TArgs... args)
    {
        TExceptionType(s); // TExceptionType must be constructible using a std::string
        (oss << ... << args); // All args must be streamable
    };
 
    // ...
 
    template<typename TExceptionType = BasicException, typename ...TArgs>
    requires TemplatedTypesConstraints<TExceptionType, TArgs...>
    void raise_error(const TArgs & ...args)
    {
        // ...
    }
 
    template<typename TExceptionType = BasicException, typename ...TArgs>
    requires TemplatedTypesConstraints<TExceptionType, TArgs...>
    void assert(bool predicate, const TArgs & ...args)
    {
       // ...
    }
};

Débat sur le besoin de performance

Les flux de chaînes sont lents. C’est un fait2. De plus, dans notre cas, ils ne sont pas pratiques à utiliser (on a besoin de déclarer un std::ostringstream localement, ce qui oblige à faire un #include spécifiquement pour ça). Est-ce qu’il existe un moyen de s’en débarrasser ?

La principale raison pour laquelle les flux de chaine sont lents sont les conversions objet-vers-chaîne. Cependant, pour rester le plus simple possible (dans le cadre du ErrorHandler), on veut laisser le std::ostringstream gérer lui-même les conversions, même si cela implique un code plus lent.

L’optimisation du temps d’exécution est rarement critique (on peut sans crainte avancer qu’elle est inutile  80% du temps). Ce qu’on développe est un « leveur d’erreurs ». La seule raison pour laquelle un « leveur d’erreurs » pourrait faire partie d’un code sensible en temps d’exécution serait si on l’utilisait comme flux de contrôle.

Mais ce serait une faute. On l’a appelé ErrorHandler, pas FlowControlHandler. Par construction, il n’est pas pensé pour être utilisé dans du code critique. La seule manière viable d’utiliser le gestionnaire d’erreur dans du code critique est pour en sortir (dans le cas où une erreur survient).

Donc non, on ne va pas « optimiser » le gestionnaire pour qu’il ait un meilleur temps d’exécution. On va le laisser simple et concis. On n’a pas besoin de performance.

Le code complet

Voici ce que donne le code complet dans sa version finale :

#pragma once
 
#include <iostream>
#include <sstream>
 
namespace ErrorHandler
{   
    template<typename TExceptionType, typename ...TArgs>
    concept TemplatedTypesConstraints = requires(std::string s, std::ostringstream oss, TArgs... args)
    {
        TExceptionType(s); // TExceptionType must be constructible using a std::string
        (oss << ... << args); // All args must be streamable
    };
 
    class BasicException : public std::exception
    {
    protected:
        std::string m_what;
    public:
        BasicException(const std::string & what): m_what(what) {}
        BasicException(std::string && what): m_what(std::forward<std::string>(what)) {}
        const char * what() const noexcept override { return m_what.c_str(); };
    };
 
    template<typename TExceptionType = BasicException, typename ...TArgs>
    requires TemplatedTypesConstraints<TExceptionType, TArgs...>
    void raise_error(const TArgs & ...args)
    {
        std::ostringstream oss;
        (oss << ... << args);
        const std::string error_str = oss.str();
        std::cerr << error_str << std::endl;
        throw TExceptionType(error_str);
    }
 
    template<typename TExceptionType = BasicException, typename ...TArgs>
    requires TemplatedTypesConstraints<TExceptionType, TArgs...>
    void assert(bool predicate, const TArgs & ...args)
    {
        if (!predicate)
            raise_error<TExceptionType>(args...);
    }
};

Conclusion

La version 2 du ErrorHandler est encore plus concise et simple que la première. C’est une amélioration bien appréciable.

Je remercie les quelques personnes qui ont repéré ces erreurs et suggérer des améliorations.

Merci de votre attention et à la semaine prochaine !

Article original : Retrospective: The simplest error handler | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addenda

Repo Github

SenuaChloe/SimplestErrorHandler (github.com)

Notes

  1. Principalement pour éviter les dépassements de pile (stack overflow) et rendre le débogage plus lisible. En soi, la récursivité n’est pas fondamentalement mauvaise, surtout dans des cas où les dépassements de pile sont improbables, mais utiliser une fold expression est plus concis et plus clair. De plus, la récursivité ralentit la compilation (puisque le compilateur, dans sa volonté d’optimiser, va lui aussi être récursif) et peut même crasher si elle est mal encadrée. Cela ne peut pas arriver avec une fold expression.
  2. C’est assez compliqué à documenter comme étant un « fait établi », car la plupart des gens préfère parler des solutions à employer à la place de stringstream plutôt que de prouver qu’elles sont bel et bien lentes. Puisque je suis assez mauvaise en benchmarking (j’y travaille), je ne m’étendrai pas sur la question. Si vous voulez partager vos propres recherches (que ce soit pour confirmer ou infirmer mes dires), n’hésitez pas à le faire en commentaires.

Un des gestionnaires d’erreurs les plus simples jamais écrits

Article original : One of the simplest error handlers ever written | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Cette semaine je vais vous présenter un petit projet que j’ai écrit et qui permet de gérer les erreurs, par le biais d’un code très simple et très générique.

Il est certain que ce code n’est pas parfait (avant tout parce que la perfection est subjective), mais il est court et facile à utiliser.

Si vous voulez directement consulter le code source, vous pouvez d’ores et déjà vous rendre sur la page Github qui lui est dédiée : SenuaChloe/SimplestErrorHandler (github.com).

Spécifications

En général, en terme de gestion d’erreurs, mes besoins sont les suivants :

  • Le gestionnaire d’erreur doit écrire un message sur la sortie d’erreur (std::cerr).
  • Le gestionnaire d’erreur doit être capable de prendre plusieurs arguments (peu importe leur type) et les envoyer en tant que flux dans le message d’erreur.
  • Le gestionnaire d’erreur doit lever une exception.
  • Le what() de l’exception doit être le même message que celui qui est affiché sur std::cerr.
  • L’exception levée doit être changeable.
  • L’action de lever une erreur doit être une unique expression (un seul appel de fonction).
  • Le gestionnaire d’erreur ne doit pas utiliser de macros.

Ce seront donc les principaux critère sur lesquels nous allons nous baser pour concevoir le gestionnaire d’erreur (d’autres besoins pourront apparaître en cours de route).

Étape 0 : Mise en place

Pour garder le tout simple et léger, tout le code sera contenu dans un unique fichier d’en-tête (c’est toujours plus simple d’inclure un fichier d’en-tête plutôt qu’une librairie à votre projet). Mais puisqu’il y aura sans doute des fonctions auxiliaires, il faut trouver un moyen de les obfusquer.

C’est pour cela qu’on mettra tout le code dans une classe entièrement statique1. Il y aura des fonctions membres privées (celles qu’on veut obfusquer), des fonctions membres publiques (l’interface) et éventuellement des types et d’autres éléments.

Étape 1 : Récursivité basique et templates variadiques

Pour commencer : une récursivité simple

Pour avoir un message d’erreur complet et entièrement personnalisable, on a besoin d’un nombre variadique d’arguments (et, du coup, des templates variadiques). On effectuera une récursivité sur les arguments2, en envoyant le premier argument de la liste dans un flux et en répétant l’opération avec les arguments restants.

Voici l’implémentation que je suggère :

template<typename THead>
static void raise_error_recursion(const THead & arg_head)
{
    std::cerr << arg_head << std::endl;
    throw;
}
 
template<typename THead, typename ...TTail>
static void raise_error_recursion(const THead & arg_head, const TTail & ...arg_tail)
{
    std::cerr << arg_head;
    raise_error_recursion(arg_tail...);
}

La première raise_error_recursion représente la condition de base de la récursivité : s’il n’y a qu’un seul argument, alors on l’affiche et on throw.

La seconde raise_error_recursion représente la boucle récursive. Tant qu’il y a plus d’un argument dans le paquet de paramètres, on prend le premier, on l’affiche et on rappelle la fonction. Si arg_tail ne contient plus qu’un seul argument, alors c’est la condition de base qui est appelée, sinon c’est de nouveau la boucle récursive qui entre en jeu.

Avec un flux et une vraie exception

Le problème est que, dans le code ci-dessus, on ne lève pas une exception, on ne fait que throw;. Pour rappel, deux des spécifications étaient :

  • Le gestionnaire d’erreur doit lever une exception.
  • Le what() de l’exception doit être le même message que celui qui est affiché sur std::cerr.

Donc on doit lever une « vraie » exception, qui doit être construite à partir du message d’erreur.

À titre d’exemple, on va utiliser std::runtime_error, qui peut être construite à partir d’une std::string.

Le problème est qu’on ne peut pas juste envoyer le message d’erreur dans le flux cerr à chaque boucle récursive, on doit trouver un moyen de « mémoriser » le message d’erreur pour construire la runtime_exception et l’envoyer dans cerr à la fin.

Une solution possible est d’utiliser une stringstream et de la passer en paramètre de chaque fonction récursive.

template<typename THead>
static void raise_error_recursion(std::ostringstream & error_string_stream, const THead & arg_head)
{
    error_string_stream << arg_head;
    const std::string current_error_str = error_string_stream.str(); 
 
    std::cerr << current_error_str << std::endl;
    throw std::runtime_error(current_error_str);
}
 
template<typename THead, typename ...TTail>
static void raise_error_recursion(std::ostringstream & error_string_stream, const THead & arg_head, const TTail & ...arg_tail)
{
    error_string_stream << arg_head;
    raise_error_recursion(error_string_stream, arg_tail...);
}

Ici, dans la boucle récursive, on envoie le message d’erreur dans le flux représenté par error_string_stream à la place de cerr. Ce n’est qu’à la fin qu’on affiche tout le contenu de error_string_stream et qu’on construit notre exception avec.

Étape 2 : Ajout d’une interface

Mais cette ostringstream est peu commode et son utilisation devrait être invisible pour l’utilisateur. Du coup, on va cacher les fonctions précédentes et en implémenter une qui sera publique et qui prendra moins d’argument, prenant le soin de cacher l’instanciation du flux de chaîne :

template<typename ...TArgs>
static void raise_error(const TArgs & ...args)
{
    std::ostringstream error_string_stream;
    raise_error_recursion(error_string_stream, args...);
}

raise_error est désormais une fonction très simple à utiliser.

Étape 3 : Possibilité de changer d’exception

L’exception en tant que template

La seule spécification qu’on n’a toujours pas implémenter est « L’exception levée doit être changeable ».

Pour la réaliser on va ajouter un template à chacune de nos fonctions qui représentera l’exception à lever.

class ErrorHandler
{
    ErrorHandler(); // Private constructor -- this is a full-static class
     
    template<typename TExceptionType, typename THead>
    static void raise_error_recursion(std::ostringstream & error_string_stream, const THead & arg_head)
    {
        error_string_stream << arg_head;
        const std::string current_error_str = error_string_stream.str();
 
        std::cerr << current_error_str << std::endl;
        throw TExceptionType(current_error_str);
    }
 
    template<typename TExceptionType, typename THead, typename ...TTail>
    static void raise_error_recursion(std::ostringstream & error_string_stream, const THead & arg_head, const TTail & ...arg_tail)
    {
        error_string_stream << arg_head;
        raise_error_recursion<TExceptionType>(error_string_stream, arg_tail...);
    }
 
public:
 
    template<typename TExceptionType, typename ...TArgs>
    static void raise_error(const TArgs & ...args)
    {
        std::ostringstream error_string_stream;
        raise_error_recursion<TExceptionType>(error_string_stream, args...);
    }
 
    template<typename TExceptionType>
    static void raise_error()
    {
        raise_error<TExceptionType>("<Unknown error>");
    }
};

En faisant ça, on peut maintenant appeler raise_error avec n’importe quelle exception qui est constructible avec une std::string, comme ceci :

ErrorHandler::raise_error<std::runtime_error>("Foo ", 42);

Cependant, c’est un peu lourd. Parfois, on veut juste lever une erreur générique sans se soucier qu’il s’agisse d’une runtime_error ou d’une invalid_argument ou autre.

C’est pourquoi on va ajouter une valeur par défaut au template TExceptionType. Malheureusement, on ne pourra pas utiliser std::exception comme valeur par défaut, car elle n’est pas constructible par std::string.

Ce que je suggère, c’est qu’on écrive notre propre exception générique, dans l’espace de nom du ErrorHandler. Avec ça, on aura une exception qu’on pourra utiliser comme valeur par défaut et avec laquelle on pourra créer des exceptions plus personnaliser (en héritant de cette exception générique) qui seront toutes reliées aux mécanismes du ErrorHandler (ce qui peut être pratique pour les attraper).

Une exception générique et personnalisable pour le gestionnaire d’erreurs

class BasicException : public std::exception
{
protected:
    std::string m_what;
public:
    BasicException(const std::string & what): m_what(what) {}
    BasicException(std::string && what): m_what(std::forward<std::string>(what)) {}
    const char * what() const noexcept override { return m_what.c_str(); };
};

Bien sûr, il y a un héritage public à std::exception pour que la BasicException puisse être utilisée comme n’importe quelle autre exception standard3.

J’ai implémenté deux constructeurs, un qui construit le message d’erreur avec une référence constante et une qui construit le message d’erreur avec une r-value reference (pour pouvoir utiliser les sémantiques de move).

Et bien sûr, le what() est une surcharge virtuelle qui renvoie le message d’erreur.

En utilisant cette exception par défaut, la fonction raise_error ressemble désormais à ça :

template<typename TExceptionType = BasicException, typename ...TArgs>
static void raise_error(const TArgs & ...args)
{
    std::ostringstream error_string_stream;
    raise_error_recursion<TExceptionType>(error_string_stream, args...);
}
 
template<typename TExceptionType = BasicException>
static void raise_error()
{
    raise_error<TExceptionType>("<Unknown error>");
}

Maintenant on peut lever une erreur sans avoir à fournir une exception :

ErrorHandler::raise_error("Foo ", 42);

Cela lèvera, comme voulu, l’exception ErrorHandler::BasicException.

Étape 4 : Ajout d’une fonction d’assertion

Le cas le plus courant où on doit lever une erreur est si <quelque chose va mal> alors <on lève une erreur>. On peut aussi le voir comme ça : j’affirme que <expression> est vraie. Si ce n’est pas le cas, <on lève une erreur>.

Cette deuxième forme est extrêmement courante dans les tests unitaires par exemple, et s’écrit souvent comme ça : assert(expression, message_if_false);

C’est pourquoi je pense que c’est une bonne idée d’écrire une surcharge simple qui permettra d’évaluer une expression et de lever l’erreur que si l’expression est fausse.

template<typename TExceptionType = BasicException, typename ...TArgs>
static void assert(bool predicate, const TArgs & ...args)
{
    if (!predicate)
        raise_error<TExceptionType>(args...);
}

Avec cette nouvelle fonction, plutôt que d’écrire ceci :

bool result = compute_data(data);
if (result != ErroCode::NO_ERROR)
    ErrorHandler::raise_error("Error encountered while computing data. Error code is ", result);

On pourra écrire cela :

bool result = compute_data(data);
ErrorHandler::assert(result == ErroCode::NO_ERROR, "Error encountered while computing data. Error code is ", result);

Étape 5 : concepts et contraintes

On a utilisé beaucoup de templates. Avoir beaucoup de templates signifie qu’on a un gros risque de mauvaises utilisations. Mauvaises utilisations qui mènent à des erreurs de compilations. Et quand on parle de templates, les erreurs de compilation sont souvent illisibles.

Mais comme nous sommes chanceux (et bien préparés), il y a un moyen en C++20 de rendre ces erreurs plus lisibles tout en protégeant nos fonctions : les concepts et les contraintes.

Fonctionnellement, nous avons actuellement deux contraintes :

  • TExceptionType doit être constructible avec une std::string.
  • Tous les TArgs... doivent pouvoir être envoyés dans un flux de sortie.

On va donc implémenter ces deux contraintes dans un seul concept4 :

template<typename TExceptionType, typename ...TArgs>
concept ErrorHandlerTemplatedTypesConstraints = requires(std::string s, std::ostringstream oss, TArgs... args)
{
    TExceptionType(s); // TExceptionType must be constructible using a std::string
    (oss << ... << args); // All args must be streamable
};

Maintenant, il ne reste plus qu’à utiliser ce concept en tant que contrainte pour les deux fonctions de l’interface :

template<typename TExceptionType = BasicException, typename ...TArgs>
requires ErrorHandlerTemplatedTypesConstraints<TExceptionType, TArgs...>
static void raise_error(const TArgs & ...args)
{
    std::ostringstream error_string_stream;
    raise_error_recursion<TExceptionType>(error_string_stream, args...);
}
 
template<typename TExceptionType = BasicException, typename ...TArgs>
requires ErrorHandlerTemplatedTypesConstraints<TExceptionType, TArgs...>
static void assert(bool predicate, const TArgs & ...args)
{
    if (!predicate)
        raise_error<TExceptionType>(args...);
}

Le code complet

Si on emboîte tout ça dans un seul fichier d’en-tête, on obtiendra ceci :

#pragma once
 
#include <iostream>
#include <sstream>
 
template<typename TExceptionType, typename ...TArgs>
concept ErrorHandlerTemplatedTypesConstraints = requires(std::string s, std::ostringstream oss, TArgs... args)
{
    TExceptionType(s); // TExceptionType must be constructible using a std::string
    (oss << ... << args); // All args must be streamable
};
 
class ErrorHandler
{
    ErrorHandler(); // Private constructor -- this is a full-static class
     
    template<typename TExceptionType, typename THead>
    static void raise_error_recursion(std::ostringstream & error_string_stream, const THead & arg_head)
    {
        error_string_stream << arg_head;
        const std::string current_error_str = error_string_stream.str();
 
        std::cerr << current_error_str << std::endl;
        throw TExceptionType(current_error_str);
    }
 
    template<typename TExceptionType, typename THead, typename ...TTail>
    static void raise_error_recursion(std::ostringstream & error_string_stream, const THead & arg_head, const TTail & ...arg_tail)
    {
        error_string_stream << arg_head;
        raise_error_recursion<TExceptionType>(error_string_stream, arg_tail...);
    }
 
public:
 
    class BasicException : public std::exception
    {
    protected:
        std::string m_what;
    public:
        BasicException(const std::string & what): m_what(what) {}
        BasicException(std::string && what): m_what(std::forward<std::string>(what)) {}
        const char * what() const noexcept override { return m_what.c_str(); };
    };
 
    template<typename TExceptionType = BasicException, typename ...TArgs>
    requires ErrorHandlerTemplatedTypesConstraints<TExceptionType, TArgs...>
    static void raise_error(const TArgs & ...args)
    {
        std::ostringstream error_string_stream;
        raise_error_recursion<TExceptionType>(error_string_stream, args...);
    }
 
    template<typename TExceptionType = BasicException, typename ...TArgs>
    requires ErrorHandlerTemplatedTypesConstraints<TExceptionType, TArgs...>
    static void assert(bool predicate, const TArgs & ...args)
    {
        if (!predicate)
            raise_error<TExceptionType>(args...);
    }
};

Aller plus loin

On pourrait augmenter la généricité de ce code d’un cran en rendant possible le fait de pouvoir utiliser n’importe quel flux sortant à la place de std::cerr (avec std::cerr comme valeur par défaut pour ce flux).

Cependant, ça voudrait dire implémenter plus de fonction, donc un code-source plus long, et un de mes objectifs était de le garder aussi court de possible.

À vous de voir donc si vous voulez aller plus loin ou pas.

Conclusion

Ce n’est certainement pas la manière la plus complète de gérer les erreurs dans un programme mais c’est, d’après moi, une des manières les plus simples et propres de le faire tout en répondant aux spécifications que je me suis posé.

Charge à vous maintenant de définir vos propres spécifications et de les implémenter dans votre propre gestionnaire d’erreurs si vos besoins sont différents des miens.

Vous pouvez utiliser mon code (presque) aussi librement que vous le souhaitez, puisque je l’ai mis sous Licence CC0-1.0.

Merci de votre attention et à la semaine prochaine.

Article original : One of the simplest error handlers ever written | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addenda

Repo Github

SenuaChloe/SimplestErrorHandler (github.com)

Documentation utile

J’ai utilisé pas mal de notions avancées de C++ dans cet article. Pour apprendre à mieux les connaître, voici quelques liens :

Notes

  1. Une classe « entièrement statique » est une classe qu’il n’est pas possible d’instancier. Tous ces membres (fonctions et variables) sont statiques et le constructeur privé. C’est pour cela qu’on utilise une classe et pas simplement un espace de nom : on a besoin de cacher certaines fonctions. Avec un espace de nom, on ne pourrait pas cacher les fonctions auxiliaires.
    Si vous ne voulez pas utiliser une classe entièrement statique et voulez quand même cacher les fonctions auxiliaires, vous pourriez les extraire du fichier d’en-tête et le mettre dans un fichier source. Mais en faisant cela vous auriez besoin de compiler le gestionnaire d’erreur à part et d’en faire une librairie pour pouvoir l’importer dans d’autres projets.
  2. Pour en apprendre plus sur la récursivité, lisez cette page : Recursion – GeeksforGeeks. Pour en apprendre plus sur les templates variadiques, allez voir Variadic arguments – cppreference.com et Parameter pack(since C++11) – cppreference.com.
  3. La page std::exception – cppreference.com vous permettra de mieux comprendre comment fonctionnent les exceptions en C++.
  4. Le seul petit défaut est qu’on doit implémenter le concept dans l’espace de nom global, on ne peut pas le faire dans le gestionnaire d’erreur. C’est pour ça que je lui ai donné un nom assez long : pour éviter les collisions de noms autant que possible.

Les trois types de développement

Article original : The three types of development | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Cette semaine nous allons discuter d’un sujet sérieux qui concerne la communauté de développeurs. Cela peut toucher plusieurs langages, mais le C++ est un des plus concernés par cela1.

Il y a plusieurs « manières » de développer en C++. Par « manières », je veux dire un ensemble de contraintes et de circonstances qui vont affecter ce que vous pouvez faire, ce que vous devez faire et comment vous faites ces choses.

Cela peut sembler vague, mais vous pouvez considérer que ce sont des types d’environnement qui peuvent drastiquement changer votre approche du code que vous lisez, modifiez, écrivez.

En me reposant sur mon expérience, j’ai réussi à détacher trois types de développement2.

Les trois catégories

Le développement (quasiment) solo

C’est le type de développement qui a le moins de contraintes (sinon aucune). Quand vous développez seul·e ou avec peu de collaborateurs, vous pouvez librement choisir ce que vous voulez faire et comment vous allez le faire.

Le développement collaboratif sous licence

Si vous êtes sur un plus gros projet, vous pouvez voir des contraintes apparaître. La plupart du temps, ces contraintes seront axées sur quelles librairies vous pouvez utiliser ou pas.

Par exemple, si vous voulez vendre votre logiciel, vous ne pouvez pas utiliser de librairie qui est sous la licence JRL, car elle interdit les utilisations commerciales.

C’est un type de développement qui concerne principalement les petites entreprises et les développeurs indépendants.

Le développement industriel

Certains projets sont lancés par des grosses entreprises ou groupes d’entreprises. Ils peuvent être en développement pendant plusieurs années (voire même décennies si on compte la phase de maintenance), mais surtout ils doivent respecter de lourdes contraintes à propos de quelles librairies vous pouvez utiliser et sur l’intégralité de l’environnement de développement.

C’est typiquement sur ce genre de projet qu’on utilise les plus vieilles versions du C++ (souvent antécédentes au C++17, parfois même en C++03). C’est souvent parce que c’est la hiérarchie (et non les développeurs eux-mêmes) qui pilotent le budget de ce genre de projet et décident si l’environnement doit être migré ou pas.

Beaucoup de développeurs qui travaillent sur ce genre de projet arrivent au milieu de celui-ci et font face à une résistance tenace quand ils essayent d’améliorer l’environnement de développement3.

Dans ce genre de situation, vous avez souvent affaire à du code existant (du legacy code) ou à une part de la codebase que vous ne pouvez tout simplement pas modifier4.

Qu’est-ce qui est spécifique au C++ ?

Le C++ est un langage complexe, pas seulement par sa syntaxe et les spécificités qui lui sont propres, mais aussi parce qu’il y a des centaines (sinon des milliers) d’environnement différents possibles.

Il y a des dizaines de compilateurs pour le C++, portés sur un nombre important de systèmes d’exploitation. Aujourd’hui, il existe 5 versions différentes du standard5 qu’on peut rencontrer des projets professionnels.

Il est donc essentiel pour chaque développeur C++ d’adapter son discours à la personne adressée. En effet, en fonction des circonstances et des particularités de chacun, vous pourrez être amené·e à dire une chose ou son contraire.

Terrains d’affrontement

Il existe un endroit où les trois types de développement peuvent être représentés en même temps : internet. Quand vous rôdez sur les forums dédiés, vous finirez fatalement par rencontrer des gens qui sont actuellement en train de travailler sur un projet d’un type différent du vôtre.

Dans l’absolu, c’est une bonne chose que les développeurs venant d’horizons différents puissent échanger sur le C++, mais cela peut mener à des problèmes de communication.

En effet, si un développeur – qui n’a jamais réalisé qu’un seul type de développement – essaye de donner des conseils ou de faire des commentaires à un développeur venant d’un autre type de projet, une part importante de ces conseils et commentaires risque de ne pas prendre en compte les contraintes et circonstances spécifiques, et ainsi être inutile.

Prenons quelques exemples pour illustrer cela.

Exemple provenant de r/cpp

Le premier exemple vient de Reddit, plus spécifiquement du subreddit r/cpp :

Je ne suis pas spécialement convaincu·e par cet argument. Tous les compilateurs C++ modernes génèrent des warnings si les arguments du printf ne correspondent pas à la chaîne formatée, même si l’API elle-même ne force pas ce fait.

Oui, ça t’oblige à activer -Wall mais honnêtement tu devrais toujours avoir les warnings de compilation activés.

Ce commentaire est typique : bien que courtois, il est à côté du sujet en se basant sur deux sophismes :

  • « Tous les compilateurs C++ modernes génèrent des warnings […] ». Cela dépend beaucoup de ce qu’on entend par « moderne », mais au-delà de ça il existe beaucoup de compilateur qui ne fonctionnent pas comme les compilateurs standards parce qu’ils répondent à des besoins spécifiques. Je pense en particulier aux compilateurs visant des systèmes embarqués, des microcontrôleurs, les compilateurs expérimentaux ou encore les compilateurs faits-maison conçus pour certains projets particuliers ou encore tout simplement des compilateurs un peu âgés qui ne l’implémentait pas à l’époque où ils ont été distribués. Essayer de généraliser, dans ce contexte, est fallacieux, surtout entendant que « l’API elle-même ne [le] force pas« .
  • « […] honnêtement tu devrais toujours avoir les warnings de compilation activés. ». C’est une phrase que j’entends souvent et je pense que celles et ceux qui la prononce n’ont jamais travaillé sur un projet de taille industrielle. Notre travail (en tant que vétérans du C++) est d’essayer de changer les mentalités en mieux, mais parfois (sinon souvent) cela peut ne pas marcher, malheureusement. Il y a également des situations où vous arrivez sur un projet et vous constatez qu’il y a des centaines et des centaines de warnings déjà présents. Dans ce cas, la hiérarchie ne vous donne que très rarement la possibilité de les corriger et, dans ce genre de contexte, la chasse aux warnings est une cause perdue.

Bien sûr, on devrait toujours essayer de changer le monde pour le meilleur et essayer de détruire les environnement de développement inadéquats, mais nier leur existence c’est nier une part de la réalité, réalité que nous sommes beaucoup à vivre quotidiennement.

Quand cela arrive, essayez de nuancer vos propos, laissez votre pensée ouverte pour que vos interlocuteurs puissent y préciser leurs conraintes.

À la place de dire

« Oui, ça t’oblige à activer -Wall mais honnêtement tu devrais toujours avoir les warnings de compilation activés. »

Dites plutôt

« Si tu peux activer -Wall tu devrais parce que ça t’aidera à surmonter cette problématique et bien d’autres encore. »

Exemple provenant de Stack Overflow

Voici un second exemple, issu de Stack Overflow :

Mon meilleur conseil serait de ne pas écrire de macro comme ça. Pourquoi tu as besoin d’utiliser __LINE__ ?

Un commentaire court, mais il y a beaucoup de choses à en dire.

« Mon meilleur conseil serait de ne pas écrire de macro comme ça ». D’accord, mais pourquoi ? À cause du fonctionnement intrinsèque des macros ? Parce que je ne pourrai pas atteindre mon objectif avec ? Parce que les macros sont fondamentalement mauvaises et qu’il existe une alternative fonctionnelle ?

La question originale pose la contrainte suivante :

FOO et FOO_END doivent être des macros. C’est parce que j’ai besoin d’utiliser __LINE__ et __FILE__ en leur sein.

Sachant cela, est-ce que la question « Pourquoi tu as besoin d’utiliser __LINE__ ? » est vraiment pertinente ? Puisque le post se base sur cette contrainte, que tu saches ou pas pourquoi l’utilisateur a besoin de __LINE__ ne t’aidera pas à l’aider6.

Écrire un commentaire pertinent est simple quand on y réfléchit un peu. Par exemple :

Ce commentaire précise très simplement que les pointeurs sont dans la plupart des cas à éviter, tout en admettant qu’il existe des situations dans lesquels ils sont nécessaires. Il a été écrit pour sensibiliser l’utilisateur original aux problématiques que peuvent cause les pointeurs tout en restant pertinent.

En conclusion

Quand vous voulez aider les autres développeurs, vous devez absolument faire attention aux circonstances qui les contraignent. Votre réponse n’atteindra pas sa cible si vous n’êtes pas pertinent·e.

De plus, vous devez vous poser la question suivant : aidez-vous qui que ce soit si votre conseil pourrait se résumer à « Tu dois changer d’environnement de développement » à quelqu’un qui ne peut ou veut pas en changer ? Vous devez vous adapter à ces situations, mettre vos paroles en perspective, pour que votre interlocuteur·ice retienne votre conseil, même s’iel ne peut pas l’appliquer dans son cas précis.

Il est très facile de tomber dans le sophisme et l’argument d’autorité. Essayez toujours d’expliquer vos arguments, même si ça vous semble trivial ou « de bon sens ». Cela leur donnera du poids. Et si jamais vous n’arrivez pas à expliquer simplement et clairement vos arguments, il y a une (très) forte chance pour qu’ils soient, en réalité, fallacieux.

Merci de votre attention et à la semaine prochaine !

Article original : The three types of development | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addendum

Notes

  1. Dans cet article, j’utiliserai le C++ pour illustrer, mais tout ce qui sera dit peut être applicable à n’importe quel langage de programmation. J’expliquerai plus tard dans l’article les spécificités inhérentes au C++.
  2. Selon votre propre expérience, vous pouvez découvrir d’autres types de développement. Ceci s’ajoutent naturellement à ceux présentés ici.
  3. La définition du terme améliorer est fondamentale ici. Ce qu’un jeune développeur sur un projet considère comme étant une amélioration n’est pas la même chose que ce qu’un développeur chevronné, un responsable, un commercial ou un client peuvent considérer comme une amélioration. « C’est très bien que tu ais passé une année complète à mettre à jour la codebase en C++20, avec un nouveau GCC et clang, mais tu n’as corrigé ni reporté aucun bug, ni implémenté une des nombreuses fonctionnalités promises au client, et en plus maintenant on ne peut plus maintenir le code legacy… ».
  4. Par exemple : parce qu’elle appartient à une autre équipe ou entreprise, parce qu’elle a déjà été livrée au client, ou encore parce qu’elle a été validée par la QA et qu’il faudrait des semaines pour la faire revalider.
  5. Je compte seulement à partir du C++03 (donc C++03, 11, 14, 17 et 20) vu que le C++98 es très similaire au C++03.
  6. Il peut arriver que l’utilisateur original ait fait mention de contraintes qu’il pourrait en fait contourner. Mais ce n’est pas pour autant constructif de le « babysitter », il faudrait par exemple lui proposer des alternatives avec des exemples à la clé.

Des switch-case plus jolis

Article original : Prettier switch-cases | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

J’ai appris cette syntaxe au cours d’une présentation de la CppCon de 2021, donnée par Herb Sutter : Extending and Simplifying C++: Thoughts on Pattern Matching using `is` and `as` – Herb Sutter – YouTube. On peut aussi retrouver sa présentation sur son blog : Sutter’s Mill – Herb Sutter on software development.

Contexte

Mettons que vous ayez un bloc switch-case sans plongeon1 (c’est important), comme ceci) :

enum class Foo {
    Alpha,
    Beta,
    Gamma,
};
 
int main()
{
    std::string s;
    Foo f;
 
    // ...
    // Do things with s and f 
    // ...
 
    switch (f)
    {
        case Foo::Alpha:
            s += "is nothing";
            break;
        case Foo::Beta:
            s += "is important";
            f = Foo::Gamma;
            break;
        case Foo::Gamma:
            s += "is very important";
            f = Foo::Alpha;
    }
     
    // ...
}

Rien de particulièrement compliqué ici : on ajoute un suffixe à la chaîne en fonction de la valeur de f, parfois en modifiant f au passage.

Maintenant, mettons qu’on ajoute un littéral Delta à l’énumération Foo, qui fait exactement la même chose que Gamma avec une petite différence dans la chaîne. Il y a une bonne chance pour qu’on écrive ceci :

enum class Foo {
    Alpha,
    Beta,
    Gamma,
    Delta,
};
 
int main()
{
    std::string s;
    Foo f;
 
    // ...
    // Do things with s and f 
    // ...
 
    switch (f)
    {
        case Foo::Alpha:
            s += "is nothing";
            break;
        case Foo::Beta:
            s += "is important";
            f = Foo::Alpha;
            break;
        case Foo::Gamma:
            s += "is very important";
            f = Foo::Alpha;
        case Foo::Delta:
            s += "is not very important";
            f = Foo::Alpha;
    }
 
    // ...
}

Le nouveau bloc case est certainement du copier-coller. Mais avez-vous remarqué le bug qui s’est introduit ?

Comme dans première version, le développeur n’a pas jugé nécessaire de rajouter un break à la fin du bloc Gamma (puisque c’était le dernier élément), quand on a copié-collé ce bloc on l’a laissé sans break. Du coup, quand on va passer dans le code de Gamma, on va toujours plonger dans le code de Delta.

Nouvelle syntaxe

La syntaxe présentée dans cet article permet (entre autre) de rendre ce genre de fautes moins fréquent, et de faire en sorte que le code soit un peu plus clair.

La voici :

switch (f)
{
    break; case Foo::Alpha:
        s += "is nothing";
    break; case Foo::Beta:
        s += "is important";
        f = Foo::Alpha;
    break; case Foo::Gamma:
        s += "is very important";
        f = Foo::Alpha;
    break; case Foo::Delta:
        s += "is not very important";
        f = Foo::Alpha;
}

Cela consiste à mettre l’instruction break; juste avant chaque case.

Cela peut sembler étrange au premier regard, car le tout premier break est inutile et il n’y en a pas à la fin, mais cette syntaxe est fonctionnelle et surtout confortable.

En effet, si vous commencez tous vos blocs avec un break; case XXX:, vous êtes garantis de ne jamais faire de plongeon, même avec des hordes de copier-coller.

Avantages

Le premier avantage est celui d’éviter le genre de bug que j’ai mentionné plus haut, où on oublie un break ce qui cause un plongeon indésirable. Et même si vous ne faites pas de copier-coller, l’oubli du break sera visuellement évident puisque votre case ne sera pas aligné avec les autres.

Mais le réel avantage (d’après moi) est que cette syntaxe est, dans sa globalité, visuellement plus agréable. Pour chaque case, on économise une ligne de code (où devrait être le break; normalement), et il sera évident pour quiconque regarde le code que ce switch-case ne contient aucun plongeon.

Bien sûr, la beauté est subjective, y compris en programmation2. Cependant, des choses comme un meilleur alignement, des intentions plus claires et l’économie de lignes3 sont, il me semble, des critères plutôt objectifs.

Avertissement

La première fois que j’ai vu cette syntaxe, j’ai rapidement compris son fonctionnement et son intérêt. Je sais ceci dit qu’il y a plusieurs personnes qui ont dû se les faire expliquer.

Mais c’est presque toujours le cas quand on introduit une nouvelle syntaxe.

Gardez-donc à l’esprit que si vous voulez l’utiliser dans un code partagé, elle risque d’embrouiller vos collègues. Soyez-sûr·e de bien l’expliquer (que ce soit en personne ou en commentaire) pour qu’elle soit rapidement prise en main.

En conclusion

Ça ne va certainement pas changer votre vie quotidienne ni même votre vision du C++, mais je voulais la partager, car je l’aime beaucoup.

C’est une brique de plus dans la construction d’un code plus agréable à lire.

Merci de votre attention et à la semaine prochaine !

Article original : Prettier switch-cases | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addendum

Notes

  1. Le terme « plongeon » est utilisé ici comme traduction de « fallthrough » en anglais, qui dans le contexte d’un switch-case renvoie au fait qu’on peut se dispenser du mot-clé break pour qu’un bloc case « plonge » dans un autre, comme dans l’exemple suivant :
switch (a)
{
    case 0:
        ++b; // Dans le cas où a == 0, on fait ++b puis on plonge dans le cas 1
    case 1:
        ++b;
        break; // Ici il y a un break, donc on ne plonge pas dans le cas par défaut
    default:
        --b;
}
  1. Surtout en programmation, oserai-je même dire.
  2. L' »économie de lignes » n’est une bonne chose que lorsqu’elle défausse des expressions peu instructives, comme c’est le cas avec une ligne qui contient uniquement l’instruction break;. Jamais vous ne m’entendrez dire que les gros one-liners sont préférables à un code plus détaillés (parce qu’ils ne le sont tout simplement pas). Réunir le break et le case laisse le code respirer. De plus, vous pouvez toujours laisser une ligne vide à l’endroit où était le break auparavant — votre code n’en sera que plus agréable.