Logo Zéphyrnet

Cours de type dans Scala3 : un guide du débutant | registre

Date :

Ce document est destiné au développeur Scala3 débutant qui connaît déjà la prose Scala, mais qui est perplexe quant à tous les `implicits` et les traits paramétrés dans le code.

Ce document explique le pourquoi, le comment, le où et le moment de Classes de types (TC).

Après avoir lu ce document, le développeur Scala3 débutant acquerra de solides connaissances à utiliser et se plongera dans le code source de beaucoup des bibliothèques Scala et commencez à écrire du code Scala idiomatique.

Commençons par le pourquoi…

Le problème de l'expression

En 1998, Philippe Wadler a déclaré que « l’expression problème est un nouveau nom pour un vieux problème ». C’est le problème de l’extensibilité logicielle. Selon les écrits de Monsieur Wadler, la solution au problème d'expression doit respecter les règles suivantes :

  • Règle 1 : Autoriser la mise en œuvre de comportements existants (pensez au trait Scala) à appliquer à nouvelles représentations (pensez à une classe de cas)
  • Règle 2 : Autoriser la mise en œuvre de nouveaux comportements à appliquer à représentations existantes
  • Règle 3 : Il ne faut pas mettre en péril la sécurité des types
  • Règle 4 : Il ne doit pas nécessiter de recompilation code existant

Résoudre ce problème sera le fil conducteur de cet article.

Règle 1 : implémentation du comportement existant sur la nouvelle représentation

Tout langage orienté objet possède une solution intégrée pour la règle 1 avec polymorphisme de sous-type. Vous pouvez implémenter n'importe quel ` en toute sécuritétrait` défini dans une dépendance sur un `class` dans votre propre code, sans recompiler la dépendance. Voyons cela en action :

Scala

def todo = 42
type Height = Int
type Block = Int

object Lib1:
  trait Blockchain:
    def getBlock(height: Height): Block

  case class Ethereum() extends Blockchain:
    override def getBlock(height: Height) = todo

  case class Bitcoin() extends Blockchain:
    override def getBlock(height: Height) = todo


object Lib2:
  import Lib1.*

  case class Polkadot() extends Blockchain:
    override def getBlock(height: Height): Block = todo

val eth = Lib1.Ethereum()
val btc = Lib1.Bitcoin()
val dot = Lib2.Polkadot()

Dans cet exemple fictif, la bibliothèque `Lib1` (ligne 5) définit un trait `Blockchain` (ligne 6) avec 2 implémentations de celui-ci (lignes 9 et 12). `Lib1` restera le même dans TOUS ce document (application de la règle 4).

`Lib2` (ligne 15) implémente le comportement existant `Blockchain`sur une nouvelle classe `Polkadot` (règle 1) de manière sécurisée (règle 3), sans recompilation `Lib1` (règle 4). 

Règle 2 : mise en œuvre de nouveaux comportements à appliquer aux représentations existantes

Imaginons dans `Lib2`nous voulons un nouveau comportement`lastBlock`à mettre en œuvre spécifiquement pour chacun`Blockchain`.

La première chose qui me vient à l’esprit est de créer un gros changement en fonction du type de paramètre.

Scala

def todo = 42
type Height = Int
type Block = Int

object Lib1:
  trait Blockchain:
    def getBlock(height: Height): Block

  case class Ethereum() extends Blockchain:
    override def getBlock(height: Height) = todo

  case class Bitcoin() extends Blockchain:
    override def getBlock(height: Height) = todo

object Lib2:
  import Lib1.*

  case class Polkadot() extends Blockchain:
    override def getBlock(height: Height): Block = todo

  def lastBlock(blockchain: Blockchain): Block = blockchain match
      case _:Ethereum => todo
      case _:Bitcoin  => todo
      case _:Polkadot => todo
  

object Lib3:
  import Lib1.*

  case class Polygon() extends Blockchain:
    override def getBlock(height: Height): Block = todo

