Faut-il bannir les boucles "for" du C++
Au profit des algorithmes de la bibliothèque standard ?

Les rubriques (actu, forums, tutos) de Développez
Réseaux sociaux


 Discussion forum

Le 02/04/2012, par gbdivers, Responsable C++
J'ai donné un jour un exercice à l'un de mes stagiaires : modifier tout le code d'un projet qu'il avait écrit pour remplacer toutes les boucles for par des algorithmes de la bibliothèque standard.

Au-delà de ma simple tendance naturelle à torturer les stagiaires, je trouvais cet exercice intéressant pour plusieurs raisons.
La première est pédagogique, pour habituer mon stagiaire à apprendre et à utiliser les outils existants de la bibliothèque standard plutôt que repartir systématiquement de zéro.
La seconde raison est une question d'expressivité. Lorsque l'on rencontre un for dans le code, on ne peut pas savoir ce que va faire ce code. Il est nécessaire de lire le contenu du bloc et cela peut devenir très complexe, surtout si on appelle beaucoup de fonctions et que l'on manipule de nombreux objets dans ce bloc.
La dernière raison est plus conceptuelle. Lorsque l'on conçoit son algorithme en terme de boucle for, on réfléchit en fait en terme d'implémentation. Penser en termes d'algorithmes de la bibliothèque standard, c'est penser en termes de concepts que l'on manipule. On obtiendra alors plus facilement du code propre, maintenable et évolutif.

Que pensez-vous de cette attitude de supprimer, sauf choix conscient et justifié, les boucles for du code au profit de la STL ?
Voyez-vous d'autres justifications ou contre-arguments pour l'utilisation systématique des algorithmes de la STL ?
D'un point de vue pédagogique, que pensez-vous de cet exercice ?
Dans un cadre professionnel, pensez-vous que cette règle soit utile ? Applicable ?
Avez-vous des exemples de code utilisant des boucles for qui ne pourraient pas être réécrits avec les algorithmes de la STL ?


 Poster une réponse

Avatar de r0d r0d
Expert Confirmé Sénior
le 13/12/2012 11:24

Citation:





Envoyé par gbdivers
Voir le message

Et tu n'es pas obligé de passer par get pour utiliser une fonction membre


