Les conteneurs : l’embarras du choix

Article original : I don’t know which container to use (and at this point I’m too afraid to ask) | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Quand on parle de conteneurs en C++, c’est std::vector qui remporte la palme de la plus utile (et utilisĂ©e) d’entre toutes (juste devant l’occasionnelle std::map pour les fois oĂą on a besoin d’une association clĂ©-valeur1). Ainsi, c’est facile d’oublier que d’autres types de conteneurs existent.

Chaque conteneur a ses forces et ses faiblesses. Si vous avez tendance à oublier quelles elles sont pour chacun, cet article est un bon début.

Avertissement : Tous les conteneurs C++ ne sont pas listĂ©s ici, juste ceux qui sont les plus utiles (d’après moi). Si vous voulez aller plus loin, vous trouverez deux liens en addendum qui vous renverront vers des documentations plus complètes.

Comment choisir un conteneur

Critères

Premier™ critère : séquentiel ou associatif

La première question que vous devez vous poser est : est-ce que mon conteneur est séquentiel ou associatif ?

Dans les conteneurs sĂ©quentiels, les donnĂ©es sont organisĂ©es de manière ordonnĂ©e et sĂ©quentielle, avec chaque valeur Ă  la suite de la prĂ©cĂ©dente. En mĂ©moire, ce n’est pas toujours contigĂĽe (et souvent ça ne l’est pas), mais en pratique, vous accĂ©dez Ă  une valeur avec l’indice de sa position dans le conteneur.

Contrairement aux conteneurs sĂ©quentiels, les conteneurs associatifs ne conservent pas les donnĂ©es en tant que sĂ©quence, mais en associant la valeur Ă  une clĂ©. Ă€ la place d’utiliser un indice pour accĂ©der Ă  la valeur, on utilise la clĂ©.

Critères des conteneurs séquentiels

  • Est-ce que la taille du conteneur est statique ? Si oui, c’est un std::array que vous cherchez (et les autres critère ne sont de toute façon pas importants). Dans tout autre cas de figure, vous devez vous en rĂ©fĂ©rer aux autres critères.
  • Est-ce que la taille du conteneur va beaucoup varier ? Ce critère est important pour l’allocation mĂ©moire. Si vous faites beaucoup varier la taille d’un conteneur qui n’est pas prĂ©vu pour ça, vous pourrez ressentir des ralentissements et une surutilisation de la mĂ©moire2.
  • Est-ce que l’ordre est important ? Ce critère concerne les structures de donnĂ©es oĂą on ne peut ajouter et retirer des valeurs qu’au dĂ©but ou Ă  la fin (c’est Ă  dire les structures FIFO et FILO).
  • Est-ce que vous avez besoin d’insĂ©rer/supprimer des valeurs aux extrĂ©mitĂ©s du conteneur (c’est-Ă -dire au dĂ©but et Ă  la fin) ? Certains conteneurs sont plus efficaces que d’autres pour ces opĂ©rations.
  • Est-ce que vous avez besoin d’insĂ©rer/supprimer des valeurs au milieu de la structure ? Tout comme le critère prĂ©cĂ©dent, parfois vous devez faire beaucoup d’ajout et de suppressions, mais au milieu de la structure. C’est l’apanage d’autres types de conteneurs.
  • Est-ce que vous avez besoin de trouver le nième Ă©lĂ©ment ? En fonction du conteneur, la recherche n’est pas toujours optimale.
  • Est-ce que vous avez besoin de fusionner des collections ? En fonction du conteneur, la fusion de deux collections n’est pas toujours optimale non plus (certaines ont besoin de rĂ©allouer la mĂ©moire et d’autres non).

Critères des conteneurs associatifs

  • Il y a deux sortes de structures associatives : celles qui associent une clĂ© Ă  une valeur (Ă©ventuellement de types diffĂ©rents), ce sont les map, et celles pour lesquelles la clĂ© est la valeur, et ce sont les set.
  • Par dĂ©faut, les clĂ©s sont uniques. Mais il existe une variante pour lesquelles les clĂ©s peuvent avoir plusieurs associations. Ce sont les multimap/multiset.
  • La structure interne de ces conteneurs peut ĂŞtre implĂ©mentĂ©e de deux façons. Par dĂ©faut, elles sont ordonnĂ©es par clĂ©. Mais elles peuvent aussi ĂŞtre non-ordonnĂ©es et utiliser une clĂ© de hachage. Ce sont les versions unordered_ de chacun des conteneurs.

