Background Image
TECHNOLOGIE

Rendre les États invalides irreprésentables

February 28, 2024 | 10 Lecture minute

Utilisez des types et laissez le compilateur faire le travail difficile de validation des données à votre place.

Supposons que vous ayez un type personne dans votre programme, et qu'une classe personne possède un âge. Quel type doit avoir l'élément âge doit-il être ?

âge en tant que chaîne

case class Person(age: String)

"Bien sûr, il ne devrait pas s'agir d'une chaîne", pensez-vous. Mais pourquoi ? La raison est que nous pouvons alors nous retrouver avec un code comme :

val person = Person("Jeff")

Si nous voulions faire quoi que ce soit avec un âge : Chaînenous devrions le valider partout.

def isOldEnoughToSmoke(person: Person): Boolean = {
  Try(person.age.toInt) match {
    case Failure(_) => throw new Exception(s"cannot parse age '${person.age}' as numeric")
    case Success(value) => value >= 18
  }
}

def isOldEnoughToDrink(person: Person): Boolean = {
  Try(person.age.toInt) match {
    case Failure(_) => throw new Exception(s"cannot parse age '${person.age}' as numeric")
    case Success(value) => value >= 21
  }
}

// etc.

C'est lourd pour le programmeur qui qui écrit le code et complique la tâche de tout programmeur qui lisant le code.

Nous pourrions déplacer cette validation dans une méthode distincte :

def parseAge(age: String): Int = {
  Try(age.toInt) match {
    case Failure(_) => throw new Exception(s"cannot parse age '$age' as numeric")
    case Success(value) => value
  }
}

def isOldEnoughToSmoke(person: Person): Boolean =
  parseAge(person.age) >= 18

def isOldEnoughToDrink(person: Person): Boolean =
  parseAge(person.age) >= 21

...mais ce n'est pas encore la solution idéale. Le code est un peu plus propre, mais nous devons toujours analyser une chaîne en un Int chaque fois que nous voulons faire quelque chose de numérique (comparaison, arithmétique, etc.) avec l'élément âge. C'est ce qu'on appelle souvent "données "stringly-typées.

Cela peut également faire passer le programme dans un état illégal en lançant une Exception. Si nous devons échouer de toute façon, nous devrions échouer rapidement. Nous pouvons faire mieux.

âge comme un Int

Votre premier réflexe a peut-être été de faire de âge un Intplutôt qu'une chaîne de caractères.

case class Person(age: Int)

Si c'est le cas, vous avez un bon instinct. Un âge : Int est beaucoup plus facile à utiliser.

def isOldEnoughToSmoke(person: Person): Boolean =
  person.age >= 18

def isOldEnoughToDrink(person: Person): Boolean =
  person.age >= 21

Ce...

  • est plus facile à écrire

  • est plus facile à lire

  • échoue rapidement

Vous ne pouvez pas ne pouvez pas construire une instance de la classe personne avec une chaîne de caractères chaîne âge maintenant. Il s'agit d'un état non valide. Nous l'avons l'avons rendu irreprésentableen utilisant le système de type. Le compilateur ne permettra pas à ce programme de se compiler.

Problème résolu, n'est-ce pas ?

val person = Person(-1)

Il s'agit clairement d'un état invalide. Une personne ne peut pas avoir un âge négatif.

val person = Person(90210)

Ceci est également invalide - on dirait que quelqu'un a accidentellement entré son code postal au lieu de son âge.

Alors, comment pouvons-nous limiter encore plus ce type de données ? Comment pouvons-nous faire en sorte que des états encore plus invalides ne soient pas représentables ?

l'âge en tant que Int avec des contraintes à l'exécution

Nous pouvons imposer des contraintes à l'exécution dans tout langage à typage statique.

case class Person(age: Int) {
  assert(age >= 0 && age < 150)
}

En Scala, assert lancera une java.lang.AssertionError si l'assertion échoue.

Maintenant, nous pouvons être sûrs que l'élément age pour toute personne sera toujours compris dans l'intervalle [0, 150). Les deux...

val person = Person(-1)

et...

val person = Person(90210)

... vont maintenant échouer. Mais ils échoueront au moment de l'exécution, interrompant l'exécution de notre programme.

Ceci est similaire à ce que nous avons vu dans "l'âge en tant que chaîne", ci-dessus. Ce n'est toujours pas idéal. Existe-t-il une meilleure solution ?

à la compilation

De nombreux langages permettent une au moment de la compilation à la compilation. En général, cela se fait par le biais de macros qui inspectent le code source pendant la phase de compilation. Ces macros sont souvent appelées types raffinés.

Scala a assez bien pour les types raffinés dans plusieurs bibliothèques. Une solution utilisant l'option raffiné peut ressembler à ceci pourrait ressembler à ceci :

case class Person(age: Int Refined GreaterEqual[0] And Less[150])

