Covariance et contravariance

Définitions #

Nous allons rentrer dans les détails de la covariance et la contravariance. Commençons par donner leur définition et leur contexte associé.

Définition générale #

Terme Définition
covariance permet de substituer un type par son sous-type
contravariance permet de substituer un type par son super-type
invariance n’autorise aucune substitution
Rappel
Si un type T' dérive/hérite/est un T, T peut référencer un T'.

Integer dérive de Number et cette syntaxe est valide:

1
2
Integer i = 42;
Number n = i; // n peut référencer i si i hérite de n

rappel

Nous allons nous intéresser, dans le cadre des génériques, aux référencements pour déterminer le type de variance.

Covariance et contravariance avec les types génériques #

Soient un générique G et un type T' dérivé de T, on dit que G est

Terme Définition
covariant si G<T'> est un sous-type de G<T>
contravariant si G<T> est un sous-type de G<T'>
invariant si aucun des deux n’est vrai

Que l’on peut illustrer ainsi pour la covariance ou contravariance:

Covariance

Contravariance

Et pour l’invariance, que l’on peut illustrer à l’aide des deux schémas ci-dessous. Rappelez-vous qu’une List peut référencer une ArrayList. Seul le paramètre est pris en considération.

Problématique #

Prenons l’exemple de l’interface Serializable en Java. Un objet déclaré (qui l’implémente), indique qu’il est possible de le sérialiser et le désérialiser pour l’échanger à travers le réseau. Imaginons que nous souhaitons envoyer une liste d’objets sur un serveur à l’aide du code ci-dessous.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
/*    Serializable
 *       ^    ^
 *      /      \
 *  Integer    String
 */
void send(Serializable s) { ... }
void sendAll(List<Serializable> sers) {
    for (Serializable s: sers) {
        send(s);
    }
}

La méthode sendAll récupère les éléments pour les envoyer. A priori, il n’y a pas de danger et nous aimerions pouvoir lui passer par exemple une List<String> ou une List<Integer> librement. Mais pour l’instant, l’argument sers est invariant et nous devons transformer nos listes (List<String> ou List<Integer>) en une List<Serializable>.

Une analogie toute simple #

Prenons un instant pour comprendre la problématique avec un exemple très académique. Imaginons que votre voisin vous demande un panier de fruits sans donner plus de précisions. Pouvez-vous lui donner un panier de bananes ? autrement dit, un panier de bananes est-il (hérite-t-il d’) un panier de fruits ? Intuitivement, nous avons envie de dire oui et le panier de fruits serait covariant. Nous pouvons l’exprimer ainsi:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// votre voisin vous demande un panier de fruits
void eatFrom(List<Fruit> fruits) {
    for (Fruit f: fruits) {
        eat(f); /* si f référence une banane, 
                 * cela ne pose aucun problème */
    }
}
void eat(Fruit oneFruit) { ... }

// vous lui donnez un panier de bananes
List<Banana> bananas = ...
eatFrom( bananas );

Cela ne semble pas poser de problème, et ce, du moment où il récupère un fruit, celui-ci peut être une banane.

Mais que se passe-t-il si, pour nous remercier, il nous offre une pomme ?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
void eatFrom(List<Fruit> fruits) {
    for (Fruit f: fruits) {
        eat(f);
    }
    fruits.add( new Apple() );
}

List<Banana> bananas = ...
eatFrom( bananas );
Banane b = bananas.get( bananas.size()-1 ); /* ARGHHHH, une 
                                             * banane ne peut référencer
                                             * une pomme !!! */

Eh bien, il a réussi à rajouter une pomme dans un panier de bananes! Ce qui est problématique: un panier de bananes ne doit contenir que des bananes…

Choix du type de variance #

Cette analogie est utile pour comprendre que le choix de la variance va nous permettre soit de récupérer le paramètre en question (E get()), soit de le donner (void add(E elem)).

Dans le jargon, nous nous plaçons toujours du point de vue de la classe générique pour préciser si l’action consomme ou si elle produit.

  • E get() signifie que la liste produit un élément E
  • void add(E elem) signifie que la liste consomme un élément E.

Mnémonique

PECS: Producer Extends and Consumer Super