Note : la distinction ordonnĂ©/non-ordonnĂ© est importante (voire primordiale) pour les conteneurs associatifs. La plupart du temps, les conteneurs associatifs ordonnĂ©s sont implĂ©mentĂ©s sur des arbres binaires Ă©quilibrĂ©s, tandis que les non-ordonnĂ©s sont des tables de hachage. Cela a un impact sur les performances et sur le fait que, pour les tables de hachage, le type de valeur n’a pas besoin d’implĂ©menter un opĂ©rateur de comparaison.

La matrice des conteneurs

Voici deux tableaux – un pour les conteneurs séquentiels et un pour les conteneurs associatifs.

Chaque tableau reprĂ©sente quels critères s’appliquent le mieux pour chaque conteneur3.

Conteneurs séquentiels

Conteneurs associatifs

Résumé sous forme de diagramme de flux

Voici un rĂ©sumĂ© simple Ă  lire, sous forme d’un diagramme de flux, qui permet de choisir quel conteneur choisir en toute circonstance4 : Joe Gibson’s data structure selection flowchart.

Qu’en est-il de la rĂ©alitĂ© des gens vĂ©ritables ?

Comme je l’ai mentionnĂ© dans l’introduction, dans la vraie vie std::vector et std::map sont la plupart du temps largement suffisants. Avec ça en tĂŞte, Ă  quel point est-il utile de chercher le conteneur « optimal » Ă  chaque fois ?

SĂ©mantiquement, chaque conteneur est liĂ© Ă  la manière dont on l’utilise. Quand vous voyez deque, vous pensez « insĂ©rer et supprimer aux extrĂ©mitĂ©s », quand vous voyez queue, vous pensez « insĂ©rer au dĂ©but et supprimer Ă  la fin », quand vous voyez list, vous pensez « insĂ©rer et supprimer au milieu ».

On voit chaque conteneur en fonction de comment on les utilise, plus que Ă  quel point ils sont efficaces dans telle ou telle situation (ce qui est liĂ© mais pas Ă©quivalent). Il y a beaucoup d’opĂ©rations qui sont disponibles dans plusieurs conteneurs Ă  la fois. Par exemple, on peut très facilement ajouter et enlever des valeurs Ă  la fin d’un vector, de mĂŞme que dans un deque (dans les deux cas, on utilise les fonctions membre push_back et pop_back).

Vous avez peut-ĂŞtre envie d’objecter : « Mais ajouter et supprimer des valeurs Ă  la fin d’un vecteur peut ĂŞtre très inefficace ! » En thĂ©orie, oui. Mais en pratique, ce n’est le cas que si vous vous trouvez dans une section critique du code. La plupart du temps, si vous ĂŞtes dans les 80% du Principe de Pareto, utiliser un vector plutĂ´t qu’un deque n’aura aucun effet sur les performances.

Laissez-moi alors vous poser la question : si un vector fonctionne bien et n’impacte pas les performances, pourquoi utiliser un autre conteneur ?

Le vecteur est une des structures les plus faciles Ă  comprendre grâce Ă  leur similaritĂ© avec les bons-vieux-tableaux. Pour la plupart des dĂ©veloppeurs, std::vector est le conteneur qu’ils savent le mieux utiliser. On ne devrait pas complexifier le code le plus mondain ni le rendre plus dur Ă  lire, alors que ce n’est pas nĂ©cessaire.

Bien sĂ»r, dès que vous avez des besoins spĂ©cifiques, vous devez utiliser le conteneur le plus appropriĂ©, mais ça n’arrive pas si souvent.

(J’ai pris std::vector comme exemple ici, mais c’est aussi valable pour std::map Ă  propos des conteneurs associatifs)

Et l’optimisation alors ?

L’optimisation doit ĂŞtre un comportement a posteriori, et non a priori.

Quand vous Ă©crivez du code, vous ne devez pas penser aux performances. Vous devez l’Ă©crire de la manière la plus claire possible.

C’est seulement après que vous pouvez penser Ă  l’impact de ce code sur les performances globales. En faisant Ă©ventuellement des benchmarks, des analyses statiques et/ou dynamiques pour savoir si il doit ĂŞtre optimisĂ© et selon quels critères d’optimisation (temps d’exĂ©cution ? Utilisation de la mĂ©moire ? etc.).