import Lib1.*, Lib2.*, Lib3.*
println(lastBlock(Bitcoin()))
println(lastBlock(Ethereum()))
println(lastBlock(Polkadot()))
println(lastBlock(Polygon()))

Cette solution est une réimplémentation faible du polymorphisme basé sur les types qui est déjà intégré au langage !

`Lib1` est laissé intact (rappelez-vous, la règle 4 est appliquée partout dans ce document). 

La solution implémentée dans `Lib2` est ok jusqu'à ce qu'une autre blockchain soit introduite dans `Lib3`. Il enfreint la règle de sécurité de type (règle 3) car ce code échoue lors de l'exécution à la ligne 37. Et en modifiant `Lib2` enfreindrait la règle 4.

Une autre solution consiste à utiliser un `extension`.

Scala

def todo = 42
type Height = Int
type Block = Int

object Lib1:
  trait Blockchain:
    def getBlock(height: Height): Block

  case class Ethereum() extends Blockchain:
    override def getBlock(height: Height) = todo

  case class Bitcoin() extends Blockchain:
    override def getBlock(height: Height) = todo

object Lib2:
  import Lib1.*

  case class Polkadot() extends Blockchain:
    override def getBlock(height: Height): Block = todo

    def lastBlock(): Block = todo

  extension (eth: Ethereum) def lastBlock(): Block = todo

  extension (btc: Bitcoin) def lastBlock(): Block = todo

import Lib1.*, Lib2.*
println(Bitcoin().lastBlock())
println(Ethereum().lastBlock())
println(Polkadot().lastBlock())

def polymorphic(blockchain: Blockchain) =
  // blockchain.lastBlock()
  ???

`Lib1` est laissé intact (application de la règle 4 dans l’ensemble du document). 

`Lib2` définit le comportement pour son type (ligne 21) et les `extensions` pour les types existants (lignes 23 et 25).

Lignes 28 à 30, le nouveau comportement peut être utilisé dans chaque classe. 

Mais il n’y a aucun moyen d’appeler ce nouveau comportement de manière polymorphe (ligne 32). Toute tentative en ce sens conduit à des erreurs de compilation (ligne 33) ou à des commutateurs basés sur le type. 

Cette règle n°2 est délicate. Nous avons essayé de l'implémenter avec notre propre définition du polymorphisme et notre astuce « extension ». Et c'était bizarre.

Il manque une pièce appelée polymorphisme ad hoc : la capacité de répartir en toute sécurité une implémentation de comportement selon un type, partout où le comportement et le type sont définis. Entrer le Type de classe motif.

Le modèle de classe de types

La recette du modèle Type Class (TC en abrégé) comporte 3 étapes. 

  1. Définir un nouveau comportement
  2. Mettre en œuvre le comportement
  3. Utiliser le comportement

Dans la section suivante, j'implémente le modèle TC de la manière la plus simple. C’est verbeux, maladroit et peu pratique. Mais attendez, ces mises en garde seront corrigées étape par étape plus loin dans le document.

1. Définir un nouveau comportement
Scala

object Lib2:
  import Lib1.*

  trait LastBlock[A]:
    def lastBlock(instance: A): Block

