Todos aquellos a los que nos gusta Scala dedicamos ratos muertos a hacer experimentos, investigando aspectos del lenguaje que no acabamos de entender o controlar. En este artículo relato cómo aprendí a usar F-bound sobre traits genéricos, usando higher-kinded types y forzándome a no utilizar type classes.

Atención! Se va a hablar de Comonads, si sabes qué son, genial, y si no lo sabes, no te preocupes, por que este artículo no trata de Comonads si no de cómo llegar a la conclusión de usar F-bound con traits genéricos.

En mi odisea por entender los Comonads, lo primero que hice tras leer sobre ellos fue implementar una serie de tests que me dejaran un poco más claro qué eran, y los llevé a cabo usando la implementación NonEmptyList, de la librería scalaz. Pero obviamente hacer tests sobre una implementación se me quedaba algo corto, así que decidí implementar yo mismo una IdentityComonad, un comonad sin funcionalidad añadida.

Me quedó algo tal que así:


case class IdentityComonad[A](a: A) {
  def map[B](f: A => B): IdentityComonad[B] = IdentityComonad(f(a))
  def counit: A = a
  def duplicate: IdentityComonad[IdentityComonad[A]] = IdentityComonad(this)
  def cojoin = duplicate
  def cobind[B](f: IdentityComonad[A] => B): IdentityComonad[B] =  IdentityComonad(f(this))
}

Cumplía con los test que había hecho para el NonEmptyList, y a mis ojos me parecía que no estaba mal, así que pensé en abstraer una interfaz para poder hacerme más comonads, por diversión, ya sabéis.

Y la primera versión de la interfaz, diseñando todos los métodos que ya tenía definidos, fue esta:


trait IComonad[A] {
  def map[B](f: A => B): IComonad[B]
  def counit: A
  def cojoin: IComonad[IComonad[A]]
  def duplicate = cojoin
  def cobind[B](f: IComonad[A] => B): IComonad[B]
}

Por lo que el IdentityComonad evolucionó a esto:


