retour à l'accueil dernière actualité articles interviews qcm dictionnaires bibliothèque forums inscription membre profile recherche sauvegardes contacts aides
entete 0titre de la page
menu du haut




Langage C++ : l'optimisation de code (1)
 Auteur : Tanguy Fautré Dernière révision : 12 Juillet 2005
Faire un commentaire :   0 message(s)








Introduction
   

Suite à l'article Langage C : l'optimisation de code de Jean-Francois, je me suis enfin décidé à écrire un article sur l'optimisation de code en C++. Cela fait depuis un moment que cette idée me trotte dans dans la tête, et l'article de JF est une bonne excuse pour en faire un autre en parallèle, mais sur le C++.

Le sujet étant vaste, ce premier article ne va se concentrer que sur un problème bien précis en C++. Il n'est d'ailleurs pas question de se lancer dans des optimisations obscures et illisibles, mais bien de faire du code propre, lisible, et rapide. Bref, prenez cet article, avec plus ou moins de distance, comme une série de conseils pratiques en C++.





Philosophie de conception
   

Un des leitmotive derrière la conception du C++ peut être résumé par 'Ce que vous n'utilisez pas ne peut vous faire du mal'. Le principe est que si une fonctionnalité du langage n'est pas utilisée dans un programme, alors ce dernier ne doit pas en souffrir (par exemple, par des pertes de performances). Bien sûr cette philosophie de design n'est pas toujours respectée par le C++, mais dans la plupart des cas elle est vérifiée.

Dans la même logique, un programme utilisant des fonctionnalités dont il n'a pas besoin, va probablement en subir les conséquences. La règle d'or est donc de ne pas taper du code inutile, même quand on pourrait penser que ca ne peut faire de mal. Pour illustrer ce fait, cet article va s'intéresser aux opérateurs par défaut, qui sont automatiquement définis pour n'importe quelle classe en C++. L'idée étant que si le compilateur implémente déjà certains opérateurs pour une classe, la réimplémentation inutile de ceux-ci en sera pénalisé.





Opérateurs par défaut
   

Le terme 'opérateurs par défaut' est un abus de ma part et désigne en fait quatre fonctions membres d'une classe, automatiquement définies (si possible) par le langage, et donc par le compilateur :

  • Constructeur par défaut Fonction qui construit un objet d'une classe, et qui peut être appelée sans avoir à passer des paramètres. Par exemple, le consructeur par défaut de la classe plop c'est plop().

  • Constructeur par copie Fonction qui construit un objet d'une classe à partir d'un autre objet de la même classe. Le constructeur par copie de la classe plop c'est plop(const plop &).

  • Destructeur Fonction appelée automatiquement lorsqu'un objet arrive en fin de vie. Elle sert généralement à faire le ménage au niveau des ressources. Le destructeur de la classe plop c'est ~plop().

  • Opérateur d'assignation Fonction qui assigne un object sur un autre. Elle est appelée automatiquement, par exemple, quand on fait a = b. L'opérateur d'assignation de la classe plop c'est plop & operator = (const plop &).

Ces quatres fonctions membres d'une classe sont automatiquement définies si possible, c'est-à-dire si toutes les variables membres ont un opérateur correspondant qui peut être utilisé. Par exemple, si un objet de la classe plop encapsule deux objets a et b, la classe plop aura un destructeur automatiquement défini si a et b ont tous les deux un destructeur utilisable.





Erreurs possibles
   

En connaissant les parties qui sont définies automatiquement par le compilateur, on peut faire des classes plus légères et plus efficaces. Pour l'exemple, faisons une classe qui ne contient qu'un entier. En sachant que le compilateur va automatiquement s'occuper du constructeur par défaut, du constructeur par copie, du destructeur, et de l'opérateur d'assignation, une telle classe peut être implémentée de la manière suivante:



Il est difficile de faire plus simple. Mais une grande majorité de programmeurs l'implémentent de la manière suivante :



A première vue, cette deuxième version est identique à la première du point du vue du comportement de la classe. Mais quelles sont les erreurs commises ?
  • Dans la classe bad_example, on retrouve bien dans l'orde: le constructeur par défaut, le constructeur par copie, le destructeur, et en dernier l'opérateur d'assignation. Ils font tous strictement la même chose que s'ils avaient été implicitement défini par le compilateur. C'est donc une erreur de les définir explicitement. Non seulement ca prend beaucoup de ligne de code pour rien (c'est redondant: cela rend le code illisible, et augmente sensiblement les chances d'introduire des bugs dont on se serait bien passé), mais cela ne sera jamais plus efficace que la solution produite par le compilateur (dans le meilleur des cas, ce sera aussi efficace, mais dans les cas contraires...)

  • Le lecteur attentif aura remarqué que les deux classes ne sont pas tout à fait équivalentes. En effet, le destructeur de bad_example est déclaré virtual, alors que le destructeur défini implicitement pour good_example ne l'est pas. De nombreux livres, ainsi que d'autres ressources sur le C++, conseillent de déclarer automatiquement tous vos destructeurs comme étant virtuels: cela évite d'oublier de le faire quand c'est vraiment utile, et donc évite des bugs obscures. C'est une bien mauvaise idée! Cela va à l'encontre du fameux 'Ce que vous n'utilisez pas ne peut vous faire du mal'. Utiliser un destructeur virtuel quand on n'en a pas besoin, va causer du mal (comme on le verra plus loin dans cet article).