`Lib1» est, une fois de plus, laissé intact.

Le nouveau comportement is le TC matérialisé par le trait. Les fonctions définies dans le trait sont un moyen d'appliquer certains aspects de ce comportement.

Le paramètre `A` représente le type auquel nous voulons appliquer le comportement, qui sont des sous-types de `Blockchain`dans notre cas.

Quelques remarques :

  • Si besoin, le type paramétré `A` peut être davantage contraint par le système de type Scala. Par exemple, nous pourrions appliquer `A`être un`Blockchain`. 
  • En outre, le TC pourrait contenir beaucoup plus de fonctions déclarées.
  • Enfin, chaque fonction peut avoir de nombreux autres paramètres arbitraires.

Mais gardons les choses simples par souci de lisibilité.

2. Mettre en œuvre le comportement
Scala

object Lib2:
  import Lib1.*

  trait LastBlock[A]:
    def lastBlock(instance: A): Block

  val ethereumLastBlock = new LastBlock[Ethereum]:
    def lastBlock(eth: Ethereum) = eth.lastBlock

  val bitcoinLastBlock = new LastBlock[Bitcoin]:
    def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

Pour chaque type, le nouveau `LastBlock` est attendu, il existe une instance spécifique de ce comportement. 

Le `Ethereum` la ligne d'implémentation 22 est calculée à partir du `eth` instance passée en paramètre. 

La mise en œuvre de `LastBlock'pour'Bitcoin` La ligne 25 est implémentée avec une IO non gérée et n'utilise pas son paramètre.

Alors, "Lib2`implémente un nouveau comportement`LastBlock'pour'Lib1`cours.

3. Utilisez le comportement
Scala

object Lib2:
  import Lib1.*

  trait LastBlock[A]:
    def lastBlock(instance: A): Block

  val ethereumLastBlock = new LastBlock[Ethereum]:
    def lastBlock(eth: Ethereum) = eth.lastBlock

  val bitcoinLastBlock = new LastBlock[Bitcoin]:
    def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

import Lib1.*, Lib2.*

def useLastBlock[A](instance: A, behavior: LastBlock[A]) =
  behavior.lastBlock(instance)

println(useLastBlock(Ethereum(lastBlock = 2), ethereumLastBlock))
println(useLastBlock(Bitcoin(), bitcoinLastBlock))

Ligne 30 'useLastBlock` utilise une instance de `A' et le 'LastBlock`comportement défini pour cette instance.

Ligne 33 'useLastBlock` est appelé avec une instance de `Ethereum` et une implémentation de `LastBlock` défini dans `Lib2`. Notez qu'il est possible de transmettre n'importe quelle implémentation alternative de `LastBlock[A]` (pensez à injection de dépendance).

`useLastBlock` est le lien entre la représentation (le A réel) et son comportement. Les données et le comportement sont séparés, ce que préconise la programmation fonctionnelle.

a lieu

Rappelons les règles du problème d'expression :

  • Règle 1 : Autoriser la mise en œuvre de comportements existants  à appliquer à nouveaux cours
  • Règle 2 : Autoriser la mise en œuvre de nouveaux comportements à appliquer à cours existants
  • Règle 3 : Il ne faut pas mettre en péril la sécurité des types
  • Règle 4 : Il ne doit pas nécessiter de recompilation code existant

La règle 1 peut être résolue immédiatement avec le polymorphisme de sous-type.

Le modèle TC qui vient d'être présenté (voir capture d'écran précédente) résout la règle 2. Il est de type sécurisé (règle 3) et nous n'avons jamais touché à `Lib1` (règle 4). 

Cependant, son utilisation n’est pas pratique pour plusieurs raisons :

  • Aux lignes 33 et 34, nous devons transmettre explicitement le comportement le long de son instance. Il s’agit d’une surcharge supplémentaire. Nous devrions simplement écrire `useLastBlock(Bitcoin())`.
  • Ligne 31, la syntaxe est rare. Nous préférerions rédiger un texte concis et plus orienté objet  `instance.lastBlock()`déclaration.

Soulignons quelques fonctionnalités de Scala pour une utilisation pratique de TC. 

Expérience de développeur améliorée

Scala possède un ensemble unique de fonctionnalités et de sucres syntaxiques qui font de TC une expérience vraiment agréable pour les développeurs.

Implicites

La portée implicite est une portée spéciale résolue au moment de la compilation où une seule instance d'un type donné peut exister. 

Un programme place une instance dans la portée implicite avec le `given` mot-clé. Alternativement, un programme peut récupérer une instance de la portée implicite avec le mot-clé `using`.

