Shapeless: funciones polimórficas

Hace ya un tiempo, nuestro amigo Javier Fuentes nos ilustró con una introducción a Shapeless.
Unos meseses después, en el meetup de Scala en Madrid, dio una interesante charla sobre inducción estructural con Shapeless y HLists. No pudimos evitarlo y nos contagiaron el entusiasmo 😛

Lo que queremos hacer

Pongamos como caso de estudio lo que yo creo que a más de uno le ha debido pasar: querer mezclar en la misma for-comprehension distintos tipos. Algo del estilo:

import scala.util.Try

for {
  v1 <- List(1,2,3)
  v2 <- Try("hi, person ")
} yield s"$v2 $v1"

con la consiguiente frustración que produce ver el siguiente error:

<console>:15: error: type mismatch;
 found   : scala.util.Try[String]
 required: scala.collection.GenTraversableOnce[?]
         v2 <- Try("hi, person ")
            ^

Necesitamos por tanto, una manera de transformar estos tipos de datos (Future, Try) en ‘cosas’ iterables (algo GenTraversable[T] nos podría valer). En nuestro ejemplo no tendremos en cuenta la información sobre el error si, por ejemplo, un tipo Try o un Future ha fallado e impide seguir evaluando la for-comprehension. Para entender un poco mejor el problema planteado, vamos a ver algunas pinceladas de teoría.

53132063

Monomorfismo vs polimorfismo

Se define un método como monomórfico cuando solo se puede aplicar al tipo que indican los argumentos en su signatura, mientas que los métodos polimórficos pueden aplicarse a argumentos de cualquier tipo (siempre que encajen en la signatura: en el caso de Scala, tipos parametrizados). En cristiano:

def monomorphic(parameter: Int): String

def polymorphic[T](parameter: T): String

Tipos de polimorfismo

Otra cuestión importante es que un método puede ser polimórfico debido a los parameter types o bien por sub-tipado, por ejemplo:

def parametricallyPolymorphic[T](parameter: T): String

def subtypedPolymorphic(parameter: Animal): String

subtypedPolymorphic(new Cat)

Si usamos parameter types, y no tenemos NADA de información sobre dichos tipos, nos encontramos ante un caso de polimorfismo paramétrico.

Si usamos parameter types pero tenemos algún view / context bound sobre dicho tipo ( T <: Whatever o T:TypeClass ), entonces hablamos de polimorfismo ad-hoc.

Problema: Function values

Con los métodos no hay problema a la hora de usar genéricos pero, ¿qué ocurre con los valores que son funciones? En Scala, el polimorfismo paramétrico no puede expresarse en base a valores que son funciones:

val monomorphic: Int => String = _.toString

val anotherMonomorphic: List[Int] => Set[Int] = 
  _.toSet

Nótese que la definición de una función que pasa de List a Set es independiente del tipo de elemento que contiene la lista; pero la sintaxis de Scala no nos permite definir nada parecido. Podríamos intentarlo asignando a un val (eta expansion) :

def polymorphic[T](l: List[T]): Set[T] = l.toSet

val sadlyMonomorphic = polymorphic _

Pero el compilador, que es muy listo, inferirá que que el tipo de la lista es Nothing: un tipo peculiar, pero concreto al fin y al cabo.

64331666

Polimorfismo paramétrico en Shapeless

¿Cómo soluciona este problema Shapeless? Si por ejemplo tuviéramos que definir una función de transformación de Option a List en Scala, tendríamos la limitación antes citada para usar function values y sólo podríamos hacerlo definiendo un método:

def toList[T](o: Option[T]): List[T] =
  o.toList

Sin embargo Shapeless, haciendo gala de toda su alquimia, nos aporta varias formas de tener function values polimórficas. Es lo que en teoría de categorías, cuando hacemos referencia a transformaciones de constructores de tipos, se denomina transformaciones naturales. La primera de ellas tiene la siguiente notación:

import shapeless.poly._

val polyFunction = new (Option ~> List){
  def apply[T](f: Option[T]): List[T] = f.toList
}

Fijaros que lo que hace es trasladar el polimorfismo paramétrico a la definición del objeto. Para usar posteriormente esta función basta con:

val result: List[Int] = polyFunction(Option(2))

La otra notación posible consiste en definir el comportamiento de la función en base a casos, es decir, si queremos que la función solo valga para Int, String y Boolean, añadiríamos un caso para cada uno de estos tipos.

import shapeless.Poly1

object polymorphic extends Poly1 {

  implicit optionIntCase = 
    at[Option[Int]](_.toList.map(_ + 1))

  implicit optionStringCase = 
    at[Option[String]](_.toList.map(_ + " mutated"))

  implicit optionBooleanCase = 
    at[Option[Boolean]](_.toList.map(!_))

}

Como podéis ver, si queremos que nuestra función esté definida para el caso en que un argumento de entrada sea Option[Int], definimos que a todos los elementos de la lista que se devuelve, por ejemplo, se les sume 1.

Esta expresión devuelve un this.Case[Option[Int]], donde this hace referencia a la función polymorphic que estamos definiendo:

implicit optionIntCase = 
  at[Option[Int]](_.toList.map(_ + 1))

¿Lo bueno de esto? Que en caso de usar la función sobre un tipo de entrada que no tiene un caso definido en la función, obtendremos un error en tiempo de compilación (Brutal, ¿no?):

El resultado

Aplicando esta última forma de expresar funciones polimórficas en base a casos, obtenemos el resultado deseado que se planteaba en la introducción: poder usar una for-comprehension sobre valores de distintos tipos: iterables, Try, Future…

Podéis ver en detalle la solución propuesta en el siguiente fichero.

En nuestra función tenemos un caso para los GenTraversable, el tipo Try y el tipo Future (en este último caso necesitamos disponer del valor del futuro para poder iterar sobre él, de manera que nos hace falta un timeout):

object values extends Poly1 {

  implicit def genTraversableCase[T,C[_]](implicit ev: C[T] => GenTraversable[T]) = 
    at[C[T]](_.toStream)

  implicit def tryCase[T,C[_]](implicit ev: C[T] => Try[T]) = 
    at[C[T]](_.toOption.toStream)

  implicit def futureCase[T,C[_]](implicit ev: C[T] => Future[T], atMost: Duration = Duration.Inf) =
    at[C[T]](f => Try(Await.result(f,atMost)).toOption.toStream)

}

Ahora podremos utilizarlo en nuestro controvertido snippet de código:

import scala.concurrent.ExecutionContext.Implicits.global

case class User(name: String, age: Int)

val result: Stream[_] = for {
  v1 <- values(List(1,2,3))
  v2 <- values(Set("hi","bye"))
  v3 <- values(Option(true))
  v4 <- values(Try(2.0))
  v5 <- values(Future(User("john",15)))
} yield (v1,v2,v3,v4,v5)

¿Única solución?

¡En absoluto!, siempre se puede implementar usando type classes tradicionales de la huerta de Scala, aunque implique definir un trait que represente el iterable del ADT. Puedes ver el ejemplo en el contenido del siguiente gist.

¡Agur de limón!

Anuncios

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s