¡No te pierdas ninguna publicación! Suscríbete a The Softtek Blog
Las mónadas (nada que ver con Leibniz) son un patrón de diseño popular en algunos lenguajes de programación funcional como Haskell o Purescript.
Nos permiten abstraer código repetitivo al componer una secuencia de funciones, cuando las funciones, además de devolver valores, hacen gala de un “efecto” en común. El efecto concreto puede variar —posible ausencia de resultado, multiplicidad de resultados, asincronía en el cálculo— pero siempre es representado en las signaturas por una clase genérica que toma un parámetro.
Para entender una idea abstracta siempre es mejor comenzar por ejemplos concretos. Pero antes incluso de eso, repasemos lo que significa la composición de funciones.
Supongamos que tenemos un valor inicial start
de tipo String
, y una serie de funciones fun1
, fun2
y fun3
:
String start = ...
Function<string,character> fun1 = ...
Function<character,integer> fun2 = ...
Function<integer,boolean> fun3 = ...
Asumimos que las funciones son “puras”: siempre devuelven el mismo resultado para los mismos argumentos y no realizan operaciones de entrada/salida.
Podemos aplicar las funciones en secuencia:
Boolean result = fun3.apply(fun2.apply(fun1.apply(start)));
También podemos componer las funciones entre sí, antes de aplicar el argumento:
Boolean result = fun3.compose(fun2).compose(fun1).apply(start);
Ya que la composicion de funciones es asociativa, también podemos empezar componiendo fun2
con fun1
:
Boolean result =
fun3.compose(fun2.compose(fun1)).apply(start);
La asociatividad es una propiedad muy conveniente. Nos permite olvidarnos de los paréntesis, despreocuparnos de en qué orden procedió la composición.¿fun3
con fun2
y el resultado con fun1
? ¿O fun3
con el resultado de componer fun2
y fun1
? ¡Da igual! Una cosa menos a tener en cuenta, lo que para un programador siempre es de agradecer.
El método andThen
es compose
en orden inverso, y mucha gente lo encuentra más natural:
Boolean result = func1.andThen(func2).andThen(func3).apply(start)
Boolean result = func1.andThen(func2.andThen(func3)).apply(start) // asociatividad, de nuevo
Supongamos que las funciones pasan ahora a devolver Optional
para expresar una posible ausencia de resultado. Quizás están buscando sus argumentos en algún mapa y no los encuentran, o quizás devuelven un Optional
vacío para expresar alguna condición de error.
Function<String,Optional<Character>> fun1 = ...
Function<Character,Optional<Integer>> fun2 = ...
Function<Integer,Optional<Boolean>> fun3 =
Queremos encadenar las funciones como antes, de manera que si una función “falla” la composición devuelva un Optional
vacío, pero si todas finalizan exitosamente se devuelva un Optional
con el resultado.
El problema es que ya no podemos usar los compose
o andThen
de Function
para esta tarea porque ahora la composición tiene que ocuparse de un detalle adicional: comprobar si la función precedente ha fallado y, en base a ello, detener o proseguir el cálculo.
Afortunadamente, Option
nos proporciona métodos a tal efecto:
Optional<Boolean> result =
Optional.of(start)
.flatMap(fun1)
.flatMap(fun2)
.flatMap(fun3)
El método of
nos permite construir un Optional
“habitado” que contiene nuestro valor inicial. Una vez que ya tenemos el Optional
, pasamos a encadenar las funciones usando el método flatMap
.
Las signatura de flatMap
resulta misteriosa a primera vista. Recibe una función como parámetro; una función que toma un objeto de la clase que parametriza al Optional
y nos devuelve un nuevo Optional
. ¡Ojo! La función no va de Optional
a Optional
, va del parámetro genérico del Optional
en el cual invocamos flatMap
a un nuevo Optional
.
Una consecuencia interesante de haber usado flatMap
es que en ningún momento hemos tenido que comprobar si uno de los Optional
estaba vacío, y tampoco hemos tenido que escribir un condicional para interrumpir la computación en ese caso. Esos detalles habrían sido repetitivos y tediosos, pero flatMap
ya se ha encargado de ellos detrás de la bambalinas.
Por cierto, flatMap
es una operación asociativa, igual que la composición de funciones vista anteriormente. Podríamos haber escrito algo como:
Optional<Boolean> result =
Optional.of(start)
.flatMap(fun1)
.flatMap(somechar -> fun2.apply(somechar).flatMap(fun3));
Una multiplicidad de resultados nos complica la vida tanto como su posible ausencia:
Function<String, Stream<Character>> fun1 = ...
Function<Character, Stream<Integer>> fun2 = ...
Function<Integer, Stream<Boolean>> fun3 = ...
Ahora estamos en una situación en la que nuestras funciones pueden devolver una secuencia de resultados, y nos interesa aplicar cada función a todos los resultados devueltos por la función anterior.
Unos métodos de Optional
nos sacaron las castañas del fuego hace un momento. ¿Existen métodos similares para Stream
?
Afortunadamente la respuesta es sí:
Stream<Boolean> result =
Stream.of(start)
.flatMap(fun1)
.flatMap(fun2)
.flatMap(fun3);
Stream
también dispone de of
y flatMap
. Aunque funcionan de manera similar, tienen signaturas análogas y comparten nombres, no provienen de una interfaz común. La razón la veremos más adelante.
No sólo importa el número de resultados, sino también cuándo se producen. ¿Qué hacer si nuestras funciones pasan a ser asíncronas?
Function<String, CompletableFuture<Character>> fun1 = ...
Function<Character, CompletableFuture<Integer>> fun2 = ...
Function<Integer, CompletableFuture<Boolean>> fun3 = ...
Queremos encadenar estas operaciones, de manera que cada función comience a ejecutarse cuando el CompletableFuture
de la función anterior devuelva un resultado.
Sin embargo, parece que para CompletableFuture
no hay un método of
que nos permita “inyectar” el valor inicial en el contexto de la clase genérica (el constructor por defecto no nos sirve). Y tampoco hay un flatMap
, al menos con ese nombre.
Agudizando un poco la vista, vemos que existen métodos análogos bajo otros nombres:
CompletableFuture<Boolean> result =
CompletableFuture.supplyAsync(() -> start)
.thenCompose(fun1)
.thenCompose(fun2)
.thenCompose(fun3)
Podemos usar supplyAsync
para crear un CompletableFuture
a partir del valor inicial. Y thenCompose
es la función análoga a flatMap
: como flatMap
, toma una función como parámetro, función que toma un objeto de la clase que parametriza al CompletableFuture
y nos devuelve otro CompletableFuture
.
Una mónada es un patrón de diseño aplicable a clases genéricas que aceptan un parámetro. Para ser monádica, la clase tiene que proporcionar (al menos) dos métodos:
Una función o método estático “constructor” que toma un objeto y devuelve una instancia de la clase genérica, parametrizada con la clase del argumento.
class M<A> {
static M<A> construye(A)
...
}
Un método de instancia “enlazador” que toma una función como argumento. Dicha función toma a su vez una instancia de la clase que parametriza a la clase genérica, y devuelve una nueva instancia de la clase genérica, que puede estar parametrizada de manera distinta.
class M<A> {
...
M<B> enlaza(Function<A,M<B>>)
}
La existencia de estos métodos no es suficiente por sí misma. Los métodos deben satisfacer también el siguiente contrato:
El método constructor debe poner a su argumento en un contexto neutro, sin efectos.
Por ejemplo, no sería correcto que el of
de Optional
devolviese un Optional
vacío, o que el of
de Stream
generase dos copias de su argumento.
Una manera más formal de decir lo mismo es que aplicar el constructor y luego enlazar con una funcion debe ser equivalente a aplicar directamente el valor original a la funcion.
construye(x).enlaza(fun1) = fun1.apply(x)
Y también que enlazar con el constructor no debe tener efecto alguno:
m.enlaza(x -> construye(x)) = m
El método enlazador tiene que funcionar de manera asociativa, como sucedía con las funciones puras.
El código:
m.enlaza(fun1).enlaza(fun2).enlaza(fun3)
debe ser equivalente a
m.enlaza(fun1).enlaza(x -> fun2.apply(x).enlaza(fun3));
Este contrato no es tan diferente de los contratos requeridos a ciertas interfaces Java como Comparable
, o el que mantiene la
consistencia de las definiciones de equals()
y hashCode()
.
El contrato restringe el rango de implementaciones válidas del constructor y el enlazador, prohibiendo comportamientos inesperados y poco intuitivos sobre los que resultaría difícil razonar.
En cierta manera, las mónadas son un intento de mantener en lo posible la claridad de la que gozábamos al manipular funciones puras, cuando pasamos a movernos en un mundo de efectos complejos.
Algunos patrones como Observador han sido incorporados a la librería estándar de Java. De manera similar, podríamos plantearnos el crear una interfaz Monad
, que las clases Optional
, Stream
y CompletableFuture
implementarían.
Ésto tendría la ventaja de que podríamos escribir código muy general que utilizase tan sólo métodos de Monad
y que sería aplicable a cualquier clase que implementase la interfaz.
Desgraciadamente el sistema de tipos de Java no es lo bastante expresivo para definir Monad
como interfaz genérica, por dos razones:
1. En la interfaz, el método constructor no sabría qué clase concreta crear.
Durante la invocación de métodos, Java selecciona la implementación basándose en el objeto que recibe la llamada. Pero en el método constructor de Monad
, necesitaríamos seleccionar la implementación basándonos en el tipo del resultado esperado, ya que inicialmente sólo tenemos el objeto que queremos “envolver” en la clase monádica. Esta propiedad de algunos sistemas de tipado se denomina polimorfismo en el tipo de retorno.
Cuando trabajamos con clases concretas no tenemos este problema, porque podemos definir un método constructor estático en la clase misma.
2. La interfaz necesitaría referise a los parámetros de las clases genéricas que implementan la interfaz. Supongamos que queremos definir la interfaz Monad
inspirándonos en Comparable
public interface Comparable<T> {
int compareTo(T o)
}
class MiComparable implements Comparable<MiComparable> {
int compareTo(MiComparable o) { ... }
}
Comparable esto es suficiente. Pero las clases monádicas tienen un argumento genérico, y los métodos
construye y
enlaza hacen referencia a ese argumento. Para poder expresar
enlaza, la interfaz tendría que conocer el parámetro genérico de su propio parámetro genérico:
public interface Monad<M<A>> { // esto no es válido en Java!
M<B> enlaza(Function<A,M<B>>)
}
En Java los parámeros genéricos no tienen “estructura interna”. Una interfaz no puede exigir que su parámetro genérico sea a su vez genérico. Esta declaración no compila:
interface Oops<A> {
public A<Integer> oops();
}
Llegados a este punto, podríamos preguntarnos: but why? ¿Por qué tomarnos la molestia de definir un concepto si no podemos implementarlo de manera general y crear código que aproveche la abstracción?
Una posible respuesta es que reconocer la “monadicidad” en un tipo genérico nos permite reutilizar la intuición que ya hemos desarrollado para otros tipos similares, estableciendo conexiones que de otra manera permanecerían ocultas, y evitándonos comenzar desde cero en la ardua tarea de entender las cosas.
También que explotar propiedades algebraicas como la asociatividad puede ayudarnos a crear APIs más usables e intuitivas, con menos sorpresas desagradables para el usuario.
La clase Escritor
representa valores que van acompañados de mensajes de log. Hay que rellenar las implementaciones de construye
y enlaza
de manera que satisfagan el contrato de Monad
.
import java.util.function.Function;
public final class Escritor<T> {
private final T valor;
private final String log;
public Escritor(T valor, String log) {
super();
this.valor = valor;
this.log = log;
}
public T getValor() {
return valor;
}
public String getLog() {
return log;
}
public Escritor<Void> escribe(String msg) {
return new Escritor<Void>(null,msg);
}
public static<T> Escritor<T> construye(T valor) {
return null; // TODO
}
public static<R> Escritor<R> enlaza(Function<T,Escritor<R>> fun) {
return null; // TODO
}
}