La portée implicite est résolue au moment de la compilation, il existe un moyen connu de la modifier dynamiquement au moment de l'exécution. Si le programme se compile, la portée implicite est résolue. Au moment de l'exécution, il n'est pas possible de manquer des instances implicites là où elles sont utilisées. La seule confusion possible peut provenir de l'utilisation d'une mauvaise instance implicite, mais cette question est laissée à la créature située entre la chaise et le clavier.

C’est différent d’une portée globale car : 

  1. C’est résolu contextuellement. Deux emplacements d'un programme peuvent utiliser une instance du même type donné dans une portée implicite, mais ces deux instances peuvent être différentes.
  2. En coulisses, le code transmet des arguments implicites de fonction à fonction jusqu'à ce que l'utilisation implicite soit atteinte. Il n’utilise pas d’espace mémoire global.

Revenons à la classe de type ! Prenons exactement le même exemple.

Scala

def todo = 42
type Height = Int
type Block = Int
def http(uri: String): Block = todo

object Lib1:
  trait Blockchain:
    def getBlock(height: Height): Block

  case class Ethereum() extends Blockchain:
    override def getBlock(height: Height) = todo

  case class Bitcoin() extends Blockchain:
    override def getBlock(height: Height) = todo

`Lib1` est le même code non modifié que nous avons défini précédemment. 

Scala

object Lib2:
  import Lib1.*

  trait LastBlock[A]:
    def lastBlock(instance: A): Block

  given ethereumLastBlock:LastBlock[Ethereum] = new LastBlock[Ethereum]:
    def lastBlock(eth: Ethereum) = eth.lastBlock

  given bitcoinLastBlock:LastBlock[Bitcoin] = new LastBlock[Bitcoin]:
    def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

import Lib1.*, Lib2.*

def useLastBlock[A](instance: A)(using behavior: LastBlock[A]) =
  behavior.lastBlock(instance)

println(useLastBlock(Ethereum(lastBlock = 2)))
println(useLastBlock(Bitcoin()))

Ligne 19 un nouveau comportement `LastBlock` est défini, exactement comme nous l'avons fait précédemment.

Ligne 22 et ligne 25, `val` est remplacé par `given`. Les deux implémentations de `LastBlock` sont placés dans la portée implicite.

Ligne 31 'useLastBlock` déclare le comportement `LastBlock` comme paramètre implicite. Le compilateur résout l'instance appropriée de `LastBlock` à partir d'une portée implicite contextualisée à partir des emplacements de l'appelant (lignes 33 et 34). La ligne 28 importe tout depuis `Lib2`, y compris la portée implicite. Ainsi, le compilateur transmet les instances définies aux lignes 22 et 25 comme dernier paramètre de `useLastBlock`. 

En tant qu'utilisateur de bibliothèque, utiliser une classe de types est plus simple qu'auparavant. Lignes 34 et 35, un développeur doit seulement s'assurer qu'une instance du comportement est injectée dans la portée implicite (et cela peut être un simple `import`). Si un implicite n'est pas `given`où se trouve le code`using`, lui dit le compilateur.

Les implicites de Scala facilitent la tâche de transmission des instances de classe ainsi que des instances de leurs comportements.

Sucres implicites

Les lignes 22 et 25 du code précédent peuvent être encore améliorées ! Parcourons les implémentations de TC.

Scala

given LastBlock[Ethereum] = new LastBlock[Ethereum]:
    def lastBlock(eth: Ethereum) = eth.lastBlock

  given LastBlock[Bitcoin] = new LastBlock[Bitcoin]:
    def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

Lignes 22 et 25, si le nom de l'instance n'est pas utilisé, il peut être omis.

Scala


  given LastBlock[Ethereum] with
    def lastBlock(eth: Ethereum) = eth.lastBlock

  given LastBlock[Bitcoin] with
    def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

Lignes 22 et 25, la répétition du type peut être remplacée par `with` mot-clé.

Scala

given LastBlock[Ethereum] = _.lastBlock

  given LastBlock[Bitcoin] = _ => http("http://bitcoin/last")