Si le code que vous Ă©crivez n’est pas dans les 20% du Principe de Pareto, vous ne devriez mĂŞme pas penser aux performances. S’il est dans ces 20%, vous devez y penser après l’avoir Ă©crit.

Une approche plus efficace

Laissez-moi vous présenter le diagramme de flux suivant, dans le même esprit que celui de Joe Gibson, qui résume les propos de la section précédente :

Le principe est simple. Vous avez deux questions Ă  vous poser :

  1. Est-ce que je peux faire ce dont j’ai besoin avec un vector ou une map ?
  2. Est-ce que je suis dans une section critique du code ?

Si vous répondez « Oui » à la première, et « Non » à la deuxième, alors vous devriez utiliser std::vector ou std::map.

Conclusion

Il est plus qu’utile de connaĂ®tre les diffĂ©rences entre les conteneurs du C++. Cependant, dans la vie quotidienne, il est plus pratique de ne pas trop y penser. C’est une erreur assez commune de sur-rĂ©flĂ©chir Ă  des problĂ©matiques qui ont des solutions simples. Mais ces connaissances ne sont pas perdues : de temps en temps, vous serez dans une situation assez spĂ©cifique qui nĂ©cessitera des connaissances avancĂ©es sur les conteneurs.

Le Principe de Pareto peut aussi s’appliquer Ă  cela : plus de 80% des outils qu’on connaĂ®t sont utiles dans moins de 20% des situations.

Merci de votre attention et Ă  la semaine prochaine !

Article original : I don’t know which container to use (and at this point I’m too afraid to ask) | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addenda

Sources et ressources

Le diagramme de flux de Gibson vient d’un repo GitHub oĂą vous pourrez trouver d’autres informations utiles sur les conteneurs : cpp-cheat-sheet/Data Structures and Algorithms.md at master · gibsjose/cpp-cheat-sheet · GitHub.

Il existe aussi une autre fiche-rĂ©sumĂ©, plus dense, parlant des conteneurs (et d’autres sujets Ă©galement) : C++ Cheat Sheets & Infographics | hacking C++ (hackingcpp.com).

Le diagramme de flux pour le choix des structures de données de Joe Gibson


Joe Gibson’s data structure selection flowchart (source: GitHub – gibsjose/cpp-cheat-sheet: C++ Syntax, Data Structures, and Algorithms Cheat Sheet)

Notes

  1. En vĂ©ritĂ©, tout le monde peut se mettre d’accord sur le fait que std::unordered_map est meilleur que std::map. Mais en pratique, il y a bien plus d’usages indiscriminĂ© de map que de sa contrrepartie.
  2. De plus, ce n’est pas juste la taille globale qui importe, mais aussi la taille maximale locale. Par exemple, si un conteneur ne dĂ©passera pas 20 valeurs dans une section critique du code, on peut prĂ©-allouer ces 20 cases pour Ă©viter toute rĂ©allocation sauvage, tout en conservant la flexibilitĂ© d’une structure dynamique.
  3. Bien sûr, chaque conteneur peut faire la plupart des opérations indiquées. Mais les tableaux résument lesquels sont les plus efficaces pour chacune.
  4. Malheureusement le diagramme ne présente pas les versions unordered_ des conteneurs associatifs. Mais vous pouvez y palier simplement en vous demandant : « Les valeurs doivent être ordonnées ? Si oui, map/set ; Si non, unordered_map/unordered_set« .

Une rĂ©flexion sur « Les conteneurs : l’embarras du choix »

  1. Très bon article théorique, en pratique il se trouve que la performance de chaque conteneur peut-être incroyablement différente suivant l’implémentation.
    Il se trouve qu’aujourd’hui les processeurs fonctionnent tous avec des caches, et que lors du chargement d’un élément, en mémoire, le cache charge automatiquement tous les éléments autours rendant extrêmement plus efficace un conteneur continue (std::vector ou std::array). Toutes les structures se basant sur un lien entre les valeurs sont extrêmement lentes à l’usage (lié fortement à l’implémentation).

    D’ailleurs, ce n’est pas pour rien que Qt a décidé qu’une QList était en fait un alias sur un QVector, que Java utilise des ArrayList qui sont plus des tableaux que des listes…

    Bonne journée à vous !

Laisser un commentaire