Opérateur d'affectation: copie implicite ou explicite

Le , par koala01, Expert éminent sénior
Salut,

J'ouvre ce débat à la suite de cette discussion dans laquelle je présentais le principe du copy-and swap sous la forme de
Code : Sélectionner tout
1
2
3
4
5
6
7
TableauInt & operator = (TableauInt const & rhs) 
{ 
   TableauInt temp(rhs); // copie de l'élément affecté 
   swap(temp); //inversion des membre de this avec la copie 
   return *this; // renvoie une référence sur l'élément courent 
                 // la copie est détruite en sortant de la fonction 
}
et à laquelle médinoc a répondu ceci
Citation Envoyé par Médinoc  Voir le message
Juste une chose pour le copy-and-swap: prendre directement la source par valeur peut éviter la copie quand on utilise l'opérateur = avec un temporaire:
Code C++ : Sélectionner tout
1
2
3
4
TableauInt::TableauInt& operator=(TableauInt tmp) { 
	swap(tmp); 
	return *this; 
}

Après mure réflexion, j'en viens à la conclusion que cela revient "choux vert et vert choux" du fait que la seule différence présente entre les deux possibilités vient de la copie explicite dans mon code de l'objet assigné contre la copie implicite dans le code de Médinoc.

Personnellement, j'aurais cependant tendance à préférer la copie explicite pour la simple raison (sans fondement technique) que j'ai l'impression que l'on code souvent "par habitude", et que cela aura l'avantage de garder le code cohérent par rapport aux autres fonctions manipulant des paramètres qu'elles ne doivent pas modifier.

On répète en effet inlassablement que
si une fonction ne doit pas modifier l'objet qui lui est transmis en paramètre et que l'objet est plus gros qu'un simple primitif, il y a lieu de le passer par référence (pour éviter la copie de l'objet) constante (pour éviter que la fonction ne tente de le modifier indument)

Ce principe est très bien dans le sens où il permet de gagner en performances à peu de frais en évitant la copie de l'objet passé en paramètre, mais qu'en est il si la fonction en question doit, justement, travailler sur une copie de l'objet (si tant est que l'objet soit copiable)
est-il préférable de laisser la copie se faire de manière implicitement, quitte à "briser" la cohérence par rapport au reste du code
est-ce au codeur de la fonction de veiller à créer sa propre copie de l'objet en question
y a-t-il la moindre raison technique qui pourrait justifier la préférence d'une technique par rapport à l'autre

Ces questions sont, évidemment, à mettre en perspective par rapport à la discussion citée, à savoir, le fait de devoir redéfinir l'opérateur d'affectation dans le respect de la grande règle des trois (si l'on a du définir un comportement spécifique pour une des fonctions parmi le constructeur par copie, l'opérateur d'affectation ou le destructeur, il y a lieu de définir un comportement spécifique pour les trois).

Je sais parfaitement qu'il y a maintenant la possibilité d'utiliser éléments RAII pour s'éviter cette peine, mais je vous demande de ne pas faire entrer cet aspect en ligne de compte


Vous avez aimé cette actualité ? Alors partagez-la avec vos amis en cliquant sur les boutons ci-dessous :


 Poster une réponse

Avatar de gbdivers gbdivers - Inactif http://www.developpez.com
le 30/03/2013 à 18:21
Salut

Je conseille la lecture de la série "Object Swapping" de Andrew Koenig sur Dr. Dobb's (part 1, part 2, part 3, part 4, part 5, part 6 et part 7)

Pour résumer :
* il faut aussi la move semantic :
Code : Sélectionner tout
1
2
3
4
5
TableauInt & operator = (TableauInt && rhs) 
{ 
   swap(temp); 
   return *this; 
}
* sans move semantic, (TableauInt const& rhs) et (TableauInt rhs), c'est pareil (on évite juste de nommer la temporaire dans le second cas)
* avec la move semantic, il y a ambiguïté entre (TableauInt rhs) et (TableauInt && rhs) pour les rvalues, donc il faut mieux utiliser (TableauInt const& rhs)
Avatar de Flob90 Flob90 - Membre expert http://www.developpez.com
le 30/03/2013 à 18:27
Bonjour,

Cette technique présentée par Medinoc est "assez" vielle (présentée en 2009 sur C++Next et probablement bien plus ancienne que ça). Son seule fondement est d'éviter une copie (et donc potentiellement gagner en performance) dans le cas où l’affection est faite depuis un temporaire.

Cette technique n'est pas toujours applicable (typiquement pour les opérateurs binaires, le gain de performance n'est pas évident (*)), mais dans ce cas il y a certitude que ça ne pourra être qu'un gain (éventuellement nul, mais ça ne sera pas une perte).