Parce que nous utilisons un trait dégénéré contenant une seule fonction, l'EDI peut suggérer de simplifier le code avec une expression SAM. Bien que ce soit exact, je ne pense pas que ce soit une utilisation appropriée de SAM, à moins que vous ne jouiez au code avec désinvolture.

Scala propose des sucres syntaxiques pour rationaliser la syntaxe, en supprimant les dénominations, les déclarations et les redondances de types inutiles.

Extension

Utilisé à bon escient, le `extension` peut simplifier la syntaxe d’utilisation d’une classe de types.

Scala

object Lib2:
  import Lib1.*

  trait LastBlock[A]:
    def lastBlock(instance: A): Block

  given LastBlock[Ethereum] with
    def lastBlock(eth: Ethereum) = eth.lastBlock

  given LastBlock[Bitcoin] with
    def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

  extension[A](instance: A)
    def lastBlock(using tc: LastBlock[A]) = tc.lastBlock(instance)

import Lib1.*, Lib2.*

println(Ethereum(lastBlock = 2).lastBlock)
println(Bitcoin().lastBlock)

Lignes 28-29 une méthode d'extension générique `lastBlock` est défini pour tout `A'avec un 'LastBlock` Paramètre TC dans la portée implicite.

Aux lignes 33 et 34, l'extension exploite une syntaxe orientée objet pour utiliser TC.

Scala

object Lib2:
  import Lib1.*

  trait LastBlock[A]:
    def lastBlock(instance: A): Block

  given LastBlock[Ethereum] with
    def lastBlock(eth: Ethereum) = eth.lastBlock

  given LastBlock[Bitcoin] with
    def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

  extension[A](instance: A)(using tc: LastBlock[A])
    def lastBlock = tc.lastBlock(instance)
    def penultimateBlock = tc.lastBlock(instance) - 1

import Lib1.*, Lib2.*

val eth = Ethereum(lastBlock = 2)
println(eth.lastBlock)
println(eth.penultimateBlock)

val btc = Bitcoin()
println(btc.lastBlock)
println(btc.penultimateBlock)

Ligne 28, le paramètre TC peut également être défini pour toute l'extension afin d'éviter les répétitions. Ligne 30 on réutilise le TC dans l'extension pour définir `penultimateBlock` (même si cela pourrait être implémenté sur `LastBlock`trait directement)

La magie opère lorsque le TC est utilisé. L'expression semble beaucoup plus naturelle, donnant l'illusion que le comportement "lastBlock` est confondu avec l'instance.

Type générique avec TC
Scala

import Lib1.*, Lib2.*

def useLastBlock1[A](instance: A)(using LastBlock[A]) = instance.lastBlock

def useLastBlock2[A: LastBlock](instance: A) = instance.lastBlock

val eth = Ethereum(lastBlock = 2)
assert(useLastBlock1(eth) == useLastBlock2(eth))

Ligne 34, la fonction utilise un TC implicite. Notez que le TC n'a pas besoin d'être nommé si ce nom n'est pas nécessaire.

Le modèle TC est si largement utilisé qu'il existe une syntaxe de type générique pour exprimer « un type avec un comportement implicite ». Ligne 36, la syntaxe est une alternative plus concise à la précédente (ligne 34). Cela évite de déclarer spécifiquement le paramètre TC implicite sans nom.

Ceci conclut la section sur l’expérience du développeur. Nous avons vu comment les extensions, les implicites et certains sucres syntaxiques peuvent fournir une syntaxe moins encombrée lorsque le TC est utilisé et défini.

Dérivation automatique

De nombreuses bibliothèques Scala utilisent TC, laissant au programmeur le soin de les implémenter dans leur base de code.

Par exemple Circé (une bibliothèque de désérialisation json) utilise TC `Encoder[T]` et `Decoder[T]` pour que les programmeurs l'implémentent dans leur base de code. Une fois implémentée, toute la portée de la bibliothèque peut être utilisée. 