J'ai l'impression que cela revient au même, car quand tu fais pointer->DoSomething(), l'operateur ->() de unique_ptr est appelé. D'ailleurs, le test que je viens de faire semble le confirmer (l'asm généré est le même avec et sans get) J'ai pris le code suivant:
Code :
1234567891011121314151617
struct Bar
{
	Bar(int val = 0) : val(val) {}
	int val;
};

main()
{
	// 1. avec unique_ptr
	std::unique_ptr<Bar> p( new Bar(5) );
	std::cout << p->val << std::endl;
	
	
	// 2. avec pointeur nu
	Bar * p = new Bar(5);
	std::cout << p->val << std::endl;
}

J'ai compilé les 2 (en commentant l'un puis l'autre). Note: le cout c'est pour "faire quelque chose" avec mon pointeur sinon le compilateur ne prend pas la peine de le créer.
Compilé avec vs2010, optimisation /O2 (maximize speed)
L'asm généré est (je montre seulement la ligne du cout), pour 1 (avec unique_ptr):

Code :




1
2
3
4
5
6
7
8
9
10
11
12
13
; 163  : 	std::cout << p->val << std::endl;

  00049	a1 00 00 00 00	  mov	 eax, DWORD PTR __imp_?endl@std@@YAAAV?$basic_ostream@DU?$char_traits@D@std@@@1@AAV21@@Z
  0004e	8b 0e		  mov	 ecx, DWORD PTR [esi]
  00050	50		  push	 eax
  00051	51		  push	 ecx
  00052	8b 0d 00 00 00
	00		  mov	 ecx, DWORD PTR __imp_?cout@std@@3V?$basic_ostream@DU?$char_traits@D@std@@@1@A
  00058	ff 15 00 00 00
	00		  call	 DWORD PTR __imp_??6?$basic_ostream@DU?$char_traits@D@std@@@std@@QAEAAV01@H@Z
  0005e	8b c8		  mov	 ecx, eax
  00060	ff 15 00 00 00
	00		  call	 DWORD PTR __imp_??6?$basic_ostream@DU?$char_traits@D@std@@@std@@QAEAAV01@P6AAAV01@AAV01@@Z@Z

Et pour 2. (avec pointeur nu):

Code :




1
2
3
4
5
6
7
8
9
10
11
12
13
; 167  : 	std::cout << p->val << std::endl;

  00019	8b 0d 00 00 00 00 mov	 ecx, DWORD PTR __imp_?endl@std@@YAAAV?$basic_ostream@DU?$char_traits@D@std@@@1@AAV21@@Z
  0001f	8b 10		  mov	 edx, DWORD PTR [eax]
  00021	51		  push	 ecx
  00022	8b 0d 00 00 00
	00		  mov	 ecx, DWORD PTR __imp_?cout@std@@3V?$basic_ostream@DU?$char_traits@D@std@@@1@A
  00028	52		  push	 edx
  00029	ff 15 00 00 00
	00		  call	 DWORD PTR __imp_??6?$basic_ostream@DU?$char_traits@D@std@@@std@@QAEAAV01@H@Z
  0002f	8b c8		  mov	 ecx, eax
  00031	ff 15 00 00 00
	00		  call	 DWORD PTR __imp_??6?$basic_ostream@DU?$char_traits@D@std@@@std@@QAEAAV01@P6AAAV01@AAV01@@Z@Z

On voit bien que, avec unique_ptr, on a un push en plus ( 00050 50 push eax).

Par contre, il me semble que là on reste au niveau des registres du processeur, donc pas d'accès ram. Mais tout de même, on a un push en plus.
Avatar de gbdivers gbdivers
Responsable C++
le 13/12/2012 11:25
Autre chose, ça n'a pas de sens de parler de "quelques cycles" dans l'absolue, sans donner de référentiel.
Si tu code un IHM qui passe 90% de son temps à attendre une action d'un utilisateur ou si tu fais des calculs qui prennent plusieurs milliers de cycles, perdre quelques cycles n'est pas du tout important.
Exemple sur un jeu. Tu as 60 FPS sur un proc 3 GHz, la perte d'un FPS correspond à 50 millions de cycles (calcul grossier). Ca laisse de la marge pour perdre quelques cycles avant que cela ait un impact visible

Bref, sauf si tu es dans un appli critique dans laquelle tu comptes les cycles un par un, l'impact sera ridicule par rapport à la sécurité apportée


Citation:





Envoyé par John Carmack


La plupart du temps, cela ne présente qu'un intérêt théorique, car nous échangeons tout le temps des performances contre de la productivité.



(source : Programmation fonctionnelle en C++)
Pourtant Carmack est bien le genre à aller chipoter dans le code pour faire des hacks permettant de gagner quelques cycles...
Avatar de r0d r0d
Expert Confirmé Sénior
le 13/12/2012 11:30

Citation:





Envoyé par gbdivers
Voir le message

Autre chose, ça n'a pas de sens de parler de "quelques cycles" dans l'absolue, sans donner de référentiel.



Je suis tout à fait d'accord. Mais il existe tout de même des cas (extrêmement rares certes, mais ça existe, je suis sur un de ces cas en ce moment d'ailleurs), où le moindre cycle est important. Basiquement, j'ai une opération qui est appelée des milliards de fois (potentiellement des milliers de milliards de fois) et qui utilise plusieurs paramètres. La vitesse d'accès à ces paramètres est critique.
Avatar de gbdivers gbdivers
Responsable C++
le 13/12/2012 11:32
Tu lis mal. Tu as bien un move et un push dans les 2 cas (sur ecx pour unique_ptr et edx pour le pointeur nu, pas contre, le push de edx est inversé avec le move de ecx)
Avatar de gbdivers gbdivers
Responsable C++
le 13/12/2012 11:37

Citation:





Envoyé par r0d
Voir le message

Je suis tout à fait d'accord. Mais il existe tout de même des cas (extrêmement rares certes, mais ça existe, je suis sur un de ces cas en ce moment d'ailleurs), où le moindre cycle est important. Basiquement, j'ai une opération qui est appelée des milliards de fois (potentiellement des milliers de milliards de fois) et qui utilise plusieurs paramètres. La vitesse d'accès à ces paramètres est critique.



Ok, dans ce cas, tu es dans la situation (qui arrive dans 0,01% des cas) où

Citation:




préfère privilégier ce qui facilite la sécurité, l'évolutivité, la maintenabilité, etc. sauf quand j'ai besoin explicitement de faire autrement.