Code : Sélectionner tout
On répète en effet inlassablement que
On pourrait objecter que la façon décrite par Medinoc est aussi idiomatique pour certain codeur :
That realization leads us directly to this guideline:

Guideline: Don’t copy your function arguments. Instead, pass them by value and let the compiler do the copying.

At worst, if your compiler doesn’t elide copies, performance will be no worse. At best, you’ll see an enormous performance boost.

(Dave Abrahams)

Ce constat nous conduit directement à ce conseil :

Conseil: Ne copiez pas les arguments de votre fonction. A la place, passer les par valeur et laissez le compilateur faire la copie.

Au pire, si votre compilateur n'élide pas les copies, la performance ne sera pas pire. Au mieux, vous obtiendrez un énorme gain de performance.

(traduction libre)

Bien entendu l'article, et même la série, est plus complète que ça et d'autre cas doivent être traités (**). Cependant dans le cas de l'opérateur d'affectation, le conseil s'applique parfaitement.

Donc pour tes questions, dans l'ordre, mon avis est :
  1. Oui, pour obtenir les meilleurs performances possible. Ce n'est pas contraignant niveau écriture, il n'y a donc pas de raisons de s'en priver, quelque soit le gain, AMA.
  2. J'ai un peu de mal à comprendre le sens de cette question. Que tu fasses la copie implicitement ou explicitement, c'est bien toi codeur qui en prend la décision. Une solution ou l'autre (en supposant que tu puisses écrire les deux) n'impactent pas la façon dont la fonction sera utilisable.
  3. Oui, profiter de l’élision proposée par le compilateur.


(*) Pour deux raisons, liés au retour, qui est un nouvel objet, et le caractère binaire et donc de priorité d'opérateur.

(**) Le premier point étant lorsque la fonction retourne aussi un objet du même type que le paramètre. L'objectif serait de profiter de deux élisions. La technique suggéré étant d'utiliser un swap :
Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
 
A foo(A a) 
{ 
  using std::swap; 
 
  A r; 
  //stuff 
  swap(a,r); 
  //stuff 
  return r; 
}
J'avouerais ne pas avoir tester si le compilateur est bel et bien capable d'effectuer les deux élisions avec un tel code.

PS: En C++11, et si l'on écrit autre chose qu'un opérateur d'affectation par copie, la donne peut légèrement changer selon la façon dont l'on veut profiter de la move semantic.
Avatar de koala01 koala01 - Expert éminent sénior http://www.developpez.com
le 30/03/2013 à 20:37
Citation Envoyé par gbdivers  Voir le message
Pour résumer :
* il faut aussi la move semantic :
Code : Sélectionner tout
1
2
3
4
5
TableauInt & operator = (TableauInt && rhs) 
{ 
   swap(temp); 
   return *this; 
}

Je présumes que c'est une erreur de typo et que tu voulais écrire
Code : Sélectionner tout
1
2
3
4
5
TableauInt & operator = (TableauInt && rhs) 
{ 
   swap(rhs); 
   return *this; 
}

* sans move semantic, (TableauInt const& rhs) et (TableauInt rhs), c'est pareil (on évite juste de nommer la temporaire dans le second cas)

C'est à peu près le raisonnement que je suivais
* avec la move semantic, il y a ambiguïté entre (TableauInt rhs) et (TableauInt && rhs) pour les rvalues, donc il faut mieux utiliser (TableauInt const& rhs)

Mais, mis en perspective avec la discussion précitée, est-ce réellement à prendre en compte

Je rappelle que la discussion s'adresse à un débutant qui doit remettre quelque chose de valable à un prof.

La move semantic étant sommes toutes assez récente, je crains que ca ne passe largement au dessus de la tête et du prof et de l'étudiant, non

Ici, je voudrais vraiment nous placer dans un contexte ou:
  1. il faut connaitre la forme canonique orthodoxe de coplien
  2. la grande loi des trois est d'application
Toute considération n'entrant pas dans ce contexte étant à éviter si possible (bien qu'il soit utile de les préciser le cas échéant )
Avatar de Flob90 Flob90 - Membre expert http://www.developpez.com
le 31/03/2013 à 1:09
Citation Envoyé par koala01  Voir le message
C'est à peu près le raisonnement que je suivais

