C++ Moderne - Le passage d'objets par argument

Beaucoup de programmeurs C++ pensent qu'il faut passer les objets par référence constante, mais il est possible de faire mieux.

C++ Moderne - Le passage d'objets par argument

Quand tu passes un objet en paramètre à une fonction, il faut le passer par référence constante pour éviter la copie !

No

Depuis les débuts du C++, il est recommandé de passer les types primitifs (booléens, entiers, flottants, pointeurs) par valeur (aussi incorrectement appelé "par copie"), et les objets (classes/structures) par référence constante.

Pour mettre tout le monde d'accord :

Ceci est un passage de paramètre par valeur :

void func(T param);

Et ceci est un passage de paramètre par référence constante :

void func(const T& param);
void func(T const& param); // équivalent

T représente un type (comme int ou std::string).

L'immense majorité des cours de C++ vous diront qu'il faut passer le type par référence constante lorsqu'il s'agit d'un objet, pour empêcher la copie inutile.

Et ils avaient raison... jusqu'en 2011.

En effet, en septembre 2011 la norme C++ 2011 (communément appelée C++11) est sortie, et celle-ci a bouleversé le monde des développeurs C++, au point de séparer le langage en deux morceaux : d'un côté le vieux C++ (pré-2011) et d'un autre, le C++ moderne (2011+)[1].

Une des raisons expliquant cela est l'apparition du mouvement.
Pour expliquer simplement cette notion, il est possible depuis cette fameuse norme de faire ceci:

std::string a = "Hello world";
std::string b = std::move(a); // Je déplace le contenu de a vers b

std::cout << b << std::endl; // Hello world
std::cout << a << std::endl; // état indéfini mais destructible (très souvent : rien du tout)

Ce qu'il s'est passé ici est simple, le contenu de la variable a a été transféré à la variable b, aucune copie de chaîne de caractère n'a été effectuée.
Notre objet a que nous venons de vampiriser se retrouve dans un état indéfini mais valide, et par conséquent destructible, ce qui nous arrange bien car c'est le destin de la majorité des objets après un mouvement.

Le fameux mouvement du C++, souvent symbolisé par std::move, consiste simplement en un vol de contenu[2].
Un des points les moins compris au sujet du mouvement est que std::move ne fait absolument rien ici en terme d'exécution, ce n'est pas cette fonction qui effectue le mouvement.

En réalité, le mouvement est bien incorporé au langage et std::move ne fait rien d'autre que l'autoriser, j'insiste bien sur ce point, fondamentalement cette fonction ne fait rien d'autre qu'un cast, c'est-à-dire qu'elle change le type de la variable en entrée, ici un std::string&, vers un type autorisant le mouvement (on parle de rvalue[3]), ici std::string&&.

Derrière cette notation étrange se cache juste une référence autorisant le mouvement.

Le mouvement en lui-même se produit au moment de la construction ou affectation d'un objet.
Il y a beaucoup de choses à dire sur le mouvement, et il me faudrait faire un autre billet pour correctement en parler, donc revenons à nos moutons.

Concernant les paramètres de fonctions, il y en a trois catégories :

  • Les paramètres In[4]
  • Les paramètres Out[5]
  • Les paramètres Inout[6]

Je reviendrais sur les deux dernières catégories dans un autre billet, celles-ci n'ayant pas été affectées par la norme C++11.
En revanche, pour la catégorie In les choses ont bien changées, car si dans le vieux C++ la règle était :

  • Si type primitif : passage par valeur.
  • Sinon : passage par référence constante.

En C++ moderne, la règle est maintenant :

  • Si type primitif : passage par valeur. (pas de changement)
  • Sinon, si la fonction s'approprie l'objet : passage par valeur + mouvement.
  • Sinon, si la fonction ne s'approprie pas l'objet donc : passage par référence constante.

En code, cela s'exprime comme ceci :

  • Appropriation de l'objet :
void Player::UpdateName(std::string name)
{
    m_name = std::move(name);
}

  • Pas d'appropriation de l'objet :
void Arena::KickPlayer(const std::string& name)
{
    std::find(m_players.begin(), m_players.end(), name)->Kick();
}

[7]

Les plus sceptiques me diront "mais ça ne change rien ton truc, vu qu'on passe le paramètre par copie, il sera de toute façon copié".
Et c'est précisément pour cette raison qu'on ne doit pas parler de passage d'argument par copie mais bien par valeur, car ce sont bien deux choses différentes.