D'où l'intérêt d'avoir de la liberté pour ces 0,01% des cas et de fortement recommander (voir plus) les smarts pointeurs par défaut
Avatar de r0d r0d
Expert Confirmé Sénior
le 13/12/2012 11:39

Citation:





Envoyé par gbdivers
Voir le message

Tu lis mal. Tu as bien un move et un push dans les 2 cas (sur ecx pour unique_ptr et edx pour le pointeur nu, pas contre, le push de edx est inversé avec le move de ecx)


Ha oui, tu as raison (du coup j'ai une grosse remise en question à faire). Mais alors quoi, c'est le compilateur qui "efface" l'appel à l'opérateur ->() ? Juste pour info, p.get()->value et p->value génèrent le même asm. p.get()->value:
Code :
12345678910111213
; 163  : 	std::cout << p.get()->val << std::endl;

  00049	a1 00 00 00 00	 mov	 eax, DWORD PTR __imp_?endl@std@@YAAAV?$basic_ostream@DU?$char_traits@D@std@@@1@AAV21@@Z
  0004e	8b 0e		 mov	 ecx, DWORD PTR [esi]
  00050	50		 push	 eax
  00051	51		 push	 ecx
  00052	8b 0d 00 00 00
	00		 mov	 ecx, DWORD PTR __imp_?cout@std@@3V?$basic_ostream@DU?$char_traits@D@std@@@1@A
  00058	ff 15 00 00 00
	00		 call	 DWORD PTR __imp_??6?$basic_ostream@DU?$char_traits@D@std@@@std@@QAEAAV01@H@Z
  0005e	8b c8		 mov	 ecx, eax
  00060	ff 15 00 00 00
	00		 call	 DWORD PTR __imp_??6?$basic_ostream@DU?$char_traits@D@std@@@std@@QAEAAV01@P6AAAV01@AAV01@@Z@Z

Avatar de gbdivers gbdivers
Responsable C++
le 13/12/2012 11:40
Autre remarque. Pour moi, partir sur des pointeurs nus par défaut au lieu des smart pointeurs, sans avoir testé dans l'algo que l'on implémente que cela à un impact visible, c'est une erreur. (prémature optimization, source des tous les maux)
Avatar de gbdivers gbdivers
Responsable C++
le 13/12/2012 11:42
Ah ok, je vois ce que tu veux dire. Oui, il y a bien un optimisation qui est faites par le compilateur, qui consiste à remplacer une fonction inliné directement par son code, pour éviter l'appelle d'une fonction.
Avatar de r0d r0d
Expert Confirmé Sénior
le 13/12/2012 11:48

Citation:





Envoyé par gbdivers
Voir le message

Ah ok, je vois ce que tu veux dire. Oui, il y a bien un optimisation qui est faites par le compilateur, qui consiste à remplacer une fonction inliné directement par son code, pour éviter l'appelle d'une fonction.


En fait je crois que c'est un peu plus "sioux" que ça. Le fait d'inliner une fonction permet juste d'éviter un appel de fonction (donc le honnis changement de contexte). Ici, si on avait juste un "inlinage", le code avec unique_ptr serait (après "inlinage" mais avant génération):
Code :
(&**p)->value;

Le "&**" venant de l'opérateur ->() de unique_ptr.

Là il y a "inlinisation" + optimisation.
Avatar de camboui camboui
Membre éprouvé
le 13/12/2012 12:08

Citation:





Envoyé par gbdivers
Voir le message




Désolé d'interrompre pour si peu...
Le lien est invalide.

EDIT gbdivers : lien corrigé, merci
Avatar de JolyLoic JolyLoic
Rédacteur/Modérateur
le 13/12/2012 15:47

Citation:





Envoyé par r0d
Voir le message

En fait je crois que c'est un peu plus "sioux" que ça. Le fait d'inliner une fonction permet juste d'éviter un appel de fonction



C'est là je pense ton erreur : L'inlining ne permet pas uniquement de supprimer un appel de fonction, mais aussi d'optimiser le code comme si tout était défini en un seul endroit. Parfois, le gain de perfs lié à ces potentialités d'optimisation supplémentaires est supérieur au gain de perfs lié à la suppression de l'appel de fonction.
 
 
 
 
Partenaires

Hébergement Web