Para los que empezamos con Scala sin conocimientos previos sobre programación funcional, una palabra empieza a repetirse con cierta frecuencia sin saber muy bien qué es: mónada. Empiezas a oír hablar de mónadas y ves que alguna gente sienten cierto entusiasmo hacia ese concepto que aún no acabas de entender, mientras ves que otros recelan y miran con suspicacia.

Este artículo va dedicado a aquellos que no saben qué es una mónada ni cuáles son las ventajas que ofrece su uso.

Definición básica de mónada

Una mónada es un patrón de diseño funcional que tiene su origen en la teoría de las categorías, aunque no hace falta ser ningún experto en matemáticas para utilizarlas. Una mónada siempre encapsula un tipo de datos que nosotros elegimos para crear instancias de otro tipo nuevo asociado a una computación especial. Es común que dicha computación maneje un caso especial de dicho tipo. Una mónada siempre define un tipo de datos y cómo podemos combinar los valores de dicho tipo. ¿Qué quiere decir todo esto? Vemos un par de ejemplos que nos pueden ayudar a familiarizarnos con el concepto.

Buscando objetos que pueden no existir

Desde la versión 8 de Java tenemos disponible en el SDK la clase Optional. En Scala, el constructor de tipo Option ha estado disponible desde que se creó el lenguaje. Ambos sirve para representar la misma idea: la posibilidad de que el valor del tipo que queremos construir exista o no. Por ejemplo, supongamos que tenemos un repositorio que nos permite acceder a objetos de la clase Account. De esta forma, podemos pasarle un número de cuenta al método find y éste nos devolverá la cuenta que queremos:

def find(accountNumber: String): Account

Existe la posibilidad de que, pese a que el número de cuenta que pasemos a este método parezca correcto, no exista ninguna cuenta asociada a dicho número. ¿Qué deberíamos devolver entonces? Podríamos devolver null aunque seguramente no sea una opción muy adecuada, ya que nos puede traer problemas en el futuro si los clientes no incorporen el manejo de casos nulos siempre que llamen a nuestro método (tienes entonces excepciones del tipo NullPointerException). Controlar los casos en los que un objeto puede ser nulo todo el tiempo es engorroso y no ayuda mucho a que nuestro código sea legible. Además, la única forma en la que podemos indicar que nuestra función find puede no encontrar la cuenta requerida y devolver null es mediante la documentación que creemos para la misma.

Podemos paliar el problema devolviendo siempre un objeto no nulo, utilizando, por ejemplo, el patrón NullObject. Esta solución puede aliviar el problema de tener que controlar que el objeto devuelto no sea nulo aunque, si lo que queremos es que, viendo la definición de nuestro método, quede claro que existe la posibilidad de que la cuenta no sea encontrada, esta solución todavía no nos soluciona el problema.

Otra opción es utilizar Option (Optional en Java). Este tipo deja claro que el resultado puede existir o no y, al mismo tiempo, nos protege de tener que manejar casos nulos en nuestro código cliente.

def find(accountNumber: String): Option[Account]

¿Qué podemos hacer cuando obtenemos una instancia de Option[Account]? Podemos recuperar el valor que hemos buscado:

val account = AccountRepository.find(accountNumber).get

Pero get lanzará una excepción en caso de que no exista el valor que buscamos. Por ello, disponemos de otro método que nos devuelve el valor que habíamos buscado, si existe, y, en caso contrario, nos proporciona un valor que definimos por defecto:

val account = AccountRepository.find(accountNumber).getOrElse(DefaultAccount)

Supongamos que queremos crear una transacción. Para eso, necesitamos dos cuentas: una desde la que vamos a transferir el dinero y otras que va a ser la que reciba la cantidad transferida. ¿Cómo podríamos crear una función que recibiese los números de ambas cuentas y la cantidad a transferir utilizando el repositorio que teníamos antes? Tendríamos que comenzar buscando dichas cuentas y después, en caso de que existieran, crear la transacción con ellas.

def tx(accFrom: String, accTo: String, amount: Money): Transaction = {
  val from = AccountRepository.find(accFrom)
  val to = AccountRepository.find(accTo)

  Transaction(from, to, amount)
}

