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.

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 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é.

Est-ce que mon chat est Turing-complet ?

Article original : 
Traductrice : Chloé Lourseyre

Cet article est une retranscription d’un Lightning Talk que j’ai donné à l’occasion de la CppCon2021

On va parler d’un sujet plus léger cette semaine, mais malgré tout important : est-ce que mon chat est Turing-complet ?

Peluche, enchantée

Peluche est un chat tout doux qui, suite à un concours de circonstances, habite dans ma maison.

Elle sera notre sujet de test aujourd’hui

Est-ce que Peluche est Turing-complète?

C’est quoi, « Turing-complet » ?

On dit qu’une machine est Turing-complète si elle peut émuler une machine de Turing. Toute machine qui est Turing-complète peut exécuter n’importe quel programme informatique1.

Cela signifie que toute machine qui implémente les huit instructions ci-après est équivalente à n’importe quel ordinateur.

  • . et , : Gestion des entrées et des sorties.
  • + et - : Augmentation et diminution de la valeur contenue dans la cellule mémoire pointée2.
  • > et < : Déplacement vers la gauche ou vers la droite la cellule pointée sur le ruban de mémoire.
  • [ et ] : Faire des boucles.

Si on arrive à prouver que Peluche peut exécuter ces huit instructions, on prouvera qu’elle est Turing-complète.

Preuve de la Turing-complétude

Entrées et sorties

D’abord, j’ai essayé d’obtenir une réaction en utilisant mon doigt :

Suite à cela, elle s’est tournée vers moi, m’a fixée quelques secondes, puis s’est détournée de moi.

Donc voilà : je l’ai pokée et j’ai obtenu une réaction. Elle peut donc computer des entrées et renvoyer une sortie.

Entrée/sortie : check !

Augmentation et diminution d’une valeur en mémoire

L’autre jour, en rentrant du travail, j’ai découvert ceci :

Des croquettes partout…

Mais en y regardant de plus près, j’ai remarqué quelque chose d’intéressant. Si on numérote les dalles de carrelage comme suit :

Cela ressemble fortement à un ruban de mémoire, si le nombre de croquettes contenu correspond à la valeur mémorisée ! Et comme elle n’hésite pas à les manger à même le sol, elle peut tout aussi bien faire diminuer cette valeur.

Augmentation/diminution de mémoire : check !

Déplacement vers la gauche ou la droite du ruban de mémoire

Un jour lointain, je faisais la vaisselle lorsque j’ai accidentellement renversé de l’eau sur Peluche. Elle s’est mise à courir partout et a mis le bazar dans la cuisine.

Si vous regardez bien (en suivant la flèche rouge), vous remarquerez qu’elle a déplacé son bol à croquette.

De fait, cela signifie que lorsqu’elle renversera ses croquettes sur une autre dalle de carrelage que la première, c’est-à-dire ailleurs sur la bande mémoire.

Déplacement de la bande mémoire : check !

Les boucles

Bon, après ce bazar j’ai (évidemment) dû tout nettoyer.

Mais pas plus de cinq minutes après avoir fini de nettoyer, je me suis retrouvée devant ça :

Okay… Elle peut SANS AUCUN DOUTE faire des boucles.

Boucles : check !

Nous venons de prouver que Peluche est Turing-complète. Du coup, la question qui se pose est : comment l’exploiter pour effectuer des calculs de haute performance ?

Que faire avec elle ?

Peluche est Turing-complète : ça veut dire qu’on peut faire ce qu’on veut avec !

J’ai alors essayé de lui donner un morceau de code simple à exécuter, pour tester3:

😾😾😾😾😾😾😾😾
😿
🐈😾
🐈😾😾
🐈😾😾😾
🐈😾😾😾😾
🐈😾😾😾😾😾
🐈😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾😾😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾😾😾😾😾😾😾😾😾
🐈😾😾😾😾😾😾😾😾😾😾😾😾😾😾😾😾
😻😻😻😻😻😻😻😻😻😻😻😻😻😻😻😻🐾
😸
🐈🐈🐈🐈🐈🐈🐈🐈🐈🙀😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐾🐾🐾🙀😾😾😾😻😻😻😻😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐾🐾🐾🐾🙀😾😾😾😾😻😻😻😻😻😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐾🐾🐾🐾🙀😾😾😾😾😻😻😻😻😻😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐾🙀😾😻😻😻😻😻😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🙀😻😻😻😻
🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐾🙀😾😻😻😻😻😻😻😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐾🙀😾😻😻😻😻😻😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈😾😾🙀🐾🐾😻😻😻😻😻😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐾🐾🐾🐾🙀😾😾😾😾😻😻😻😻😻😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐈🐾🐾🐾🐾🙀😾😾😾😾😻😻😻😻😻😻😻😻😻😻😻😻😻
🐈🐈🐈🐈🐈🐈🐾🐾🙀😾😾
😻😻😻😻😻😻🙀

Le résultat était sans appel : elle n’a pas voulu bouger.

Au final, peut-être que les chats ne sont pas conçus pour exécuter du code ? Et ce, même s’ils sont Turing-complets ?

À propos de la « chat-formatique »

Blague à part, la chat-formatique (ou cat-computing) est le nom que je donne à cette pratique généralisée. D’expérience, il arrive assez souvent que quand quelqu’un découvre une nouvelle fonctionnalité du langage, iel commence à l’utiliser à tors et à travers. Juste parce qu’iel le peut.

Cependant, tout comme vous pouvez exécuter du code avec un chat4 mais ne devriez pas, ce n’est pas parce que vous pouvez utiliser une fonctionnalité que vous devriez.

En conclusion

La chat-formatique peut sembler être une erreur de débutant (et ça l’est), mais même le plus grand·e·s expert·e·s commettent des erreurs de débutant de temps à autre (et il n’y a pas de honte à cela).

Tous les trois ans, une nouvelle version du C++ est publiée. À chaque fois, ça me donne envie d’utiliser les nouvelles fonctionnalités partout, dans tous mes programmes. Bien que ce soit une opportunité pour gagner de l’expérience sur ces fonctionnalités, c’est aussi un terrain favorable à l’acquisition de mauvaises pratiques.

Demandez-vous toujours si une fonctionnalité est nécessaire5 avant de l’utiliser, sinon vous pourriez être en train de faire de la chat-formatique.

Vos savants étaient si préoccupés par ce qu’ils pourraient faire ou non qu’ils ne se sont pas demandé s’ils devraient le faire6.

Docteur Ian Malcolm, Jurassic Park

Aussi, la chat-formatique c’est de la maltraitance animale, ne le faites pas 😠 !

Merci de votre attention et à la semaine prochaine !

Article original : 
Traductrice : Chloé Lourseyre

Addendum

Notes

  1. Ceci est une définition simplifiée et vulgarisée, mais suffisante pour le bien cet article. Si vous voulez la vraie définition, suivez le lien suivant : Turing completeness – Wikipedia
  2. Je ne l’ai pas mentionné explicitement, mais une machine Turing a un « ruban de mémoire », contenant des « cellules de mémoire ». La machine pointe toujours vers une cellule, qui est désignée comme la cellule « pointée ».
  3. Vous n’arrivez peut-être pas à lire ce morceau de code — il s’agit d’un joli nouveau langage que j’ai baptisé « braincat ».
  4. Oui, je sais, dans la vraie vie, vous ne pouvez pas exécuter du code avec un chat. Mais pour le bien de la métaphore, essayez d’imaginer que vous pouvez.
  5. Bien, la « nécessité » survient quand il y a un bénéfice net à l’emploi d’une fonctionnalité. On ne parle pas ici de nécessité absolue mais de nécessité pratique.
  6. Ce n’est pas exactement phrase que prononce docteur Malcolm dans le film. En français, la phrase complète est : « Vos savants étaient si préoccupés par ce qu’ils pourraient faire ou non qu’ils ne se sont pas demandé s’ils en avaient le droit. ». Afin de mieux adhérer au contexte, j’ai pris la liberté de la retraduire depuis la version originale, qui est : « They were too busy wondering if they could to think about whether they should. »

