Magnolia (chopped and screwed)

Introduction

Vous l’aurez surement remarqué, écrire des typeclass peut être assez mécanique et répétitif.

Magnolia est une bibliothèque logicielle qui permet de faire de la dérivation de typeclass, c'est-à-dire, d’écrire des typeclass pour nous.

La littérature technique à propos de magnolia n’est pas simple à comprendre.

Dans cet article, nous allons tâcher de comprendre, par étape, comment fonctionne magnolia par la pratique.

https://fr.wikipedia.org/wiki/Chopped_and_screwed

Qu’est-ce qu’on bricole ?

Imaginons un ensemble de case class assez différentes les unes des autres :

  sealed trait Race
  case class Human(firstName: String, sound: String, socialSecurityNumber: Int) extends Race
  case class Dwarf(name: String, sound: String, goldAmount: Int)                extends Race
  case class Elf(name: String, skincareRoutine: String)                         extends Race

Pour chacune de ces case class, on souhaite pouvoir écrire le code suivant :

val human = Human(firstName = "John", lastName = "Doe", sound = "Hello", socialSecurityNumber = 123)

println(human.shout)            
println(human.epicDescription)  

Et obtenir une super sortie épique sur notre terminal :

John Doe shouts: HELLO

FIRSTNAME              : John
LASTNAME               : Doe
SOUND                  : Hello
SOCIAL SECURITY NUMBER : 123

Approches naïves : Pourquoi utiliser la dérivation de typeclass ?

Afin d’apprécier les bénéfices d’un système de dérivation de typeclass tel que magnolia, intéressons-nous à ce que serait la vie sans magnolia.

Méthode à la mano

On va commencer par écrire une typeclass qui représente notre comportement.

On veut que nos personnages soient en mesure de pousser un cri et de décrire leurs attributs :

  sealed trait Shout[A] {
    def epicDescription(a: A): String
    def shout(a: A): String
  }

Commençons par implémenter ce comportement pour un Human et un Dwarf:

  val shoutingHuman: Shout[Human] =
    new Shout[Human] {
      override def epicDescription(a: Human): String =
        s"""
           |FIRSTNAME              : ${a.firstName}
           |LASTNAME               : ${a.lastName}
           |SOUND                  : ${a.sound}
           |SOCIAL SECURITY NUMBER : ${a.socialSecurityNumber}
           |""".stripMargin
      override def shout(a: Human): String = a.sound.toUpperCase
    }

  val shoutingDwarf: Shout[Dwarf] =
    new Shout[Dwarf] {
      override def epicDescription(a: Dwarf): String =
        s"""
           |NAME  : ${a.name}
           |SOUND : ${a.sound}
           |GOLD  : ${a.goldAmount}
           |""".stripMargin
      override def shout(a: Dwarf): String = a.sound.toUpperCase
    }

On se rajoute une petite classe implicite afin de faciliter l’écriture de notre comportement :

  implicit class HumanOps(human: Human) {
    def shout: String           = shoutingHuman.shout(human)
    def epicDescription: String = shoutingHuman.epicDescription(human)
  }

  implicit class DwarfOps(dwarf: Dwarf) {
    def shout: String           = shoutingDwarf.shout(dwarf)
    def epicDescription: String = shoutingDwarf.epicDescription(dwarf)
  }

Et voilà le travail :

val johnDoe = Human(firstName = "John", lastName = "Doe", sound = "Hello", socialSecurityNumber = 123)

println(johnDoe.shout)
println(johnDoe.epicDescription)

//  HELLO
//
//  FIRSTNAME              : John
//  LASTNAME               : Doe
//  SOUND                  : Hello
//  SOCIAL SECURITY NUMBER : 123

Vous pouvez trouver le code de cette implémentation ici

Allez courage, il en reste encore un comme ça… pour le moment

On remarque que le code de shoutingDwarf et shoutingHuman est sensiblement le même, bien que l’on ne puisse pas le généraliser “facilement”.

Vous l’aurez compris, on va pouvoir se passer du processus répétitif d’implanter, plus ou moins, la même typeclass plusieurs fois.

