Programmation et valeur de retour

En tant que programmeur, il est courant d'avoir à faire aux valeurs de retour. La valeur de retour est la valeur que retourne une fonction après avoir effectué un traitement en utilisant ses paramètres.

Un exemple C++ est int n = pow(10, 3); qui est un appel de la fonction pow pour calculer 10 puissance 3 et placer la valeur de retour dans la variable n, c'est à dire 1000.

Bien que cette notion soit utilisée depuis l'âge de pierre de l'informatique, elle n'en reste pas moins complètement inadaptée à un modèle informatique.

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 :

  1. Constructeur de 0xbffffbd8
  2. Recopie de 0xbffffbd8 dans 0xbffffc38
  3. Destructeur de 0xbffffbd8
  4. Recopie de 0xbffffc38 dans 0xbffffc39
  5. Destructeur de 0xbffffc38
  6. Destructeur de 0xbffffc39

Une petite explication de chaque ligne :

  1. création de la variable plop dans la fonction Create ;
  2. à l'appel du mot-clé return, la variable plop est copiée dans l'espace mémoire de la valeur de retour par un constructeur par recopie ;
  3. la fonction se terminant, la variable plop est maintenant détruite ;
  4. création de la variable plopMain en recopiant la valeur de retour ;
  5. la variable temporaire est détruite juste après la fin de l'exécution de la fonction Create ;
  6. à 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 strings 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 :

  1. Constructeur de 0xbffffc38, construction de plopMain en utilisant le constructeur par défaut appelé pour la construction de plop dans la fonction Create, durant l'exécution de la fonction, plop et plopMain désigne le même espace mémoire ;
  2. Destructeur de 0xbffffc38, destruction de plopMain.

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.

Haut de page