Applicative Functors

Como mencioné en el escrito anterior de esta serie, los Applicative Functors son extensiones naturales de los Functors. ¿Cuál es la diferencia entonces?

Cuando hablamos de Functors, especificamos que estos saben cómo aplicar una función a los elementos que contienen, y el resultado queda “envuelto” en otro Functor del mismo tipo (lo que se conoce como endomorfismo). Pero ¿qué pasa si la función que queremos aplicar está contenida dentro de un Functor?

Recordemos que un para mapear una función en un Functor (levantarla a su contexto), la definición es:

map (f: A => B): F[A] => F[B]

Se puede apreciar que f es simplemente una función que toma valores de tipo A y regresa valores de tipo B; la función no está contenida en ningún contexto. Entonces, si tenemos algo como:

(f: F[A => B]): F[A] => F[B]

no concuerda con lo que map espera, puesto que la función ya está dentro del Functor, y map espera que no lo esté.

Los applicative entran en escena aquí. Definidos en una typeclass, definen 2 operaciones básicas:

  • pure: Tomar un valor cualquiera y ponerlo en el contexto del Applicative.
  • apply: Learn you A Haskell For Great Good lo define muy bien: “es una especie de “fmap” (Haskell) mejorado, el cual toma un Functor que tiene una función en él y otro Functor, extrae la función del primero y la mapea en el segundo”.

En ScalaZ, pure es nombrado point, y como la definición de arriba dice, es simplemente poner un valor en el contexto deseado. Es decir: toma un A y regresa F[A] (recuerden que seguimos hablando de Functors):

scala> "Hola".point[List]
res1: List[String] = List("Hola")

scala> 42.point[Option]
res2: Option[Int] = Some(42)

scala> 2.point[List] map {_ + 2}
res3: List[Int] = List(4)

Tener una función dentro de un Functor no es nada del otro mundo, simplemente la ponemos en contexto también y listo. Podemos usar point, o directamente el constructor de la clase:

scala> val sumPlus10 = (x: Int) => (y: Int) => x + y + 10
sumPlus10: Int => (Int => Int) = <function1>

scala> val o = Option(sumPlus10)
o: Option[Int => (Int => Int)] = Some(<function1>)

Aquí definimos una función llamada sumPlus10, que toma un valor entero y regresa una función que toma otro entero, que a su vez regresa la suma de los 2 valores dados más 10. Apliqué el currying a la función directamente. Después, simplemente metí esa función dentro de un contexto, en este caso Option. Scala me dice que o es  una Option que contiene una función a la cual se le aplicó currying. Raro, pero nada fuera del otro mundo.

Ahora bien, Apply es interesante. ¿Recuerdan qué pasa si mandamos llamar a una función con menos parámetros de los que necesita? Obtenemos otra función que fija los parámetros ya establecidos y toma como parámetros los que hicieron falta (aplicación parcial de funciones). sumPlus10 necesita, a final de cuentas, 2 parámetros, pero gracias a que ya se le aplicó currying, podemos aplicarla parcialmente, solo necesitamos que el valor que le pasaremos esté dentro de un Functor idéntico al que contiene la función a aplicar; es este caso Option. Pero eso es fácil. La pregunta es, ¿cómo aplicar eso? Es justamente lo que apply hace. En Haskell y Scalaz, apply está definido como <*>. Sí, esos símbolos “raros” es apply.

Vamos a suponer que el primer parámetro que enviaremos será 2:

scala> val sumPlus10 = (x: Int) => (y: Int) => x + y + 10
sumPlus10: Int => (Int => Int) = <function1>

scala> val o = sumPlus10.point[Option]
o: Option[Int => (Int => Int)] = Some(<function1>)

scala> Option(2) <*> o
res0: Option[Int => Int] = Some(<function1>)

(Noten que ahora usé point en vez del constructor de Option, solo para mostrar que el resultado es el mismo).

Fíjense en el resultado de la última línea. Ya no es la misma definición que o, puesto que un parámetro ya ha sido aplicado. Ahora digamos que el segundo valor que queremos enviar como parámetro es 4. Pongamos el resultado de la última línea en una constante y usemos apply nuevamente.

scala> val o2 = res0
o2: Option[Int => Int] = Some(<function1>)

scala> Option(4) <*> o2
res1: Option[Int] = Some(16)

¡Un resultado! El proceso fue simplemente hacer 2 + 4 + 10, pero todo sin salir del contexto en el que la función ya estaba contenida, y al final simplemente tenemos el resultado de todo el proceso contenido en el mismo tipo de Functor. Además, como podrán imaginarse, en este caso que usamos Option, si uno de los contextos no contiene nada, es decir, es None, toda la operación falla. Intentemos aplicar el paso anterior, pero con None en vez de Option(4):

