jueves, 12 de mayo de 2022

Mónadas en Cats


Las mónadas son una de las abstracciones más comunes en Scala. Muchos programadores de Scala rápidamente se familiarizan intuitivamente con las mónadas, incluso si no las conocemos por su nombre. Informalmente, una mónada es cualquier cosa con un constructor y un método flatMap. Todos los funtores también son mónadas, incluidos Option, List y Future. Incluso tenemos una sintaxis especial para admitir mónadas: para comprensiones. Sin embargo, a pesar de la ubicuidad del concepto, la biblioteca estándar de Scala carece de un tipo concreto para abarcar "cosas que se pueden mapear planas". Esta clase de tipo es uno de los beneficios que nos brinda Cats. 

Pero que es una monada? Esta es la pregunta que se ha planteado en mil publicaciones de blog, con explicaciones y analogías que involucran conceptos tan diversos como gatos, comida mexicana, trajes espaciales llenos de desechos tóxicos y monoides en la categoría de endofuntores (lo que sea que eso signifique). Vamos a resolver el problema de explicar las mónadas de una vez por todas afirmando de manera muy simple: una mónada es un mecanismo para secuenciar cálculos. ¡Eso fue fácil! Problema resuelto, ¿verdad? Pero, dijimos que los funtores eran un mecanismo de control para exactamente lo mismo. Ok, tal vez necesitemos más discusión...

Los funtores nos permiten secuenciar cálculos ignorando alguna complicación. Sin embargo, los funtores están limitados porque solo permiten que esta complicación ocurra una vez al comienzo de la secuencia. No tienen en cuenta más complicaciones en cada paso de la secuencia.

Aquí es donde entran las mónadas. El método flatMap de una mónada nos permite especificar qué sucede a continuación, teniendo en cuenta una complicación intermedia. El método flatMap de Option tiene en cuenta las opciones intermedias. El método flatMap de List maneja listas intermedias. Y así. En cada caso, la función que se pasa a flatMap especifica la parte específica de la aplicación del cómputo, y flatMap se encarga de la complicación permitiéndonos flatMap nuevamente. Aterricemos las cosas mirando algunos ejemplos.

Option nos permite secuenciar cálculos que pueden o no devolver valores. Aquí hay unos ejemplos:

def parseInt(str: String): Option[Int] = scala.util.Try(str.toInt).toOption

def divide(a: Int, b: Int): Option[Int] = if(b == 0) None else Some(a / b)

Cada uno de estos métodos puede "fallar" al devolver None. El método flatMap nos permite ignorar esto cuando secuenciamos operaciones:

def stringDivideBy(aStr: String, bStr: String): Option[Int] =

    parseInt(aStr).flatMap { aNum =>

        parseInt(bStr).flatMap { bNum =>

            divide(aNum, bNum)

        }

   }

La semántica es:

  • la primera llamada a parseInt devuelve None o Some;
  • si devuelve un Some, el método flatMap llama a nuestra función y nos pasa el entero aNum;
  • la segunda llamada a parseInt devuelve None o Some;
  • si devuelve Some, el método flatMap llama a nuestra función y nos pasa bNum;
  • la llamada a dividir devuelve Ninguno o Algunos, que es nuestro resultado.

En cada paso, flatMap elige si llamar a nuestra función, y nuestra función genera el siguiente cálculo en la secuencia. 

El resultado del cálculo es una Option, que nos permite volver a llamar a flatMap y así continúa la secuencia. Esto da como resultado el comportamiento de manejo de errores rápido que conocemos y amamos, donde un None en cualquier paso da como resultado un None en general:

stringDivideBy("6", "2")
// res0: Option[Int] = Some(3)
stringDivideBy("6", "0")
// res1: Option[Int] = None
stringDivideBy("6", "foo")
// res2: Option[Int] = None
stringDivideBy("bar", "2")
// res3: Option[Int] = None

Cada mónada también es un funtor, por lo que podemos confiar tanto en flatMap como en map para secuenciar cálculos que introducen y no introducen una nueva mónada. Además, si tenemos tanto flatMap como map, podemos usar las comprensiones para aclarar el comportamiento de la secuencia:

def stringDivideBy(aStr: String, bStr: String): Option[Int] =
    for {
        aNum <- parseInt(aStr)
        bNum <- parseInt(bStr)
        ans <- divide(aNum, bNum)
    } yield ans


No hay comentarios.:

Publicar un comentario