Una introducción a las clases tipográficas en Scala

  • Parte 1: Esta publicación
  • Parte 2: Serialización segura de tipos en Scala
  • Parte 3: Manejo de errores tipográficos con tipos de mayor contenido

Qué es una clase de tipo

Una de las mayores barreras para comprender y usar clases de tipo en su propio código es el nombre en sí. Al contrario de lo que evoca el nombre, una clase de tipo no es una clase de un lenguaje orientado a objetos. Según wikipedia:

En informática, una clase de tipo es una construcción de sistema de tipo que admite polimorfismo ad hoc. Esto se logra agregando restricciones a las variables de tipo en tipos polimórficos paramétricos.

Tenemos que desempacar:

  • ¿Qué es el polimorfismo «ad hoc»? Sea lo que sea, ese es el tipo de clases que nos ayudarán a lograr.
  • ¿Qué es un tipo «polimórfico paramétrico»? Sea lo que sea, así es como vamos a crear nuestras propias clases de tipo.

Las clases de tipo aparecieron por primera vez en Haskell, y son una forma fundamental de construir abstracciones en Haskell. Son tan vitales que se presentan en la primera página del libro Aprende tú a Haskell:

Una clase de tipo es una especie de interfaz que define algún comportamiento. Si un tipo es parte de una clase de tipo, significa que soporta e implementa el comportamiento que describe la clase de tipo.

Ok, por lo que parece que estamos tratando de definir algún comportamiento para nuestros tipos, e implementar ese comportamiento de manera diferente dependiendo del tipo. Esa última parte, «implementar el comportamiento de manera diferente dependiendo del tipo» es el bit de ‘polimorfismo’ de antes. Así que el término está empezando a tener sentido: es una clase para tipos. Una clase que implementa el comportamiento designado por la clase de tipo se dice que es un miembro de esa clase de tipos — ‘clase de tipo’.

En este punto sería apropiado decir que esto suena como herencia. Ambos nos permiten construir abstracciones, y proporcionar polimorfismo.

Entonces, ¿por qué explorar clases de tipo? En muchos casos son un poco más potentes y extensibles que una jerarquía de herencia. Pueden ser mucho más simples y encajan mucho mejor cuando su base de código ya se inclina FP sobre OO. Aunque incluso en un proyecto fuertemente OO, las clases de tipo pueden salvar el día.

Definir una clase de tipo

Es suficiente introducción: para el resto del artículo exploraremos una funcionalidad muy familiar, que en realidad se implementa en Java a través de herencia, y la implementaremos usando una clase de tipo: stringification.

Todos estamos familiarizados con el método que existe en todos los objetos en Java (y por lo tanto en Scala): .toString. Su propósito es producir una representación de cadena del objeto en cuestión. Veamos cómo usaríamos clases de tipos para lograr esto. La versión de Haskell de toString se llama Show, por lo que seguiremos con ese nombre:

trait Showable {
def show(a: A): String
}

Eso es todo: ahí está nuestra clase de tipo Showable. ¿Por qué toma un parámetro de tipo A? Si recordamos nuestra primera definición de clases de tipo, leemos que » se logra agregando restricciones a las variables de tipo en tipos polimórficos paramétricos.»Showable es nuestro tipo polimórfico paramétrico, y A es nuestra variable de tipo, lo que simplemente significa que toma un tipo (A) como parámetro de tipo. (Nota: no pusimos ninguna restricción en A, según la definición, pero está bien. Eso es algo que hacemos cuando componemos clases de tipos juntos, o cuando hacemos uso de ellas, lo que vendrá más adelante.)

Implementando una clase de tipo

Ahora debemos proporcionar implementaciones del rasgo Showable para cualquier tipo que deseemos poder mostrar. Estas implementaciones se denominan «instancias» de nuestra clase de tipo, ya que literalmente serán instancias del rasgo Showable. En la mayoría de los casos, estas serán clases anónimas que anulan .show, y dado que hay algo de repetitivo involucrado en su definición (¿recuerda los ‘objetos de función’ en la programación Java pre-lambda?), definiremos un ayudante llamado make. Comencemos con una implementación para un tipo de nombre de usuario:

object Showable {
def make(showFn: A => String): Showable = new Showable {
override def show(a: A): String = showFn(a)
}
}final case class UserName(first: String, last: String)implicit val defaultUserNameShowable = Showable.make(userName => s"${userName.first} ${userName.last}")val janeName = UserName("Jane", "Doe")

Entonces, ¿cómo invocaríamos esto? Podríamos llamar directamente al método desde la instancia mostrable, así:

defaultUserNameShowable.show(janeName) // "Jane Doe"

Sintaxis mejorada

Hemos logrado invocar nuestra implementación Showable de UserName, pero no fue muy agradable. Teníamos que tener una referencia a nuestra instancia de clase de tipo alrededor (defaultUserNameShowable) y conocer su nombre. Esto es algo que queremos que se abstraiga. Si la historia terminara aquí, las clases de escritura en Scala no serían tan agradables de usar. Afortunadamente, podemos mejorar drásticamente la ergonomía utilizando implicitos. Aumentaremos el objeto acompañante de Showable con un ayudante de invocación:

implicit class ShowSyntax(a: A) extends AnyVal {
def show(implicit showable: Showable): String = showable.show(a)
}
...
janeName.show

