Covariance, contravariance et botanique

La notion de variance est une notion souvent énigmatique pour les développeurs. On retrouve cette notion dès lors que nous sommes en présence d'un langage utilisant à la fois du sous-typage et des types paramétrés (ou type générique). Ce qui inclut une bonne partie des langages proposant un style de programmation orienté objet basé sur des classes et parmi eux, les langages qui utilisent les génériques (Java, C#, Scala...).

Si j'ai écrit énigmatique, c'est bien parce qu'à travers la notion de variance nous nous retrouvons parfois avec des erreurs de compilation difficiles à comprendre. Il faudra alors jouer avec T extends U ou ? super T en Java. Et en Scala, ce sera avec les expressions +A, -A, A <: B et A >: B.

Pourtant le choix est relativement simple : sommes-nous en position de producteur / fournisseur de données ou en position de consommateur ? De cette question, vous pourrez déterminer si un paramètre est covariant ou contravariant.

Covariance

La cas le plus souvent rencontré et celui qui est le plus intuitif est la covariance. Ce cas apparaît avec les collections, avec le type Option, mais aussi avec le type IO et les types représentant des fonctions (le type de la sortie d'une fonction est covariant). La covariance s'applique dès qu'il est possible d'utiliser l'analogie avec une boîte, un conteneur, un burrito ou tout ce qui s'apparente à de la production ou à de la fourniture de données.

En exemple, nous allons créer une arborescence représentant des aliments, des fruits et des légumes (classification purement culinaire, tout en s'évitant le cas de la tomate).

trait Aliment {
  def name: String
}
case class Fruit(name: String) extends Aliment
case class Legume(name: String) extends Aliment

val fraise = Fruit("fraise")
val brocoli = Legume("brocoli")

Nous avons maintenant deux gourmands : Mary qui mange plutôt varié et Johnny qui ne mange que des fruits. Représentons les par des fonctions.

def maryMange(l: List[Aliment]): Unit = ()
def johnnyMange(l: List[Fruit]): Unit = ()

Dans ce cas, passer une liste de fruits à ces deux fonctions ne posent pas problème. Le cas de Johnny est trivial. Pour Mary, il n'y a pas d'inconvénient puisque les fruits sont des aliments.

val fruits: List[Fruit] = List(fraise, fraise)

maryMange(fruits)   // OK 😻 - grâce à la covariance, cela compile !
johnnyMange(fruits) // OK 👌 - ça compile !

Par contre :

val aliments: List[Aliment] = List(brocoli, fraise)

maryMange(aliments)   // OK 👌 - ça compile !
johnnyMange(aliments) // NOK 🙀 - erreur de compilation

Nous avons alors une erreur de compilation, puisque Johnny ne peut/veut pas manger n'importe quel aliment.

List[A] est covariant puisque le type de List varie dans le même sens que le type de A. Puisque Fruit est un sous-type de Aliment, alors List[Fruit] est un sous-type de List[Aliment]. Ça fonctionne avec les types Stream[A], Option[A], RDD[A], Parser[A], X => A (uniquement si nous nous intéressons à la variance de A), Either[E, A]...

De manière générale, nous pouvons imaginer un type Producteur[A] sur lequel nous avons une méthode get permettant de récupérer une valeur. Nous allons utiliser le symbole + en prefix de la définition du paramètre de type pour indiquer que le paramètre de type est covariant.

case class Producteur[+A](get: A)

val jardinFraise: Producteur[Fruit] = Producteur(fraise)
val jardinAliment: Producteur[Aliment] = Producteur(brocoli)

def recolte[A](producteur: Producteur[A]): A = producteur.get

recolte[Aliment](jardinFraise)  // OK 😻 - grâce à la covariance, cela compile !
recolte[Aliment](jardinAliment) // OK 👌 - ça compile !

recolte[Fruit](jardinFraise)    // OK 👌 - ça compile !
recolte[Fruit](jardinAliment)   // NOK 🙀 - erreur de compilation

Contravariance

Alors là, accrochez-vous. La contravariance est pour le coup moins intuitif.

Nous avons vu que la covariance apparaît lorsqu'un type générique varie dans le même sens que son type sous-jacent. Et bien, il y a des cas où le type générique varie dans le sens inverse par rapport à son type sous-jacent. Autrement dit, on a bien Fruit qui est sous-type de Aliment, mais DownUnder[Aliment] est un sous-type DownUnder[Fruit], si DownUnder est un tel type.

Cette situation arrive dans un cadre de consommation de données. Par exemple, lorsque vous fournissez des données en entrée d'une fonction, lorsque vous envoyez des messages à Kafka, lorsque vous avez un setter ou lorsque vous exécutez des INSERT dans une base.

Imaginons un type Consommateur[A] qui possède une méthode mange qui absorbe dans élément de type A. Nous allons utiliser le symbole - pour indiquer que le type est contravariant. Nous allons aussi reprendre nos personnages : Mary qui mange plutôt varié et Johnny qui ne mange que des fruits.

case class Consommateur[-A](mange: A => Unit)

val mary = Consommateur[Aliment](mange = aliment => ())
val johnny = Consommateur[Fruit](mange = fruit => ())

Créons une fonction liée au service dans un restaurant.

def serviceRestaurant[A](consommateur: Consommateur[A]): A => Unit =
  a => consommateur.mange(a)

Testons avec Mary :

serviceRestaurant[Fruit](mary)   // OK 😻 - grâce à la contravariance, ça compile !
serviceRestaurant[Aliment](mary) // OK 👌 - ça compile !

Puisque Mary mange toute sorte d'aliments, elle peut aussi manger des fruits.

Par contre, avec Johnny :

serviceRestaurant[Fruit](johnny)   // OK 👌 - ça compile !
serviceRestaurant[Aliment](johnny) // NOK 🙀 - erreur de compilation

Johnny ne mange que des fruits. Du coup, il ne peut pas manger des aliments quelconques tant qu'il n'a pas la garanti qu'il s'agit de fruits.

Ainsi, Fruit a beau être un sous-type de Aliment, Consommateur[Aliment] est vu comme un sous-type de Consommateur[Fruit].

Autres cas d'application

Les notions de covariance et contravariance ne s'appliquent pas seulement aux types. Elles vont aussi s'appliquer aux catégories de types. C'est le cas avec les functors.

En règle générale, quand on imagine la notion de Functor et l'opération map, on l'imagine dans un cadre covariant :

trait Functor[F[_]] {
  def map[A, B](fa: F[A])(f: A => B): F[B]
}

def map[A, B, F[_]: Functor](fa:F[A])(f: A => B): F[B]

Il en existe une version contravariante :

trait CoFunctor[F[_]] {
  def comap[A, B](f: B => A)(fa:F[A]): F[B]
}

def comap[A, B, F[_]: CoFunctor](f: B => A)(fa: F[A]): F[B]

Reprenons le type Consommateur[A].

Imaginons que nous avons un Consommateur[String]. Mais nous avons en entrée des données de type Int. Pour convertir notre Consommateur[String] en Consommateur[Int], il suffit de fournir une fonction qui convertit des Int en String et de l'appliquer à notre Consommateur[String] en utilisant comap.

// create CoFunctor instance for Consommateur
implicit val consommateurCoFunctor =
  new CoFunctor[Consommateur] {
    def comap[A, B](f: B => A)(fa: Consommateur[A]):Consommateur[B] = 
        Consommateur(b => fa.mange(f(b)))
  }

val stringConsommateur: Consommateur[String] = ???

val intConsommateur: Consommateur[Int] =
  comap((i: Int) => i.toString)(stringConsommateur)

Nous retrouvons aussi les notions de covariance et de contravariance dans le cadre de Magnolia. Magnolia est un petit framework permettant d'associer des typeclasses à des case class et des sealed traits.