Pour nous permettre d’être plus efficace, on va avoir une approche réflexive, autrement dit que l’on va chercher à écrire un programme capable de s’inspecter lui-même.

Il existe deux types de réflexion :

  • la réflexion au runtime :
    • Elle permet d’inspecter les types des objets y compris génériques (après réification)
    • D’instancier de nouveau objets
    • D’appeler des méthodes d’un objet
  • la réflexion au compiletime :
    • Permet de faire de la métaprogrammation, c'est-à-dire du code qui va explorer le code (en particulier pour magnolia, la structure des types) via certaines instructions spéciales et générer du code

C’est la deuxième approche qui nous intéresse.

En effet, on veut que notre compilateur “écrive” les typeclasses pour nous.

Un des avantages de la réflexion au compiletime est que le compilateur vérifie que le code généré est correct. À l’instar de la réflexion au runtime qui peut planter… au runtime

C’est là que magnolia intervient

Niveau 1 : Join

Dans un premier temps, nous allons chercher à faire en sorte que le comportement de notre typeclass soit disponible pour n’importe quelle case class

C’est la fonction join qui permet de faire ça. On trouve aussi cette fonction sous le nom combine

Ci-dessous, la structure de base d’une dérivation de typeclass :

object ShoutDerivation {

  // Alias de type
  type Typeclass[T] = Shout[T]

  // Fonction qui va permettre de dériver notre comportement
  def join[T](ctx: CaseClass[Shout, T]): Shout[T] =
    new Shout[T] {
      override def epicDescription(a: T): String = ???

      override def shout(a: T): String = ???
    }

	// Cette fonction nous permet de générer nos typeclass
  implicit def gen[T]: Shout[T] = macro Magnolia.gen[T]
}

Commençons par dériver la méthode shout :

  • On sait que nos case class ont généralement un argument sound
  • On voudrait que la sortie ressemble à SOUND TO UPPER CASE
  • Les paramètres de nos case class sont accessibles via ctx.parameters

override def shout(a: T): String =
        // on cherche un argument sound
        ctx.parameters.find(_.label == "sound") match {
          // si il existe on le renvoie en capitales
          case Some(value) => value.typeclass.shout(value.dereference(a)).toUpperCase
          // sinon tant pis
          case None        => "I can't shout"
        }

Si on essaye de compiler maintenant, voilà ce qu’il risque d’arriver :

magnolia: could not find Shout.Typeclass for type String
    in parameter 'firstName' of product type io.univalence.magnolia_chopped_and_screwed.Race.Human

  val shoutingHuman: Shout[Human] = ShoutDerivation.gen[Human]

Effectivement, à ce stade, magnolia ne sait pas quoi faire des String et des Int qui composent nos Race

Pour notre sujet, nous avons juste besoin de renvoyer leur valeur sous forme de String :

implicit val shoutString: Shout[String] =
    new Shout[String] {
      override def epicDescription(a: String): String = ??? // on garde l'implantation d'epicDescription pour le niveau 2
      override def shout(a: String): String           = a
    }

implicit val shoutInt: Shout[Int] =
    new Shout[Int] {
      override def epicDescription(a: Int): String = ???
      override def shout(a: Int): String           = a.toString
    }

Et ça marche !

val shoutingHuman: Shout[Human] = ShoutDerivation.gen[Human]

val human: Human = Human(firstName = "John", lastName = "Doe", sound = "Hello world", socialSecurityNumber = 123)
println(shoutingHuman.shout(human))

//HELLO WORLD

Résumé du Niveau 1 :

  • Le code du Niveau 1 est disponible ici
  • La méthode join nous permet de dériver le comportement d’une typeclass sur n’importe quelle case class
  • On peut agir sur les paramètres de nos case class
  • Il faut décrire le comportement des primitives telles que String et Int afin d’effectuer une dérivation

Comment améliorer le Niveau 1 :

  • On se rend compte que c’est un peu bizarre de devoir définir epicDescription pour un String ou un Int
  • Il n’est pas très ergonomique d’écrire shoutingHuman.shout(human) mais human.shout

Niveau 2

Dans un premier temps, nous allons commencer par découper notre typeclass Shout

  sealed trait Shout[A] {
    def shout(a: A): String
  }

  // Comportement spécifiques aux case class
  trait ShoutCC[A] extends Shout[A] {
    def epicDescription(a: A): String
  }

  // Comportement pour les valeurs (String et Int dans notre cas)
  trait ShoutValue[A] extends Shout[A]

Les avantages de ce pattern sont multiples.

Dans un premier temps, il nous permet de définir plus facilement la manière dont nos valeurs doivent se comporter

On verra un autre intérêt lorsqu’on s’attaquera au code de join

implicit val shoutString: ShoutValue[String] = str => str
implicit val shoutInt: ShoutValue[Int]       = i => i.toString // plus besoin de définir epicDescription pour un String ou un Int

Aussi, on peut ajouter une implicit class qui va nous permettre d’écrire human.shout

// On prend n'importe quelle case class et, si disponible, son implantation de Shout
implicit class ShoutCCOps[A <: AnyRef with Product](a: A)(implicit shoutCC: ShoutCC[A]) {
    // on expose pour les case class shout et epicDescription
    def shout: String           = shoutCC.shout(a)
    def epicDescription: String = shoutCC.epicDescription(a)
  }

val human = Human(firstName = "John", lastName = "Doe", sound = "Hello world", socialSecurityNumber = 123)

// Grace à l'implicit class précédente, on peut écrire :
println(human.shout) //HELLO WORLD

Intéressons-nous maintenant au code de join pour epicDescritpion.

C’est un cas classique, ctx nous permet d’obtenir le nom des paramètres et le déréférencement de la case class nous permet d’obtenir les valeurs associées.

