Si vous souhaitez comparer ce qui suit avec une version plus simple, ne permettant de lister que des entiers et n'offrant pas une interface d'itérateurs (ou d'énumérateurs), jetez un coup d'oeil à ce comparatif d'une classe ListeEntiers.
Ce qui suit montre une implémentation simple d'une classe Liste<T>, représentant une liste simplement chaînée d'instances d'un type T, dans trois langages distincts soit Java, C# et C++, de même que ses itérateurs ou ses énumérateurs (selon le vocabulaire de chacun des langages). Chacune a ses forces, faiblesses et caractéristiques diverses; l'idée ici est de mettre en relief les similitudes et différences entre elles, pas de critiquer ou d'encenser l'une ou l'autre.
Nous débuterons par un comparatif côte à côte des trois implémentations, suite à quoi nous reviendrons sur les détails de chacune (détail de la version C#, détail de la version C++, détail de la version Java). Notez que, contrairement au cas de la liste d'entiers sans itérateurs ni énumérateurs, cette proposition est plus près d'une classe utilisable en pratique et est, par consquent, plus sophistiquée. La version C++, en particulier, sera nettement plus puissante que les versions antérieures – en pratique, on utilisera un tel conteneur plus souvent qu'on ne le programmera, mais vous aurez tout de même un exemple à vous mettre sous la dent si vous le souhaitez. Pour cette raison, les éléments présentés seront identifiés comme étant essentiels (sans quoi la classe n'est pas utilisable) ou souhaitables (sans quoi la classe fonctionne mais n'est pas de calibre commercial).
Placées côte à côte, les implémentations que nous examinerons dans ces trois langages vont comme suit.
Implémentation C# | Implémentation C++ | Implémentation Java |
---|---|---|
|
|
|
Exécution, implémentation C# | Exécution, implémentation C++ | Exécution, implémentation Java |
|
|
|
Ne vous fiez pas au nombre de lignes dans chaque cas pour évaluer la complexité relative de chacune des implémentations (bien que celle en C++ soit plus complexe ici... parce qu'elle en fait beaucoup plus que les deux autres!). J'ai disposé le code dans le respect (à ma connaissance) des pratiques de chaque langage, et certains (C#, par exemple) tendent à générer du code plus « vertical » alors que d'autres (Java, par exemple) mènent à du code plus « horizontal », ne serait-ce que dû aux usages quant à la disposition des accolades.
Quelques notes d'ordre général :
Pour le reste, un sympathique constat nous attend : les similitudes sont de loin plus nombreuses que les différences!
Le détail de notre implémentation à l'aide du langage C# suit.
Avec C#, tout type d'exception doit dériver, directement ou non, de la classe Exception, et la plupart des exceptions des programmes que nous écrivons devraient dériver plus précisément de la classe ApplicationException. C'est ce que nous faisons ici. |
|
La classe Liste<T> est terminale. Son interface ne se prête pas à spécialisation sans effets adverses. Pour qu'elle puisse exposer un énumérateur standard sur des T, il faut lui faire implémenter l'interface IEnumerable<T>. |
|
Une liste simplement chaînée se compose typiquement de noeuds, tels que chacun connaît son successeur. Ainsi, de manière minimaliste, connaître le premier noeud (la tête) de la liste donne un accès indirect à tous les autres. Notre classe Noeud est interne à la classe Liste<T>, comme il se doit (le noeud est un détail d'implémentation et ne concerne pas le code client). Pour cette raison, les accès à la valeur (en lecture, du moins) et au successeur sont publics, n'étant en pratique accessibles qu'aux noeuds et à la liste. La valeur d'un Noeud est immuable une fois le noeud construit. Puisque nous sommes ici en C#, les propriétés sont de mise. N'étant pas vouée à être spécialisée, cette classe est aussi terminale. Remarquez que le constructeur paramétrique initialise le sucesseur d'un Noeud à null, et que le constructeur « de copie » ne copie pas le successeur; organiser les noeuds est le travail de la liste; les noeuds ont pour mandat d'entreposer les données, pas de s'organiser entre eux. Une caractéristique de C# est qu'il ne permet la généricité sur sur les types « références ». En pratique, il est possible d'écrire une Liste<int>, mais le code généré utilisera alors de manière sous-jacente du Boxing, instanciant une classe correspondant à int pour chaque int utilisé. Conséquemment, un Noeud ne « possède » pas le T qui y est entreposé; ce T est partagé avec tous les autres objets qui tiennent une référence sur lui. Ainsi, il est fortement recommandable de n'utiliser une classe telle que Liste<T> que pour des types T immuables (sauf si votre code est écrit pour tenir compte de cette réalité). Cet avertissement est doublement important si vous faites de la multiprogrammation. |
|
Les états de notre Liste<T> sont au nombre de trois, soit :
S'ajoute à ces propriétés la propriété en lecture seulement Vide, prédicat qui s'avère seulement si la liste est vide et dont l'implémentation est de complexité constante, donc . |
|
Une Liste<T> par défaut sera une liste vide : sa téte et sa fin sont toutes deux des références nulles, et son nombre d'éléments est zéro. |
|
J'ai implémenté un « constructeur de copie » privé, donc un constructeur acceptant en paramètre lst, une référence sur une ListeEntiers, et faisant de la ListeEntiers en cours de construction une copie, noeud par noeud, de la liste lst. L'optimisation permise par l'utilisation d'une référence sur le dernier noeud, qui donne à la méthode Ajouter() sa complexité constante, , fait de ce constructeur une opération raisonnable (complexité linéaire, ). Sans cette optimisation, la méthode Ajouter() serait de complexité linéaire (il faudrait parcourir la liste entière à chaque fois pour trouver le dernier élément avant de lui apposer un successeur), et ce constructeur serait alors de complexité quadratique, , ce qui la rendrait inutilisable en pratique. Si la Liste<T> avait été vouée à une spécialisation polymorphique, ce constructeur aurait été protégé et la duplication d'une Liste<T> aurait été faite par clonage. Notez qu'il est difficile de déterminer ce que serait un bon clonage sans savoir quelles sont les caractéristiques de T dans un langage comme C#. J'ai choisi ici d'offrir le constructeur de copie privé et d'exposer un service public de duplication, la méthode Dupliquer(), parce que le recours au constructeur de copie ne fait pas partie des usages en C#. |
|
La méthode Ajouter() permet de réaliser une insertion en fin de liste. Cette opération se réalise en temps constant dû à la connaissance a priori du dernier Noeud de la liste; si nous n'avions pas cette information, alors il faudrait de prime abord trouver le dernier noeud de la liste, une opération coûteuse. Notez que l'on ajoute bel et bien un T à la liste, pas un Noeud. Les noeuds sont des outils organisationnels internes, pas un élément de l'interface d'une Liste<T>. |
|
La méthode Inverser() est la plus complexe du lot en termes d'écriture (sa complexité algorithmique, elle, est linéaire, ). Il s'agit d'un mutateur : elle inverse l'ordre dans lequel les noeuds de la liste apparaissent, ce qui n'est pas nécessairement une pratique recommandable – il serait en général préférable de retourner une nouvelle liste dont les éléments seraient dans l'ordre inverse de ceux de la liste originale, et de laisser cette dernière inchangée. Pour une liste de éléments, cette implémentation fait allocations dynamiques de mémoire pour des noeuds. Ici, la présence d'un ramasse-miettes permet de ne pas se préoccuper de la libération des noeuds « périmés », mais accroît la probabilité qu'une collecte d'ordures soit provoquée pendant l'exécution de la méthode, surtout si la liste comprend un nombre élevé de noeuds. Il existe une solution pour cette méthode qui ne fait aucune allocation de mémoire. La voyez-vous? Serait-elle à privilégier en C#? |
|
La méthode Extraire() élimine un Noeud de la liste et retourne la valeur que ce Noeud entreposait. C'est aussi une méthode de complexité constante, ou . Cette implémentation dépend d'un ramasse-miettes pour assurer le nettoyage des noeuds périmés. |
|
Un énumérateur sur des T en C# est une classe qui implémente l'interface IEnumerator<T>. Ici, cette classe se nomme Énumérateur (en français) et est privée à Liste<T>, puisque ses instances ne seront manipulées qu'à travers l'interface IEnumerator<T>. Pour des raisons historiques, les énumérateurs de C# traînent avec eux un bagage qui alourdit leur écriture : ils sont inspirés d'une technologie antérieure permettant de parcourir des séquences à distance, ce qui amène les services Dispose() et Reset() qui, sans être essentiels, peuvent permettre d'énumérer des éléments de la séquence parcourue de manière plus efficace dans ce cas, et ils ont d'abord été implémentés (en C# v.1,0) de manière non-générique... L'erreur tactique fut sans doute de dériver les interfaces IEnumerator<T> de l'ancienne interface IEnumerator (non-générique), encore aujourd'hui utilisée par foreach en C#, ce qui nous impose d'implémenter certains services « en double ». Le contrat de l'interface IEnumerator<T> est :
Le reste de l'implémentation est directement reliée au conteneur à parcourir. Une séquence standard en C# débute juste avant le premier élément; l'implémentation de cette fonctionnalité nous appartient. |
|
Puisque notre conteneur est un IEnumerable<T>, il doit implémenter le contrat décrit par cette interface, donc offrir la méthodeGetEnumerator() qui retournera un IEnumerator<T> permettant de le parcourir. Ce service doit être offert en deux « saveurs », soit celle susmentionnée qui retourne un IEnumerator<T> et une plus traditionnelle (mais utilisée par foreach) qui retourne un IEnumerator non-générique. |
|
Le programme de test accomplit la tâche attendue, soit tester de manière directe ou indirecte la plupart des services de la classe Liste<T>. Pour les besoins de la démonstration, il utilise une Liste<int> et une Liste<string>, et réalise à la fois des copies et des mutations des conteneurs. |
|
Le détail de notre implémentation à l'aide du langage C++ suit. Notez que dans le code ci-dessous, les clauses throw() devraient être remplacées par des clauses noexcept, mais le compilateur à ma disposition quand j'ai rédigé le tout n'était pas suffisamment à jour pour supporter ce mécanisme de C++ 11. J'indiquerai la complexité algorithmique des opération chaque fois que cela sera possible; j'exprimerai la complexité de T::T(const T&) par , tout comme j'exprimerai la complexité de T::T(const U&) par , dans les deux cas pour des raisons de lisibilité. Nous considérerons aussi les opération d'allocation dynamique de mémoire comme étant de complexité constante, , faute de pouvoir faire mieux – en pratique, le temps requis pour de telles opérations est indéterministe.
Pour les besoins de notre implémentation, nous aurons recours à quelques bibliothèques standards :
Avec C++, tout type peut servir pour signaler une exception. L'usage est de définir un type dont le nom représente le problème à souligner (ici ListeVide) pour éliminer les nombreux ennuis associés aux messages qu'ont les exceptions « traditionnelles » (internationalisation, langue). |
|
La classe Liste<T> est terminale. Son interface ne se prête pas à spécialisation sans effets adverses. Elle expose les types pertinents à son utilisation dans son interface, sous la forme de types internes et publics, ce qui permet au code client de s'exprimer d'une manière telle qu'il pourra s'adapter sans réécriture aux changements d'implémentation. |
|
Une liste simplement chaînée se compose typiquement de noeuds, tels que chacun connaît son successeur. Ainsi, de manière minimaliste, connaître le premier noeud (la tête) de la liste donne un accès indirect à tous les autres. Notre classe Noeud est interne à la classe Liste<T>, comme il se doit (le noeud est un détail d'implémentation et ne concerne pas le code client). Pour cette raison, les accès à la valeur (en lecture, du moins) et au successeur sont publics, n'étant en pratique accessibles qu'aux noeuds et à la liste. Notez que le value_type de Noeud est celui défini plus haut dans Liste<T>, donc T, comme il se doit. La valeur d'un Noeud devrait être immuable une fois le noeud construit; ici, ce sera assuré par discipline, mais il aurait été possible de faire de val un attribut privé auquel les accès passent par un accesseur pour garantir cela. N'étant pas vouée à être spécialisée, cette classe est aussi terminale. Remarquez que le constructeur paramétrique initialise le sucesseur d'un Noeud à nullptr, et que le constructeur de copie ne copie pas le successeur; organiser les noeuds est le travail de la liste; les noeuds ont pour mandat d'entreposer les données, pas de s'organiser entre eux. Complexité : |
|
Les attributs de notre Liste<T> sont au nombre de trois, soit :
J'ai choisi ici de les initialiser par défaut au zéro de leur type. |
|
Une Liste<T> par défaut sera une liste vide : sa téte et sa fin sont toutes deux des pointeurs nuls, et son nombre d'éléments est zéro. Complexité : |
|
Les consignes ne le demandaient pas, mais j'ai implémenté la sémantique de mouvement dans la classe Liste<T>. Après tout, ces méthodes sont simples à écrire, ne lèvent pas d'exceptions, et permettent de nombreuses optimisations. Ce serait fou de s'en passer! Complexité : pour le constructeur, et pour l'affectation. |
|
Deux services clés de la liste sont des accesseurs de premier ordre, soit empty(), prédicat qui s'avère seulement si la liste est vide, et size(), qui donne accès au nombre de noeuds dans la liste. Complexité : dans chaque cas (la complexité de empty() ici dépend directement de la complexité de size()). |
|
La méthode append() permet d'insérer une séquence de valeurs à la fin d'une Liste<T>. Je procède comme suit :
Complexité : , ce qui signifie si It est un itérateur de type Random Access (un pointeur, un itérateur sur un std::vector, sur une std::string, etc.) et pour une séquence de éléments si It est un itérateur de type Forward Iterator. |
|
La méthode push_back() insère un nouvel élément en fin de liste. Notez que l'on ajoute bel et bien un value_type à la liste, pas un Noeud. Les noeuds sont des outils organisationnels internes, pas un élément de l'interface d'une Liste<T>. Complexité : dû à la présence de l'attribut queue, qui permet de connaître a priori le point d'insertion dans un tel cas; si nous n'avions pas cette information, alors il faudrait de prime abord trouver le dernier noeud de la liste, une opération coûteuse. |
|
La méthode clear() vide la liste. Elle est plus rapide que ne le serait une série d'appels successifs à pop_front() du fait qu'elle réalise moins de validations et ne modifie nelems qu'une seule fois en fin de parcours. Complexité : |
|
Le destructeur vide la liste, tout simplement. Complexité : |
|
Pour ce qui est des itérateurs, pour couvrir le cas const (parcourir les éléments sans pouvoir les modifier) et non-const, il serait possible d'écrire deux classes à part entière, mais cela mène à du code quelque peu redondant. J'ai plutôt choisi d'écrire une seule classe, interne et privée, nommée base_iterator<U,N> où U est le type des valeurs et N est le type de noeud à parcourir. Ainsi :
Notez les quelques alias de types définis au début de cette classe : value_type, iterator_category, pointer, reference et difference_type. Avant C++ 17, une classe nommée std::iterator était typiquement utilisée à titre de parent pour la plupart des types d'itérateurs, et définissait ces types de manière à alléger (légèrement) l'écriture. Cette classe est désormais considérée dépréciée, et il est maintenant préférable de définir ces types nous-mêmes. |
|
Muni du type base_iterator<U,N>, la définition des types iterator et const_iterator devient toute simple : iterator est un base_iterator<value_type,Noeud> alors que const_iterator est un base_iterator<const value_type,Noeud>. Le caractère const ou non du type pointé par l'itérateur assure la disponibilité des services appropriés et l'indisponibilité des autres services. |
|
Les itérateurs sur les extrémités de la liste sont exposés à traver les méthodes begin() et end(), en déclinaisons const et non-const, de même qu'à travers les méthodes cbegin() et cend() qui, elles, sont toujours const. Complexité : dans chaque cas. |
|
J'ai implémenté un constructeur de copie public, comme il se doit pour les classes concrètes en C++. Notez le recours à une délégation au constructeur par défaut, pour réduire la redondance d'écriture dans le code; cette manoeuvre sera répétée à plusieurs endroits. Complexité : |
|
J'ai implémenté un constructeur acceptant une séquence d'initialisation en paramètre, donc une expression de la forme où chaque est du même type et peut être utilisé pour initialiser un value_type. Complexité : |
|
La méthode swap(), qui facilite entre autres l'expression de l'opérateur d'affectation, s'implémente par des appels à std::swap() (ou à une implémentation plus spécialisée de cet algorithme, si tel est le cas) un attribut à la fois. Complexité : |
|
L'affectation repose sur l'idiome d'affectation sécuritaire, ce qui est sans doute le choix le plus sage ici. Complexité : |
|
Le constructeur de conversion permet des constructions covariantes pour une Liste<T>, donc la construction d'une Liste<int> à partir d'une Liste<short>, ou la construction d'une Liste<Parent*> à partir d'une Liste<Enfant*> si Enfant dérive publiquement de Parent. Complexité : |
|
L'affectation covariante est au constructeur de conversion ce que l'affectation usuelle est au constructeur de copie. Complexité : |
|
Le constructeur de séquence permet d'initialiser une Liste<T> avec n'importe quelle séquence standard, dans la mesure où les éléments de la séquence standard peuvent être utilisés pour construire des value_type. Complexité : |
|
La comparaison de deux Liste<T> à l'aide de l'opérateur == aura pour résultat true seulement si les deux listes ont le même nombre d'éléments et si les éléments aux mêmes positions dans chacune des deux listes comparées sont eux aussi égaux au sens de l'opérateur ==. évidemment, ceci suppose que value_type a une valeur exacte; avec des nombres à virgule flottante, cet opérateur est d'une utilité limitée. Complexité : où vaut size() |
|
La comparaison d'une Liste<T> à une Liste<U> à l'aide de l'opérateur == aura pour résultat true seulement si les deux listes ont le même nombre d'éléments et si les éléments aux mêmes positions dans chacune des deux listes comparées sont eux aussi égaux au sens de l'opérateur ==. Complexité : où vaut size() |
|
La comparaison de deux Liste<T> à l'aide de l'opérateur != équivaut à la négation logique de leur comparaison à l'aide de l'opérateur ==. Complexité : où vaut size() |
|
La comparaison d'une Liste<T> à une Liste<U> à l'aide de l'opérateur != équivaut à la négation logique de leur comparaison à l'aide de l'opérateur ==. Complexité : où vaut size() |
|
L'ajout d'un élément en tête de liste se fait avec push_front(). Complexité : |
|
La méthode pop_front() élimine un Noeud de la liste. Elle ne retourne rien, comme il se doit, car s'il s'avérait que la copie d'un value_type retourné lève une exception, alors il y aurait perte d'information. Pour cette raison, le code client accédera à la valeur en tête de liste par un autre service (la méthode back()). Complexité : |
|
La méthode front() et retourne la valeur entreposée dans le Noeud en tête de liste. Complexité : pour la version non-const, pour la version const. |
|
La méthode back() et retourne la valeur entreposée dans le Noeud en fin de liste. Complexité : pour la version non-const, pour la version const.
|
|
La méthode inverser() est la plus complexe du lot en termes d'écriture (sa complexité algorithmique, elle, est linéaire, ). Il s'agit d'un mutateur : elle inverse l'ordre dans lequel les noeuds de la liste apparaissent, ce qui n'est pas nécessairement une pratique recommandable – il serait en général préférable de retourner une nouvelle liste dont les éléments seraient dans l'ordre inverse de ceux de la liste originale, et de laisser cette dernière inchangée. Pour une liste de éléments, cette implémentation fait allocations dynamiques de mémoire pour des noeuds. Chaque noeud est détruit une fois son utilité chose du passé, puisque C++ ne suppose pas un ramasse-miettes. Une alternative aurait été d'utiliser des pointeurs intelligents, mais dans ce cas la gestion à faire était suffisamment simple pour que je me limite à de vulgaires pointeurs bruts. Complexité : Il existe une solution pour cette méthode qui ne fait aucune allocation de mémoire. La voyez-vous? Serait-elle à privilégier en C++? |
|
Le programme de test que j'ai utilisé est proposé à droite. On y trouve :
Cette liste de tests n'est pas exhaustive, mais a pour but de donner quelque peu confiance en l'implémentation proposée. |
|
Le détail de notre implémentation à l'aide du langage Java suit.
Avec Java, tout type d'exception doit dériver, directement ou non, de la classe Exception. C'est ce que nous faisons ici. |
|
La classe Liste<T> est terminale. Son interface ne se prête pas à spécialisation sans effets adverses. Pour qu'elle puisse exposer un énumérateur standard sur des T, il faut lui faire implémenter l'interface Iterable<T>. |
|
Une liste simplement chaînée se compose typiquement de noeuds, tels que chacun connaît son successeur. Ainsi, de manière minimaliste, connaître le premier noeud (la tête) de la liste donne un accès indirect à tous les autres. Notre classe Noeud est interne à la classe ListeEntiers, comme il se doit (le noeud est un détail d'implémentation et ne concerne pas le code client). Pour cette raison, les accès à la valeur (en lecture, du moins) et au successeur sont publics, n'étant en pratique accessibles qu'aux noeuds et à la liste. La valeur d'un Noeud est immuable une fois le noeud construit. Notez que dû au rôle circonscrit de cette classe et au fait qu'elle ne peut être utilisée qu'à l'interne par ListeEntiers, j'ai laissé l'attribut succ public (et oui, dans ce cas, c'est légitime). Remarquez que le constructeur paramétrique initialise le sucesseur d'un Noeud à null, et que le constructeur « de copie » ne copie pas le successeur; organiser les noeuds est le travail de la liste; les noeuds ont pour mandat d'entreposer les données, pas de s'organiser entre eux. Une caractéristique de Java est qu'il ne permet la généricité sur sur les types « références ». Conséquemment, un Noeud ne « possède » pas le T qui y est entreposé; ce T est partagé avec tous les autres objets qui tiennent une référence sur lui. Ainsi, il est fortement recommandable de n'utiliser une classe telle que Liste<T> que pour des types T immuables (sauf si votre code est écrit pour tenir compte de cette réalité). Cet avertissement est doublement important si vous faites de la multiprogrammation. |
|
Les attributs de notre Liste<T> sont au nombre de trois, soit :
Deux services clés de la liste sont des accesseurs de premier ordre, soit estVide(), prédicat qui s'avère seulement si la liste est vide, et getTaille(), qui donne accès au nombre de noeuds dans la liste. Ces deux fonctions sont de complexité constante, donc . |
|
Une Liste<T> par défaut sera une liste vide : sa téte et sa fin sont toutes deux des références nulles, et son nombre d'éléments est zéro. |
|
J'ai implémenté un « constructeur de copie » privé, donc un constructeur acceptant en paramètre lst, une référence sur une Liste<T>, et faisant de la Liste<T> en cours de construction une copie, noeud par noeud, de la liste lst. L'optimisation permise par l'utilisation d'une référence sur le dernier noeud, qui donne à la méthode ajouter() sa complexité constante, , fait de ce constructeur une opération raisonnable (complexité linéaire, ). Sans cette optimisation, la méthode ajouter() serait de complexité linéaire (il faudrait parcourir la liste entière à chaque fois pour trouver le dernier élément avant de lui apposer un successeur), et ce constructeur serait alors de complexité quadratique, , ce qui la rendrait inutilisable en pratique. Si la Liste<T> avait été vouée à une spécialisation polymorphique, ce constructeur aurait été protégé et la duplication d'une Liste<T> aurait été faite par clonage. Notez qu'il est difficile de déterminer ce que serait un bon clonage sans savoir quelles sont les caractéristiques de T dans un langage comme Java. J'ai choisi ici d'offrir le constructeur de copie privé et d'exposer un service public de duplication, la méthode dupliquer(), parce que le recours au constructeur de copie ne fait pas partie des usages en Java. |
|
La méthode ajouter() permet de réaliser une insertion en fin de liste. Cette opération se réalise en temps constant dû à la connaissance a priori du dernier Noeud de la liste; si nous n'avions pas cette information, alors il faudrait de prime abord trouver le dernier noeud de la liste, une opération coûteuse. Notez que l'on ajoute bel et bien un T à la liste, pas un Noeud. Les noeuds sont des outils organisationnels internes, pas un élément de l'interface d'une Liste<T>. |
|
La méthode inverser() est la plus complexe du lot en termes d'écriture (sa complexité algorithmique, elle, est linéaire, ). Il s'agit d'un mutateur : elle inverse l'ordre dans lequel les noeuds de la liste apparaissent, ce qui n'est pas nécessairement une pratique recommandable – il serait en général préférable de retourner une nouvelle liste dont les éléments seraient dans l'ordre inverse de ceux de la liste originale, et de laisser cette dernière inchangée. Pour une liste de éléments, cette implémentation fait allocations dynamiques de mémoire pour des noeuds. Ici, la présence d'un ramasse-miettes permet de ne pas se préoccuper de la libération des noeuds « périmés », mais accroît la probabilité qu'une collecte d'ordures soit provoquée pendant l'exécution de la méthode, surtout si la liste comprend un nombre élevé de noeuds. Il existe une solution pour cette méthode qui ne fait aucune allocation de mémoire. La voyez-vous? Serait-elle à privilégier en Java? |
|
La méthode extraire() élimine un Noeud de la liste et retourne la valeur que ce Noeud entreposait. C'est aussi une méthode de complexité constante, ou . Cette implémentation dépend d'un ramasse-miettes pour assurer le nettoyage des noeuds périmés. La clause throws ListeVide est obligatoire en Java si extraire() est susceptible de lever ListeVide. |
|
Un itérateur sur des T en Java est une classe qui implémente l'interface Iterator<T>. Ici, cette classe se nomme Itérateur (en français) et est privée à Liste<T>, puisque ses instances ne seront manipulées qu'à travers l'interface Iterator<T>. Le contrat de l'interface Iterator<T> est :
Le reste de l'implémentation est directement reliée au conteneur à parcourir. Une séquence standard en Java débute juste avant le premier élément; l'implémentation de cette fonctionnalité nous appartient. |
|
Puisque notre conteneur est un Iterable<T>, il doit implémenter le contrat décrit par cette interface, donc offrir la méthode iterator() qui retournera un Iterator<T> permettant de le parcourir. |
|
Le programme de test accomplit la tâche attendue, soit tester de manière directe ou indirecte la plupart des services de la classe Liste<T>. Pour les besoins de la démonstration, il utilise une Liste<Integer> et une Liste<string>, et réalise à la fois des copies et des mutations des conteneurs. Remarquez que le code Java doit insérer une forme de gestion d'exception lors d'appels à extraire(), même s'il est évident à l'oeil nu qu'aucune exception ne sera levée ici. |
|
Vous remarquerez que les ressemblances sont beaucoup plus nombreuses que les divergencesn en particulier dans les versions Java et C# car ces langages se ressemblent philosophiquement (avec C++, qui se conforme à la STL, on trouve des concepts et des formalismes plus sophistiqués).
Personnellement, j'ai écrit l'une des versions, et la traduction dans les autres langages s'est faite en quelques minutes chaque fois. Les plus importantes différences ont trait à la sémantique d'accès (directe ou indirecte) et à ses conséquences : en C++, écrire la Sainte-Trinité; en Java et en C#, compenser le partage implicite par une duplication manuelle. Les métaphores des itérateurs et des énumérateurs sont teintés de la culture de chacun des langages.