Passer un objet par valeur signifie qu'un nouvel objet sera créé (d'une durée de vie temporaire), mais ne dit rien d'autre sur le contenu de l'objet.
Cela signifie, comme on l'a vu plus haut, que cet objet temporaire peut prendre son contenu d'un autre endroit, à l'aide du mouvement.

Pour illustrer, prenons plusieurs cas d'utilisation de la fonction UpdateName plus haut :

std::string name = ReadDbResult();

player.UpdateName(name);

L'opération est la suivante, je récupère un nom depuis la base de donnée et je l'affecte au joueur.

J'ai donc, une copie de la variable name au moment de l'appel de la fonction.

Cependant, comme je ne vais plus utiliser le nom à partir de là, j'ai la possibilité de le passer par mouvement (à l'aide de std::move), autorisant le paramètre temporaire de ma fonction de voler la valeur de mon std::string pour se construire.

std::string name = ReadDbResult();

player.UpdateName(std::move(name)); //< name n'est plus utilisé, je déplace son contenu

Nombre de copie : 0.

Autre exemple :

player.UpdateName(ReadDbResult());

Ce code est fonctionnellement identique au précédent, car le retour d'une fonction est implicitement déplaçable (movable), n'ayant pas de nom (il s'agit d'une rvalue[3:1] par nature).

Nombre de copie : 0.

Dernier exemple montrant l'intérêt de cette méthode :

player.UpdateName("SirLynixVanFriejtes");

Ici un objet temporaire est créé avec la chaîne littérale et son contenu sera attribué à la variable membre m_name.
Avec une référence constante, il nous serait impossible de déplacer le contenu de notre objet temporaire, obligeant une copie.

Nombre de copie : toujours 0.

En revanche ici :

std::string name = ReadDbResult();

player.UpdateName(name);

arena.KickPlayer(name); // Autre opération sur name

Ici nous avons un cas où la copie semble inévitable, car nous avons besoin de notre variable après la fonction l'utilisant, notre paramètre temporaire fera donc une copie qui sera déplacée (move) vers m_name.
Nous n'avons donc pas d'avantage dans ce cas d'utilisation par rapport à une référence constante, mais pas non plus de désavantage.

Mais s'il est vrai que parfois la copie est inévitable, elle ne l'est pas dans ce cas-ci si notre joueur dispose d'une méthode capable de retourner son nom[8] (ce dont il dispose vraisemblablement) :

const std::string& Player::GetName() const

À ce moment-là, nous pouvons être malins et adapter notre code pour éviter la copie :

std::string name = ReadDbResult();

player.UpdateName(std::move(name));

arena.KickPlayer(player.GetName());

Nous déplaçons donc la propriété de notre chaîne de caractère vers le joueur, et nous demandons ensuite à celui-ci de nous donner accès en lecture à cette même chaîne de caractère.
Comme KickPlayer n'a pas besoin de s'approprier le nom pour effectuer son job, celui-ci prendra également une référence constante.[9]

Nombre de copie : zéro !

J'espère que vous avez maintenant compris l'intérêt de passer vos paramètres par valeur quand c'est pertinent (lorsque la fonction s'approprie le contenu de l'objet), mais n'oubliez pas de continuer à les passer par référence constante dans les autres cas. ;)

Voilà qui conclut ce premier billet sur le C++ moderne, n'hésitez pas à demander des éclaircissement dans les commentaires, ou à demander un autre thème concernant le C++ moderne.

Je vous laisse quelques liens pour vous aider à mieux appréhender le mouvement:


  1. À l'heure où j'écris ces lignes, deux nouvelles normes sont égalements sorties (C++14 et C++17), cependant leur impact a été mineur en comparaison de celle du C++11. ↩︎

  2. Ou changement de propriétaire du contenu. ↩︎

  3. voir http://en.cppreference.com/w/cpp/language/value_category ↩︎ ↩︎

  4. Paramètre en lecture seule (une référence constante/passage par valeur). ↩︎

  5. Paramètre en écriture seule (une référence/pointeur sur un autre objet). ↩︎

  6. Paramètre en lecture/écriture (une référence/pointeur sur un autre objet dont le contenu servira à la fonction). ↩︎

  7. Faute d'imagination, cet exemple est simplifié et n'a aucune application réelle. ↩︎

  8. Et de le retourner par référence constante, ce qui est très souvent possible et que je vous encourage à faire. ↩︎

  9. En revanche, on peut se demander l'utilité de changer le nom d'un joueur pour ensuite faire une recherche dessus pour appeler enfin la méthode Kick, mais n'hésitez pas à proposer un meilleur exemple dans les commentaires. ↩︎