Une raison de plus pour ne pas utiliser printf (ou écrire du code C en général)

Article original : Yet another reason to not use printf (or write C code in general) | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Cet article est né du tweet suivant, par Joe Groff @jckarter:

De manière assez évidente, le tweet est une blague, mais discutons un peu de ce qui se passe dans ce code.

Et donc, que se passe-t-il ?

Juste pour être 100% claire, l’expression double(2101253) ne calcule pas le double de la valeur 2101253, c’est une conversion (de style C) d’un entier vers un double.

En l’écrivant différemment, on obtient :

#include <cstdio>
 
int main() {
    printf("%d\n", 666);
    printf("%d\n", double(42));
}

En compilant sous x86_64 gcc 11.2, on a le résultat suivant :

666
4202506

On peut donc voir que la valeur 4202506 n’a rien à voir avec le 666 ou le 42.

D’ailleurs, si on lance le même code sous x86_64 clang 12.0.1, on obtient un résultat différent :

666
4202514

Vous pouvez voir les résultats exécutés ici : [https://godbolt.org/z/c6Me7a5ee].

Vous l’avez peut-être déjà deviné, mais cela vient de la ligne 5, où on affiche un double comme s’il s’agissait d’un int. Mais ce n’est pas à proprement parler une erreur de conversion (votre machine sait très bien convertir un flottant en entier, si ce n’était que ça il n’y aurait pas de soucis), mais d’un tout autre problème.

La vérité

Si on veut comprendre comment tout cela fonctionne, il faut se plonger dans le code assembleur correspondant au code (https://godbolt.org/z/5YKEdj73r) :

.LC0:
        .string "%d\n"
main:
        push    rbp
        mov     rbp, rsp
        mov     esi, 666
        mov     edi, OFFSET FLAT:.LC0
        mov     eax, 0
        call    printf
        mov     rax, QWORD PTR .LC1[rip]
        movq    xmm0, rax
        mov     edi, OFFSET FLAT:.LC0
        mov     eax, 1
        call    printf
        mov     eax, 0
        pop     rbp
        ret
.LC1:
        .long   0
        .long   1078263808

(allez-voir le Godbolt pour une correspondance plus visuelle : https://godbolt.org/z/5YKEdj73r).

En jaune dans le code assembleur (lignes 6 à 9, équivalent de printf("%d\n", 666);), on peut voir que tout va bien, la valeur 666 est positionnée dans le registre esi et ensuite la fonction printf est appelée. On peut donc confortablement supposer que quand la fonction ptintf lit un %d dans la chaîne qui lui est transmise, elle va afficher ce qu’elle a dans ce registre esi.

Or, quand on regarde le code en bleu (lignes 10 à 14, l’équivalent de printf("%d\n", double(42));), la valeur 42 est positionnée dans un autre registre, qui est xmm0 (du fait que c’est un double). Comme on passe à la fonction printf la même chaîne qu’avant, elle va regarder dans le même registre qu’avant (à savoir esi) et afficher quoique ce soit qui s’y trouve, d’où une valeur incohérente.

On peut prouver cela assez simplement :

\#include <cstdio>
 
int main() {
    printf("%d\n", 666);
    printf("%d %d\n", double(42), 24);
}

Il s’agit du même code qu’avant, sauf qu’on a ajouté l’affichage d’un autre entier dans le second printf.

Quand on regarde l’assembleur (https://godbolt.org/z/jjeca8qd7) :

.LC0:
        .string "%d %d\n"
main:
        push    rbp
        mov     rbp, rsp
        mov     esi, 666
        mov     edi, OFFSET FLAT:.LC0
        mov     eax, 0
        call    printf
        mov     rax, QWORD PTR .LC1[rip]
        mov     esi, 24
        movq    xmm0, rax
        mov     edi, OFFSET FLAT:.LC0
        mov     eax, 1
        call    printf
        mov     eax, 0
        pop     rbp
        ret
.LC1:
        .long   0
        .long   1078263808

Le double(42) est toujours positionné dans le registre xmm0 et l’entier 24, en toute logique, tombe dans le registre esi. À cause de cela, on obtient la sortie suivante :

666
24 0

Pourquoi ? Et bien comme printf comprend qu’il faut afficher deux entiers, elle va regarder dans le registre esi (et donc afficher 24) puis dans le registre d’entier suivant, (edx) et afficher ce qui s’y trouve (0, de manière fortuite).

Au final, ce comportement survient à cause de la manière dont l’architecture x86_64 est faite. Si vous voulez vous documenter à ce propos, voici deux liens :

Que dit la doc à ce sujet ?

Le point chaud de tout cela est, selon la référence (printf, fprintf, sprintf, snprintf, printf_s, fprintf_s, sprintf_s, snprintf_s – cppreference.com), le prédicat suivant :

If a conversion specification is invalid, the behavior is undefined.

Si une spécification de conversion est invalide, alors le ocmportement est indéfini.

Cette même référence est équivoque quant à la spécification %d :

converts a signed integer into decimal representation [-]dddd.
Precision specifies the minimum number of digits to appear. The default precision is 1.
If both the converted value and the precision are ​0​ the conversion results in no characters.

Convertit un entier signé en sa représentation décimale [-]dddd.
[…]

De fait, transmettre un double à un printf alors que, d’après la chaîne de formattage, il s’attend à un entier est un comportement indéfini. Ce comportement est donc de notre propre faute.

D’ailleurs, ce code déclenche toujours un warning sous clang. Sous gcc, il faut activer -Wall pour le voir.

En résumé

Le langage C est un très, très vieux langage. Il est plus vieux que le C++ (évidemment) qui est lui-même très vieux. Pour rappel, le K&R a été publié en 1978. C’était treize ans avant ma propre naissance. Et (contrairement à nous autres développeur·se·s), les langages de programmation vieillissent mal.

J’aurais pu résumer cet article par un bon vieux « N’écrivez pas de comportements indéfinis » mais je pense que c’est un peu à côté de la plaque dans cette situation. Du coup je vais le dire franchement : n’utilisez pas printf du tout.

Le problème n’est pas avec printf lui-même, c’est d’utiliser une fonctionnalité qui est issue d’un autre langage1 dont la publication originale est vieille de quarante-trois ans. En un mot : n’écrivez pas du code C en C++.

Merci de votre attention et à la semaine prochaine !

(Merci tout particulièrement à Guillaume Delacourt, qui nous a partagé le tweet qui a servi de base à cet article)

1. Oui, que ça vous plaise ou non, le C et le C++ sont bien deux langages disctincts. Ils sont distincts dans leurs intentions, leurs pratiques et leur méta. C’est pourquoi je refuses systématiquement les offres d’emploi pour des postes de type « C/C++ », parce que je ne travaille pas pour des gens qui ne savent pas quel langage ils utilisent.

Article original : Yet another reason to not use printf (or write C code in general) | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Ne partez pas du principe que les accesseurs sont rapides

Article original : You shouldn’t assume accessors are fast | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

« Les accesseurs sont censés être rapides. » J’ai entendu ce leitmotiv tellement de fois au cours de ma carrière que je ne pouvais pas ne pas en parler.

Explication

À quoi servent les accesseurs ?

En faisant des recherches pour cet article, j’ai été surprise de constater qu’il y avait une définition formelle des accesseurs1.

Vous pouvez trouverez cette définition dans la section § 18.3.5 Accessor functions de la quatrième édition de The C++ Programming Language (Bjarne Stroustrup).

Pour faire court, la definition stipule que, même s’il est déconseillé d’en abuser, vous pouvez développer une méthode pour lire ou modifier les attributs privés d’une classe pour que votre algorithme (qui a besoin de lire/écrie ces données) reste simple et clair.

Donc, en clair, les accesseurs sont une interface entre les membres privés d’une classe et l’utilisateur de cette classe.

Comme toute interface, les accesseurs peuvent masquer la réelle manière donc la classe accède aux donnée voulues, encapsulant les comportement complexe pour que la classe soit simple à utiliser.

1. Je ne discute pas ici du fait qu’il existe une définition pseudo-consensuelle des accesseurs. Comme cette article vise à déconstruire cette définition, je ne construis pas mon argumentaire dessus. Elle est cependant (implicitement) décrite dans la section suivante, où j’explique pourquoi c’est une mauvaise définition.

Les gens n’utilisent pas les accesseurs correctement…

Tous les jours je vois des gens qui utilisent les accesseurs de manière impropre. Voici quelques exemples :

  • Certaines classes ont des attributs privés mais tous ces attributs ont des accesseurs simple get et set. Si votre class rend accessible toutes ses données sans distinction ni subtilité, alors ce n’est pas une class, c’est une struct2.
  • Certains accesseurs sont déclarés, implémentés, mais jamais utilisés. N’écrivez pas une fonction dont vous n’avez pas besoin, cela pollue la codebase.
  • Certaines personnes, quand elles constatent qu’elles ont besoin d’un accesseur qui n’existe pas, l’implémentent sans réfléchir. Parfois (souvent), si un accesseur n’est pas implémenté, c’est qu’il y a une bonne raison. Réfléchissez bien quand vous faites cela.
  • Certains getters ne sont pas const alors qu’ils le devraient. Par défaut, utilisez const partout, à moins que vous ayez spécifiquement besoin que ce soit non-const.
  • Certains getters sont artificiellement rendus const alors qu’ils ne devraient pas l’être. C’est un cas très rare, mais j’ai déjà vu plusieurs fois un développeur utiliser un const-cast juste pour rendre le getter const, bien que cela ne soit (dans ce cas de figure spécifique) pertinent. Vous devez éviter les const-cast à tout prix.

Il y a pas mal d’autres mauvaises pratiques qui existent mais qui sont trop verbeuses à décrire dans un article qui n’y est pas dédié.

Tout ça pour dire que beaucoup de gens n’utilisent pas les accesseurs correctement.

2. Ici, j’utilise abusivement et volontairement les termes class pour désigner une structure de donnée avec des membres privé et struct pour désigner un simple data bucket.

…ce qui mène à de mauvaises suppositions

Les pratiques incorrectes engendrent des états d’esprit incorrects, et Les états d’esprits incorrects engendrent les mauvaises suppositions.

La mauvaise supposition la plus répandue concernant les accesseurs est la suivante :

Tous les accesseurs doivent être rapides à exécuter

Partir du principe que les accesseurs sont rapide est une contrainte inutile

Sans parler du fait que ça peut être dangereux (si vous appelez une fonction en pensant qu’elle est rapide, le jour où ce n’est pas le cas vous aurez une mauvaise surprise), en faisant cette supposition vous vous infligez une contrainte.

Cette contrainte n’est pas réellement utile (tout ce que vous avez à y gagner, c’est l’économie d’aller vérifiez la documentation de l’accesseur, documentation que vous devriez dans tous les cas consulter). Cela restreint de ce fait votre capacité à innover et faire quelque chose d’utile de cet accesseur.

Illustration avec un pattern utile

Je vais vous montrer un pattern que j’aime beaucoup car il peut, dans certains cas, s’avérer très pratique.

Considérez cela : vous devez implémenter une classe qui, selon un certain nombre d’entrées, fournit plusieurs sorties (deux dans notre exemple). Vous devez utiliser cette classe pour remplir deux tableaux avec ces sorties. Cependant, les paramètres ne varient pas toujours, donc parfois, refaire le calcul est inutile. Comme le calcul à effectuer est coûteux (en temps d’exécution), vous ne voulez pas le relancer quand c’est inutile3.

Une façon (naïve) de le faire est ainsi (code complet à https://godbolt.org/z/a4x769jra) :

class FooBarComputer
{
public:
    FooBarComputer();
 
    // These update the m_has_changed attribute if relevant
    void set_alpha(int alpha);
    void set_beta(int beta);
    void set_gamma(int gamma);
 
    int get_foo() const;
    int get_bar() const;
 
    bool has_changed() const;
    void reset_changed();
 
    void compute();
 
private:
    int  m_alpha, m_beta, m_gamma;
    int m_foo, m_bar;
    bool m_has_changed;
};
 
 
//  ...
 
 
//==============================
bool FooBarComputer::has_changed() const
{
    return m_has_changed;
}
 
void FooBarComputer::reset_changed()
{
    m_has_changed = false;
}
 
 
//==============================
// Output getters
int FooBarComputer::get_foo() const
{
    return m_foo;
}
 
int FooBarComputer::get_bar() const
{
    return m_bar;
}
 
 
//  ...
 
 
//==============================
// main loop
int main()
{
    std::vector<int> foo_vect, bar_vect;
    FooBarComputer fbc;
 
    for (int i = 0 ; i < LOOP_SIZE ; ++i)
    {
        fbc.set_alpha( generate_alpha() );
        fbc.set_beta( generate_beta() );
        fbc.set_gamma( generate_gamma() );
 
        if ( fbc.has_changed() )
        {
            fbc.compute();
            fbc.reset_changed();
        }
 
        foo_vect.push_back( fbc.get_foo() );
        bar_vect.push_back( fbc.get_bar() );
    }
}

Cependant, il est possible d’écrire une meilleure version de cette classe en modifiant la manière dont les getters sont implémentés, comme ceci (code complet à https://godbolt.org/z/aqznsr6KP) :

class FooBarComputer
{
public:
    FooBarComputer();
 
    // These update the m_has_changed attribute if relevant
    void set_alpha(int alpha);
    void set_beta(int beta);
    void set_gamma(int gamma);
 
    int get_foo();
    int get_bar();
 
private:
    void check_change();
    void compute();
 
    int  m_alpha, m_beta, m_gamma;
    int m_foo, m_bar;
    bool m_has_changed;
};
 
 
//  ...
 
 
//==============================
void FooBarComputer::check_change()
{
    if (m_has_changed)
    {
        compute();
        m_has_changed = false;
    }
}
 
 
//==============================
// Output getters
int FooBarComputer::get_foo()
{
    check_change();
    return m_foo;
}
 
int FooBarComputer::get_bar()
{
    check_change();
    return m_bar;
}
 
 
//  ...
 
 
//==============================
// main loop
int main()
{
    std::vector<int> foo_vect, bar_vect;
    FooBarComputer fbc;
 
    for (int i = 0 ; i < LOOP_SIZE ; ++i)
    {
        fbc.set_alpha( generate_alpha() );
        fbc.set_beta( generate_beta() );
        fbc.set_gamma( generate_gamma() );
 
        foo_vect.push_back( fbc.get_foo() );
        bar_vect.push_back( fbc.get_bar() );
    }
}

Voici les avantages de la seconde version :

  • Le code du main est plus concis est clair.
  • Le compute() n’a plus besoin d’être public.
  • Vous n’avez plus besoin du has_changed(), à la place vous avez check_change() mais qui est privé.
  • L’utilisateur de votre classe sera moins enclin à mal l’utiliser. Il ne pourra pas appeler le compute() à tout bout de champs, puisque celui-ci est devenu paresseux.

C’est ce dernier point qui est le plus important. N’importe quel utilisateur, dans la première version, aurait pu omettre la conditionnelle et appeler le compute() à chaque tour de boucle la rendant inefficace.

3. Si vous le souhaitez, vous pouvez imaginer que les paramètres changent environ une fois sur cent. Ainsi, il est important de ne pas refaire le calcul quand celui-ci est inutile.

N’est-il pas possible de faire autrement ?

L’argument contre cette pratique que j’ai le plus entendu est le suivant : « Et bien, pourquoi tu ne renomme pas tout simplement le getter ? Comme computeFoo() par exemple ? ».

Et bien, le getter ne fait pas toujours un compute(), c’est donc impropre de l’appeler ainsi. De plus, sémantiquement, le mot compute signifie « faire une opération », avec comme sous-entendu qu’elle ne retourne pas de valeur (au mieux un code d’erreur). Et même si certains développeurs le font, je n’aime pas utiliser ce mot ainsi.

« Dans ce cas, appelle-la computeAndGetFoo() ! »

Sauf que, une fois encore, elle ne fait pas toujours un conpute(). Si on voulait être exhaustif (ce qu’on doit toujours essayer d’être d’après moi), on devrait l’appeler sometimesComputeAndAlwaysGetFoo(), ce qui juste ridicule pour une méthode aussi simple.

« Alors vas-y, trouve un nom adéquat ! »

C’est chose faite. Ce nom est getFoo(). C’est exactement ce que ça fait : ça get le foo. Le fait que le compute() est paresseux ne change rien au fait que c’est un getter. De plus, il est mentionné en commentaire que le get peut être coûteux à exécuter, donc lit la documentation et tout se passera bien.

« Et on ne pourrait pas mettre la vérification dans compute() au lieu de dans le getter ? »

On pourrait, mais quel serait l’intérêt ? Rendre le compute() public est inutile puisqu’on a forcément besoin du getter pour accéder aux données, et on ne veut exécuter le compute() que si on s’en sert.

Il est possible de faire autrement…

À vrai dire, on peut effectivement faire autrement, ce qui est très pratique si, par exemple, on veut ajouter un getter pour lequel veut être sûr qu’il n’appellera jamais le compute() (nécessaire si vous avez besoin d’un getter constant).

Dans ce cas, je conseille d’utiliser deux noms différents pour les deux getters, car ils ne font pas tout à fait la même chose. Leur donner le même nom (avec seulement le mot-clé const comme différence) sera déroutant.

Personnellement, j’utilise cette graphie :

  • getActiveFoo()
  • getPassiveFoo()

J’aime bien cette façon d’écrire car on indique explicitement que le getter est potentiellement coûteux ou pas. De plus, cela indique implicitement que le getter passif peut vous donner une valeur périmée de foo.

En outre, quiconque tente d’appeler getFoo() se confrontera à une erreur de compilation et devra choisir entre une des deux version, le forçant à réfléchir. C’est toujours une bonne chose de forcer l’utilisateur à réfléchir.

Le plus important : propagez la bonne parole

Puisqu’il est possible de mettre du code lent dans des getters, vous trouverez forcement des développeurs qui le feront.

Le comportement le plus dangereux à adopter et d’ignorer ce fait et laisser le gens penser que les accesseurs sont rapide en exécution.

Vous devriez prévenir vos collègues que les accesseurs sont, au final, comme n’importe quelle autre méthode : ils peuvent être lent et tout le monde doit lire leur documentation avant de les utiliser.

Dans cet article, je parle surtout de temps d’exécution, mais mon propos s’applique aussi à toutes les autres formes de performances : taille mémoire, accès disque, accès réseau, etc.

Merci de votre attention et à la semaine prochaine !

Article original : You shouldn’t assume accessors are fast | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

N’utilisez pas les boucles brutes

Article original : Don’t use raw loops | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Note d’intention

Sean Parent a un jour dit « No raw loops », pas de boucle brute. C’était il y a huit ans.

Aujourd’hui, si vous n’avez pas un bagage C++ extrêmement fort, vous n’avez sans doute jamais entendu cette maxime (ni même entendu parler de Sean Parent). cela fait qu’aujourd’hui, en 2021, beaucoup de projets C++ utilisent des tonnes de boucles brutes et pratiquement aucun algorithme.

Cet article est destiné à vous, les développeuses et développeurs qui utilisez des boucles brutes en 2021, et va expliquer pourquoi et comment utiliser des algorithmes à la place.

Ressources

Ce sujet a été couvert par nombre d’experts ces dernières années. Si vous vous sentez d’attaque pour du contenu très technique et en anglais, suivez les liens suivants :

L’exposé de Sean Parent où il parle (en autres) des boucles brutes : C++ Seasoning | GoingNative 2013 | Channel 9 (msdn.com)

Un exposé de Jason Turner qui parle de « code smells » (ce qui inclue les boucles brutes) :
C++ Code Smells – Jason Turner – CppCon 2019 – YouTube

L’exposé de Jonathan Boccara à propos des algorithmes de la STL : CppCon 2018: Jonathan Boccara “105 STL Algorithms in Less Than an Hour” – YouTube

En bonus, une carte (littérale) où sont représentés les algorithmes de la STL :
The World Map of C++ STL Algorithms – Fluent C++ (fluentcpp.com)

Les boucles brutes : c’est quoi ?

Ce que j’appelle boucles brutes ou dans leur version originale raw loops sont les boucles telles que vous les imaginez : les mots-clés forwhile ou encore do while rattachés à des blocs de code.

Les boucles brutes sont opposées aux algorithmes, qui sont des boucles (ou pas ?), encapsulées dans des fonctions. Vous appelez ensuite ces fonctions avec tels ou tels arguments en fonction de l’usage que vous voulez faire de cet algorithme.

Pourquoi vous ne devriez pas utiliser les boucles brutes

C’est une question de sémantique. Une boucle brute n’exprime pas un but, mais une manière technique de procéder. Elles expriment comment votre algorithme fonctionne.

Quand vous appelez un algorithme, vous décrivez une intention, vous écrivez ce que vous voulez obtenir.

Par exemple, jetons un œil à ce morceau de code :

//...
 
for (size_t i = 0; i < my_vect.size() && predicate(my_vect, i) ; ++i)
{
    //...
}
 
//...

Cela vous indique que le programme effectue une boucle, indexée sur un vector et contrôlée par un prédicat personnalisé. Mais ça ne dit pas ce que la boucle réalise et quel est le résultat attendu : vous devez plonger dans le corps de la boucle pour le savoir.

Regardons maintenant ce morceau de code :

//...
 
auto it_found = std::find_if(cbegin(my_vect), cend(my_vect), predicate);
 
//...

Même si vous ne savez pas comment find_if() fonctionne en interne, vous comprenez aisément qu’elle revoie un élément de my_vect qui vérifie la condition predicate(). Vous ne savez pas comment ça marche, mais vous savez ce qui est renvoyé.

À partir de là, il faut prendre en compte plusieurs points :

  • Les algorithmes élèvent le niveau d’abstraction et vous aide à comprendre les intentions cachées derrière les lignes de code.
  • Une sémantique adéquate améliore la lisibilité, une meilleure lisibilité rend le code plus maintenable, un code mieux maintenable est moins sujet à des régressions.
  • Appeler un algorithme est souvent moins verbeux que le réécrire.
  • Les boucles pures sont sujette à des erreurs assez communes, comme les dépassements-de-un, les boucles vides, la complexité naïve, etc.

Que faire quand il n’existe pas d’algorithme adapté ?

Il arrive parfois qu’aucun algorithme existant ne corresponde parfaitement à votre besoin. Dans ce cas-là, que faire ?

Combiner des algorithmes pour en faire un nouveau

Souvent, votre algorithme spécifique est simplement une combinaison de deux algorithmes existants ou une spécification d’un algorithme déjà existant. Dans ce cas, il suffit de l’implémenter dans une fonction dédiée, en prenant bien soin de lui donner un nom clair et explicite.

Par exemple : vous devez vérifier que, dans un vector, tous les éléments qui remplissent la condition A remplissent aussi la condition B. Pour faire cela, vous pouvez utiliser l’algorithme std::all_of() avec un prédicat personnalisé :

template< typename Iterator, typename PredA, typename PredB >
bool both_or_none( Iterator first, Iterator last, PredA & predA, PredB & predB )
{
    auto pred = [&predA,&predB](const auto& elt)
    {
        return predA(elt) == predB(elt); 
    };
    return all_of(first, last, pred);
}

Le corps de cet algorithme est assez court : il crée une fonction combine nos deux prédicats pour implémenter notre condition spécifique, puis applique l’algorithme std::all_of(), qui vérifie que cette condition est vraie pour tous les éléments de la collection.

Enrober une boucle pure dans une fonction

Si vraiment vous n’avez aucun moyen de combiner les algorithmes qui ne soit pas trop forcé ou qui ne fasse pas artificiel, vous n’avez plus qu’à implémenter votre boucle brute en l’encapsulant dans une fonction dédiée.

Ainsi, vous aurez implémenté votre propre algorithme, qui pourra être appelé par quiconque en a besoin. N’oubliez pas de lui donner un nom clair et explicite.

Par exemple: vous avez une collection et avez besoin de savoir, parmi tous les éléments qui respectent une condition donnée, lequel est le plus grand d’entre eux. En somme, cela correspondrait à l’algorithme max_if() s’il existait.

Ce comportement est compliqué à implémenter en n’utilisant que la STL, car il faudrait réussir à détacher le sous-ensemble de la collection qui valide la condition pour pouvoir lui appliquer l’algorithme std::max() derrière. Hors, le seul algorithme permettant de faire cela est std::copy_if(), qui copie les éléments. Or, les copies peuvent être coûteuses, donc on ne veut pas faire ça.

Que faire alors ? Écrivons une boucle qui implémente ce max_if() nous-même, en l’encapsulant correctement :

template< typename Iterator, typename Pred >
constexpr Iterator max_if( Iterator first, Iterator last, Pred & pred )
{
    Iterator max_element = last;
    for (auto it = first ; it != last ; ++it)
    {
        if (pred(*it) && (max_element == last || *it > *max_element))
            max_element = it;
    }
    return max_element;
}

Dans le reste du programme, l’utilisation de max_if() sera sémantiquement explicite, avec tous les avantages qu’apportent les algorithmes.

Quelques exemples d’algorithmes de la STL

Il y a beaucoup d’algorithmes dans la STL. Je vous suggère d’être curieux(se) et de l’explorer par vous-même : Algorithms library – cppreference.com

En tant que mise en bouche, voici une petite liste d’algorithme que, si vous ne les connaissez pas déjà, vous devriez apprendre à connaître :

  • std::find() : Recherche un élément égal à une valeur donnée.
  • std::find_if() : Recherche un élément qui vérifie une condition donnée.
  • std::for_each() : Applique une fonction donnée à tous les éléments de la collection.
  • std::transform() : Applique une fonction donnée à tous les éléments de la collection et stocke le résultat dans une autre collection.
  • std::all_of() : Vérifie si tous les éléments de la collection vérifient un prédicat donné.
  • std::any_of() : Vérifie si au moins un élément de la collection vérifient un prédicat donné.
  • std::copy_if() : Copie les éléments de la collection s’ils vérifient une condition donnée.
  • std::remove_if() : Enlève le premier élément de la liste qui vérifie une condition donnée.
  • std::reverse() : Inverse l’ordre des éléments dans la collection.
  • Et bien plus…

Si vous voulez aller plus loin, il y a une présentation d’une heure qui présente plus de cent algorithmes de la STL : CppCon 2018: Jonathan Boccara “105 STL Algorithms in Less Than an Hour” – YouTube

En conclusion

Beaucoup d’experts en C++ sont d’accord pour dire que les boucles sont vouées à disparaître dans les plus hautes couches d’abstraction, et ne seront utilisées que pour les algorithme de plus bas niveau. Cette déclaration n’est pas absolue, mais un but à poursuivre, un idéal à garder en tête quand on code.

Si comme beaucoup de développeur(se)s C++ vous avez tendance à utiliser des boucles brutes au lieu d’algorithmes, vous devriez aller faire un tour dans les ressources que j’ai mentionnées au début de l’article. Comme vous vous familiariserez avec eux et les utiliserez de plus en pratique, vous les trouverez de plus en plus commodes.

Merci pour votre attention et à la semaine prochaine !

Article original : Don’t use raw loops | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Une liste de mauvaises pratiques couramment rencontrées dans le développement de logiciels industriels

Article original : A list of bad practices commonly seen in industrial projects | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Sur beaucoup de projets C++ industriel, il est très courant de voir un nombre conséquents de développeurs partager une même codebase.

Aujourd’hui, une grande majorité des développeurs C++ ne sont pas des experts du langage. Il y en a beaucoup qui sont issus d’autres langages avec d’autres pratiques, des gens qui ont seulement appris le C++ à l’école et qui ne sont pas spécialement intéressés par les mécaniques spécifiques au langage et qui ont des spécialités de plus haut niveau, ou encore des développeurs issus des temps anciens qui codent encore en « C avec des classes ».

L’idée de cet article est de lister une série (non-exhaustive) de mauvaises pratiques plutôt répandues afin que chacun, que que soit son niveau, puisse contribuer à une codebase plus saine et plus maintenable.

Mauvaise pratique : les fonctions trop longues

Je ne suis pas homme (ni femme) à imposer des limites strictes pour la longueur des fonctions. Cependant, quand on commence à avoir des fonctions qui font plusieurs milliers, voire plusieurs dizaines de milliers de lignes de code, il est nécessaire de mettre le holà.

Une fonction (comme tout bloc) est une unité architecturale du code. Plus elle est longue, plus elle est difficile à assimiler dans son entièreté. Si elle est divisée en unités plus petites, chaque unité est individuellement plus aisée à comprendre car l’esprit est tour-à-tour focalisé sur des problèmes de plus petite taille. Il suffit ensuite pour lui de les assembler pour comprendre la fonction dans sa globalité.

En d’autre terme, pour comprendre une grosse problématique, il est plus simple de la diviser en problématiques plus petites.

Ainsi, vos fonctions serviront parfois juste à appeler des fonctions auxiliaires en succession, ce qui est pratique car cela permet d’avoir le cheminement fonctionnel de la fonction sans avoir à se plonger dans le détail des implémentations.

La limite que je me fixe personnellement tourne autour de 200 lignes par fonction (parfois plus, parfois moins).

Mauvaise pratique : créer des classes là où il n’y en a pas besoin.

C’est quelque chose qui est surprenamment assez commun, de ce que j’ai pu en voir, probablement dû à des pratiques importées d’autres langages où la notion de classes est perçue différemment.

Il y a deux manières sous lesquelles cette mauvaise pratique peut survenir :

Les classes complètement statiques (avec parfois des constructeurs)

Il est plus simple d’illustrer cela avec un exemple, alors allons-y :

class MyMath
{
public:
    MyMath();
    ~MyMath();
    static int square(int i);
};
 
MyMath::MyMath()
{
}
 
MyMath::~MyMath()
{
}
 
int MyMath::square(int i)
{
    return i*i;
}
 
int main()
{
    MyMath mm;
    int j = mm.square(3);
    return j;
}

Voici les points problématiques avec ce code :

  • Pourquoi implémenterait-on un constructeur et un destructeur vide, alors que ceux par défaut feraient très bien l’affaire ?
  • Pourquoi implémenterait-on un constructeur et un destructeur dans une classe entièrement statique ?
  • Pourquoi créerait-in une instance de cette classe juste pour appeler la méthode statique ?
  • Pourquoi créerait-on une classe pour faire cela alors qu’un namespace ferait complètement l’affaire ?

Voici à quoi ce code devrait plutôt ressembler :

namespace MyMath
{
    int square(int i);
};
 
int MyMath::square(int i)
{
    return i*i;
}
 
int main()
{
    int j = MyMath::square(3);
    return j;
}

Plus concis, plus propre, meilleur sous tout rapport.

Certes, parfois les classes complètement statiques peuvent être utiles, mais dans les situations similaires à cette exemple, elles ne le sont pas.

Il n’y a aucun intérêt à utiliser une classe alors qu’on pourrait ne pas le faire. Si vous pensez que le namespace pourrait avoir besoin d’être transformé en classe dans le futur (avec des attributs et des méthodes), souvenez vous juste de ce petit dicton :

Ne codez jamais en prévision d’un futur hypothétique qui pourrait ou ne pourrait pas se réaliser. Le temps que vous passez à être trop prévoyant pour le futur est du temps perdu. Vous pourrez toujours refactorer plus tard au besoin.

Les classes complètement transparentes

J’ai mis ce sujet en second car il est beaucoup plus sujet à controverse que le premier.

Juste pour être clair : la seule différence entre une class et une struct, c’est que les membres d’une class sont privés par défaut, alors que les membres d’une struct sont publiques par défaut. C’est la seule différence.

Du coup, si votre classe :

  • … n’a que des méthode publiques
  • … a des accesseurs (get/set) pour tous ses attributs
  • … n’a que des accesseur simplistes

… alors ce n’est pas une classe, c’est une structure.

Voici un exemple d’illustration :

class MyClass
{
    int m_foo;
    int m_bar;
 
public:
    int addAll();
    int getFoo() const;
    void setFoo(int foo);
    int getBar() const;
    void setBar(int bar);
};
 
int MyClass::addAll()
{ 
    return m_foo + m_bar;
}
int MyClass::getFoo() const
{
    return m_foo;
}
void MyClass::setFoo(int foo)
{
    m_foo = foo;
}
int MyClass::getBar() const
{
    return m_bar;
}
void MyClass::setBar(int bar)
{
    m_bar = bar;
}

Ce code peut être écrit beaucoup plus simplement avec une structure :

struct MyClass
{
    int foo;
    int bar;
 
    int addAll();
};
 
int MyClass::addAll()
{ 
    return foo + bar;
}

Ces deux codes sont assez équivalent, la seule réelle différence étant que la classe fournit un degré d’encapsulation supplémentaires (et inutile).

La controverse vient du caractère « inutile » que je viens de mentionner. En effet, dans une mentalité complètement orienté-objet, aucune encapsulation n’est inutile. De mon humble avis, ce genre de structure ne nécessite pas d’encapsulation car elle représente un ensemble de données stockées telles quelles, et je pense qu’il est contre-productif d’essayer de trop encapsuler le code de manière générale.

Attention cependant, car la pratique que je décrit ici n’est valide que si tout les attributs sont en accès direct à la fois en lecture et en écriture. Si vous avez des accesseurs spécifiques ou des attributs en lecture-seule ou écriture-seule, n’utilisez pas une struct (enfin, vous pouvez mais il faudra que vous y réfléchissiez sérieusement).

Mauvaise pratique : implémenter un comportement indéfini

Les undefined behavior, aussi appelés UB ou encore « comportements indéfinis » sont, pour faire court, un contrat que le développeur passe avec le compilateur, promettant à celui-ci qu’il n’implémentera pas certains comportements. Cela permet au compilateur de faire des hypothèses en prenant cela en compte et de faire des optimisations en conséquence.

Allez voir la conférence de Piotr Padlewski si vous voulez plus de détails à ce propos : CppCon 2017: Piotr Padlewski “Undefined Behaviour is awesome!” – YouTube.

Voici une liste non-exhaustive des UB que vous devez absolument connaître pour éviter d’avoir des comportements indéfinis à votre insu dans votre codebase :

  • Appeler la fonction main()
  • Le dépassement d’entier
  • Le dépassement de buffer
  • Utiliser des variables non-initialisées
  • Déréférencer nullptr
  • Omettre l’instruction return
  • Nommer une variable en commençant avec deux underscores __
  • Définir une fonction dans le namespace std
  • Spécialiser un type qui n’a pas été défini par l’utilisateur dans le namespace std
  • Prendre l’adresse d’une fonction de la std

En conséquence, et que ce soit dit une bonne fois pour toute : n’utilisez jamais, au grand jamais le dépassement d’entier comme condition de sortie d’une boucle ou pour toute autre raison. Parce que cela ne fonctionne pas comme vous le pensez et qu’un jour ça va se retourner contre vous.

Mauvaise pratique : comparer un entier signé avec un entier non-signé

Quand vous comparez un entier signé avec un non-signé, il y aura forcement une conversion arithmétique des types qui a une chance non-négligeable de fausser les valeurs et rendre caduque la comparaison.

Utilisez le type size_t quand c’est pertinent, et appliquez une static_cast sur vos données quand c’est nécessaire.

Mauvaise pratique : essayer d’optimiser le code tout en l’écrivant

Oui, c’est peut-être dur à avaler, mais voici deux faits :

  • 80% du temps (voire même plus), le code que vous écrivez n’a pas besoin d’être optimisé. Puisque la plupart du temps d’exécution su programme survient dans 20% du code (principe de Pareto à l’œuvre), les 80% restants n’ont pas besoin d’être optimisés.
  • L’optimisation ne doit pas être une préoccupation a priori. Ce que vous devez faire est : écrire votre code, prendre du recul et optimiser en conséquence.

La chose la plus importante à faire n’est pas d’optimiser votre programme, mais de vous arranger avant tout pour que votre code soit concis, élégant et maintenable. Si il l’est, vous pourrez toujours revenir plus tard pour optimiser si vous en avez effectivement besoin.

Mauvaise pratique : ne pas assez réfléchir

Certes, vous ne devez pas spécialement chercher à optimiser en écrivant du code, mais vous ne devez pas non plus tomber dans l’extrême inverse, c’est à dire sous-optimiser votre code à mesure que vous l’écrivez.

Connaissez les spécificités des algorithmes que vous utilisez, soyez conscients des buts des différentes structures de données et à quels besoins elles répondent, connaissez vos design pattern et soyez capables de les implémentez, et n’hésitez surtout pas à lire la doc des features que vous comptez utiliser.

Il y a un bon équilibre à atteindre entre coder sans réfléchir et la sur-optimisation.

Mauvaise pratique : « il faut faire ça parce qu’on en aura besoin plus tard… »

Je l’ai déjà dit et je le répète : ne codez pas en fonction d’un futur hypothétique, car tout peut changer au cours du développement. La seule chose qui est réellement inamovible, c’est le principe fondamental de votre programme, et rien de plus.

Vos besoins peuvent changer, vos specs peuvent changer, ce que le client demande peut changer, vos procédures peuvent changer, tout ce qui n’est pas le principe de base de votre programme peut changer. Parfois ça ne changera pas, mais souvent ces notions vont evoluer.

Si vous êtes en proie au doute, demandez-vous simplement :

Si j’implémente ça plus tard, est-ce que ça sera plus coûteux ?

Souvent, la réponse sera « non » ou « pas tant que ça ». Dans ce cas, il vaut mieux laisser le futur au futur.

En conclusion…

Là, vous avez un bon point de départ pour écrire du code élégant et surtout maintenable. Si tous les développeurs de votre projet appliquent ces règles, cela facilitera la vie de tout le monde.

Cependant il y a beaucoup d’autres mauvaises pratiques qui existent et ne sont pas listées ici. Peut-être publierai-je un autre article parlant de cela, peut-être pas.

Merci de votre attention et à la semaine prochaine !

Article original : A list of bad practices commonly seen in industrial projects | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Encore un pamphlet sur `inline`

Article original : Yet another pamphlet about inlining | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Introduction

Le sujet de cette semaine sera le mot-clé inline.

C’est un sujet de longue date, qui pour moi est clos depuis longtemps. À chaque fois que je le rouvre pour me re-documenter à son sujet, j’en arrive aux mêmes conclusions.

Cela dit, je lis très souvent du code qui utilise mal ce mot-clé, ce qui finit toujours par des situations dangereuses et contre-productives.

Je vais essayer ici de résumer tout ce qu’il faut savoir à propos de inline, les avantages, les inconvénients et lister les situations où il faut ou ne faut pas l’utiliser.

Définition

Je vais commencer par une définition académique donnée par inline specifier – cppreference.com et traduite par mes soins:

Le but originel du mot-clé inline était de servir d’indicateur pour l’optimiseur que la substitution du corps de la fonction était préférable à son appel, c’est-à-dire au lieu d’exécuter l’instruction CPU d’appel de la fonction pour transférer le contrôle au corps de la fonction, une copie de ce corps de fonction est créé par l’appel de fonction sans générer effectivement d’appel. Cela permet d’éviter les instructions supplémentaires créées par l’appel de fonction (passer les argument et retourner le résultat) mais peut avoir pour conséquences un plus gros exécutable, vu que le corps de la fonction sera copié plusieurs fois.

Étant donné que ce mot-clé n’implique pas de contrainte forte, les compilateurs sont libres de substituer le corps de la fonction là où le mot-clé inline n’est pas précisé et de générer des appels de fonction là où le mot-clé inline est précisé. Ces choix d’optimisation ne changent pas les règles à propos des multiples définitions.

Il faut retenir deux choses de cette définition :

  • Le mot-clé inline permet d’éviter des instructions assembleur inutiles, remplaçant l’appel d’une fonction par directement le code qu’elle implémente.
  • Il s’agit simplement d’une indication donnée au compilateur, qu’il est libre de respecter ou non.

Avec cela en tête, faisons un petit tour des avantages et des inconvénients.

Avantages

Tout d’abord, gardons en tête que même s’il ne s’agit que d’une indication, le compilateur peut effectivement la suivre. Il a le choix de le faire, mais c’est une possibilité plausible. Il suffit d’imaginer le compilateur le moins intelligent du monde, qui prendrait à la lettre les indications de inline. Il suit parfaitement le standard (selon le standard C++11 §12.1.5) et le mot-clé est utile dans ce cas.

Selon l’article What is C++ inline functions – C++ Articles (cplusplus.com)2, les avantages sont :

  1. Cela accélère le programme en évitant les instructions d’appel de fonction.
  2. Cela économise de l’espace sur la stack car cela ne push/pull pas les paramètres de fonction.
  3. Cela économise l’instruction de retour de la fonction.
  4. Cela créé de la localité de référence, en utilisant le cache d’instruction.
  5. En la marquant comme inline, vous pouvez mettre la définition d’une fonction dans un header (i.e. cela pourra être inclus dans plusieurs unités de compilation sans déranger le linker).

Les points 1, 2 et 3 sont globalement les principaux bénéfices de cette fonctionnalité et le but originel de ce mot-clé. Pour ceux qui connaissent un peu l’assembleur, cela évite notamment de pousser les paramètre de la fonction sur la pile, ce qui coûte de nombreuses instructions.

Le point 4 semble être un effet de bord non négligeable, mais comme le cache d’instruction est loin d’être ma spécialité, je ne m’épancherai pas sur ce sujet.

Le point 5 n’est un avantage que dans certaines situations spécifique, mais un avantage malgré tout.

Désavantages

Selon l’article What is C++ inline functions – C++ Articles (cplusplus.com)2, les désavantages sont :

  1. Cela augmente la taille de l’exécutable.
  2. L’inlining est résolu à la compilation. Cela signifie que si vous changez le code de la fonction inlinée, vous devrez re-compiler tous le code utilisant cette fonction.
  3. Ça augmente la taille de votre header avec des informations qui ne sont pas utiles pour les utilisateurs.
  4. Comme mentionné ci-dessus, cela augmente la taille de l’exécutable, ce qui peut augmenter les fautes de pagination en mémoire, et de ce fait diminuer les performances de votre programme.
  5. Cela peut être dérangeant dans les projets ayant des contraintes de mémoire, comme les projets embarqués.

Les point 1 et 4, qui sont méconnus parmi les développeurs, sont la raison principale pour laquelle le mot-clé inline peut diminuer les performances de votre programme. Il est important de s’en souvenir quand on l’utilise.

Le point 2 peut être un inconvénient majeur, en fonction de la nature de votre projet, mais n’arrive pas excessivement souvent selon mon expérience.

Le point 3 est, d’après moi, le plus gros désavantage de cette fonctionnalité. Pour avoir du code maintenable, vous vous devez d’être clair et organisé. inline est un point noir vis-à-vis de cette notion.

Le point 5 concerne des projets spécifiques, aussi je ne m’étendrai pas dessus. Mais gardez en tête que si vous avez des contraintes de mémoire, inline peut avoir des conséquences.

Conclusion : quand faut-il utiliser inline ?

Éviter les instructions d’appel de fonction en inlinant votre code est seulement utile dans les sections critiques de votre programme.

N’oubliez pas la loi de Paretos : « 80% de l’exécution ne se déroule que dans 20% du code. ». Cela signifie que votre programme passe la plupart de son temps dans des goulots d’étranglement. De fait, si vous inlinez du code qui n’est pas dans un goulot, cela aura peu voire pas d’effet sur vos performances, tout en augmentant la taille de l’exécutable et rendant votre code moins lisible.

Ce qui m’a poussé à écrire cet article est le fait que durant ma vie de codeur, un bon 95% des inline que j’ai pu voir sur des projets industriels étaient utilisé dans du code non-critique.

Il n’y a absolument aucun intérêt à réduire la lisibilité du code dans ce but.

Voici mon conseil : n’utilisez pas inline à moins que vous soyez sûr(e) à 100% qu’il sera appelé dans un goulot d’étranglement.

C’est le seul moyen d’avoir du code à la fois propre et efficace.

Une autre manière d’inliner à éviter

Je vais terminer cet article en disant un mot à propos d’une pratique à particulièrement éviter.

Il s’agit des fonctions implémentées de la manière suivante :

inline void setValue(int i) { m_value = i; }

Cette pratique empêche certains debuggers de faire correctement leur travail.

Par exemple, sous VisualStudio, su vous mettez un point d’arrêt dans cette fonction et qu’il percute, le debugger ne sera pas capable de vous donner la valeur de m_value.

Alors s’il vous plaît, ne faites pas ça. Cela ne coûte pas grand chose de rajouter un ou deux retours à la ligne.

Sur ce, je vous dis à la semaine prochaine !

Article original : Yet another pamphlet about inlining | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Passer un enum par paramètre : mauvaise pratique ?

Article original : Passing an enum by parameter | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Mettons que vous ayez une fonction qui prend deux paramètres :

  • Le premier est un enum décrivant un « mode d’utilisation » de la fonction.
  • Le second est une valeur numéraire servant de paramètre de calcul.

En l’occurence, prenons l’exemple suivant :

#include <iostream>
 
// Enum describing the mode used in the following function
enum class ChangeMode
{
    Before,
    After
};
 
// Function that squares a value and increases it (wether before or after), then prints the result
void increase_and_square(const ChangeMode& m, int v)
{
    if (m == ChangeMode::Before)
        ++v;
     
    v = v*v;
     
    if (m == ChangeMode::After)
        ++v;
 
    std::cout << v << std::endl; 
}
 
// main function
int main()
{
    const int a = 42;
    increase_and_square(ChangeMode::Before, a);
    increase_and_square(ChangeMode::After, a);
}

Ici, la fonction increase_and_square() fonctionne légèrement différemment selon la valeur du « mode ».
C’est un comportement plutôt commun qui peut survenir sous de nombreuses formes différentes.

Néanmoins, cette implémentation-ci n’est pas la plus optimisée. Une meilleure façon d’obtenir ce comportement est d’utiliser un template sur l’enum ChangeMode plutôt que de le passer en paramètre :

Comme ceci :

#include <iostream>
 
// Enum describing the mode used in the following function
enum class ChangeMode
{
    Before,
    After
};
 
// Function that squares a value and increases it (wether before or after), then prints the result
template<ChangeMode m>
void increase_and_square(int v)
{
    if (m == ChangeMode::Before)
        ++v;
     
    v = v*v;
     
    if (m == ChangeMode::After)
        ++v;
 
    std::cout << v << std::endl; 
}
 
// main function
int main()
{
    const int a = 42;
    increase_and_square<ChangeMode::Before>(a);
    increase_and_square<ChangeMode::After>(a);
}

Cette implémentation est meilleure sous tous les angles :

  • Le nombre d’opération effectuées à la compilation : bien sûr, on y retrouve le principal avantage des templates qui est d’évaluer des expressions à la compilation, ce qui économise du temps d’exécution.
  • Optimisations du compilateur : comme on effectue plus d’opération à la compilation, le compilateur sera plus à même d’optimiser le code, ce qui dans les fait lui permettra de se départir des conditionnelles if.
  • Taille de l’exécutable : plus surprenamment, l’exécutable du code templaté est plus petit que l’autre. C’est parce que le fait de supprimer les if permet de grandement réduire la taille des fonctions.

Pour illustrer cela, voici l’instanciation des tempates que le compilateur génère :

void increase_and_square<ChangeMode::Before>(int v)
{
    ++v;
    v = v*v;
    std::cout << v << std::endl; 
}
 
void increase_and_square<ChangeMode::After>(int v)
{
    v = v*v;
    ++v;
    std::cout << v << std::endl; 
}

Elles sont bien plus simples que la grosse fonction qui contient les deux if.

Si vous n’êtes toujours pas convaincu, voici le nombre d’instructions assembleur générées par le compilateur dans les deux cas (en utilisant godbolt.org avec le compilateur clang est le options de compilation --std=c++20 -O3) :

  • Avec paramètre : 123 instructions
  • Avec template : 76 instructions

La version templatées est plus concise, plus rapide et plus belle.

Avantage : avoir deux valeurs par défaut disjointes

Utiliser un template nous offre un autre avantage : la capacité de préciser une valeur par défaut à la fois pour le mode et pour le paramètre, et ce de manière disjointe.

On peut ainsi écrire cela :

#include <iostream>
 
// Enum describing the mode used in the following function
enum class ChangeMode
{
    Before,
    After
};
 
// Function that squares a value and increases it (wether before or after), then prints the result
template<ChangeMode m=ChangeMode::Before>
void increase_and_square(int v = 2)
{
    if (m == ChangeMode::Before)
        ++v;
     
    v = v*v;
     
    if (m == ChangeMode::After)
        ++v;
 
    std::cout << v << std::endl; 
}
 
int main()
{
    const int a = 42;
    increase_and_square(a);
    increase_and_square<ChangeMode::After>();
    increase_and_square();
}

Cette notation est intéressant car le mode et le paramètre ont en réalité une signification sémantique différente, cela a donc du sens qu’elles aient un comportement disjoint comme celui-là.

Limitations

Au final, pourquoi tout ça marche aussi bien ? Tout simplement parce que le « mode » est un enum avec peu de valeurs possibles.

Si jamais nous avions un plus gros enum (comme par exemple avec cinq valeurs ou plus) ou un autre type au lieu d’un enum, alors la version templatée serait bien pire que la version paramétrée.

De manière générale, la pratique décrite dans cet article ne vous sera pas très utile, mais dans le cas spécifique où vous avez un « mode d’utilisation », alors prenez le temps de réfléchir à quelle est la meilleure pratique à suivre.

À propos de sémantique

Je concluerai cet article en parlant un peu de sémantique.

Ce qu’on a accompli ici pourrait être considéré comme une surcharge de la fonction increase_and_square(). En effet, on a réalisé plusieurs implémentations de cette fonction (enfin, le template l’a réalisé pour nous) qui décrit un comportement similaire même si légèrement différent. C’est exactement l’utilité des surcharges d’opérations.

C’est aussi pour ça que l’enum est décrit comme un « mode » : il sert plus à décrire comment la fonction fonctionne que ce n’est un véritable paramètre.

Merci de votre attention et à la semaine prochaine !

Article original : Passing an enum by parameter | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre