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 argumentsound
- On voudrait que la sortie ressemble à
SOUND TO UPPER CASE
- Les paramètres de nos
case class
sont accessibles viactx.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’unetypeclass
sur n’importe quellecase class
- On peut agir sur les paramètres de nos
case class
- Il faut décrire le comportement des primitives telles que
String
etInt
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 unString
ou unInt
- Il n’est pas très ergonomique d’écrire
shoutingHuman.shout(human)
maishuman.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 deRace
auquelT
correspond - Une fois trouvé, d’invoquer
shout
ouepicDescription
avec l’instance deRace
fournie(icia
), 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 dessealed trait
à l’aide de la méthodesplit
- 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/