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.

Laisser un commentaire