scala> None <*> o2
res2: Option[Int] = None

Obtenemos como resultado None, lo cual tiene mucho sentido: esperamos un parámetro dentro de un contexto; si el contexto no tiene nada, quiere decir que la función no puede ser aplicada, y por ende ésta falla.

Obviamente podemos hacer todo lo anterior en una sola línea:

scala> Option(4) <*> (Option(2) <*> sumPlus10.point[Option])
res3: Option[Int] = Some(16)

Scalaz cuenta con un “estilo Applicative” que es, a mi gusto, mucho más conciso y fácil de leer. Para ello, existe el operador |@|

Usando el mismo ejemplo anterior:

scala> (Option(4) |@| Option(2))(sumPlus10(_)(_))
res9: Option[Int] = Some(16)

Se puede notar que no necesitamos poner explícitamente a la función sumPlus10 dentro del contexto requerido. |@| lo hará por nosotros, lo cual es mucho más claro.

¿Para qué nos puede servir esto?

Por ejemplo: imaginen que tienen una clase “User” que contiene el nombre y la edad del usuario. Para poder crear un usuario, es necesario que el nombre tenga por lo menos 5 letras, y que sea mayor de edad, lo que significa que tenemos que validar 2 cosas, y el usuario solamente se creará si los 2 valores son válidos. Definamos lo anterior en términos de Applicatives y pongamos 3 ejemplos, dos en donde la creación del usuario sea fallida y uno en donde sea exitosa:

scala> case class User(name: String, age: Int)
defined class User

scala> def validateName(name: String): Option[String] = if (name.length < 5) None else Some(name)
validateName: (name: String)Option[String]

scala> def validateAge(age: Int): Option[Int] = if (age < 18) None else Some(age)
validateAge: (age: Int)Option[Int]

scala> val name1 = "Hola"
name1: String = Hola

scala> val age1 = 22
age1: Int = 22

scala> val name2 = "CincoLetras"
name2: String = CincoLetras

scala> val age2 = 14
age2: Int = 14

scala> val name3 = "Santa Claus"
name3: String = Santa Claus

scala> val age3 = 2000
age3: Int = 2000

scala> (validateName(name1) |@| validateAge(age1))(User)
res10: Option[User] = None

scala> (validateName(name2) |@| validateAge(age2))(User)
res11: Option[User] = None

scala> (validateName(name3) |@| validateAge(age3))(User)
res12: Option[User] = Some(User(Santa Claus,2000))

Solamente tenemos un usuario con valores correctos al usar las constantes name3 y age3; en los otros casos algo falló, y por tanto el usuario no puede ser creado.

Seguramente muchos se preguntarán: “pero así no sabemos cuál de las 2 falló; no hay mensajes ni nada que podamos enviar de regreso informando el error”. Justamente para eso Scalaz incluye una clase llamada Validation, que es un Applicative functor que puede contener uno de dos valores:

Validation[+E, +A]

Esto indica que la instancia que se cree de Validation puede tener o un valor de tipo E o uno de tipo A. Además, Validation tiene 2 subclases, que se asemejan mucho a como Option está definido (Some y None):

  • Success
  • Failure

Success contiene valores de tipo A, mientras que Failure contiene valores de tipo E.

¿Qué quiere decir esto? Que si en vez de Options definimos las funciones de validación en términos de Validation, en caso de que fallen podemos definir un valor para regresar en forma de una instancia de Failure. Usemos String como el tipo E, es decir, regresaremos Failure[String] en caso de que algo falle:

scala> :paste
// Entering paste mode (ctrl-D to finish)

/* Tipo de datos del error: String,
   Tipo de datos del éxito: String (nombre de usuario)
*/
def validateNameVal(name: String): Validation[String, String] =
  if (name.length < 5)
    Failure("Nombre de usuario muy corto")
  else
    Success(name)


// Exiting paste mode, now interpreting.

validateNameVal: (name: String)scalaz.Validation[String,String]

scala> :paste
// Entering paste mode (ctrl-D to finish)

/* Tipo de datos del error: String,
   Tipo de datos del éxito: Int (edad del usuario)
*/
def validateAgeVal(age: Int): Validation[String, Int] =
  if (age < 18)
    Failure("El usuario debe tener 18 años o más")
  else
    Success(age)


// Exiting paste mode, now interpreting.

validateAgeVal: (age: Int)scalaz.Validation[String,Int]

scala> (validateNameVal(name1) |@| validateAgeVal(age1))(User)
res13: scalaz.Validation[String,User] = Failure(Nombre de usuario muy corto)

scala> (validateNameVal(name2) |@| validateAgeVal(age2))(User)
res14: scalaz.Validation[String,User] = Failure(El usuario debe tener 18 años o más)

