Révélez la magie du polymorphisme des sous-types

Le mot polymorphisme vient du grec pour «plusieurs formes». La plupart des développeurs Java associent le terme à la capacité d'un objet à exécuter par magie un comportement de méthode correct aux points appropriés d'un programme. Cependant, cette vision orientée implémentation conduit à des images de magie, plutôt qu'à une compréhension des concepts fondamentaux.

Le polymorphisme en Java est invariablement un polymorphisme de sous-type. Examiner de près les mécanismes qui génèrent cette variété de comportements polymorphes nous oblige à abandonner nos préoccupations habituelles d'implémentation et à penser en termes de type. Cet article étudie une perspective orientée type d'objets, et comment qui sépare en perspective ce que le comportement d' un objet peut exprimer de la façon dont l'objet exprime en réalité ce comportement. En libérant notre concept de polymorphisme de la hiérarchie d'implémentation, nous découvrons également comment les interfaces Java facilitent le comportement polymorphique entre des groupes d'objets qui ne partagent aucun code d'implémentation.

Quattro polymorphi

Le polymorphisme est un terme large orienté objet. Bien que nous assimilions généralement le concept général à la variété de sous-type, il existe en fait quatre types différents de polymorphisme. Avant d'examiner en détail le polymorphisme des sous-types, la section suivante présente un aperçu général du polymorphisme dans les langages orientés objet.

