Découvrez la puissance du polymorphisme paramétrique

Supposons que vous souhaitiez implémenter une classe de liste en Java. Vous commencez par une classe abstraite,, Listet deux sous-classes, Emptyet Cons, représentant respectivement des listes vides et non vides. Puisque vous prévoyez d'étendre les fonctionnalités de ces listes, vous concevez une ListVisitorinterface et fournissez des accept(...)hooks pour ListVisitors dans chacune de vos sous-classes. De plus, votre Consclasse a deux champs firstet rest, avec les méthodes d'accesseur correspondantes.

Quels seront les types de ces champs? De toute évidence, restdevrait être de type List. Si vous savez à l'avance que vos listes contiendront toujours des éléments d'une classe donnée, la tâche de codage sera considérablement plus facile à ce stade. Si vous savez que vos éléments de liste seront tous des integers, par exemple, vous pouvez attribuer firstà être de type integer.

Cependant, si, comme c'est souvent le cas, vous ne connaissez pas ces informations à l'avance, vous devez vous contenter de la superclasse la moins courante qui contient tous les éléments possibles contenus dans vos listes, qui est généralement le type de référence universelle Object. Par conséquent, votre code pour les listes d'éléments de type variés a la forme suivante:

classe abstraite List {objet public abstrait accepté (ListVisitor that); } interface ListVisitor {objet public _case (vide cela); public Object _case (Contre cela); } class Empty étend la liste {Public Object accept (ListVisitor that) {return that._case (this); }} class Cons étend List {Private Object first; repos de liste privée; Inconvénients (objet _first, liste _rest) {first = _first; reste = _rest; } public Object first () {return first;} public List rest () {return rest;} public Object accept (ListVisitor that) {return that._case (this); }}

Bien que les programmeurs Java utilisent souvent la superclasse la moins courante pour un champ de cette manière, l'approche a ses inconvénients. Supposons que vous créez un ListVisitorqui ajoute tous les éléments d'une liste de Integers et renvoie le résultat, comme illustré ci-dessous:

la classe AddVisitor implémente ListVisitor {private Integer zero = new Integer (0); public Object _case (Empty that) {return zero;} public Object _case (Cons that) {return new Integer (((Integer) that.first ()). intValue () + ((Integer) that.rest (). accept (this)). intValue ()); }}

Notez les casts explicites Integerdans la deuxième _case(...)méthode. Vous effectuez à plusieurs reprises des tests d'exécution pour vérifier les propriétés des données; idéalement, le compilateur doit effectuer ces tests pour vous dans le cadre de la vérification du type de programme. Mais comme vous n'êtes pas assuré que AddVisitorce ne sera appliqué qu'aux Lists de Integers, le vérificateur de type Java ne peut pas confirmer que vous ajoutez en fait deux Integers à moins que les transtypages soient présents.

