Background Image
TECNOLOGÍA

Hacer irrepresentables los Estados no válidos

February 28, 2024 | 10 Minuto(s) de lectura

Utilice tipos y deje que el compilador haga el trabajo duro de validación de datos por usted.

Suponga que tiene un tipo Persona en tu programa, y que una clase Persona tiene una edad. ¿Qué tipo debe tener la clase edad ser?

edad como Cadena

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: Cadenatendrí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 Inten 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 Intpero 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 Intpero 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 Edadnecesitamos 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, enums son el camino a seguir aquí (en Scala 2, un enum puede modelarse usando un trait selladocomo 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 enums. ¿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.

Tecnología

Reflexiones más recientes

Explore las entradas de nuestro blog e inspírese con los líderes de opinión de todas nuestras empresas.
Asset - Multi-Cloud Strategies for Developers image 2
NUBE

Aprovechamiento de SAP Analytics Cloud mediante widgets personalizados

SAP Muestra de widget personalizado