C'est équivalent à la différence près que la version const T& empêche toute élision d'éventuelles copies de temporaires : c'est donc potentiellement moins performant. Et ce n'est pas un gain d'écriture ni un gain conceptuel assez important pour justifier de se passer d'un éventuel gain de performance, AMA.

Si on reprend les conseils qu'on a l'habitude de donner aux débutants, l'un qui revient est de ne pas chercher à faire à la main ce que le compilateur / la bibliothèque standard fait mieux que nous. Et le conseil de Dave Abrahams est à rapproché de ça : tu vas avoir besoin d'une copie d'un argument ? Alors laisse le compilateur la faire bien mieux que toi.

Ensuite pour la move-semantic dans ce cas, il faut que les débutants se rendent comptent que les arguments peuvent être de deux genres :
  • lvalue, la source est un objet qui n'a pas vocation à être détruite sous peu : il ne faut pas l'utiliser.
  • rvalue, la source est un objet qui va être détruite sous peu : on peut l'utiliser.

Et c'est là que la décision d'implémenter une ou deux versions d'une fonction intervient : est-ce que notre fonction doit (pour être performante) se comporter différemment dans les deux cas. Reprenons notre opérateur d'affectation :
  • C'est identique, alors :
    Code : Sélectionner tout
    1
    2
    3
    4
    5
    6
    7
    8
     
    A& operator=(A rhs) 
    { 
      using std::swap; 
      //stuff on rhs 
      swap(*this,rhs); 
      return *this; 
    }
    Fait le travail, il est même possible que le comportement soit différent selon les deux cas en fonction de constructeur de copie/move : font-ils une distinction ou non ? (*)
  • C'est différent, alors
    Code : Sélectionner tout
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
     
    A& operator=(const A& rhs) 
    { 
      A tmp = rhs; 
      //stuff on tmp 
      return *this = std::move(tmp); 
    } 
     
    A& operator=(A&& rhs) 
    { 
      using std::swap; 
      //stuff on rhs 
      swap(*this,rhs); 
      //or simpler than swap, but safe anyway 
      return *this; 
    }
    Fait le travail. Il n'est pas obligatoire de faire un appel de l'un vers l'autre, mais c'est typiquement ce qui peut se passer.


(*) En réalité on peut toujours effectuer une distinction, si l'on veut être certain d'éviter les constructions par copie/move sans compter sur l'élision (qui n'est pas obligatoire), c'est un moyen certain d'y arriver.
Avatar de koala01 koala01 - Expert éminent sénior http://www.developpez.com
le 31/03/2013 à 3:52
Je comprends très bien ce que tu expliques, mais il y a quand même une chose qui me travaille.

La l'élision de copie ne peut, à mon sens, être opérable que lorsque il n'y a rien entre la création de la copie et le moment où la copie est renvoyée, non

Ainsi, une fonction
Code : Sélectionner tout
1
2
3
4
UnType copy(Untype t) 
{ 
    return t; 
}
pourrait sans doute très bien profiter de l'élision de copie par rapport à sa version
Code : Sélectionner tout
1
2
3
4
UnType copy(Untype const &t) 
{ 
    return UnType(t); // appel explicite au constructeur par copie 
}
Sur ce point, je suis tout à fait d'accord

Là où je me pose sérieusement la question, c'est quand il y a forcément quelque chose à faire entre le moment de la création de la copie et celui de son renvoi.

Je rappel le contexte d'origine:

Un étudiant nous a posé la question de "ce qui ne va pas avec sa classe TableauInt" qui se présentait sous la forme de
Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
class TableauInt 
{ 
    public: 
        TableauInt(); 
        TableauInt(int taille); 
        ~TableauInt(){delete[] ptr;} 
         /* ni constructeur par copie ni opérateur d'affectation */ 
    private: 
        int * ptr; 
        int taille; 
};
En précisant que comme c'était pour son prof (hé oui, il y en a toujours qui croient qu'il faut fatalement gérer la mémoire à la main en C++ ), il ne pouvait pas se faciliter la tache en utilisant std::vector