Este código no va a funcionar, ya que una transacción tiene que ser creada a partir de dos cuentas. No podemos pasar instancias de Option[Account] al constructor de Transaction. Podemos resolver nuestra opción usando get y pasando el resultado a dicho constructor:

def tx(accFrom: String, accTo: String, amount: Money): Transaction = {
  val from = AccountRepository.find(accFrom)
  val to = AccountRepository.find(accTo)

  Transaction(from.get, to.get, amount)
}

Pero esto resultaría en una excepción en caso de que alguna de las dos cuentas no existiera. Para que nuestro código fuera completamente seguro, tenemos que validar si dichas cuentas existen. A continuación vemos dos formas posibles para hacer esto. Una utiliza el método isDefined proporcionado por Option mientras que la otra se basa en pattern matching:

def tx(accFrom: String, accTo: String, amount: Money): Option[Transaction] = {
  val from = AccountRepository.find(accFrom)
  val to = AccountRepository.find(accTo)

  if (from.isDefined && to.isDefined) Some(Transaction(from.get, to.get, amount))
  else  None
}

def tx(accFrom: String, accTo: String, amount: Money): Option[Transaction] = {
  val from = AccountRepository.find(accFrom)
  val to = AccountRepository.find(accTo)

  from match {
    case Some(f) => {
      to match {
        case Some(t) => Some(Transaction(f, t, amount))
        case None => None
      }
    }
    case None => None
  }
}

Vemos que ahora el tipo de retorno de nuestra función ha cambiado a Option[Account]. Ninguna de las dos opciones resulta especialmente atractiva. En el primer caso, tenemos que comprobar explícitamente si ambas cuentas existen mediante una expresión if. En el segundo, nuestro código queda anidado. Los dos casos son difíciles de leer cuando, realmente, la lógica que encierran no es tan compleja. Además, ¿no se supone que debería ser Option quien nos abstrajera del manejo de estas situaciones? Hay que señalar que, en este caso, el método getOrElse, a diferencia del primer ejemplo, no nos ayuda a conseguir lo que queremos:

def tx(accFrom: String, accTo: String, amount: Money): Transaction = {
  val from = AccountRepository.find(accFrom)
  val to = AccountRepository.find(accTo)

  Transaction(from.getOrElse(DefaultAccount), to.getOrElse(DefaultAccount), amount)
}

Porque solamente podremos ejecutar la transacción resultante si ambas cuentas, origen y destino, no son DefaultAccount. Por lo tanto, en algún momento vamos a tener que hacer:

val transaction = tx(from, to, amount)

...

if (transaction.from != DefaultAccount && transaction.to != DefaultAccount) execute(transaction)
else doSomethingElse()

Al final, no hemos conseguido reducir la complejidad. Únicamente la hemos trasladado a otra parte que todavía está dentro de nuestro código.

Haciendo llamadas a servicios remotos

Supongamos ahora que vamos a trabajar en un servicio que permita abrir cuentas nuevas. Para ello, recibimos un token que nos permite recuperar la información del que va a ser titular de la cuenta, que ya ha sido validada, generamos un número de cuenta nuevo y, finalmente, creamos la nueva cuenta. La validación de los datos del titular ha sido realizada por otro servicio, que nos permite recuperar los datos, de igual forma que otro servicio será el encargado de generar el nuevo número de cuenta, asegurando que éstos serán siempre únicos. A dichos servicios nos conectamos mediante dos proxies locales que tienen la siguiente interfaz:

trait ValidatedCustomerInformation {
  def retrieveCustomer(validationToken: String): Future[CustomerInformation]
}

trait AccountNumberGenerator {
  def newAccountNumber(branchCode: String): Future[AccountNumber]
}

Nuestro servicio se encargará de crear nuevas cuentas para lo que recibe el token generado por el servicio de validación de información de clientes y se apoyará en ValidatedCustomerInformation y AccountNumberGenerator. Podría tener una interfaz como la siguiente:

trait NewAccountService {
  def open(customerValidationToken: String): Account
}

La implementación de este servicio no sería muy complicada aunque debemos tener en cuenta que, debido a la naturaleza remota de los servicios que proveen de la información de los clientes y nuevos números de cuenta, tenemos que manejar instancias de Future en los resultados que éstos devuelven, por lo que el siguiente código no funcionarīa:

val customerInfo = validatedCustomerInformation.retrieveCustomer(customerValidationToken)

val accountNumber = accountNumberGenerator.newAccountNumber(customerInfo.requestedAtBranch.code)

Account(customerInfo, accountNumber)

customerInfo no es una instancia de CustomerInformation sino de Future[CustomerInformation], por lo que no podemos estar seguros de que los datos del cliente van a estar disponibles cuando hacemos la llamada a newAccountNumber. Debemos resolver la instancia de Future para poder acceder a los datos del cliente:

val futureCustomerInfo = validatedCustomerInformation.retrieveCustomer(customerValidationToken)

val customerInfo = Await.wait(futureCustomerInfo, 1.second)

val accountNumber = accountNumberGenerator.newAccountNumber(customerInfo.requestedAtBranch.code)

Similarmente, tendríamos que utilizar la función await sobre el resultado obtenido de la función newAccountNumber. Este código presenta algunos problemas. En primer lugar, hemos tenido que bloquear la ejecución para poder obtener una instancia concreta de CustomerInformation antes de poder usarla. En segundo lugar, hemos definido un tiempo de espera máximo para obtener la información del cliente de una forma un tanto arbitraria. Por último, la espera puede no tener éxito pasado el tiempo que hemos especificado (1 segundo), lo cual resultaría en una excepción que tendremos que manejar si no queremos que se propague a quien llame a nuestro nuevo servicio de apertura de cuentas (cosa que no hemos hecho en la implementación básica que hemos proporcionado).

Combinando resultados de forma efectiva

Los problemas que hemos tenido cuando creábamos transacciones y abríamos cuentas son casos concretos de un problema más abstracto y genérico que, aplicando principios de programación funcional, podemos resolver una única vez y para siempre: ¿cómo combinar instancias del mismo constructor de tipos?

La solución está en emplear el concepto de mónadas heredado de la teoría de categorías matemáticas. La operación que nos va a ayudar a resolver nuestro problema de combinación de instancias de Option y Future es flatMap. Esta operación se define de la siguiente forma:

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

Una diferencia fundamental entre map y flatMap está en el tipo de función que aceptan como entrada. En el caso de map, es una función que transforma A en B, f: A=>B; sin embargo, en el caso de flatMap, se trata de una función que transforma A en F[B], f: A=>F[B]. Esta diferencia va a hacer que podamos anidar distintas llamadas a flatMap y que tengamos como tipo de retorno F[B] mientras que, si lo intentáramos hacer con map, obtendríamos F[F[…F[B]]].

Volviendo a nuestro ejemplo en el que queríamos crear una transacción a partir de dos cuentas que teníamos que buscar en un repositorio, podemos hacer:

def tx(accFrom: String, accTo: String, amount: Money): Option[Transaction] = {
  val from = AccountRepository.find(accFrom)
  val to = AccountRepository.find(accTo)

  from.flatMap {
    accFrom => {
      to.flatMap {
        accTo => Transaction(accFrom, accTo, amount)
      }
    }
  }
}

Vemos cómo ahora han desaparecido completamente las llamadas a getOrElse. El manejo de los casos en los que alguna de las cuentas no existen se ha vuelto mucho más sencillo. Nuestra nueva función devuelve Option[Transaction] y dicho valor solamente será distinto de None si ambas cuentas existen, algo que tiene sentido conceptualmente y que no tenemos que implementar explícitamente, pues es un comportamiento que ya se ha definido en la mónada Option.

¿Cómo quería nuestro código para abrir nuevas cuentas bancarias? Como Future también es una mónada, intuimos que vamos a poder crear una solución bastante similar a la anterior:

def open(customerValidationToken: String): Future[Account] = {
  val futureCustomerInfo = validatedCustomerInformation.retrieveCustomer(customerValidationToken)

  futureCustomerInfo.flatMap {
    customerInfo => {
      val futureAccountNumber = accountNumberGenerator.newAccountNumber(customerInfo.requestedAtBranch.code)

      futureAccountNumber.flatMap {
        accountNumber => Account(customerInfo, accountNumber)
      }
    }
  }
}