case class IdentityComonad[A](a: A) extends IComonad[A] {
  override def map[B](f: A => B): IdentityComonad[B] = IdentityComonad(f(a))
  override def counit: A = a
  override def cojoin: IdentityComonad[IdentityComonad[A]] = IdentityComonad(this)

y justo aquí nos encontramos el primer problema:

hasta este punto, IdentityComonad(IComonad en el trait) solo lo encontrabamos en los tipos de vuelta, pero…


  override def cobind[B](f: IdentityComonad[A] => B): IdentityComonad[B] =  IdentityComonad(f(this))

oh vaya, en el método cobind, el tipo también está presente en el parámetro de entrada(en f)

y ya os anticipo yo que esto no compilará, porque IdentityComonad[A] no es IComonad[A]

Llegados a este punto es cuando decimos, compilación o muerte, y cambiamos el cobind para usar IComonad:


case class IdentityComonad[A](a: A) extends IComonad[A] {
  override def map[B](f: A => B): IdentityComonad[B] = IdentityComonad(f(a))
  override def counit: A = a
  override def cojoin: IdentityComonad[IdentityComonad[A]] = IdentityComonad(this)
  override def cobind[B](f: IComonad[A] => B): IdentityComonad[B] =  IdentityComonad(f(this))
}

 

Hey! todo compila, incluso funciona, guay, no?

El problema es que si dejamos esta firma para cobind en la interfaz:



override def cobind[B](f: (IComonad[A]) => B): IComonad[B] = ???

Podríamos aceptar cualquier otro comonad que extienda de IComonad como parametro en f.

Necesitamos cambiar IComonad para forzar que el tipo usado en f es exactamente la subclase que estamos implementando, no un IComonad genérico.

Pero ahi no acaba todo.. El tipo de retorno del método map también es IComonad… Ouch. Esto significa que en nuestra implementación de map en IdentityComonad.

Podríamos devolver cualquier otra implementación de IComonad, no necesariamente IdentityComonad, con lo que dejaría de tener sentido nuestro comonad.

Llegados a este punto recuerdas haber leído sobre algo llamado F-Bounded types, así que que buscamos información y tratamos de usarlo:


trait IComonad[A, F[A]] {
  def map[B](f: A => B): F[B]
  def counit: A
  def cojoin: F[F[A]]
  def duplicate = cojoin
  def cobind[B](f: F[A] => B): F[B]
}

Si no estás muy acostumbrado a genéricos, ahora mismo te habrá empezado a entrar migraña. 

De esta manera IComonad usa A, como antes, pero ahora también usa F[A], de determina el tipo usado en la implementación. 

Puede parecer muy lioso, pera resumir, la implementación le indica a su interfaz quién es, para que la interfaz pueda forzar los tipos.

I la implentación quedaría de este modo:


case class IdentityComonad[A](a: A) extends IComonad[A, IdentityComonad] {
  override def map[B](f: A => B): IdentityComonad[B] = IdentityComonad(f(a))
  override def counit: A = a
  override def cojoin: IdentityComonad[IdentityComonad[A]] = IdentityComonad(this)
  override def cobind[B](f: IdentityComonad[A] => B): IdentityComonad[B] =  IdentityComonad(f(this))
}

Genial, eh!? Ahora el tipo de retorno debe ser exáctamente el tipo de la clase que estamos implementando(IdentityComonad), ya no tenemos el problema que teníamos antes(lo de poder usar tipos hermanos de IdentityComonad).

Pero… hueles eso? Yo también lo huelo… En ninguna parte estoy siendo obligado a indicarle a IComonad que T es IdentityComonad. Es decir, IdentityComonad podría hacer un extends IComonad[A, Something] (siendo Something otra una clase con genérico, para aceptar [A]) Y todo funcionaria(usando Something donde F).

Es dificil de explicar, así que voy a hacer un dibujo para ilustrarlo:

 


case class IdentityComonad[A](a: A) extends IComonad[A, Something] {
              |                                            |                                             
              v                                            v 
           esta clase                y                 esta clase

Podrían no ser la misma, y funcionaría! Mirad este ejemplo con un “ListComonad”(no es una implementación válida):


  class ListComonad[A](list: List[A]) extends IComonad[A, List] { // I think that this could be improved to +- work
    override def map[B](f: A => B): List[B] = list.map(f)
    override def counit: A = list.head
    override def cojoin: List[List[A]] = List(list)
    override def cobind[B](f: List[A] => B): List[B] =  List(f(list))
  }

Véis? Ahora tengo un ListComonad, mola eh? Pero esto en realidad no es un Comonad de lista válido, no estoy retornando un comonad, así que no puedo encadenarlos tampoco.

Además, el parámetro de ListComonad no es del tipo A, es de tipo List[A], esto es un problema, porque ListComonad no debería depender de List(que es un monad). 

Pero da igual, centrémonos, el caso es que acabo de demostrar que puedo engañar a la interfaz para usar tipos que no debería(o que no quiero poder usar, depende de cómo lo mires). 

Y así llegamos a la implementación final, la mejor a la que he llegado por el momento(sin usar typeclasses). 

Ahora trataremos de forzar que F[A] en IComonad sea un subtipo de IComonad(F[A] <: IComonad[A,F]), esto tiene más sentido, ahora List, no podría ocupar el lugar de F. 

Sin embargo, sí que podría hacerlo un FakeComonad o cualquier otro tipo hermano de IdentityComonad, que extienda de IComonad, pero al menos hemos limitado las posibilidades a la hora de “equivocarnos”:


object IComonad
{
  trait IComonad[A, F[A]  B): F[B]
    def counit: A
    def cojoin: F[F[A]]
    def duplicate = cojoin
    def cobind[B](f: F[A] => B): F[B]
  }

  case class IdentityComonad[A](a: A) extends IComonad[A, IdentityComonad] {
    override def map[B](f: A => B): IdentityComonad[B] = IdentityComonad(f(a))
    override def counit: A = a
    override def cojoin: IdentityComonad[IdentityComonad[A]] = IdentityComonad(this)
    override def cobind[B](f: IdentityComonad[A] => B): IdentityComonad[B] =  IdentityComonad(f(this))
  }

Este sería el único caso que se nos podría colar.


  case class FakeComonad[A](a:A) extends IComonad[A, IdentityComonad]{
    override def map[B](f: A => B): IdentityComonad[B] = IdentityComonad(f(a))
    override def counit: A = a
    override def cojoin: IdentityComonad[IdentityComonad[A]] = IdentityComonad(IdentityComonad(a))
    override def cobind[B](f: IdentityComonad[A] => B): IdentityComonad[B] =  IdentityComonad(f(IdentityComonad(a)))
  }
}

Obviamente habrá gente que diga, ¡pero si esto es un F-bound de toda la vida! ¡Podrías haberlo hecho desde el principio! Bueno, si que es verdad que es un F-bound, sin embargo tiene una dificultad especial: es un F-bound en presencia de 2 parámetros de tipo(A y F), es decir, un F-Bound sobre un tipo que ya de por si es genérico, eso nos obliga a usar.

Un higher kinded type(F[A]), esa es la grácia de este experimento, llegar al punto en que solo un F-Bound no sirve, y tenemos que usar higher kinded types!

Una vez llegas hasta el final y si lo has entendido todo, no es mucho más complicado que uno normal, pero de entrada puede resultar lioso.

Eh! y una type class no te solucionaría la vida?) Claro!, con una type class nos quitamos estos problemas de encima, ya que tienen el tipo definido explícitamente para cada type class. Pero la gracia es comprobar hasta donde podemos tensar la cuerda, no? Hasta donde podemos llegar usando únicamente este set de herramientas? Una buena referencia para aquellos que queráis ver.

Cómo hacer este tipo de cosas con una type class podéis echar un ojo a este link

Conclusión: En todo este flujo de pensamiento que hemos seguido, se puede comprobar que el uso indiscriminado de genéricos puede dar pie a agujeros curiosos. ¡Eso no significa que debas dejar de usarlos! Significa que al crear tipos muy genéricos, para librerías y demás, hay que tener un cuidado especial, vigilar ciertos bugs. Que pueden crearse, de no prestar atención. 

 

Si este artículo te gustó, te puede interesar: 

La Deuda Técnica 

Simular respuestas del servidor con Nodejs

Principio de responsabilidad única 

Por qué Kotlin ?

Patrón MVP en iOS

Arquitectura de microservicios  

 

Suscríbete a nuestro newsletter para estar al día de los eventos, meet ups y demás artículos!