Vous pourriez potentiellement obtenir une vérification de type plus précise, mais uniquement en sacrifiant le polymorphisme et la duplication du code. Vous pouvez, par exemple, créer une Listclasse spéciale (avec les classes correspondantes Conset les Emptysous - classes, ainsi qu'une Visitorinterface spéciale ) pour chaque classe d'élément que vous stockez dans un fichier List. Dans l'exemple ci-dessus, vous créeriez une IntegerListclasse dont les éléments sont tous des Integers. Mais si vous vouliez stocker, disons, Booleans à un autre endroit du programme, vous devrez créer une BooleanListclasse.

Il est clair que la taille d'un programme écrit en utilisant cette technique augmenterait rapidement. Il existe également d'autres problèmes de style; L'un des principes essentiels d'une bonne ingénierie logicielle est d'avoir un point de contrôle unique pour chaque élément fonctionnel du programme, et la duplication du code de cette manière copier-coller viole ce principe. Cela entraîne généralement des coûts élevés de développement et de maintenance de logiciels. Pour voir pourquoi, considérez ce qui se passe quand un bogue est trouvé: le programmeur devrait revenir en arrière et corriger ce bogue séparément dans chaque copie faite. Si le programmeur oublie d'identifier tous les sites dupliqués, un nouveau bogue sera introduit!

Mais, comme l'illustre l'exemple ci-dessus, il vous sera difficile de conserver simultanément un point de contrôle unique et d'utiliser des vérificateurs de type statique pour garantir que certaines erreurs ne se produiront jamais lors de l'exécution du programme. En Java, tel qu'il existe aujourd'hui, vous n'avez souvent pas d'autre choix que de dupliquer le code si vous souhaitez une vérification de type statique précise. Pour être sûr, vous ne pourrez jamais éliminer complètement cet aspect de Java. Certains postulats de la théorie des automates, poussés à leur conclusion logique, impliquent qu'aucun système de type sonore ne peut déterminer avec précision l'ensemble des entrées (ou sorties) valides pour toutes les méthodes d'un programme. Par conséquent, chaque système de type doit trouver un équilibre entre sa propre simplicité et l'expressivité du langage qui en résulte; le système de type Java penche un peu trop dans le sens de la simplicité. Dans le premier exemple,un système de type légèrement plus expressif vous aurait permis de maintenir une vérification de type précise sans avoir à dupliquer le code.

Un tel système de type expressif ajouterait des types génériques au langage. Les types génériques sont des variables de type qui peuvent être instanciées avec un type spécifique approprié pour chaque instance d'une classe. Pour les besoins de cet article, je déclarerai les variables de type entre crochets au-dessus des définitions de classe ou d'interface. La portée d'une variable de type sera alors constituée du corps de la définition à laquelle elle a été déclarée (sans inclure la extendsclause). Dans cette portée, vous pouvez utiliser la variable de type partout où vous pouvez utiliser un type ordinaire.

Par exemple, avec les types génériques, vous pouvez réécrire votre Listclasse comme suit:

classe abstraite List {résumé public T accept (ListVisitor that); } interface ListVisitor {public T _case (Vide cela); public T _case (Contre cela); } class Empty étend List {public T accept (ListVisitor that) {return that._case (this); }} class Cons étend List {private T first; repos de liste privée; Inconvénients (T _first, List _rest) {first = _first; reste = _rest; } public T first () {return first;} public List rest () {return rest;} public T accept (ListVisitor that) {return that._case (this); }}

Vous pouvez maintenant réécrire AddVisitorpour tirer parti des types génériques:

la classe AddVisitor implémente ListVisitor {private Integer zero = new Integer (0); public Integer _case (Empty that) {return zero;} public Integer _case (Cons that) {return new Integer ((that.first ()). intValue () + (that.rest (). accept (this)). intValue ()); }}

Notez que les transtypages explicites en Integerne sont plus nécessaires. L'argument thatde la deuxième _case(...)méthode est déclaré être Cons, instanciant la variable de type pour la Consclasse avec Integer. Par conséquent, le vérificateur de type statique peut prouver que ce that.first()sera de type Integeret qui that.rest()sera de type List. Des instanciations similaires seraient effectuées chaque fois qu'une nouvelle instance de Emptyou Consest déclarée.

Dans l'exemple ci-dessus, les variables de type peuvent être instanciées avec any Object. Vous pouvez également fournir une limite supérieure plus spécifique à une variable de type. Dans de tels cas, vous pouvez spécifier cette limite au point de déclaration de la variable de type avec la syntaxe suivante:

  étend  

Par exemple, si vous souhaitez que vos Lists ne contiennent que des Comparableobjets, vous pouvez définir vos trois classes comme suit:

class List {...} class Cons {...} class Empty {...} 

Bien que l'ajout de types paramétrés à Java vous offrirait les avantages indiqués ci-dessus, cela ne serait pas utile si cela signifiait sacrifier la compatibilité avec le code hérité dans le processus. Heureusement, un tel sacrifice n'est pas nécessaire. Il est possible de traduire automatiquement du code, écrit dans une extension de Java qui a des types génériques, en bytecode pour la JVM existante. Plusieurs compilateurs le font déjà - les compilateurs Pizza et GJ, écrits par Martin Odersky, sont des exemples particulièrement bons. Pizza était un langage expérimental qui ajoutait plusieurs nouvelles fonctionnalités à Java, dont certaines étaient incorporées à Java 1.2; GJ est un successeur de Pizza qui n'ajoute que des types génériques. Comme il s'agit de la seule fonctionnalité ajoutée, le compilateur GJ peut produire du bytecode qui fonctionne parfaitement avec le code hérité. Il compile la source en bytecode au moyen deeffacement de type, qui remplace chaque instance de chaque variable de type par la limite supérieure de cette variable. Il permet également de déclarer des variables de type pour des méthodes spécifiques, plutôt que pour des classes entières. GJ utilise la même syntaxe pour les types génériques que j'utilise dans cet article.

Travail en cours

À l'Université Rice, le groupe de technologie des langages de programmation dans lequel je travaille implémente un compilateur pour une version à compatibilité ascendante de GJ, appelée NextGen. Le langage NextGen a été développé conjointement par le professeur Robert Cartwright du département d'informatique de Rice et Guy Steele de Sun Microsystems; il ajoute la possibilité d'effectuer des vérifications d'exécution des variables de type à GJ.

Une autre solution potentielle à ce problème, appelée PolyJ, a été développée au MIT. Il est en cours d'extension à Cornell. PolyJ utilise une syntaxe légèrement différente de celle de GJ / NextGen. Il diffère également légèrement dans l'utilisation de types génériques. Par exemple, il ne prend pas en charge le paramétrage de type de méthodes individuelles, et actuellement, ne prend pas en charge les classes internes. Mais contrairement à GJ ou NextGen, il permet d'instancier les variables de type avec des types primitifs. De plus, comme NextGen, PolyJ prend en charge les opérations d'exécution sur les types génériques.

Sun a publié une demande de spécification Java (JSR) pour ajouter des types génériques au langage. Sans surprise, l'un des principaux objectifs répertoriés pour toute soumission est le maintien de la compatibilité avec les bibliothèques de classes existantes. Lorsque des types génériques sont ajoutés à Java, il est probable que l'une des propositions décrites ci-dessus servira de prototype.

Certains programmeurs s'opposent à l'ajout de types génériques sous quelque forme que ce soit, malgré leurs avantages. Je ferai référence à deux arguments courants de ces opposants comme l'argument "les modèles sont mauvais" et l'argument "ce n'est pas orienté objet", et j'aborderai chacun d'eux à tour de rôle.

Les modèles sont-ils mauvais?

C ++ utilise des modèlespour fournir une forme de types génériques. Les modèles ont acquis une mauvaise réputation parmi certains développeurs C ++ car leurs définitions ne sont pas vérifiées sous forme paramétrée. Au lieu de cela, le code est répliqué à chaque instanciation et chaque réplication est vérifiée de type séparément. Le problème avec cette approche est que des erreurs de type peuvent exister dans le code d'origine qui n'apparaissent dans aucune des instanciations initiales. Ces erreurs peuvent se manifester plus tard si les révisions ou extensions de programme introduisent de nouvelles instanciations. Imaginez la frustration d'un développeur utilisant des classes existantes qui vérifient le type lorsqu'elles sont compilées par elles-mêmes, mais pas après avoir ajouté une nouvelle sous-classe parfaitement légitime! Pire encore, si le modèle n'est pas recompilé avec les nouvelles classes, ces erreurs ne seront pas détectées, mais corrompront à la place le programme en cours d'exécution.

En raison de ces problèmes, certaines personnes froncent les sourcils en ramenant des modèles, s'attendant à ce que les inconvénients des modèles en C ++ s'appliquent à un système de type générique en Java. Cette analogie est trompeuse, car les fondements sémantiques de Java et C ++ sont radicalement différents. C ++ est un langage non sécurisé, dans lequel la vérification de type statique est un processus heuristique sans fondement mathématique. En revanche, Java est un langage sûr, dans lequel le vérificateur de type statique prouve littéralement que certaines erreurs ne peuvent pas se produire lorsque le code est exécuté. En conséquence, les programmes C ++ impliquant des modèles souffrent d'une myriade de problèmes de sécurité qui ne peuvent pas se produire en Java.

De plus, toutes les propositions importantes pour un Java générique effectuent une vérification de type statique explicite des classes paramétrées, plutôt que de le faire simplement à chaque instanciation de la classe. Si vous craignez qu'une telle vérification explicite ralentisse la vérification de type, soyez assuré qu'en fait, c'est l'inverse: puisque le vérificateur de type ne fait qu'une seule passe sur le code paramétré, par opposition à une passe pour chaque instanciation du types paramétrés, le processus de vérification de type est accéléré. Pour ces raisons, les nombreuses objections aux modèles C ++ ne s'appliquent pas aux propositions de type générique pour Java. En fait, si vous regardez au-delà de ce qui a été largement utilisé dans l'industrie, il existe de nombreux langages moins populaires mais très bien conçus, tels que Objective Caml et Eiffel, qui prennent en charge les types paramétrés avec un grand avantage.

Les systèmes de types génériques sont-ils orientés objet?

Enfin, certains programmeurs s'opposent à tout système de type générique au motif que, comme ces systèmes ont été initialement développés pour des langages fonctionnels, ils ne sont pas orientés objet. Cette objection est fausse. Les types génériques s'intègrent très naturellement dans un cadre orienté objet, comme le montrent les exemples et la discussion ci-dessus. Mais je soupçonne que cette objection est enracinée dans un manque de compréhension de la façon d'intégrer les types génériques avec le polymorphisme d'héritage de Java. En fait, une telle intégration est possible et constitue la base de notre implémentation de NextGen.