Benchmarks
   

En théorie, tout ça est bien beau, mais cet article tourne autour de l'optimisation de code. Il est donc temps de passer à la pratique! Un petit ensemble de tests sur ces deux classes s'impose.

 IntegerGoodBadEvil
Taille
(bytes)
4488
Allocation
(millisecondes)
240240520560
A = B * 2
(millisecondes)
250240450640
Désallocation
(millisecondes)
3030390380
Résultats sur un Athlon XP 3000+ (2.1 GHz) 1024 MB avec GCC 4.0
Erreur : +- 10 msecs

Quatre implémentations de la même classe sont passées en revue:
  • Integer n'est en fait qu'un entier natif de type int;

  • Good représente la classe good_example, vue plus haut;

  • Bad représente la classe bad_example;

  • Evil se base sur la classe bad_example, mais n'utilisepas de fonctions inline (histoire d'enfoncer le clou un peu plus).


Ces quatres implémentations passent par quatres tests :
  • Taille Donne la taille d'un objet. Le but étant évidemment de voir si certains choix d'implémentation peuvent influencer ce point.

  • Allocation Ce test alloue un tableau de N objets.Il fait resortir l'influence de la taille des objets, ainsi que l'efficacité duconstructeur par défaut.

  • A = B * 2 Ce test multiplie la valeur de l'objet B par 2 et l'assigne dans A. Tel qu'il est implémenté, il met en avant l'efficatité de la création d'un objet temporaire (constructeur par copie et destructeur) et de l'assignation de cet objet sur un autre (opérateur d'assignation).

  • Désallocation Ce test désalloue le tableau d'objets créés précédemment. Il met à mal l'efficacité de l'implémentation du destructeur, et est aussi influencé par la taille des objets.

Un petit mot d'explication est peut-être nécessaire pour les résultats sur la taille des objets qui doublent avec Bad et Evil. Cette augmentation est due au destructeur virtuel; en effet, dès qu'une classe possède une ou plusieurs méthodes virtuelles, elle implémente un pointeur vers une table utilisée pour implémenter le méchanisme de polymorphisme (ce pointeur faisant 32 bits sur la machine de test).

Pour le reste, les résultats parlent d'eux-même. Les sources utilisées pour ces tests sont disponibles ici. Vous y trouverez, notamment, les résultats obtenus avec d'autres compilateurs. Je vous invite à supprimer le destructeur virtuel et à en mesurer l'impact (qui est assez surprenant, suivant le compilateur utilisé).





Conclusion
   

Le fait de re-implémenter, inutilement, les opérateurs pré-définis par le compilateur, a son lot de conséquences sur l'efficacité d'une classe. Comme le montrent les tests précédents, une telle action ne peut, au mieux, qu'être aussi efficace que d'utiliser les opérateurs définis par défaut (les résultats variant suivant le compilateur).

Il faut aussi éviter de déclarer un destructeur comme étant virtuel, si ce n'est pas nécessaire. Un destructeur virtuel n'est utile que dans le cas d'une classe à la base d'un héritage polymorphique. Un tel héritage est suffisamment évident pour se rendre compte que la classe doit avoir un destructeur virtuel (certains compilateurs vous donneront un message d'avertissement si vous faites du polymorphisme sans avoir de destructeur virtuel). Dans la majorité des cas, un destructeur virtuel est inutile, et donc à éviter.





Astuce du jour
   

En C++, comme en C, il n'est pas possible de copier directement un tableau vers un autre. Cependant, s'il est encapsulé dans une classe, le constructeur par copie et l'opérateur d'assignation, implémentés par le compilateur, se chargeront d'assigner toutes les valeurs du tableau vers un autre. Donc pas besoin d'implémenter de boucle de copie. :-)




YOUM
(analyseur syntaxique temps réel)
Nombre de définitions trouvées
19
Multi-dico par texte : actif   -   Multi-mots par définition : 4






fonction
menu de droite
fin de menu

qcm du mois
Télescope spatial Hubble
fin qcm


Page générée en : 0.033 secondes
ligne
Technologies Onversity : Hydrogen 1.0 (moteur de base de données) - SE.EN 1.0 (moteur de recherche) - YOUM 2.0 (analyseur syntaxique temps réel)
Tous droits réservés à Jean-François MAQUINÉ
ligne