La covariance dans le JDK1.5

Fabrice SZNAJDERMAN

La version 5 du JDK (Tiger) a introduit une notion qui est passée inaperçue face aux autres nouveautés (généricité, autoboxing, etc.) Cette nouveauté est la covariance des types retours. Cet article aura pour but de présenter les concepts qu'introduit cette notion. Je vous proposerai quelques exemples permettant de mettre en évidence son utilité. ♪

- Titre : La covariance dans le JDK1.5

- Auteur : Fabrice SZNAJDERMAN

- Parution : 7 juillet 2007

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2013 Fabrice SZNAJDERMAN. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Rappel

- II. La covariance

- III. Le principe

- III-A. UML

- III-B. Explications

- IV. La mise œuvre

- IV-A. Exemple 1

- IV-B. Exemple 2

- IV-B-1. UML

- IV-B-2. Code

- V. La covariance : un exemple concret

- VI. La covariance et le JDK 5 (et versions ultérieures)

- VII. Conclusion

- VIII. Remerciements

I - Rappel

La redéfinition ou la surcharge suivent des règles précises dans les relations d'héritage.

Dans le cadre de la redéfinition d'une méthode, il faut que la signature de la méthode de la classe fille ait exactement la même signature.

Redéfinition

//Classe Mère
public String maMethod(String a,String b){//...}
//Classe fille
public String maMethod(String a,String b){//...}

Dans le cadre de la surcharge d'une méthode, la méthode de la classe fille se distinguera par ses paramètres (leur nombre et leur type).

Surcharge

//Classe Mère
public String maMethod(String a,String b){//...}
//Classe fille
public String maMethod(String a,Integer c){//...}

II - La covariance

Dans les versions antérieures au JDK 1.5 ces deux règles étaient valables. Depuis la version 1.5, ces règles ont légèrement changé.

En effet, il est maintenant possible de modifier le type de retour d'une méthode que l'on redéfinit. Dans un premier temps, cette nouvelle possibilité peut paraître quelque peu déstabilisante.

Après réflexion, la covariance des types de retour donne la possibilité de s'affranchir de la conversion de type explicite (cast). Le cast, une technique qui introduit des faiblesses dans les programmes et qui met en évidence une faiblesse du typage du langage Java.

Une contrainte existe dans la mise en œuvre de cette technique, il faut que le nouveau type de retour soit un sous-type du type déclaré dans la super classe. Cette contrainte reste logique et cohérente. En effet, l'héritage raffine (spécialise) le comportement d'une classe, il est logique que les méthodes redéfinies dans les sous-classes spécialisent aussi les types de retour de ses méthodes.

L'utilisation de la covariance ne doit pas être systématique, il faut que celle-ci présente un réel intérêt.

Dans la suite de l'article, nous allons voir plusieurs cas d'utilisation de ce [nouveau] principe qui est maintenant autorisé.

III - Le principe

Nous allons voir ici un exemple simple.

III-A - UML

III-B - Explications

Dans cet exemple, le type de retour a été spécialisé (MonType -> SousType).

Entre les deux, il y a une relation de sous-type. SousType est un sous-type de MonType.

Cet exemple n'est peut-être pas très parlant et on ne saisit pas forcement l'intérêt de faire cette modification.

Ne vous inquiétez pas, dans la suite de l'article, nous verrons un exemple concret permettant de voir l'intérêt de cette pratique.

IV - La mise œuvre

IV-A - Exemple 1

Ci-dessous, nous allons voir un exemple plus parlant avec la méthode clone() définie par l'interface Cloneable. Lorsque l'on souhaite cloner en profondeur un objet, on implante cette interface et on redéfinit la méthode clone().

Voilà la signature de la méthode :

public Object clone() throws CloneNotSupportedException;

On notera que cette méthode renvoie un type Object. Si l'objet à cloner est de ce type, aucun problème. Par contre, si le l'objet renvoyé n'est pas de ce type, il faudra caster l'objet renvoyé dans le type de l'objet cloné.

package covariance;

public class ClassCloneable implements Cloneable {

private String objecName = null;

/**
* @return Renvoie objecName.
*/
public String getObjectName() {
return objecName;
}
/**
* @param objecName objecName à définir.
*/
public void setObjectName(String objecName) {
this.objecName = objecName;
}

/**
* redéfinition de la méthode clone
*/
@override
public Object clone() throws CloneNotSupportedException {
ClassCloneable newRef = (ClassCloneable) super.clone();

return newRef;
}

}

public class Main {

public static void main(String[] args) {

ClassCloneable classCloneable = new ClassCloneable();
classCloneable.setObjectName("nouvel Objet Name");
try{
AutreClassCloneable newRef = (AutreClassCloneable)classCloneable.clone();

}catch(Exception err){
err.printStackTrace();
}
}

Dans cet exemple, nous voyons que nous devons passer par un cast explicite de l'objet renvoyé par la méthode clone.

Nous sommes obligés de passer par là afin de retomber sur le type d'instance de l'objet cloné.

Cette pratique peut comporter des risques. Dans le cas ci-dessus, le code se compilera et s'exécutera correctement.

En revanche, un développeur peu scrupuleux (ou tête en l'air) aurait pu écrire le code suivant :

package covariance;

public class ClassCloneable implements Cloneable{

private String objecName = null;

/**
* @return Renvoie objecName.
*/
public String getObjectName() {
return objecName;
}
/**
* @param objecName objecName à définir.
*/
public void setObjectName(String objecName) {
this.objecName = objecName;
}

/**
* redéfinition de la méthode clone
*/
@override
public Object clone() throws CloneNotSupportedException {
ClassCloneable newRef = (ClassCloneable) super.clone();

return newRef;
}
}

package covariance;

public class AutreClassCloneable implements Cloneable{

private String objectName = null;

/**
* @return Renvoie objectName.
*/
public String getAutreObjectName() {
return objectName;
}
/**
* @param objecName objecName à définir.
*/
public void setAutreObjectName(String objectName) {
this.objectName = objectName;
}

/**
* surdéfinition de la méthode clone
*
*/
@override
public Object clone() throws CloneNotSupportedException {
ClassCloneable newRef = (ClassCloneable) super.clone();
return newRef;
}

}

public class Main {

/**
* @param args the command line arguments
*/
public static void main(String[] args) {

ClassCloneable classCloneable = new ClassCloneable();
classCloneable.setObjectName("nouvel Objet Name");
try{
AutreClassCloneable newRef = (AutreClassCloneable)classCloneable.clone();

}catch(Exception err){
err.printStackTrace();
}
}

Dans l'exemple ci-dessus, la compilation se passera sans problème. Le type de retour de la méthode est bien un sous-type de la classe Object.

En revanche, au moment de l'exécution, la liaison tardive posera quelques problèmes de cast. Le type renvoyé par la méthode est ClassCloneable et on veut le transtyper en AutreClassCloneable. Il n'y a aucune relation de type entre ces deux classes. L'exécution de ce programme aboutira à une exception de type ClassCastException.

Ce type de situation montre une faiblesse du typage en Java. En effet, le fait de devoir forcer le typage d'un objet peut donner lieu à des problèmes qui interviennent uniquement à l'exécution.

L'utilisation de la covariance sur les types de retour nous permet de sécuriser notre code.

Nous allons reprendre l'exemple vu précédemment en introduisant la covariance sur le type de retour de la méthode clone.

package covariance;

public class ClassCloneable implements Cloneable{

private String objecName = null;

/**
* @return Renvoie objecName.
*/
public String getObjectName() {
return objecName;
}
/**
* @param objecName objecName à définir.
*/
public void setObjectName(String objecName) {
this.objecName = objecName;
}

/**
* redéfinition de la méthode clone
*
*/
@override
protected ClassCloneable clone() throws CloneNotSupportedException {
return (ClassCloneable) super.clone();
}
}

Dans cette nouvelle implémentation de la classe ClassCloneable, nous pouvons remarquer que le type de retour de la méthode clone() a été spécialisé.

Malgré la modification de la signature, cette méthode redéfinit bien la méthode clone de l'interface Cloneable.

IV-B - Exemple 2

Voici un exemple plus général :

IV-B-1 - UML

IV-B-2 - Code

public class A {

A method(A x){
return this;
}

}

La classe A ci-dessus déclare une méthode de type method :A->A.

public class B extends A {

B method(A x){
return this;
}
}

La classe B ci-dessus étend la classe A en redéfinissant la méthode method déclarée.

Le type de cette méthode dans la classe B est method :A->B.

public class C extends A {

C method(A x){

return this;

}

}

La classe C ci-dessus étend la classe A en redéfinissant la méthode method déclarée.

Le type de cette méthode dans la classe C est method :A->C.

Dans cet exemple, on voit qu'au fur et à mesure que l'on descend dans l'arbre d'héritage, le type de retour de la méthode method évolue et se spécialise.

Par conséquent, on limite la portée (« scope ») de compatibilité dans les classes filles.

En revanche, on pourra s'affranchir de l'utilisation du cast pour manipuler les types retournés par la méthode method. Dans les deux cas (Classe B et C), la méthode est redéfinie et non surchargée.

Ce code compilera avec le compilateur 1.5. En revanche, des erreurs de type seront générées avec la version 1.4 du compilateur.

V - La covariance : un exemple concret

Nous allons voir, dans ce paragraphe, un cas d'utilisation concret mettant en évidence l'intérêt de la covariance.

Ci-dessous, une interface représentant un panier ainsi que deux implémentations de celle-ci :

public interface Bag {

public void addItem(Item i);

public Bag addItems(Bag b);

}

//Premiere implantation l'interface Bag

public class BooksBag implements Bag {

public void addItem(Item i) {

//code d'ajout d'un item au panier'
}

public void printTitles(){
//code d'affichage du titre de chaque livre
}

public Bag addItems(Bag b) {

//Code d'ajout des items au panier

return this;
}

}

//Deuxième implantation du panier.

public class FlowersBag implements Bag{

public void addItem(Item i) {
}

public void compterNombreDePetale(){

//Code permettant de compter le nombre de pétales
}

public Bag addItems(Bag b) {

//Code d'ajout au panier

return this;
}
}

Dans cette première version, nous n'utilisons pas le principe de covariance.

En effet, la méthode susceptible d'appliquer ce principe est addItems :Bag->Bag.
Nous allons mettre en évidence les problèmes sous-jacents dans ce cas.

Ici, nous mettons en évidence les problèmes que l'on peut rencontrer :

public class Main {

public static void main(String[] args) {
BooksBag bbAutres = new BooksBag();
FlowersBag fbAutres = new FlowersBag();

BooksBag bb = new BooksBag();(1)
FlowersBag fb = new FlowersBag();(2)

BooksBag bag = (BooksBag)bb.addItems(bbAutres); (3)
FlowersBag fbag = (FlowersBag)fb.addItems(fbAutres); (4)

FlowersBag bag = (FlowersBag)bb.addItems(bbAutres); (5)
BooksBag fbag = (BooksBag)fb.addItems(fbAutres); (6)
}
}

Nous commençons par instancier un objet de type BooksBag et un objet de FlowersBag (lignes 1 et 2).

Ensuite, nous appliquons la méthode addItems sur les deux objets instanciés précédemment
(lignes 3 et 4).

La remarque qui peut être faite concerne la nécessiter de caster les deux objets dans leur type respectif lors de la récupération de la référence de l'objet modifié. Ce qui n'est pas une bonne chose.

Remarques : il est vrai que nous pourrions être plus abstraits et utiliser le type de l'interface Bag ; cela nous éviterait d'utiliser le mécanisme de cast.

En faisant ça, nous perdrions de l'information pour chaque type :

- la méthode compterNombreDePetale pour la classe FlowersBag ;

- la méthode printTitles pour la classe BooksBag.

On peut constater aussi qu'il est possible de faire des erreurs de typage (ligne 5 et 6). Ici, la compilation se passera sans problème ; par contre, une exception de type ClassCastException sera levée lors de l'exécution.

Maintenant que nous avons mis en évidence ce problème, voyons comment la covariance va nous aider à obtenir un code plus propre, c'est-à-dire que l'on pourra se passer du mécanisme de cast et surtout d'interdire dès la compilation le mélange de types comme vu ci-dessus.

Nous allons maintenant utiliser le mécanisme de covariance afin d'éviter les problèmes vus précédemment :

public interface Bag {

public void addItem(Item i);

public Bag addItems(Bag b);

}

//Premiere implantation l'interface Bag

/**
*
* @author fabszn
*/
public class BooksBag implements Bag {

public void addItem(Item i) {

//code d'ajout d'un item au panier
}

public void printTitles(){
//code d'affichage du titre de chaque livre
}

public BooksBag addItems(Bag b) {

//Code d'ajout des items au panier

return this;
}

}

//Deuxième implantation du panier.

public class FlowersBag implements Bag{

public void addItem(Item i) {
}

public void compterNombreDePetale (){

//Code permettant de compter le nombre de pétales
}

public FlowersBag addItems(Bag b) {

//Code d'ajout au panier

return this;
}
}

Dans cette première version, nous n'utilisons pas le principe de covariance. la méthode impactée est addItems :Bag->Bag.

Selon la sous-classe, cette méthode aura un type de retour différent. Les méthodes sont bien redéfinies et non surchargées.

public class Main {

public static void main(String[] args) {
BooksBag bbAutres = new BooksBag();
FlowersBag fbAutres = new FlowersBag();

BooksBag bb = new BooksBag();(1)
FlowersBag fb = new FlowersBag();(2)

BooksBag bag = bb.addItems(bbAutres); (3)
FlowersBag fbag = fb.addItems(fbAutres); (4)

FlowersBag bag = bb.addItems(bbAutres); (5)
BooksBag fbag = fb.addItems(fbAutres); (6)

FlowersBag bag1 = (FlowersBag)bb.addItems(bbAutres); (7)
BooksBag fbag2 = (BooksBag)fb.addItems(fbAutres); (8)
}
}

Dans le code ci-dessus, nous n'avons plus recours au mécanisme de cast. Les erreurs de typage des lignes (5) (6) (7) (8) seront détectées à la compilation et à l'exécution.

Nous avons maintenant un code plus cohérent et plus robuste. Cependant, il reste une faille au niveau des arguments. Le principe de la contravariance n'est pas encore implanté dans le langage Java.

Brièvement : ce principe permet de spécialiser les arguments d'une méthode sur le même principe que la covariance.

VI - La covariance et le JDK 5 (et versions ultérieures)

La covariance n'est pas utilisée dans le cadre du JDK 5 sur les classes historiques.

On utilise bien souvent les interfaces pour laisser des points d'entrée aux développeurs souhaitant étendre les fonctionnalités de l'API.

Par exemple, l'interface List est implémentée de plusieurs façons. Ici, nous retiendrons deux classes l'ArrayList et la LinkedList. Chacune implante une méthode de type clone : Object (méthode issue de l'interface Cloneable).

Dans un contexte autre que celui d'une API (ici le JDK), il aurait été judicieux de remplacer le type de retour Object par ArrayList dans un cas et LinkedList dans l'autre. Ceci afin de pouvoir manipuler le bon type sans avoir à caster l'objet retourné.

Or, c'est bien le type Object qui a été utilisé. Lors de l'introduction de la covariance à partir de la version 5 du JDK, les développeurs de Sun ont décidé de ne pas modifier les classes existantes. Ceci sûrement dans un souci de compatibilité ascendante. Néanmoins, ils auraient pu créer de nouvelles classes ou adapter les anciennes comme ils l'ont fait pour les Générics.

VII - Conclusion

Le mécanisme de la covariance apporte une nouvelle perspective au sein du langage Java. Comme les Generics, ce principe donne la possibilité de détecter les erreurs de typage plus tôt durant le développement. Cela permet de diminuer les erreurs au moment de la liaison tardive.

J'espère que cet article vous donnera un bon aperçu du principe de la covariance de type en Java.

VIII - Remerciements

Je tiens à remercier wichtounet, adiGuba, ®om et ricky81 pour leurs conseils et leurs relectures.