Le but étant de pouvoir écrire un code ressemblant à peu près à ceci
Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
int main() 
{ 
    TableauInt tab1(10); // 10 éléments dans le tableau 
    TableauInt tab2(20); // et 20 dans celui-ci 
    tab2[2] = 5;         // (dans la discussion d'origine, le PO utilisait setEleement, mais bon... :D) 
     TableauInt tab3(tab2); // tab3[2] == 5 
     tab3[2]= 10;           // tab3[2] == 10 tab2[2] == 5 (toujours) 
     tab2[N] = x;           // tant que N <20, pas de problème ;) 
     tab1 = tab[2];         // prérequis : pas de fuites mémoire et l'utilisation de tab1  
                            // n'interfère pas sur le contenu de tab2 
}
(en gros, cela revient à fournir une classe RAIIsante ayant sémantique de valeur, sans profiter des bienfaits de C++11 )

S'il n'y avait aucun accès en écriture à la copie (comprends: si l'on n'appelait que des fonctions membres constantes de la copie ou, le cas échéant des fonctions libres qui l'utilise sous la forme de référence constante), il n'est pas impossible (mais cela reste à vérifier ) que le compilateur puisse se rendre compte que la copie n'est jamais modifiée et qu'il mette en place l'élision de la copie.
Mais, dans ce cadre particulier où il faut d'office veiller à la copie en profondeur du pointeur "copié" et à la libération de la mémoire "d'origine", j'ai beaucoup de mal à imaginer que le compilateur puisse faire une élision de copie sachant que, entre la copie et le renvoi de celle-ci, il y aura, fatalement, un swap des données et l'appel obligatoire au destructeur sur la copie (dont le pointeur contiendra à ce moment là l'adresse de la donnée d'origine).

Mais peut etre suis-je limité dans ma réflexion par l'a priori selon lequel le compilateur n'est qu'un "bête programme"
Avatar de Iradrille Iradrille - Membre expert http://www.developpez.com
le 31/03/2013 à 5:26
Bien intéressé par la discussion, naïvement je dirais que la solution de koala01 (1er post) est meilleure que celle de Médinoc (1er post aussi) car je vois ça comme deux copies dans la solution de Médinoc (bien que très certainement optimisé par le compilo, ce qui fait gagner un passage de pointeur + un déréférencement de pointeur, mais .... A moins de vérifier le code généré on est jamais sur ).

Mais ici utiliser la "move semantic" peut apporter un gain appréciable.

edit:
Citation Envoyé par koala01  Voir le message
(hé oui, il y en a toujours qui croient qu'il faut fatalement gérer la mémoire à la main en C++ )

C'est un autre sujet, mais desfois on a pas le choix (Mais entièrement d'accord que quand on peut éviter, il faut pas s'en priver.)
Avatar de koala01 koala01 - Expert éminent sénior http://www.developpez.com
le 31/03/2013 à 5:32
Citation Envoyé par Iradrille  Voir le message
Mais ici utiliser la "move semantic" peut apporter un gain appréciable.

Et encore, si j'ai bonne souvenance, les pointeurs de l'objet d'origine (celui qui est passé en paramètre) sont invalidés avec la sémantique de mouvement...

Du coup, les lignes 7 et 8 du dernier code présenté dans mon intervention précédente pourraient poser problème
Avatar de Joel F Joel F - Membre chevronné http://www.developpez.com
le 31/03/2013 à 14:22
Le vrai interet de copier l'argument implicitement est que ca renforce l'exception level de =. En effet si la copie de l'argument echoue a cause d'une exception, la cible reste inchangé, rendant la transaction roll-backable.
Avatar de gl gl - Rédacteur http://www.developpez.com
le 31/03/2013 à 21:05
Citation Envoyé par Joel F  Voir le message
Le vrai interet de copier l'argument implicitement est que ca renforce l'exception level de =. En effet si la copie de l'argument echoue a cause d'une exception, la cible reste inchangé, rendant la transaction roll-backable.

Pas sur de comprendre ton point ici. Que la copie soit explicite ou implicite, si la copie échoue, le swap n'est pas fait et on ne change pas la cible ! Non ?
Avatar de Bousk Bousk - Rédacteur/Modérateur http://www.developpez.com
le 01/04/2013 à 1:40
Personnellement j'aurais tendance à favoriser dans un premier temps l'écriture explicite.
En cas de recherche d'optimisations, et à ce moment-là uniquement, je m'attarderais à tester la copie implicite pour voir s'il s'agit d'une piste intéressante.
Offres d'emploi IT
Analyste développeur confirmé/senior C++ h/f
Sigmalis - Suisse - Lausanne
Ingénieur en développement C++
small IZ beautiful - Ile de France - Orsay (91400)
Data scientist h/f
AXA - Ile de France - Paris - Avenue Matignon

Voir plus d'offres Voir la carte des offres IT
Contacter le responsable de la rubrique C++