Podemos observar que esta solución es más abstracta que la versión anterior en la que teníamos que decidir explícitamente cuánto esperar en cada una de las invocaciones a retrieveCustomer y newAccountNumber. Ahora definimos los pasos para crear una cuenta nueva sin tener que preocuparnos de eso. Será quien utilice nuestra función para abrir cuentas quien decida cómo es más conveniente hacer el manejo de las llamadas a los servicios remotos (por ejemplo, decidiendo cuál será el tiempo máximo de espera).

Hemos visto que la implementación basada en el uso de mónadas nos permite crear un código más limpio que se centra en definir cómo combinar resultados de otras funciones, dejando otros aspectos de la implementación a la mónada en cuestión. La mónada Option nos ofrece una abstracción sobre elementos que pueden existir o no. La mónada Future nos abstrae de computaciones costosas en tiempo que pueden no estar disponibles cuando sean requeridas (lo que constituye un elemento fundamental en nuestro modelo de programación asíncrona).

Haciendo nuestra solución más elegante

Puede que hayamos conseguido combinar instancias de Option y Future sin tener que manejar los casos en los que los resultados no existen (para Option) o el tiempo de espera para una llamada remota (para Future). Pero, al hacerlo todo mediante definiciones de funciones, tenemos distintos bloques de código para cada una de las funciones que pasamos a flatMap que se van anidando, lo que puede resultar incómodo a la hora de escribir y leer nuestro código.

Afortunadamente, en Scala, los creadores del lenguaje ya previeron este problema y proporcionaron al lenguaje de una sintaxis especial para evitar el anidamiento de funciones al utilizar la función flatMap. Mediante la estructura for-comprehension, podemos utilizar la sintaxis <- en lugar de flatMap y combinar los resultados con la cláusula yield manteniendo todo nuestro código al mismo nivel, sin necesidad de identar cada vez más a la derecha debido a las funciones anidadas. Veamos el ejemplo para nuestra función que crea transacciones:

def tx(accFrom: String, accTo: String, amount: Money) = {
  for {
    from <- AccountRepository.find(accFrom)
    to <- AccountRepository.find(accTo)
  } yield {
    Transaction(accFrom, accTo, amount)
  }
}

Para la apertura de cuentas nuevas, podemos definir nuestra función del siguiente modo:

def open(customerValidationToken: String) = {
  for {
    customerInfo <- validatedCustomerInformation.retrieveCustomer(customerValidationToken)
    accountNumber <- accountNumberGenerator.newAccountNumber(customerInfo.requestedAtBranch.code)
  } yield {
    Account(customerInfo, accountNumber)
  }
}

Conclusiones

Lo que conseguimos cuando utilizamos mónadas es separar responsabilidades y definir nuestras funciones de forma más concisa. En el primer ejemplo, nuestra función tx nos dice cómo combinar los resultados obtenidos de find(accFrom) y find(accTo) para crear una nueva transacción. En ningún sitio hemos tenido que considerar la posibilidad de que la cuenta de origen o de destino no puedan encontrarse. Sin embargo, nuestra función contempla esa posibilidad y las maneja sin problema gracias al uso de Option.

En el segundo ejemplo, hemos definido la operación de apertura de una cuenta nueva a partir de los resultados obtenidos de otras dos funciones, que están implementadas por servicios remotos. Nuestro código nos dice cómo se abre una cuenta, qué elementos hacen falta para ello y cómo combinarlos, sin tener que preocuparnos por que las llamadas a servicios remotos puedan fallar ni añadir una línea de más para que nuestra función sea completamente asíncrona. Todo ello lo conseguimos utilizando Future.

Con Option tenemos el manejo de casos en los que un objeto puede existir o no mientras con Future manejamos computaciones que pueden tomar un tiempo considerable de forma asíncrona. Nuestras funciones se ocupan únicamente de definir la lógica de negocio, sabiendo que los dos problemas anteriores ya han sido bien resueltos en otra parte. El que tanto Option como Future sean mónadas nos permite combinar resultados del mismo tipo de una forma transparente y elegante gracias a las propiedades de la función flatMap. Ése es el gran beneficio de utilizar mónadas.