def join[T](ctx: CaseClass[Shout, T]): ShoutCC[T] =
    new ShoutCC[T] {
      override def epicDescription(a: T): String =
        ctx.parameters
          .map(param => s"\n \t${param.label.toUpperCase} = ${param.dereference(a)}")
          .mkString

Jusqu’ici tout va bien :

  FIRSTNAME = John
 	LASTNAME = Doe
 	SOUND = Hello world
 	SOCIALSECURITYNUMBER = 123

Mais imaginons maintenant que l’on décide d’ajouter une nouvelle case classe, composée de deux autres

Par exemple, une petite personne qui serait le produit d’un humain et d’un nain

case class LittlePerson(human: Human, dwarf: Dwarf)extends Race

val human        = Human(firstName = "John", lastName = "Doe", sound = "Hello world", socialSecurityNumber = 123)
val dwarf        = Dwarf(name = "gimli", sound = "uh", goldAmount = 1000)

val littlePerson = LittlePerson(human, dwarf)
println(littlePerson.epicDescription)

On peut facilement imaginer que la sortie ne va pas ressembler à notre super formatage avec des sauts de lignes et des tabulations 🥲

HUMAN = Human(John,Doe,Hello world,123) 	
DWARF = Dwarf(gimli,uh,1000)

Effectivement rien ne va plus et c’est là que notre pattern qui visait à séparer le comportement des valeurs et des cases classes devient pratique :

def join[T](ctx: CaseClass[Shout, T]): ShoutCC[T] =
    new ShoutCC[T] {
      override def epicDescription(a: T): String =
        ctx.parameters
          .map(param =>
            param.typeclass match {
              // On pattern match sur le type des arguments
              case cc: ShoutCC[param.PType] => s"${param.label} part: [${cc.epicDescription(param.dereference(a))}] \n"
              case cc: ShoutValue[param.PType] => s"\n \t${param.label.toUpperCase} = ${cc.shout(param.dereference(a))}"
            }
          )
          .mkString

En faisant du pattern matching sur la typeclass (disponible dans ctx) on peut avoir des comportements distincts pour chaque type :

human part: [
 	FIRSTNAME = John
 	LASTNAME = Doe
 	SOUND = Hello world
 	SOCIALSECURITYNUMBER = 123] 
dwarf part: [
 	NAME = gimli
 	SOUND = uh
 	GOLDAMOUNT = 1000]

Résumé du Niveau 2 :

  • Le code du Niveau 2 est disponible ici
  • Séparer les comportements des valeurs et des case classes nous permet d’écrire du code plus concis et nous facilite l’écriture de la dérivation.
  • La dérivation de typeclass ne nous empêche pas de créer une classe implicite

Comment améliorer le Niveau 2 :

  • Dans cet exemple, on ne manipule jamais Race directement, il se passe quoi si on fait ça ?

Niveau 3

Imaginons encore une fois une nouvelle case class composée de deux Race dont on ne connait pas le type.

case class GenericHybrid(race1: Race, race2: Race) extends Race

À l’image du niveau 2, si on combine notre humain et notre nain, on aimerait avoir le même comportement que précédemment

val human        = Human(firstName = "John", lastName = "Doe", sound = "Hello world", socialSecurityNumber = 123)
val dwarf        = Dwarf(name = "gimli", sound = "uh", goldAmount = 1000)  
val hybrid       = GenericHybrid(human, dwarf)

println(AdvancedShoutDerivation.gen[GenericHybrid].shout(hybrid))

Sauf que :

magnolia: the method `split` must be defined on the derivation object AdvancedShoutDerivation to derive typeclasses for sealed traits
  println(AdvancedShoutDerivation.gen[GenericHybrid].shout(hybrid))

On nous demande d’implémenter split afin de pouvoir dériver des typeclass pour des sealed trait, dans notre cas Race

Dans notre cas, l’implantation de split consiste à :

  • Trouver pour une instance a de type T, le sous-type de Race auquel T correspond
  • Une fois trouvé, d’invoquer shout ou epicDescription avec l’instance de Race fournie(ici a), castée dans le bon sous type de Race

def split[T](ctx: SealedTrait[ShoutCC, T]): Shout[T] =
    new ShoutCC[T] {
      override def shout(a: T): String =
        // On cherche le bon type de `Race`
        ctx.split(a) { sub =>
          sub.typeclass.shout(sub.cast(a))
        }

      override def epicDescription(a: T): String =
        ctx.split(a) { sub =>
          sub.typeclass.epicDescription(sub.cast(a))
        }
    }

Cette implémentation de split conviendrait pour un autre sealed trait que Race mais malheureusement magnolia est limité quand il s’agit de dérivations récursives (des races dans des races).

Pour palier a ce problème, on est obligé de forcer la dérivation de Race:

...
// on force la dérivation de Race
implicit val shoutRace: Shout[Race] = AwesomeShoutDerivation.bugGenR[Race]
println(AwesomeShoutDerivation.gen[GenericHybrid].epicDescription(hybrid))

}
object AwesomeShoutDerivation {
  import Awesome._

  implicit def gen[T]: ShoutCC[T] = macro Magnolia.gen[T]

  def bugGenR[T]: Shout[T] = macro Magnolia.gen[T]

  type Typeclass[T] = Shout[T]

  def split[T](ctx: SealedTrait[Shout, T]): ShoutCC[T] =
    new ShoutCC[T] {
      override def shout(a: T): String =
        ctx.split(a) { sub =>
          sub.typeclass.asInstanceOf[ShoutCC[sub.SType]].shout(sub.cast(a))
        }

      override def epicDescription(a: T): String =
        ctx.split(a) { sub =>
          sub.typeclass.asInstanceOf[ShoutCC[sub.SType]].epicDescription(sub.cast(a))
        }
    }
...

Résumé du Niveau 3 :

  • Le code du Niveau 3 est disponible ici
  • On peut dériver des typeclass pour des sealed trait à l’aide de la méthode split
  • On est obligé de forcer la dérivation de notre sealed trait dans le cas de dérivations récursives.

À suivre

Dans un prochain article, nous regarderons si Scala 3 nous permet de faire plus facilement des dérivations récursives de typeclass

Pour aller plus loin

https://univalence.io/blog/articles/typeclass-derivation-faites-eclore-vos-instances-avec-magnolia/