Ces implémentations de TC sont le plus souvent mappeurs orientés données. Ils n’ont besoin d’aucune logique métier, sont ennuyeux à écrire et difficiles à maintenir en synchronisation avec les classes de cas.

Dans une telle situation, ces bibliothèques proposent ce qu'on appelle automatique dérivation ou semi-automatique dérivation. Voir par exemple Circé automatique ainsi que semi-automatique dérivation. Avec la dérivation semi-automatique, le programmeur peut déclarer une instance d'une classe de types avec une syntaxe mineure, alors que la dérivation automatique ne nécessite aucune modification du code, à l'exception d'une importation.

Sous le capot, au moment de la compilation, les macros génériques introspectent types comme structure de données pure et générer un TC[T] pour les utilisateurs de la bibliothèque. 

La dérivation générique d'un TC est très courante, c'est pourquoi Scala a introduit une boîte à outils complète à cet effet. Cette méthode n'est pas toujours annoncée dans les documentations des bibliothèques, bien qu'il s'agisse de la manière Scala 3 d'utiliser la dérivation.

Scala

object GenericLib:

  trait Named[A]:
    def blockchainName(instance: A): String

  object Named:
    import scala.deriving.*

    inline final def derived[A](using inline m: Mirror.Of[A]): Named[A] =
      val nameOfType: String = inline m match
        case p: Mirror.ProductOf[A] => compiletime.constValue[p.MirroredLabel]
        case _ => compiletime.error("Not a product")
      new Named[A]:
        override def blockchainName(instance: A):String = nameOfType.toLowerCase

  extension[A] (instance: A)(using tc: Named[A])
    def blockchainName = tc.blockchainName(instance)

import Lib1.*, GenericLib.*

case class Polkadot() derives Named
given Named[Bitcoin] = Named.derived
given Named[Ethereum] = Named.derived

println(Ethereum(lastBlock = 2).blockchainName)
println(Bitcoin().blockchainName)
println(Polkadot().blockchainName)

Ligne 18 un nouveau TC `Named` est introduit. Ce TC n’a aucun rapport avec le métier de la blockchain à proprement parler. Son objectif est de nommer la blockchain en fonction du nom de la classe de cas.

Concentrez-vous d’abord sur les définitions, lignes 36 à 38. Il existe 2 syntaxes pour dériver un TC :

  1. Ligne 36 l'instance TC peut être définie directement sur la classe de cas avec le `derives` mot-clé. Sous le capot, le compilateur génère un ` donnéNamed`instance dans`Polkadot`objet compagnon.
  2. Lignes 37 et 38, les instances de classes de types sont données sur les classes préexistantes avec `TC.derived

Ligne 31 une extension générique est définie (voir sections précédentes) et `blockchainName` s’utilise naturellement.  

Le `derives` Le mot-clé attend une méthode avec la forme `inline def derived[T](using Mirror.Of[T]): TC[T] = ???` qui est défini à la ligne 24. Je n’expliquerai pas en profondeur ce que fait le code. Dans les grandes lignes :

  • `inline def` définit une macro
  • `Mirror` fait partie de la boîte à outils pour introspecter les types. Il existe différents types de miroirs, et à la ligne 26 le code se concentre sur «Product` miroirs (une classe de cas est un produit). Ligne 27, si les programmeurs tentent de dériver quelque chose qui n'est pas un `Product`, le code ne sera pas compilé.
  • le `Mirror` contient d'autres types. L'un d'eux, "MirrorLabel`, est une chaîne qui contient le nom du type. Cette valeur est utilisée dans l'implémentation, ligne 29, du `Named` TC.

Les auteurs de TC peuvent utiliser la méta-programmation pour fournir des fonctions qui génèrent de manière générique des instances de TC étant donné un type. Les programmeurs peuvent utiliser l'API de bibliothèque dédiée ou les outils de dérivation Scala pour créer des instances pour leur code.