Al colocar este «método de extensión» en algún lugar del alcance, podemos simplemente pretender que todos los objetos tienen un método .show.

¿Qué ha logrado esto

Así que, ¿por qué no reemplazar toString en UserName en su lugar? Bueno, digamos que UserName vive en una biblioteca de la que dependemos, y ya está compilada en el tiempo que la estamos consumiendo. No podemos subclase ya que es un final clase. Sin embargo, al usar clases de tipo, somos libres de «adjuntar» un nuevo comportamiento de manera ad hoc. Esta es la parte «ad hoc» de la definición anterior: podemos añadir este polimorfismo por separado del lugar donde se define el tipo en sí.

Además, a diferencia de la herencia, cuando definimos clases de tipo en Scala, no se requiere que sean ‘coherentes’, lo que significa que podemos definir múltiples implementaciones para Showable y elegir entre ellas. Para algunas clases de tipo esto es algo bueno, para otras no tanto.

En Haskell, las clases de tipo son coherentes. En Scala 3, según Martin Odersky, tendremos la capacidad de elegir, para cada clase de tipo, si debe ser coherente o no.

Pero por ahora, vamos a proporcionar un significado alternativo de show para UserName:

implicit val secretUserNameShowable = Showable.make(_ => "<secret person>")

Todo lo que necesitamos hacer ahora es asegurarnos de que solo una de nuestras instancias Showable esté en el alcance a la vez. Y si no, no es gran cosa, obtendremos un error de ‘valores implícitos ambiguos’ en tiempo de compilación:

janeName.show
> ambiguous implicit values:
both value secretUserNameShowable in object typeclasses of type => Showable
and value defaultUserNameShowable in object typeclasses of type => Showable
match expected type Showable

Una nota importante es que no hemos acoplado UserName y Showable. Todo lo que hemos hecho es definir un significado de Showable para UserName, manteniéndolos completamente independientes unos de otros. Este desacoplamiento tiene enormes implicaciones de mantenibilidad y ayuda a evitar relaciones de dependencia enredadas en todo el código.

Seguridad en tiempo de compilación

¿Qué sucede si intentamos llamar a .showen un objeto para el que no hemos proporcionado un significado de show? En otras palabras, ¿cuando no hay una instancia de Showable en el alcance de nuestro objetivo? Simplemente obtenemos un error de compilador:

java.util.UUID.randomUUID().show
> could not find implicit value for parameter showable: Showable

En realidad, podemos personalizar este mensaje de error cuando definimos nuestra clase de tipo:

@scala.annotation.implicitNotFound("No Showable in scope for A = ${A}. Try importing or defining one.")
trait Showable {
...
}
...
janeName.show
> No Showable in scope for A = java.util.UUID. Try importing or defining one.

Conclusiones clave

  • Las clases de tipo nos permiten definir un conjunto de comportamientos totalmente por separado de los objetos y tipos que implementarán esos comportamientos.
  • Las clases de tipo se expresan en Scala pura con rasgos que toman parámetros de tipo e implican limpiar la sintaxis.
  • Las clases de tipo nos permiten extender o implementar comportamientos para tipos y objetos cuyo origen no podemos modificar. (Proporcionan el polimorfismo ad hoc necesario para resolver el problema de la expresión).

Lo que viene a continuación

Concedido, Showable es una clase de tipo trivial, aunque no sin sus usos. Sin embargo, podemos imaginar fácilmente otros mucho más útiles:

trait SafeSerializable {
def serialize(a: A): Array
def deserialize(bytes: Array): A
}
trait Convertible {
def to(a: A): B
def from(b: B): A
}
trait Functor] {
def map(a: F, f: A => B): F
}

SafeSerializable podría ser una gran alternativa a algo como la serialización Akka, donde esperamos hasta el tiempo de ejecución para ver si un serializador está registrado para nuestro tipo o no. ¡Es mucho más seguro pasar implícitamente su implementación de serialización en tiempo de compilación!

Convertible podría ser una excelente forma reutilizable de convertir datos entre tipos. Hay una biblioteca de Twitter con el mismo objetivo en mente llamada Biyección.

Functor es un bloque de construcción para algunas abstracciones extremadamente poderosas en un área de programación funcional llamada «programación categórica». Bibliotecas como ScalaZ y Cats están construidas sobre clases de tipos como Functor. Una noción importante que no discutimos esta vez es que las clases de tipo se pueden componer fácilmente para construir un comportamiento cada vez más complejo a partir de bloques de construcción simples como Functor.

Además, muchas bibliotecas esenciales en el ecosistema de Scala (Play JSON, Slick, et. al) hacer uso de clases de tipo y exponerlas como el controlador principal de su API en muchos casos (e.g. Reproducir la clase de tipo Format de JSON). Comprender las clases de tipos por ti mismo es una excelente manera de ser más efectivo para hacer uso de lo que hay en el ecosistema de Scala, incluso si nunca escribes nada propio.

En el futuro, estaré mirando algunas clases de tipo práctico inmediato como SafeSerializable . Para una introducción a las clases de tipo categórico más abstractas como Functor] y Monad], no busque más en la documentación de la biblioteca cats. Para obtener una guía más detallada de programación en Scala utilizando clases de tipos, el underscore.io tiene un libro fantástico sobre el tema: Scala con gatos

Parte 2: Serialización segura de tipo en Scala

GIST con código

Deja una respuesta

Tu dirección de correo electrónico no será publicada.