case class Person(age: String)
"Por supuesto, no debería ser un cadena
", se podría pensar. Pero, ¿por qué? La razón es que entonces podemos acabar con código como:
val person = Person("Jeff")
Si alguna vez quisiéramos hacer algo con una edad: Cadena
tendríamos que validarla en todas partes.
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.
Esto es engorroso para el programador que escribe el código y hace que sea difícil para cualquier programador que lea el código, también.
Podríamos mover esta validación a un método separado:
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
...pero esto no es lo ideal. El código es un poco más limpio, pero todavía tenemos que analizar un método cadena
en un Int
cada vez que queramos hacer algo numérico (comparación, aritmética, etc.) con la variable edad
. Esto suele denominarse "datos de tipo cadena.
Esto también puede mover el programa a un estado ilegal lanzando una Excepción
. Si vamos a fallar de todos modos, deberíamos fallar rápido. Podemos hacerlo mejor.
edad
como Int
Tu primer instinto podría haber sido hacer que edad
un Int
en lugar de cadena.
case class Person(age: Int)
Si es así, tienes buenos instintos. Un edad Int
es mucho más agradable para trabajar.
def isOldEnoughToSmoke(person: Person): Boolean =
person.age >= 18
def isOldEnoughToDrink(person: Person): Boolean =
person.age >= 21
Esto...
es más fácil de escribir
es más fácil de leer
falla rápido
Usted no puede construir una instancia de Persona
con una clase cadena
edad
ahora. Ese es un estado inválido. Hemos hecho irrepresentableusando el sistema de tipos. El compilador no permitirá que este programa compile.
Problema resuelto, ¿verdad?
val person = Person(-1)
Esto es claramente un estado inválido también. Una persona no puede tener una edad negativa.
val person = Person(90210)
Esto tampoco es válido -- parece que alguien introdujo accidentalmente su código postal en lugar de su edad.
¿Cómo podemos restringir aún más este tipo? ¿Cómo podemos hacer que los estados no válidos sean aún más irrepresentables?
edad
como Int
con restricciones en tiempo de ejecución
Podemos aplicar restricciones en tiempo de ejecución en cualquier lenguaje de tipado estático.
case class Person(age: Int) {
assert(age >= 0 && age < 150)
}
En Scala assert
lanzará un java.lang.AssertionError
si la aserción falla.
Ahora podemos estar seguros de que edad
de cualquier Persona
siempre estará dentro del intervalo [0, 150)
. Tanto...
val person = Person(-1)
como...
val person = Person(90210)
... fallarán. Pero fallarán en tiempo de ejecución, deteniendo la ejecución de nuestro programa.
Esto es similar a lo que vimos en "age
como Cadena
"más arriba. Aún así, no es lo ideal. ¿Hay alguna forma mejor?
en tiempo de compilación
Muchos lenguajes permiten en tiempo de compilación en tiempo de compilación. Normalmente, esto se consigue mediante macros, que inspeccionan el código fuente durante la fase de compilación. A menudo se denominan tipos refinados.
Scala tiene bastante buen soporte para tipos refinados en múltiples bibliotecas. Una solución que utilice el método refinada
biblioteca podría tener este aspecto:
case class Person(age: Int Refined GreaterEqual[0] And Less[150])
Una limitación de este enfoque es que los campos que se deben restringir en tiempo de compilación deben ser literalcodificados codificados. Las restricciones en tiempo de compilación no pueden aplicarse, por ejemplo, a valores proporcionados por un usuario. En ese caso, el programa ya se ha compilado. En este caso, siempre podemos recurrir a las restricciones en tiempo de ejecución, que es lo que suelen hacer estas bibliotecas.
Por ahora, continuaremos sólo con las restricciones en tiempo de ejecución, ya que a menudo es lo mejor que podemos hacer.
edad
como Edad
con restricciones
De la implementación más sencilla a la más compleja, nos desplazamos de izquierda a derecha en el diagrama siguiente.
String => Int => Int with constraints
Este aumento de la complejidad se correlaciona directamente con la precisión con la que estamos modelando estos datos.
"Los problemas abordados tienen una complejidad inherente, y se requiere cierto esfuerzo para modelarlos adecuadamente". [fuente]
El movimiento de izquierda a derecha anterior debe estar impulsado por los requisitos de tu sistema. No deberías implementar refinamientos en tiempo de compilación, por ejemplo, a menos que tengas muchos valores codificados que validar en tiempo de compilación: de lo contrario no lo vas a necesitar. Cada línea de código tiene un coste de implementación y mantenimiento. Evitar la especificación prematura es tan importante como evitar la generalización prematuraaunque siempre es más fácil pasar de tipos más específicos a tipos menos específicosasí que prefiera la especificidad a la generalización.
Cada dato tiene un contextocontexto. No existe un tipo "puro" de Int
"puro" flotando en el universo. Una edad puede modelarse como un valor Int
pero es diferente de un peso, que también puede modelarse como un Int. Las etiquetas que atribuimos a estos valores brutos son el contexto.
case class Person(age: Int, weight: Int) {
assert(age >= 0 && age < 150)
assert(weight >= 0 && weight < 500)
}
Nos queda un problema por resolver. Supongamos que tengo 81 kg y 33 años.
val me = Person(81, 33)
Eso compila, pero... no debería. He intercambiado mi peso y mi edad.
Una forma fácil de evitar esta confusión es definir algunos tipos más. En este caso 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)
El nombre newtype para este patrón proviene de Haskell. Es una forma sencilla de asegurarnos de que no intercambiamos accidentalmente valores con el mismo tipo subyacente. Lo siguiente, por ejemplo, no compilará.
val age = Age(33)
val weight = Weight(81)
val me = Person(weight, age) // does not compile!
También podríamos usar tipos etiquetados. En Scala, el ejemplo más simple de esto es algo como...
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
Esto hace uso del hecho de que la aplicación de la función f()
es azúcar sintáctico en Scala para una función aplicar()
método. Así que f()
es equivalente a f.aplicar()
.
Este enfoque nos permite modelar la idea de que una Edad
/ a Peso
es un Int
pero un Int
no es una Edad
/ a Peso
. Esto significa que podemos tratar una Edad
/ a Peso
como un Int
y sumar, restar o hacer cualquier otra operación de tipo Int
-que queramos hacer.
Mezclando estos dos enfoques en un ejemplo, puedes ver la diferencia entre newtypes y tipos etiquetados. Debes extraer el "valor en bruto" de un newtype. No es necesario hacerlo con un tipo etiquetado.
// `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
En algunos lenguajes, el "desenvolvimiento" de los newtypes puede hacerse automáticamente. Esto puede hacer que los newtypes sean tan ergonómicos como los tipos etiquetados. Por ejemplo, en Scala, esto podría hacerse con una conversión implícita conversión implícita.
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
Otras mejoras
El punto importante de la discusión anterior es que, en la medida de lo posible, queremos hacer que los estados inválidos sean irrepresentables.
"Jeff" es una edad inválida. La edad no es una cadena, es un número.
-1 es una edad inválida. La edad no puede ser negativa, debe ser 0 o positiva, y probablemente menor que 150 aproximadamente.
Mi edad no es 88. Una edad debe ser fácilmente distinguible de otros valores integrales, como el peso.
Todo lo expuesto anteriormente ha servido para aplicar estos refinamientos al concepto de "edad", de uno en uno.
Podemos hacer más refinamientos si hay necesidad de ellos.
Por ejemplo, supongamos que queremos enviar un correo electrónico de "¡Feliz cumpleaños!" a una Persona
el día de su cumpleaños. En lugar de una Edad
necesitamos la fecha de nacimiento.
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 cantidad de información proporcionada por dateOfBirth
es estrictamente mayor que la cantidad de información proporcionada por Edad
. Podemos calcular la edad de alguien a partir de su fecha de nacimiento, pero no podemos hacer lo contrario.
Sin embargo, la implementación anterior deja mucho que desear: hay muchos estados no válidos. Una forma mejor de implementar esto sería que Mes
fuera un enum, y que Día
dependiera de la variable Mes
(por ejemplo, febrero nunca tiene 30 días).
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)
Preferir siempre tipos de baja cardinalidad a tipos de cardinalidad altasiempre que sea posible. Limita el número de posibles estados inválidos. En la mayoría de los lenguajes, enum
s son el camino a seguir aquí (en Scala 2, un enum
puede modelarse usando un trait sellado
como se muestra arriba). Pero todavía hay estados inválidos escondidos arriba. ¿Puede encontrarlos?
En algunos casos, los datos de tipo cadena validados mediante expresiones regulares pueden sustituirse completamente por enum
s. ¿Podrías modelar códigos postales canadienses de forma que sea imposible construir uno inválido?
Utiliza estos conocimientos para hacer irrepresentables los estados inválidos.
¿Quiere saber más? Póngase en contacto con Improving.