Que vous ayez besoin de code générique ou spécifique pour implémenter un TC, il existe une solution pour chaque situation. 

Résumé de tous les avantages

  • Cela résout le problème de l'expression
    • De nouveaux types peuvent implémenter un comportement existant grâce à l'héritage de traits traditionnel
    • De nouveaux comportements peuvent être implémentés sur les types existants
  • Séparation des préoccupations
    • Le code n’est pas mutilé et facilement supprimable. Un TC sépare les données et le comportement, ce qui est une devise de programmation fonctionnelle.
  • C'est sur
    • C’est sûr car cela ne repose pas sur l’introspection. Cela évite les grandes correspondances de modèles impliquant des types. si vous vous retrouvez à écrire un tel code, vous pouvez détecter un cas où le modèle TC conviendra parfaitement.
    • Le mécanisme implicite est sécurisé pour la compilation ! Si une instance est manquante au moment de la compilation, le code ne sera pas compilé. Aucune surprise au moment de l'exécution.
  • Il apporte un polymorphisme ad hoc
    • Le polymorphisme ad hoc est généralement absent de la programmation orientée objet traditionnelle.
    • Avec le polymorphisme ad hoc, les développeurs peuvent implémenter le même comportement pour différents types non liés sans utiliser de sous-typage traditionnel (qui couple le code)
  • L’injection de dépendances simplifiée
    • Une instance TC peut être modifiée dans le respect du principe de substitution de Liskov. 
    • Lorsqu'un composant dépend d'un TC, un TC simulé peut facilement être injecté à des fins de test. 

Contre-indications

Chaque marteau est conçu pour répondre à une série de problèmes.

Les classes de types sont destinées aux problèmes de comportement et ne doivent pas être utilisées pour l'héritage de données. Utilisez la composition à cet effet.

Le sous-typage habituel est plus simple. Si vous possédez la base de code et ne visez pas l’extensibilité, les classes de types peuvent être excessives.

Par exemple, dans le noyau Scala, il y a un `Numeric` classe de types :

Scala

trait Numeric[T] extends Ordering[T] {
  def plus(x: T, y: T): T
  def minus(x: T, y: T): T
  def times(x: T, y: T): T

Il est vraiment logique d'utiliser une telle classe de types car elle permet non seulement de réutiliser des algorithmes algébriques sur des types intégrés dans Scala (Int, BigInt, …), mais également sur des types définis par l'utilisateur (un `ComplexNumber` par exemple).

D'un autre côté, l'implémentation des collections Scala utilise principalement le sous-typage au lieu de la classe de types. Cette conception est logique pour plusieurs raisons :

  • L'API de collection est censée être complète et stable. Il expose les comportements courants à travers les traits hérités des implémentations. Être hautement extensible n'est pas un objectif particulier ici.
  • Il doit être simple à utiliser. TC ajoute une surcharge mentale au programmeur de l'utilisateur final.
  • TC peut également encourir de légers frais généraux en termes de performances. Cela peut être critique pour une API de collection.
  • Cependant, l'API de collection est toujours extensible via de nouveaux TC définis par des bibliothèques tierces.

Conclusion

Nous avons vu que TC est un modèle simple qui résout un gros problème. Grâce à la syntaxe riche de Scala, le modèle TC peut être implémenté et utilisé de nombreuses manières. Le modèle TC est conforme au paradigme de programmation fonctionnelle et constitue un outil fabuleux pour une architecture propre. Il n’existe pas de solution miracle et le motif TC doit être appliqué lorsqu’il convient.

J'espère que vous avez acquis des connaissances en lisant ce document. 

Le code est disponible sur https://github.com/jprudent/type-class-article. N'hésitez pas à me contacter si vous avez des questions ou des remarques. Vous pouvez utiliser des tickets ou des commentaires de code dans le référentiel si vous le souhaitez.


Jérôme PRUDENT

Software Engineer

spot_img

Dernières informations

spot_img