Covariance, contravariance et invariance

Dans les langages orientés objets, utilisant les types génériques, il peut être utile de comprendre les aspects concernant la variance. Nous allons voir du code Java pour commencer, puis j’expliquerai comment Scala résout le problème que nous allons rencontrer en Java.

Covariance en Java

Voici d’abord la hiérarchie de classes utilisées dans les exemples de cet article :

   - D
 /
A - B - C

A est à la racine. B et D dérivent de A. C dérive de B.

Les génériques sont utilisées par toutes les classes conteneurs : listes, tableaux, ensemble ou map.

B[] tableauB = new B[3];
tableauB[0] = new B();
tableauB[1] = new C();
A[] tableauA = tableauB;

Le code ci-dessus fonctionne sans problème, comme on s’y attends. On peut évidemment ajouter un B à un tableau de B. C étant un sous-type de B, on peut l’ajouter aussi à un tableau de B.

La 4e ligne fonctionne à cause de la covariance. Cela signifie que puisque B est un sous-type de A, alors tableau de B est un sous-type de tableau de A.

Continuons. Puisque D est un sous-type de A, il est possible d’ajouter un D à un tableau de A, tout comme nous avons ajouté un C à un tableau de B :

tableauA[2] = new D();

Tout ça semble bien naturel, et le compilateur Java accepte ce code sans aucun souci. Mais il y a un gros problème. En effet, nous avons créé un tableau de B au départ, auquel nous avons fini par ajouter un objet de type D. Ça ne devrait pas être possible. Java résout ce problème en ajoutant du code pour vérifier la compatibilité des types à chaque fois qu’un objet est ajouté à un tableau. Cette dernière ligne de code génère l’exception ArrayStoreException, au moment de son exécution.

Solutions possibles

Le problème vient du fait que les tableaux sont à la fois mutables et covariant. Régler un seul de ces 2 aspects suffit pour détecter les problèmes au moment de la compilation.

Si le tableau était non mutable, on ne pourrait pas y ajouter de nouveaux éléments et le problème serait résolu.

B[] tableauB = new B[] { new B(), new C() };

Si le tableau est invariant au lieu de covariant, il n’y aurait pas de problème non plus, puisqu’on ne pourrait pas affecter un tableau de B à un tableau de A. Remarquer que le fait que le tableau soit invariant n’empêche pas d’y ajouter des objets ayant un sous-type de B.

La variance en Scala

En Scala, il y a 2 versions de chaque conteneur, l’une est mutable, l’autre ne l’est pas. Il est très rare qu’on utilise les conteneurs mutables, la programmation fonctionnelle incite à n’utiliser que des objets non mutables.

Les conteneurs mutables sont invariants. Lancez la REPL scala, pour tester le code qui suit. Nous allons utiliser les tableaux dans les exemples, mais le principe reste le même pour les autres conteneurs.

class A()
class B() extends A()
class C() extends B()
class D() extends A()
val tableauB = Array(new B(), new C())

Si nous tentons maintenant de changer le type de ce tableau, nous allons obtenir une erreur de compilation :

val tableauA: Array[A] = tableauB
<console>:11: error: type mismatch;
 found   : Array[B]
 required: Array[A]
Note: B <: A, but class Array is invariant in type T.
You may wish to investigate a wildcard type such as `_ <: A`. (SLS 3.2.10)
       val tableauA: Array[A] = tableauB
                                ^

Il est quand même possible d’ajouter un élément à un conteneur immutable. La raison simple est qu’une nouvelle collection est créée, incluant ce nouvel élément.

val tableau2 = tableauB :+ new B()
tableau2: Array[B] = Array(B@e3a7be4, C@51b12677, B@219bbd08)

Contravariance

Mais là où cela devient intéressant, c’est que l’argument de la méthode d’ajout d’un élément (:+) est contravariant. Cela signifie qu’il est possible d’ajouter un objet d’un type qui est un sur-type de B (A dans notre exemple) :

val tableau3 = tableauB :+ new A()
tableau3: Array[A] = Array(B@e3a7be4, C@51b12677, A@55bce7e2)

Comment est-ce possible ? Tout simplement parce que la nouvelle collection créée a changé de type. Ce n’est plus un Array[B] mais un Array[A]. Et comme A est un sur-type de B, cette nouvelle collection peut bien sûr contenir des objets de type B (ou de ses sous-types).

En fait, on peut même ajouter n’importe quel type à une collection, le résultat étant une collection du sur-type commun à tous les objets de la nouvelle collection. Un tel type existe toujours, puisque en Scala, la racine de la hiérarchie est Any (même pour les types primitifs).

class E()
val tableau4 = tableauB :+ new E()
tableau4: Array[Object] = Array(B@e3a7be4, C@51b12677, E@7430bc45)

val tableau5 = tableauB :+ 1
tableau5: Array[Any] = Array(B@e3a7be4, C@51b12677, 1)

Ce n’est pas du tout recommandé, parce qu’on ne peut pas faire grand chose avec une collection de Object ou de Any.

Conclusion

Utiliser des objets immutables a bien des avantages, n’est-ce-pas ? Il existe des règles claires pour les endroits où il faut utiliser la covariance, la contravariance ou l’invariance. Mais je ne vais pas entrer dans ces détails, et vous n’avez pas besoin de les connaître. Sachez qu’en Scala, les collections respectent ces règles et ne vous poserons pas de problème.

Une réflexion au sujet de « Covariance, contravariance et invariance »

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Vous pouvez utiliser ces balises et attributs HTML : <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>