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îne
nous 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 Int
plutô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 Int
mais 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 Int
mais 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 âge
nous 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, enum
sont la solution (dans Scala 2, une classe de type enum
peut être modélisé à l'aide d'un sealed trait
scellé, 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 enum
s. 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.