scala> (validateNameVal(name3) |@| validateAgeVal(age3))(User)
res15: scalaz.Validation[String,User] = Success(User(Santa Claus,2000))

Ahora bien, ¿qué pasa si las 2 validaciones fallan?

scala> (validateNameVal(name1) |@| validateAgeVal(age2))(User)
res16: scalaz.Validation[String,User] = Failure(Nombre de usuario muy cortoEl usuario debe tener 18 años o más)

No, no es error de edición. Los mensajes de error están “pegados”. ¿Por qué? Sencillo: Al momento de combinar “Failures”, Validation espera que el tipo de datos E pertenezca a la typeclass “Semigroup”, es decir, que sea un semigrupo, ya que llama a su función “append”. Aquí usamos String como tipo de datos E, y si recordamos, “append” de String no es otra cosa más que la concatenación. Por ello, obtenemos el resultado de arriba.

Afortunadamente, en Scalaz contamos con un tipo de datos llamado NonEmptyList, que como su nombre lo indica, es una lista no vacía. Lo que hace el append de NonEmptyList es combinar las listas en una sola. lo cual parece perfecto para lo que queremos hacer. En vez de usar String como tipo de datos E, usemos NonEmptyList:

scala> :paste
// Entering paste mode (ctrl-D to finish)

def validateNameValNel(name: String): Validation[NonEmptyList[String], String] =
  if (name.length < 5)
    Failure(NonEmptyList("Nombre de usuario muy corto"))
  else
    Success(name)

def validateAgeValNel(age: Int): Validation[NonEmptyList[String], Int] = 
  if (age < 18)
    Failure(NonEmptyList("El usuario debe tener 18 años o más"))
  else
    Success(age)


// Exiting paste mode, now interpreting.

validateNameValNel: (name: String)scalaz.Validation[scalaz.NonEmptyList[String],String]
validateAgeValNel: (age: Int)scalaz.Validation[scalaz.NonEmptyList[String],Int]

scala> (validateNameValNel(name1) |@| validateAgeValNel(age1))(User)
res17: scalaz.Validation[scalaz.NonEmptyList[String],User] = Failure(NonEmptyList(Nombre de usuario muy corto))

scala> (validateNameValNel(name2) |@| validateAgeValNel(age2))(User)
res18: scalaz.Validation[scalaz.NonEmptyList[String],User] = Failure(NonEmptyList(El usuario debe tener 18 años o más))

scala> (validateNameValNel(name1) |@| validateAgeValNel(age2))(User)
res19: scalaz.Validation[scalaz.NonEmptyList[String],User] = Failure(NonEmptyList(Nombre de usuario muy corto, El usuario debe tener 18 años o más))

scala> (validateNameValNel(name3) |@| validateAgeValNel(age3))(User)
res20: scalaz.Validation[scalaz.NonEmptyList[String],User] = Success(User(Santa Claus,2000))

Como pueden observar, ahora, en caso de que ambas validaciones fallen, los errores están incluidos elegantemente en una lista.

Es tan común usar NonEmptyList como el tipo E de Validation, que Scalaz incluye un alias para eso:

// Dentro de package.scala, en ScalaZ
type ValidationNel[E, +X] = Validation[NonEmptyList[E], X]

Todo lo anterior es posible gracias a la teoría de Applicative Functors: simplemente es tener una función dentro de un contexto y valores dentro del mismo contexto, y aplicar esa función a los valores sin salir explícitamente de ese contexto. Sin lo anterior, tendríamos que sacar primero la función del contenedor, luego los valores del otro contenedor, aplicar la función con cada valor, acumular los resultados y al final envolverlo en otro contenedor del mismo tipo que los originales. Todo eso nos ahorran los Applicatives.

Para finalizar, si ya entienden el concepto de Monads, se darán cuenta que podríamos haber usado uno para hacer las validaciones, pero esto implicaría las siguientes consideraciones:

  • Validation es un Applicative Functor, no un Monad. No lo podríamos usar.
  • Un Monad terminaría la serie de operaciones al primer fallo, lo que impediría saber si las que faltaron habrían o no sido existosas. Si nos interesa aplicar todas las funciones dentro de una serie de composiciones, un Monad está fuera de la jugada porque es más restrictivo (más fuerte).

En general, es mucho más recomendable usar algo que sea menos restrictivo (más débil) siempre que sea posible. Habrá casos en donde solamente las conceptos más fuertes sean aplicables, pero si podemos evitarlos, es mucho mejor, porque le damos más flexibilidad a la aplicación.

En la siguiente entrega, trataré de explicar Arrows, lo cual es un concepto en teoría de categorías que simplemente puede pensarse como “funciones” en programación funcional.

2 Replies to “Applicative Functors”

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.