Luca Cardelli et Peter Wegner, auteurs de «On Understanding Types, Data Abstraction, and Polymorphism» (voir Ressources pour le lien vers l'article) divisent le polymorphisme en deux grandes catégories - ad hoc et universelle - et en quatre variétés: coercition, surcharge, paramétrique et inclusion. La structure de classification est:

| - coercition | - ad hoc - | | - polymorphisme de surcharge - | | - paramétrique | - universel - | | - inclusion

Dans ce schéma général, le polymorphisme représente la capacité d'une entité à avoir plusieurs formes. Le polymorphisme universel fait référence à une uniformité de la structure de type, dans laquelle le polymorphisme agit sur un nombre infini de types qui ont une caractéristique commune. Le polymorphisme ad hoc moins structuré agit sur un nombre fini de types éventuellement non liés. Les quatre variétés peuvent être décrites comme:

  • Coercition: une seule abstraction sert plusieurs types via une conversion de type implicite
  • Surcharge: un seul identifiant dénote plusieurs abstractions
  • Paramétrique: une abstraction fonctionne uniformément sur différents types
  • Inclusion: une abstraction opère à travers une relation d'inclusion

Je discuterai brièvement de chaque variété avant de passer spécifiquement au polymorphisme de sous-type.

Coercition

La coercition représente la conversion implicite du type de paramètre en type attendu par une méthode ou un opérateur, évitant ainsi les erreurs de type. Pour les expressions suivantes, le compilateur doit déterminer si un +opérateur binaire approprié existe pour les types d'opérandes:

 2,0 + 2,0 2,0 + 2 2,0 + «2» 

La première expression ajoute deux doubleopérandes; le langage Java définit spécifiquement un tel opérateur.

Cependant, la deuxième expression ajoute un doubleet un int; Java ne définit pas d'opérateur qui accepte ces types d'opérandes. Heureusement, le compilateur convertit implicitement le deuxième opérande en doubleet utilise l'opérateur défini pour deux doubleopérandes. C'est extrêmement pratique pour le développeur; sans la conversion implicite, une erreur de compilation en résulterait ou le programmeur devrait convertir explicitement le inten double.

La troisième expression ajoute a doubleet a String. Encore une fois, le langage Java ne définit pas un tel opérateur. Ainsi, le compilateur contraint l' doubleopérande à a String, et l'opérateur plus effectue la concaténation de chaînes.

La coercition se produit également lors de l'appel de la méthode. Supposons que la classe Derivedétend la classe Baseet que la classe Cait une méthode avec signature m(Base). Pour l'invocation de méthode dans le code ci-dessous, le compilateur convertit implicitement la derivedvariable de référence, qui a le type Derived, au Basetype prescrit par la signature de méthode. Cette conversion implicite permet au m(Base)code d'implémentation de la méthode d'utiliser uniquement les opérations de type définies par Base:

C c = nouveau C (); Dérivé dérivé = nouveau Dérivé (); cm (dérivé);

Là encore, la coercition implicite lors de l'appel de méthode évite un cast de type encombrant ou une erreur de compilation inutile. Bien sûr, le compilateur vérifie toujours que toutes les conversions de type sont conformes à la hiérarchie de types définie.

Surcharge

La surcharge permet d'utiliser le même nom d'opérateur ou de méthode pour désigner des significations de programme multiples et distinctes. L' +opérateur utilisé dans la section précédente présentait deux formes: une pour ajouter des doubleopérandes, une pour concaténer des Stringobjets. D'autres formes existent pour ajouter deux entiers, deux longs, etc. Nous appelons l'opérateur surchargé et comptons sur le compilateur pour sélectionner la fonctionnalité appropriée en fonction du contexte du programme. Comme indiqué précédemment, si nécessaire, le compilateur convertit implicitement les types d'opérande pour correspondre à la signature exacte de l'opérateur. Bien que Java spécifie certains opérateurs surchargés, il ne prend pas en charge la surcharge des opérateurs définie par l'utilisateur.

Java autorise la surcharge définie par l'utilisateur des noms de méthode. Une classe peut posséder plusieurs méthodes portant le même nom, à condition que les signatures de méthode soient distinctes. Cela signifie que soit le nombre de paramètres doit être différent, soit au moins une position de paramètre doit avoir un type différent. Les signatures uniques permettent au compilateur de distinguer les méthodes qui portent le même nom. Le compilateur modifie les noms de méthode en utilisant les signatures uniques, créant efficacement des noms uniques. À la lumière de cela, tout comportement polymorphe apparent s'évapore après une inspection plus approfondie.

La coercition et la surcharge sont classées comme ad hoc car chacune ne fournit un comportement polymorphe que dans un sens limité. Bien qu'elles relèvent d'une définition large du polymorphisme, ces variétés sont principalement des commodités pour les développeurs. La coercition évite les transtypages de types explicites encombrants ou les erreurs de type de compilateur inutiles. La surcharge, en revanche, fournit du sucre syntaxique, permettant à un développeur d'utiliser le même nom pour des méthodes distinctes.

Paramétrique

Le polymorphisme paramétrique permet l'utilisation d'une seule abstraction sur plusieurs types. Par exemple, une Listabstraction, représentant une liste d'objets homogènes, pourrait être fournie sous la forme d'un module générique. Vous réutiliseriez l'abstraction en spécifiant les types d'objets contenus dans la liste. Comme le type paramétré peut être n'importe quel type de données défini par l'utilisateur, il existe un nombre potentiellement infini d'utilisations pour l'abstraction générique, ce qui en fait sans doute le type de polymorphisme le plus puissant.

À première vue, l' Listabstraction ci-dessus peut sembler être l'utilité de la classe java.util.List. Cependant, Java ne supporte pas vrai polymorphisme paramétrique de manière type de sécurité, ce qui explique pourquoi java.util.Listet d' java.utilautres classes de collection de » sont écrits en termes de la classe Java primordiale, java.lang.Object. (Voir mon article "Une interface primordiale?" Pour plus de détails.) L'héritage d'implémentation à racine unique de Java offre une solution partielle, mais pas la vraie puissance du polymorphisme paramétrique. L'excellent article d'Eric Allen, «Behold the Power of Parametric Polymorphism», décrit le besoin de types génériques en Java et les propositions pour répondre à la demande de spécification Java de Sun # 000014, «Ajouter des types génériques au langage de programmation Java». (Voir Ressources pour un lien.)

Inclusion

Le polymorphisme d'inclusion réalise un comportement polymorphe grâce à une relation d'inclusion entre des types ou des ensembles de valeurs. Pour de nombreux langages orientés objet, y compris Java, la relation d'inclusion est une relation de sous-type. Ainsi, en Java, le polymorphisme d'inclusion est un polymorphisme de sous-type.

Comme indiqué précédemment, lorsque les développeurs Java font référence de manière générique au polymorphisme, ils signifient invariablement le polymorphisme de sous-type. Acquérir une solide appréciation de la puissance du polymorphisme de sous-type nécessite de visualiser les mécanismes produisant un comportement polymorphe dans une perspective orientée type. Le reste de cet article examine cette perspective de près. Par souci de concision et de clarté, j'utilise le terme polymorphisme pour désigner le polymorphisme de sous-type.

Vue orientée type

The UML class diagram in Figure 1 shows the simple type and class hierarchy used to illustrate the mechanics of polymorphism. The model depicts five types, four classes, and one interface. Although the model is called a class diagram, I think of it as a type diagram. As detailed in "Thanks Type and Gentle Class," every Java class and interface declares a user-defined data type. So from an implementation-independent view (i.e., a type-oriented view) each of the five rectangles in the figure represents a type. From an implementation point of view, four of those types are defined using class constructs, and one is defined using an interface.

The following code defines and implements each user-defined data type. I purposely keep the implementation as simple as possible:

/* Base.java */ public class Base { public String m1() { return "Base.m1()"; } public String m2( String s ) { return "Base.m2( " + s + " )"; } } /* IType.java */ interface IType { String m2( String s ); String m3(); } /* Derived.java */ public class Derived extends Base implements IType { public String m1() { return "Derived.m1()"; } public String m3() { return "Derived.m3()"; } } /* Derived2.java */ public class Derived2 extends Derived { public String m2( String s ) { return "Derived2.m2( " + s + " )"; } public String m4() { return "Derived2.m4()"; } } /* Separate.java */ public class Separate implements IType { public String m1() { return "Separate.m1()"; } public String m2( String s ) { return "Separate.m2( " + s + " )"; } public String m3() { return "Separate.m3()"; } } 

Using these type declarations and class definitions, Figure 2 depicts a conceptual view of the Java statement:

Derived2 derived2 = new Derived2(); 

The above statement declares an explicitly typed reference variable, derived2, and attaches that reference to a newly created Derived2 class object. The top panel in Figure 2 depicts the Derived2 reference as a set of portholes, through which the underlying Derived2 object can be viewed. There is one hole for each Derived2 type operation. The actual Derived2 object maps each Derived2 operation to appropriate implementation code, as prescribed by the implementation hierarchy defined in the above code. For example, the Derived2 object maps m1() to implementation code defined in class Derived. Furthermore, that implementation code overrides the m1() method in class Base. A Derived2 reference variable cannot access the overridden m1() implementation in class Base. That does not mean that the actual implementation code in class Derived can't use the Base class implementation via super.m1(). But as far as the reference variable derived2 is concerned, that code is inaccessible. The mappings of the other Derived2 operations similarly show the implementation code executed for each type operation.

Now that you have a Derived2 object, you can reference it with any variable that conforms to type Derived2. The type hierarchy in Figure 1's UML diagram reveals that Derived, Base, and IType are all super types of Derived2. So, for example, a Base reference can be attached to the object. Figure 3 depicts the conceptual view of the following Java statement:

Base base = derived2; 

There is absolutely no change to the underlying Derived2 object or any of the operation mappings, though methods m3() and m4() are no longer accessible through the Base reference. Calling m1() or m2(String) using either variable derived2 or base results in execution of the same implementation code:

String tmp; // Derived2 reference (Figure 2) tmp = derived2.m1(); // tmp is "Derived.m1()" tmp = derived2.m2( "Hello" ); // tmp is "Derived2.m2( Hello )" // Base reference (Figure 3) tmp = base.m1(); // tmp is "Derived.m1()" tmp = base.m2( "Hello" ); // tmp is "Derived2.m2( Hello )" 

Realizing identical behavior through both references makes sense because the Derived2 object does not know what calls each method. The object only knows that when called upon, it follows the marching orders defined by the implementation hierarchy. Those orders stipulate that for method m1(), the Derived2 object executes the code in class Derived, and for method m2(String), it executes the code in class Derived2. The action performed by the underlying object does not depend on the reference variable's type.

Cependant, tout n'est pas égal lorsque vous utilisez les variables de référence derived2et base. Comme le montre la figure 3, une Baseréférence de type ne peut voir que les Baseopérations de type de l'objet sous-jacent. Donc, bien qu'il Derived2ait des mappages pour les méthodes m3()et m4(), la variable basene peut pas accéder à ces méthodes:

String tmp; // Référence Derived2 (Figure 2) tmp = derived2.m3 (); // tmp est "Dérivé.m3 ()" tmp = dérivé2.m4 (); // tmp est "Derived2.m4 ()" // Référence de base (Figure 3) tmp = base.m3 (); // Erreur de compilation tmp = base.m4 (); // Erreur de compilation

Le runtime

Derived2

l'objet reste pleinement capable d'accepter soit le

m3()

ou

m4()

appels de méthode. Les restrictions de type qui interdisent ces tentatives d'appels via le

Base