Une limitation de cette approche est que le(s) champ(s) à contraindre au moment de la compilation doit(vent) être littéralcodées en dur littérales, codées en dur. Les contraintes de compilation ne peuvent pas être appliquées, par exemple, aux valeurs fournies par un utilisateur. À ce stade, le programme a déjà été compilé. Dans ce cas, nous pouvons toujours nous rabattre sur les contraintes d'exécution, ce que font souvent ces bibliothèques.

Pour l'instant, nous nous contenterons de contraintes d'exécution, car c'est souvent le mieux que nous puissions faire.

âge en tant que âge avec des contraintes

De l'implémentation la plus simple à la plus complexe, nous nous sommes déplacés de gauche à droite dans le diagramme ci-dessous.

String => Int => Int with constraints

Cette augmentation de la complexité est en corrélation directe avec la précision avec laquelle nous modélisons ces données.

"Les problèmes abordés ont une complexité inhérente, et il faut un certain effort pour les modéliser de manière appropriée." [source]

Le mouvement de gauche à droite ci-dessus doit être guidé par les exigences de votre système. Vous ne devriez pas mettre en œuvre de raffinements à la compilation, par exemple, à moins que vous n'ayez beaucoup de valeurs codées en dur à valider à la compilation : sinon vous n'en aurez pas besoin. Chaque ligne de code a un coût de mise en œuvre et de maintenance. Il est tout aussi important d'éviter les spécifications prématurées que les généralisation prématuréebien qu'il soit toujours plus facile de passer de types plus spécifiques à des types moins spécifiques.Il est cependant toujours plus facile de passer de types plus spécifiques à des types moins spécifiques, d'où la nécessité de préférer la spécificité à la généralisation.

Chaque donnée a un contextecontexte. Il n'existe pas de données "pures" de type Int "pure" flottant dans l'univers. Un âge peut être modélisé comme une valeur Intmais il est différent d'un poids, qui peut également être modélisé comme un Int. Les étiquettes que nous attachons à ces valeurs brutes constituent le contexte.

case class Person(age: Int, weight: Int) {
  assert(age >= 0 && age < 150)
  assert(weight >= 0 && weight < 500)
}

Il y a un autre problème à résoudre ici. Supposons que je pèse 81 kg et que j'ai 33 ans.

val me = Person(81, 33)

Cela se compile, mais... cela ne devrait pas. J'ai échangé mon poids et mon âge !

Une façon simple d'éviter cette confusion est de définir quelques types supplémentaires. Dans ce cas, newtypes.

case class Age(years: Int) {
  assert(years >= 0 && years < 150)
}

case class Weight(kgs: Int) {
  assert(kgs >= 0 && kgs < 500)
}

case class Person(age: Age, weight: Weight)

Le nom nouveautype pour ce modèle provient de Haskell. Il s'agit d'un moyen simple de s'assurer que nous n'échangeons pas accidentellement des valeurs ayant le même type sous-jacent. Ce qui suit, par exemple, ne sera pas compilé.

val age = Age(33)
val weight = Weight(81)

val me = Person(weight, age) // does not compile!

Nous pourrions également utiliser types étiquetés. En Scala, l'exemple le plus simple ressemble à quelque chose comme...

trait AgeTag
type Age = Int with AgeTag

object Age {
  def apply(years: Int): Age = {
    assert(years >= 0 && years < 150)
    years.asInstanceOf[Age]
  }
}

trait WeightTag
type Weight = Int with WeightTag

object Weight {
  def apply(kgs: Int): Weight = {
    assert(kgs >= 0 && kgs < 500)
    kgs.asInstanceOf[Weight]
  }
}

case class Person(age: Age, weight: Weight)

val p0 = Person(42, 42) // does not compile -- an Int is not an Age
val p1 = Person(Age(42), 42) // does not compile -- an Int is not a Weight	
val p2 = Person(Age(42), Weight(42)) // compiles!
val p3 = Person(Weight(42), Weight(42)) // does not 

Cet exemple utilise le fait que l'application de fonction f() est un sucre syntaxique en Scala pour une fonction apply() en Scala. Ainsi, la méthode f() est équivalent à f.apply().

Cette approche nous permet de modéliser l'idée qu'un âge / a poids est un Intmais un Int n'est pas un âge / a poids. Cela signifie que nous pouvons traiter un âge / a poids comme un Int et ajouter, soustraire ou faire n'importe quoi d'autre pour les Int-que nous voulons faire.

En mélangeant ces deux approches dans un exemple, vous pouvez voir la différence entre les nouveaux types et les types étiquetés. Vous devez extraire la "valeur brute" d'un nouveau type. Vous n'avez pas besoin de le faire avec un type étiqueté.

// `Age` is a tagged type
trait AgeTag
type Age = Int with AgeTag

