Une notion complexe
Examinons de plus près le contenu que pourrait avoir cette fonction pow
toujours en C++ :
int pow(int nValue, int nPower) { int nReturn = 1; while( nPower > 0 ) { nReturn *= nValue; nPower -= 1; } return nReturn; }
Ce n'est pas la meilleure fonction de puissance qu'il soit mais elle est simple à comprendre. Le principe est de multiplier une variable autant de fois que nécessaire par notre valeur, puis de retourner cette variable.
Mais examinons de plus près comment fonctionne le mécanisme de la variable de retour. Commençons pas l'appel de notre fonction :
int n = pow(10, 3);
Je déclare ici une variable n de type entier dans laquelle je place la valeur de retour de la fonction.
Cette fonction a été défini comme une fonction retournant une variable de type int. Pour l'appel de cette fonction, la première chose qu'effectue le compilateur est de réserver un espace mémoire sur la pile pour la valeur de retour, cette espace mémoire est ensuite passé à la fonction.
Lors de l'appel de la commande return
, le compilateur effectue en fait une copie de la valeur de notre variable nReturn
dans l'espace mémoire destiné à la valeur de retour.
Ce n'est pas clair ? C'est normal, le compilateur utilisant des types natifs, nous n'avons pas moyen de tracer les créations, copies et destructions. Le plus simple maintenant est de passer aux objets. Une petite classe Plop par exemple :
class Plop { public: Plop() { cout << "Constructeur de " << this << endl; } Plop(const Plop& plop) { cout << "Recopie de " << &plop << " dans " << this << endl; } ~Plop() { cout << "Destructeur de " << this << endl; } };
Ensuite, on peut définir une petite fonction retournant un Plop
et l'utiliser :
Plop Create() { Plop plop; return plop; } int main() { Plop plopMain = Create(); return 0; }
Le programme semble simple, et pourtant voilà ce que donne l'exécution sur la majorité des compilateurs :
Constructeur de 0xbffffbd8
Recopie de 0xbffffbd8 dans 0xbffffc38
Destructeur de 0xbffffbd8
Recopie de 0xbffffc38 dans 0xbffffc39
Destructeur de 0xbffffc38
Destructeur de 0xbffffc39
Une petite explication de chaque ligne :
- création de la variable
plop
dans la fonctionCreate
; - à l'appel du mot-clé
return
, la variableplop
est copiée dans l'espace mémoire de la valeur de retour par un constructeur par recopie ; - la fonction se terminant, la variable
plop
est maintenant détruite ; - création de la variable
plopMain
en recopiant la valeur de retour ; - la variable temporaire est détruite juste après la fin de l'exécution de la fonction
Create
; - à la fin du programme, la variable
plopMain
est détruite.
On voit ici clairement que beaucoup de recopie inutiles sont faites simplement pour permettre une utilisation du concept de valeur de retour. Ceci s'explique par le fait qu'un ordinateur n'a jamais été conçu comme une fonction, il n'y a pas d'entrées et de sorties. Toutes les variables que l'on manipule sont dans la même mémoire.
Le problème
Le problème est principalement sémantique. Que fait exactement notre fonction Create
? Elle va créer une nouvelle instance de notre classe Plop
pour que nous puissions l'utiliser dans notre programme. Je vous le donne en mille, Create
n'est sémantiquement rien d'autre qu'un constructeur supplémentaire pour la classe Plop
.
Si on regarde une fonction comme string Concat(string str1, string str2);
, on peut dire que la fonction Concat
est un constructeur de string
utilisant 2 autres string
s pour les concaténer.
Attention tout de même, par constructeur, j'entends responsable de la création de l'objet et pas forcément sa construction au sens cohérence mémoire comme le serait un vrai constructeur. Finalement la vraie responsabilité d'une fonction est d'utiliser le bon constructeur et d'appeler les bonnes méthodes sur l'objet pour créer la valeur de retour.
Le problème est que dans les langages que je connais, aucun n'inclue dans sa syntaxe cette notion sémantique de la valeur de retour, aucun ne propose de donner à une fonction la réelle responsabilité qui lui incombe, à savoir choisir le constructeur le plus correcte.
La solution retenue est de toujours faire appel au constructeur par recopie (lors de la commande return
), le programmeur ayant la responsabilité de fournir un autre objet qui sera recopié. Ce que je déplore, c'est qu'un programmeur ne puisse pas choisir le constructeur qu'il souhaite utiliser.
Un problème récurrent
Prenons l'exemple concret d'une classe Matrix
représentant une matrice carré mathématiques. Les seuls constructeurs disponibles sont le constructeur par recopie et le constructeur par défaut qui initialisera la matrice à la matrice identité.
Vous souhaitez maintenant ajouter une méthode de multiplication à votre matrice, permettant de multiplier la matrice par une autre, produisant une matrice résultat.
Matrix Multiply(const Matrix& matrix) { Matrix result; result.... return result; }
Vous vous retrouvez ainsi à appeler un constructeur qui va initialiser votre matrice alors que vous n'en avez pas besoin. La solution sera donc de définir un nouveau constructeur privé avec une signature encore inutilisée (par exemple Matrix(int)
) pour appeler un constructeur n'effectuant pas l'initialisation vous faisant ainsi gagner de précieux cycles processeurs.
Oui il existe des solutions, non elles ne sont pas parfaites
Heureusement pour nous, certaines personnes essaient de combler les lacunes du langages. Il existe notamment des optimisations disponibles dans certains compilateurs qui permettent de passer outre ces problèmes.
Le compilateur de Microsoft Visual Studio 6 supprime par exemple la partie 4 & 5. La variable plopMain
étant destinée à recevoir la valeur de retour, c'est l'espace mémoire de cette variable qui sera utilisé pour stocker la valeur de retour supprimant ainsi l'utilisation d'une variable temporaire et une copie inutile.
Le compilateur GCC intègre quant à lui la meilleure optimisation possible. Non seulement il intègre la même optimisation que Visual Studio pour la variable de retour, mais il détecte en plus toute construction à l'intérieur de votre fonction et utilise cette appel de constructeur pour construire la variable de retour plutôt que d'utiliser le constructeur par recopie au moment de la commande return
. Avec GCC, les étapes 2 & 3 sont donc elles aussi supprimées puisqu'il n'y aura pas création de la variable plop
.
Le résultat sous GCC est donc :
Constructeur de 0xbffffc38
, construction deplopMain
en utilisant le constructeur par défaut appelé pour la construction deplop
dans la fonctionCreate
, durant l'exécution de la fonction,plop
etplopMain
désigne le même espace mémoire ;Destructeur de 0xbffffc38
, destruction deplopMain
.
Cette optimisation est bien sûr époustouflante puisqu'elle enlève du pied des programmeurs une énorme épine qu'est l'utilisation d'objets volumineux comme valeur de retour. Mais attention tout de même à rester vigilant, car tous les compilateurs n'intègrent pas forcément cette optimisation qui est encore loin d'être en standard dans les compilateurs C++.
NB : Les premiers tests de ce billet ont été réalisés avec GCC en utilisant l'option -fno-elide-constructors
afin de supprimer l'optimisation décrite.
1 De loufoque -
Une solution serait de gérer les objets avec des références faibles.
2 De Vincent -
Qu'entends-tu par référence faible ?
3 De Alexis -
Je ne comprends pas pourquoi tu n'utilises pas des pointeurs. C'est vraiment la solution à ton problème. D'ailleurs, tu parles de "volumineux objets" qui n'ont pas du tout leur place sur la pile qui, elle, a une taille limitée (c'est même petit). Les pointeurs sont là pour n'avoir à copier que quelques octets (4, bien souvent) et il est faux de dire que les valeurs de retour ne s'adaptent pas à l'informatique. Il suffit simplement d'être conscient de ce qu'on fait. Dans la vie, c'est pareil : si on veut dire à quelqu'un d'aller quelque part, on ne va pas lui donner une copie du bâtiment à atteindre, mais son adresse. Ca ne coûte rien et c'est idem en informatique. Au final, cela montre bien que le C++ est rempli de subtilités (ça va bien plus loin encore que ce que tu expliques) et qu'on doit être conscient de chaque mécanisme mis en jeu. Les objets sur la pile peuvent être utiles (un lock en entrée de fonction/méthode avec délock automatique dès qu'on sort, par exemple) et les constructeurs par copie aussi, mais pas ici, c'est sûr. Et, c'est certain, si on ne veut pas manipuler des pointeurs, le C++ n'est pas adapté.
4 De Vincent -
<pour-ta-culture> La pile est beaucoup plus rapide que le tas puisqu'il n'existe pas tous les mécanismes liés à l'allocation/désallocation pour la gestion de la mémoire. De plus, la taille de la pile est prévue à la compilation ce qui implique que l'allocation des variables n'est pas dynamique et ne nécessite donc aucune intervention de l'OS lors de l'exécution du programme. </pour-ta-culture>
Tu as du mal comprendre mon point de vue. Je ne dis pas qu'il n'existe pas de solution à ce problème. Une solution serait par exemple de passer comme tu le dis par une allocation dynamique ou tout simplement d'utiliser un paramètre par référence plutôt qu'une valeur de retour afin d'obtenir de meilleures performances.
La notion de valeurs de retour est souvent faussée dans l'esprit des programmeurs qui considèrent cela comme naturel alors qu'il s'agit d'un mécanisme défini par le langage pour leur simplifier la vie.
Contrairement au caractère simple et naturel de ce mécanisme, son implémentation est généralement complexe et va générer de multiples effets de bord. C'est ce que Joel Spolsky appelle The Law of Leaky Abstractions.
5 De loufoque -
Je me suis peut-être trompé sur le terme.
Je veux dire quand tu as une donnée quelque part en mémoire et plusieurs variables qui y font référence. La donnée n'est effacée que lorsqu'elle n'est plus du tout référencée. Ce qui signifie que tant que la variable est referencée, le langage de programmation n'a pas le droit de la libérer. Il me semble que de nombreux langages de programmation implémentent les références de cette manière.
Néanmoins en C++ il me semble qu'on ne puisse faire ça qu'avec une allocation dynamique. Enfin peut-être dis-je des trucs totalement erronés, je ne suis pas vraiment un professionnel de la programmation bas niveau.
6 De Laurentj -
>Il me semble que de nombreux langages de programmation implémentent les références de cette manière.
En C++ aussi, il existe des implémentation de ce genre de truc. Dans Mozilla par exemple, ils ont un type d'objet appeller nsCOMPtr (COM pointer) qui a exactement le comportement que tu décris. C'est génial, tu n'a plus à te soucier de la libération de la mémoire, ça se fait tout seul :-)
7 De Vincent -
On appelle cela des smart pointers et ils sont en effet disponibles dans pas mal de langage. Il reste toutefois des problèmes avec ces solutions, on ne peut par exemple pas définir de référence circulaire (A possède un pointeur sur B et B possède un pointeur sur A, A et B ne seront donc jamais détruis).
De toute façon, les solutions comme le smart pointeur possèdent toujours les inconvénients des recopies multiples et inutiles qu'engendre un retour par valeur. Il s'agit de recopie de pointeur plutôt que de recopie d'objets, mais il y a tout de même trop d'accès mémoire.
Ces solutions me font penser aux Vogons dans H2G2 qui contournent la maison parce que la barrière du jardin vient d'être fermé par le héros. Il suffisait pourtant de passer la main par dessus la barrière pour la réouvrir, mais non, le Vogons est procédurier et utilisent la solution lui venant le plus rapidement à l'esprit qui est bien souvent la plus coûteuse.
J'entends par là que la solution ne se situe certainement pas dans l'utilisation de solutions aussi lourdes que le smart pointer ou l'allocation dynamique. Il existe des solutions bien plus élégantes et efficaces pour contourner ce problème comme le passage de paramètres par référence, ou les optimisations décrites de GCC.
8 De Bob -
Et un Mac-morning, ça vous dit ?