Les différents cas de figure pour une classe générique Foo<T> quelconque:

  • Foo<? extends T> (covariance)
    • peut produire un T
      • exemple: T get();
    • peut référencer Foo<T'> si T' est un sous-type de T
  • Foo<? super T> (contravariance)
    • peut consommer un T
      • exemple: void add(T e);
    • peut référencer Foo<T'> si T' est un super-type de T
  • Foo<T> (invariance)
    • peut produire et consommer un T
    • ne peut référencer que Foo<T>
  • Foo<?> (covariance générale)
    • alias pour Foo<? extends Object>
    • super-type de n’importe quel Foo
    • peut donc référencer n’importe quel Foo<...>
    • ne peut rien consommer
    • ne peut rien produire (ou alors que des Objects)
    • exemple : int size()

La covariance #

La covariance signifie que si une banane est un fruit, alors un panier de bananes est un panier de fruits également, et donc, un panier de fruits peut référencer un panier de bananes tout comme un fruit peut référencer une banane.

Ceci est vrai, uniquement si l’on récupère un fruit de notre référence.

1
2
3
4
5
6
List<Banana> bananas = ... // [Banane, Banane]
List<? extends Fruit> fruits = bananas;
Fruit f = fruits.get(0); // OK 
fruits.add( new Apple() ); /* Ne compile pas ! cela corromprait
                           * l'objet référencé, celui-ci ne peut 
                           * contenir que des bananes ! */

Conclusion: nous autorisons notre référence covariante (fruits) à produire un Fruit, et la consommation devient interdite.

La contravariance #

Pour la contravariance, il s’agit du raisonnement inverse. Si une banane est un fruit alors cette fois-ci c’est le panier de fruits qui est un panier de bananes, et donc, le panier de bananes peut référencer le panier de fruits.

Dans l’exemple ci-dessous, le panier de bananes, représenté par la référence bananas, ne manipule que des bananes. Nous comprenons cette fois-ci que si nous rajoutons une banane dedans, cela ne va pas corrompre pas l’objet référencé (fruits) qui peut contenir n’importe quels fruits. Par contre, le danger vient de la récupération d’une banane. Nous ne sommes pas sûrs que ce soit le cas !

1
2
3
4
List<Fruit> fruits = ... // [Pomme, Fraise, Poire]
List<? super Banana> bananas = fruits;
bananas.add( new Banana() ); // [Pomme, Fraise, Poire, Banane]
Banana b = bananas.get(0); // ne compile pas ! 

Si la contravariance est moins intuitive, cela signifie qu’un panier de fruits hérite de tous les paniers existants. Autrement dit, un panier de fruits est un panier de bananes, est un panier de pommes, est un panier de cerises…

Conclusion: nous autorisons notre référence contravariante (bananas) à consommer une Banana, et la production devient interdite pour notre référence bananas.

use-site variance vs declaration-site variance #

Java prévoit une syntaxe spéciale pour autoriser la variance lors du référencement. C’est ce qui s’appelle le use-site variance. Ne confondez pas avec la syntaxe des paramètres bornés.

1
2
3
List<? extends Number> ns = new ArrayList<Integer>();
List<? super Number> ms = new ArrayList<Object>();
void copy(List<? extends Number> from, List<? super Number> to) { ... }

D’autres langages comme Scala, Kotlin ou C# utilisent le concept de declaration-site variance : celle-ci est précisée lors de la déclaration d’un type générique en indiquant pour chaque paramètre son type de variance.

Extrait d’une déclaration de type en C# #

1
2
3
// exemple d'interface C# de paramètre T covariant (mot-clé `out`)
interface IReadOnlyCollection<out T>
// (comme par hasard, une collection en lecture seule)

Extrait d’une déclaration de type en Scala #

1
2
3
4
// exemple d'un trait (interface) scala pour une séquence (~ collection)
// de paramètre A covariant (symbole `+`)
trait Seq[+A]
// (comme par hasard, en scala, Seq est immutable (lecture seule))

Tableaux statiques #

Les tableaux statiques sont particuliers. Il ne s’agit pas d’un type générique conventionnel. Nous avons vu qu’ils offrent peu de fonctionnalités. Ils ont un très grand autre désavantage : ils sont covariants par nature et aucune vérification n’est faite à la compilation quant à leurs usages abusifs.

Quiz #

Ecrivez un exemple de code problématique qui démontre le danger de ce choix de conception.












comments powered by Disqus