Pages

jeudi 22 mai 2014

Valeur, référence ou pointeur ? (1/2)

Quand utiliser une variable par valeur, référence ou pointeur ? Telle fut la question qui m'a été posée ^^.

Comme je ne suis pas entièrement satisfait de la réponse que j'ai donné, je fais un article. Pour tout dire, la réponse n'est pas aussi triviale que l'on pourrait le croire depuis l'arrivée du C++11 et la sémantique de mouvement.

Tout d'abord, décomposons cette question en 2 parties:

  • Valeur ou référence constante ?
  • Référence ou pointeur ?

Je réponds ici à la première, la seconde fera l'objet d'un autre article.

Valeur ou référence constante ?

Le choix se justifie en majorité par un besoin d'optimisation. Une prise par valeur induit forcément une copie, cette dernière pouvant être extrêmement coûteuse. Par exemple, la copie d'un std::vector ne se fait pas en un claquement de doigt, il y a tout un attirail derrière: allouer un espace mémoire et copier tous les éléments du vecteur précédent qui peuvent eux-mêmes faire des opérations complexes.

Au contraire de la référence constante qui n'a de prix que la taille d'une référence. Celle-ci étant toujours la même quel que soit le type référencé (sizeof(intptr_t), oui, pareil qu'un pointeur).

J'insiste bien sur référence constante car pour être au plus proche de l'effet d'une copie, l'objet d'origine ne doit pas bouger. De plus, une instance constante ne peut appeler que des méthodes constantes, ce qui assure une invariance (cf: const-correctness).

Donc, référence constante pour les types qui ne sont pas modifiés dans la fonction. Une règle dit: "tout ce qui est plus grand qu'un pointeur pourrait être passé par référence constante". Je préfère dire tous les types en référence constante sauf les types fondamentaux (int, float, etc). Même si mettre une référence constante sur un int n'est pas une erreur, je n'adhère pas vraiment.

Une seule exception cependant, quand le paramètre va de toute façon être copié localement dans la fonction pour être modifié. On pourrait croire que le résultat sera le même, mais c'est être naïf.

class BigInt { ... };
operator+(const BigInt & a, const BigInt & b)
{
  BigInt ret(a);
  ret += b;
  return ret;
}

Supposons que BigInt fasse de l'allocation dynamique pour représenter les nombres. Avec ce code:

BigInt n1(1);
BigInt n2 = BigInt(2) + n1;

Il y a 3 allocations:

  • n1
  • BigInt(2)
  • ret

Alors que cette implémentation de operator+ n'en produit que 2.

operator+(BigInt a, const BigInt & b)
{
  a += b;
  return a;
}

Car il y a copie élision (c'est le même principe que la RVO mais pour les paramètres). (Voir aussi ici et la réponse de Flob90.)

Mais ça c'était avant...

Maintenant qu'il y a la sémantique de mouvement, les copies sont préférées quand une méthode recevant le paramètre va, quoi qu'il arrive, le copier dans une autre variable (variable membre par exemple). Premièrement parce que l'utilisateur pourra faire un move de sa variable pour s'en "débarrasser" car il n'en a plus besoin. Deuxièmement parce que la fonction a besoin d'une copie et le compilateur le fera pour nous.
En comparaison avec une copie sur le vecteur, le move-constructor ou le move-assignment est extrêmement rapide: 3 affections de pointeur pour chaque vecteur.

Par exemple avec cette base:

#include <utility>
#include <vector>

using vector_int_t = std::vector<int>;

class A {
  vector_int_t c;

public:
  A(vector_int_t cont)
  : c(std::move(cont))
  {}
};

Le code suivant fait 2 allocations (comme avec les références constantes)

vector_int_t c{1, 2};
A a(c);

Alors que celui-ci qu'une seule

vector_int_t c{1, 2};
A a(std::move(c));
// c.size() == 0;

Et ce dernier aussi

A(vector_int_t{1, 2});
// ou A({1 ,2});

Mais si le type ne gère pas une ressource, le constructeur de mouvement sera l'équivalent d'une copie (voir std::is_trivially_move_constructible ?) et alors une référence constante est mieux.

Quand la copie se fait sous condition

Cependant, il existe des paramètres pouvant être copiés, mais pas toujours. Dans ce cas, bien que la référence constante reste une bonne solution, une version prenant aussi une temporaire (rvalue ici) est probablement mieux. Mais si les types ne sont pas abstraits (comprendre full template) alors il faudra faire 2 versions: une avec rvalue et une avec constref. Ce qui se traduit, quand le code est un peu long, par l'ajout d'une fonction de prédicat ou une version template privée appelé par les 2 autres.

Les && sur les types full templates ont 2 états possibles: rvalue ou lvalue. (categorie de valeurs).

struct Foo {
  void foo(std::string const & s) { privfoo(s); }
  void foo(std::string && s) { privfoo(std::move(s)); }
  
private:
  template<class String>
  // ici && représente soit rvalue, soit une référence (constante)
  void privfoo(String && s) {
    // ...
    if (xyz) {
      // soit un move-assign soit un copy-assign
      str = std::forward<String>(s);
    }
  }
  
  std::string str;
};

Ou

struct Bar {
  void bar(std::string const & s) { if (check(s)) str = s; }
  void bar(std::string && s) { if (check(s)) str = std::move(s); }
  
private:
  bool check(const std::string & s) {
    // ...
    return xyz;
  }
  
  std::string str;
};

Prise d'objet non-copiable sous condition

Par exemple, donner la propriété d'un unique_ptr à une classe selon certains prérequis décidés par une méthode. Contrainte supplémentaire, l'objet n'est pas copiable.

  • Une référence constante n'est pas envisageable, l'objet ne pouvant pas être déplacé car constant.
  • Une prise par valeur non plus (grâce à move), car la ressource serait systématiquement transmise à la fonction même si cette dernière ne la garde pas. L'appelant est dans l'incapacité de le savoir et perd la ressource.
  • Une référence non-constante est possible, mais il ne sera alors pas possible d'envoyer un temporaire. Il faudra obligatoirement passer par une variable intermédiaire ce qui est désagréable quand on ne va rien en faire.
  • Reste la rvalue avec laquelle une temporaire fonctionne, mais il faudra automatiquement faire un move quand la variable est une référence. Cela a l'avantage d'informer l'utilisateur sur l'éventuel déplacement de ressource.

Au final, bien qu'une référence fonctionne, seule une rvalue est pratique à l'usage. Seulement, aucunes de ces méthodes n'indiquent une prise partielle, seule la doc nous le dira. Ceci pourrait par contre être une convention d'écriture: si une ressource non copiable est prise par rvalue alors la fonction est libre de se l'approprier quand certaines conditions internes sont remplies.

Quand les opérations ne sont pas connues

Reste la dernière situation: les templates. Les règles sont les mêmes qu'avant mais si le rôle du paramètre n'est pas défini (comprendre la fonction ne fait rien d'autre qu'envoyer le paramètre à une autre fonction ou que le qualifier importe peu) les paramètres sont à prendre par rvalue. À ce moment, toutes les utilisations de cette variable devraient se faire par l’intermédiaire de std::forward, même lorsque des méthodes de celle-ci sont utilisées (la faute au qualifier de référence sur fonction membre).
Toutefois, attention de ne pas déléguer plusieurs fois la responsabilité et de faire forward (et move) que sur la dernière utilisation de la variable (voir le premier commentaire de Florian Blanchet pour l'exemple).

Cependant, certains objets de par leur concept seront pris par valeur. Je pense aux itérateurs, prédicats et comparateurs. Le premier pour une bonne raison: son état change. Et les 2 autres sont sans état (pas de variables membres) ou ne possèdent que des références ou valeurs constantes et sont généralement construit au moment de l'appel de la fonction. Il faut aussi garder en tête qu'une fonction est libre de faire des copies des foncteurs, c'est pour cette raison que lui donner un état est problématique. Si avoir un état est le comportement voulut, alors il reste toujours std::reference_wrapper, std::ref et std::cref. (Note, l'opérateur de parenthèse doit par conséquent toujours être constant pour les prédicats et comparateurs. Si ce n'est pas vérifié, en prenant l'exemple d'un comparateur + sort, cela pourrait changer l'invariant de comparaison et trier n'importe comment.)

Pas de référence constante pour les observers

Bien que cela sorte du cadre de la question d'origine, il ne faut pas prendre par référence constante une valeur à observer.

J'entends par observer les variables qui sont gardées en lecture dans le but de vérifier leur état à un instant t.

Les constref peuvent être des temporaires à leur construction (Jusque-là c'est défini par la norme: prolongement de la durée de vie d'une temporaire). Le problème vient du déplacement vers un scope parent. La constref temporaire est détruite mais la référence est gardée ; référence sur une valeur qui n'existe plus. Cela débouche sur un comportement indéfini et, dans le meilleurs des cas, un segfault.

Un article qui présente une situation similaire avec une lambda retournant T à travers std::function<const T&>.

#include <iostream>
#include <string>

struct Validate
{
  const std::string & s;
  void display() const { std::cout << s; }
};

Validate f()
{ return {"plop"}; }


int main()
{
  Validate x = f();
  x.display();
}

(Moi j'ai un segfault)

Pour limiter ce bug il faut empêcher de prendre les rvalue. Soit en faisant un Validate(std::string&&)=delete + Validate(const std::string & s):s(s){} soit en utilisant std::reference_wrapper (et cref). Ou, peut-être mieux, faire un objet observable tout pareil que reference_wrapper mais avec constructeur explicite. L'intérêt d'utiliser l'un des 2 objets cités et de focaliser l'utilisateur sur l'aspect "j'ai besoin que cette variable vive au moins aussi longtemps que moi".

Résumé

  • Valeur pour les types fondamentaux (int, double, etc), ceux modifiés sans que l'utilisateur n'ai besoin de le savoir (ex: itérateurs ou premier paramètre de l'opérateur '+') ou les foncteur sans états (prédicats, comparateurs, ...).
  • Valeur + std::move quand l'objet peut être déplacé (possède un move-ctor ou/et move-assign non trivial (ex: std::string, std::vector, ...).
  • Valeur pour les ressources non copiables à transférer (std::unique_ptr, ...).
  • Référence constante pour les paramètres en lecture seule ou ne disposant pas de move-ctor ou/et move-assign non trivial.
  • Référence constante et rvalue quand le paramètre peut être copié et possède un move-ctor/move-assign non trivial.
  • Rvalue pour les ressources non copiables avec déplacement conditionnel (std::unique_ptr, ...).
  • Référence constante pour les types inconnus (template) qui n'ont pas d’intérêt à être pris par valeur (ou au pire, stratégie variable selon le résultat de std::is_trivially_*/std::is_copy_*/std::is_move_* ; même si cela n'est pas utile).
  • Rvalue pour les types inconnus (template) quand le paramètre n'a pas de rôle direct dans la fonction ou que le qualifier n'importe pas (ne pas oublier std::forward pour le transmettre à une autre fonction (seulement s'il n'est plus utilisé ensuite)).

Partie 2

2 commentaires:

Florian Blanchet a dit…

Bonjour,

Je ne suis pas d'accord quand tu dis qu'il faut systématiquement utiliser std::forward lors d'utilisation d'un paramètre template sous la forme T$$.

Je suis d'accord pour dire d'utiliser std::forward sur la dernière utilisation du paramètre et uniquement sur la dernière.

Un code pour illustrer :

template
void foo(T&& t)
{
foo(std::forward(t));
//Supposons qu'on soit dans le cas ou foo prend la responsabilité de t
foo(std::forward(t));
//Et ici ? t n'est plus dans ce scope je passe quoi ? au mieux rien au pire j'ai donné la responsabilité une seconde fois

//Deux points qui ont la responsabilité de la même ressource c'est mauvais
}

La règle que j'ai tendance à retenir : ne jamais appliquer move/forward deux fois à la même variable, l'appliquer uniquement sur la dernière utilisation de la variable.

Jonathan Poelen a dit…

Bonsoir,

J'imagine que tu fais référence au chapitre "Quand les opérations ne sont pas connu" ?
C'est vrai que je n'ai pas contre indiquer l'usage répété de forward et c'est très bien d'en parler.

J'ajoute une petite note au paragraphe.