object Age {
  def apply(years: Int): Age = {
    assert(years >= 0 && years < 150)
    years.asInstanceOf[Age]
  }
}

// `Weight` is a newtype
case class Weight(kgs: Int) {
  assert(kgs >= 0 && kgs < 500)
}

// `Age`s can be treated as `Int`s, because they _are_ `Int`s
assert(40 == Age(10) + Age(30))

// `Weight`s are not `Int`s, they _contain_ `Int`s
Weight(10) + Weight(30) // does not compile

// To add `Weight`s, we must "unwrap" them
Weight(10).kgs + Weight(30).kgs

Dans certains langages, le "déballage" des nouveaux types peut se faire automatiquement. Cela peut rendre les nouveaux types aussi ergonomiques que les types balisés. Par exemple, en Scala, cela peut être fait avec une conversion implicite. conversion implicite.

implicit def weightAsInt(weight: Weight): Int = weight.kgs

// `Weight`s are not `Int`s, but they can be _converted_ to `Int`s
Weight(10) + Weight(30) // this now compiles

Raffinements supplémentaires

Le point important de la discussion ci-dessus est que, dans la mesure du possible, nous voulons rendre les états invalides non représentables.

  • "Jeff" est un âge invalide. L'âge n'est pas une chaîne de caractères, c'est un nombre.

  • -1 est un âge non valide. L'âge ne peut pas être négatif, il doit être 0 ou positif, et probablement inférieur à environ 150.

  • Mon âge n'est pas de 88 ans. L'âge doit pouvoir être facilement distingué d'autres valeurs intégrales, comme le poids.

Tout ce qui a été discuté ci-dessus a mis en œuvre ces raffinements sur le concept d'"âge", un à la fois.

Nous pouvons procéder à d'autres raffinements si le besoin s'en fait sentir.

Par exemple, supposons que nous voulions envoyer un courriel "Joyeux anniversaire" à une personne le jour de son anniversaire. Plutôt qu'un âgenous avons besoin d'une date de naissance.

case class Date(year: Year, month: Month, day: Day)

case class Year(value: Int, currentYear: Int) {
  assert(value >= 1900 && value <= currentYear)
}

case class Month(value: Int) {
  assert(value >= 1 && value <= 12)
}

case class Day(value: Int) {
  assert(value >= 1 && value <= 31)
}

case class Person(dateOfBirth: Date, weight: Weight) {
  def age(currentDate: Date): Age = {
    ??? // TODO calculate Age from dateOfBirth
  }
}

La quantité d'informations fournies par dateDeNaissance est strictement supérieure à la quantité d'informations fournie par l'élément l'âge. Nous pouvons calculer l'âge d'une personne à partir de sa date de naissance, mais nous ne pouvons pas faire l'inverse.

L'implémentation ci-dessus laisse cependant beaucoup à désirer : il y a beaucoup d'états non valides. Une meilleure façon d'implémenter cela serait de faire en sorte que Mois soit un enum, et que Jour dépende de la valeur de l'enum mois (février n'a jamais 30 jours, par exemple)

case class Year(value: Int, currentYear: Int) {
  assert(value >= 1900 && value <= currentYear)
}

sealed trait Month

case object January extends Month
case object February extends Month
case object March extends Month
case object April extends Month
case object May extends Month
case object June extends Month
case object July extends Month
case object August extends Month
case object September extends Month
case object October extends Month
case object November extends Month
case object December extends Month

case class Day(value: Int, month: Month) {
  month match {
    case February => assert(value >= 1 && value <= 28)
    case April | June | September | November => assert(value >= 1 && value <= 30)
    case _ => assert(value >= 1 && value <= 31)
  }
}

case class Date(year: Year, month: Month, day: Day)

Toujours préférer les types à faible cardinalité aux types à types à cardinalité élevéelorsque c'est possible. Cela limite le nombre d'états invalides possibles. Dans la plupart des langages, enumsont la solution (dans Scala 2, une classe de type enum peut être modélisé à l'aide d'un sealed traitscellé, comme indiqué ci-dessus). Mais il y a encore des états invalides qui se cachent ci-dessus. Pouvez-vous les trouver ?

Dans certains cas, les données stringly-typées validées à l'aide d'expressions régulières peuvent être entièrement remplacées par des enums. Pourriez-vous modéliser codes postaux canadiens de manière à ce qu'il soit impossible d'en construire un qui ne soit pas valide ?

Utilisez les connaissances ci-dessus pour aller de l'avant et rendre les états non valides irreprésentables.

Vous souhaitez en savoir plus ? Contactez Improving.

Technologie

Dernières réflexions

Explorez nos articles de blog et laissez-vous inspirer par les leaders d'opinion de nos entreprises.
Asset - Image 1 Data Storage in a Concurrent World 
DONNÉES

Data Storage in a Concurrent World 

Data storage and event ordering in concurrent systems can spark challenges, but there are ways to be prepared.