Programación funcional en Java Puro: Ejemplos de Funciones y Mónadas

Este artículo fue inicialmente un apéndice en nuestro libro de Programación Reactiva con RxJava. Sin embargo, una introducción a las mónadas, aunque muy relacionada con la programación reactiva, no se adaptaba muy bien. Así que decidí sacarlo y publicarlo por separado como una entrada de blog. Soy consciente de que «mi propia explicación de mónadas, mitad correcta y mitad completa» es el nuevo «Hola, mundo» en los blogs de programación. Sin embargo, el artículo analiza los funtores y mónadas desde un ángulo específico de las estructuras de datos y bibliotecas Java. Por eso pensé que valía la pena compartirlo.

RxJava fue diseñado y construido sobre conceptos muy fundamentales como funtores, monoides y mónadas. A pesar de que Rx se modeló inicialmente para el lenguaje C# imperativo y estamos aprendiendo sobre RxJava, trabajando sobre un lenguaje imperativo similar, la biblioteca tiene sus raíces en la programación funcional. No debe sorprenderse después de darse cuenta de lo compacta que es la API de RxJava. Hay casi solo un puñado de clases básicas, típicamente inmutables, y todo está compuesto usando en su mayoría funciones puras.

Con un aumento reciente de la programación funcional (o estilo funcional), más comúnmente expresado en lenguajes modernos como Scala o Clojure, las mónadas se convirtieron en un tema ampliamente discutido. Hay mucho folclore a su alrededor:

Una mónada es un monoide en la categoría de endofuntores, ¿cuál es el problema?

James Iry

La maldición de la mónada es que una vez que obtienes la epifanía, una vez que entiendes – «oh, eso es lo que es» – pierdes la capacidad de explicárselo a cualquiera.

Douglas Crockford

La gran mayoría de los programadores, especialmente aquellos sin antecedentes de programación funcional, tienden a creer que las mónadas son un concepto arcano de la informática, tan teórico que no puede ayudar en su carrera de programación. Esta perspectiva negativa se puede atribuir a docenas de artículos y publicaciones de blog que son demasiado abstractas o demasiado estrechas. Pero resulta que las mónadas están a nuestro alrededor, incluso en una biblioteca Java estándar, especialmente desde Java Development Kit (JDK) 8 (más sobre eso más adelante). Lo que es absolutamente brillante es que una vez que entiendes las mónadas por primera vez, de repente varias clases y abstracciones no relacionadas, que sirven para propósitos completamente diferentes, se vuelven familiares.

Las mónadas generalizan varios conceptos aparentemente independientes, de modo que aprender otra encarnación de mónada lleva muy poco tiempo. Por ejemplo, no tiene que aprender cómo funciona CompletableFuture en Java 8, una vez que se da cuenta de que es una mónada, sabe exactamente cómo funciona y qué puede esperar de su semántica. Y entonces oyes hablar de RxJava que suena muy diferente, pero como Observable es una mónada, no hay mucho que añadir. Hay muchos otros ejemplos de mónadas que ya se cruzaron sin saberlo. Por lo tanto, esta sección será una actualización útil incluso si no usa realmente RxJava.

Funtores

Antes de explicar qué es una mónada, exploremos una construcción más simple llamada funtor . Un funtor es una estructura de datos tipificada que encapsula algunos valores. Desde una perspectiva sintáctica, un funtor es un contenedor con la siguiente API:

import java.util.function.Function;interface Functor<T> { <R> Functor<R> map(Function<T, R> f);}

Pero la mera sintaxis no es suficiente para entender lo que es un funtor. La única operación que proporciona el funtor es map () que toma una función f. Esta función recibe lo que está dentro de una caja, lo transforma y envuelve el resultado tal cual en un segundo funtor. Por favor, léalo detenidamente. Functor< T> es siempre un contenedor inmutable, por lo que map nunca muta el objeto original en el que se ejecutó. En su lugar, devuelve el resultado (o resultados – sé paciente) envuelto en un nuevo funtor, posiblemente de diferente tipo R. Además, los funtores no deben realizar ninguna acción cuando se aplica la función de identidad, es decir, map(x -> x). Tal patrón siempre debe devolver el mismo funtor o una instancia igual.

A menudo Funtor< T> se compara con una instancia de caja de T donde la única forma de interactuar con este valor es transformándolo. Sin embargo, no hay una forma idiomática de desenvolver o escapar del funtor. Los valores siempre permanecen dentro del contexto de un funtor. ¿Por qué son útiles los funtores? Generalizan múltiples expresiones comunes como colecciones, promesas, opcionales, etc. con una API única y uniforme que funciona en todas ellas. Déjame presentarte un par de funtores para que seas más fluido con esta API:

interface Functor<T,F extends Functor<?,?>> { <R> F map(Function<T,R> f);}class Identity<T> implements Functor<T,Identity<?>> { private final T value; Identity(T value) { this.value = value; } public <R> Identity<R> map(Function<T,R> f) { final R result = f.apply(value); return new Identity<>(result); }}

Se necesitaba un parámetro de tipo F adicional para compilar la identidad. Lo que viste en el ejemplo anterior fue el funtor más simple que solo tiene un valor. Todo lo que puede hacer con ese valor es transformarlo dentro del método map, pero no hay forma de extraerlo. Esto se considera más allá del alcance de un funtor puro. La única forma de interactuar con functor es aplicando secuencias de transformaciones seguras de tipo:

Identity<String> idString = new Identity<>("abc");Identity<Integer> idInt = idString.map(String::length);

O con fluidez, al igual que usted compone funciones:

Identity<byte> idBytes = new Identity<>(customer) .map(Customer::getAddress) .map(Address::street) .map((String s) -> s.substring(0, 3)) .map(String::toLowerCase) .map(String::getBytes);

Desde esta perspectiva, el mapeo sobre un funtor no es muy diferente a invocar funciones encadenadas:

byte bytes = customer .getAddress() .street() .substring(0, 3) .toLowerCase() .getBytes();

¿Por qué se molestaría con un envoltorio tan detallado que no solo no proporciona ningún valor añadido, sino que tampoco es capaz de extraer el contenido de nuevo? Bueno, resulta que puedes modelar varios otros conceptos usando esta abstracción de funtor en bruto. Por ejemplo, a partir de Java 8 Opcional es un funtor con el método map (). Permítanos implementar desde cero:

class FOptional<T> implements Functor<T,FOptional<?>> { private final T valueOrNull; private FOptional(T valueOrNull) { this.valueOrNull = valueOrNull; } public <R> FOptional<R> map(Function<T,R> f) { if (valueOrNull == null) return empty(); else return of(f.apply(valueOrNull)); } public static <T> FOptional<T> of(T a) { return new FOptional<T>(a); } public static <T> FOptional<T> empty() { return new FOptional<T>(null); }}

Ahora se vuelve interesante. Un funtor FOptional<T> puede contener un valor, pero también puede estar vacío. Es una forma segura de codificación null. Hay dos formas de construir FOptional: suministrando un valor o creando una instancia empty(). En ambos casos, al igual que con Identity,FOptional es inmutable y solo podemos interactuar con el valor desde dentro. Lo que diferencia aFOptional es que la función de transformación f puede no aplicarse a ningún valor si está vacía. Esto significa que el funtor no necesariamente puede encapsular exactamente un valor de tipo T. También puede envolver un número arbitrario de valores, al igual que List… funtor:

import com.google.common.collect.ImmutableList;class FList<T> implements Functor<T, FList<?>> { private final ImmutableList<T> list; FList(Iterable<T> value) { this.list = ImmutableList.copyOf(value); } @Override public <R> FList<?> map(Function<T, R> f) { ArrayList<R> result = new ArrayList<R>(list.size()); for (T t : list) { result.add(f.apply(t)); } return new FList<>(result); }}

La API sigue siendo la misma: tomar un functor en una transformación – pero el comportamiento es muy diferente. Ahora aplicamos una transformación a todos y cada uno de los elementos de la lista, transformando declarativamente toda la lista. Así que si tienes una lista de clientes y quieres una lista de sus calles, es tan simple como:

import static java.util.Arrays.asList;FList<Customer> customers = new FList<>(asList(cust1, cust2));FList<String> streets = customers .map(Customer::getAddress) .map(Address::street);

Ya no es tan simple como decir customers.getAddress().street(), no puedes invocargetAddress() en una colección de clientes, debes invocar getAddress() en cada cliente individual y luego volver a colocarlo en una colección. Por cierto, Groovy encontró este patrón tan común que en realidad tiene un azúcar de sintaxis para eso: customer*.getAddress()*.street(). Este operador, conocido como spread-dot, es en realidad un map disfrazado. Tal vez se esté preguntando por qué itero sobre list manualmente dentro de map en lugar de usar Streams de Java 8:list.stream().map(f).collect(toList())? ¿Te suena esto? ¿Y si te dijera quejava.util.stream.Stream<T> en Java también es un funtor? Y, por cierto, también una monada?
Ahora debería ver los primeros beneficios de los funtores: abstraen la representación interna y proporcionan una API consistente y fácil de usar sobre varias estructuras de datos. Como último ejemplo, permítanme presentar el funtor promise, similar a Future. Promise «promete» que un valor estará disponible un día. Todavía no está allí, tal vez porque se generó algún cómputo de fondo o estamos esperando un evento externo. Pero aparecerá en algún momento en el futuro. La mecánica de completar unPromise<T> no es interesante, pero la naturaleza del funtor es:

Promise<Customer> customer = //...Promise<byte> bytes = customer .map(Customer::getAddress) .map(Address::street) .map((String s) -> s.substring(0, 3)) .map(String::toLowerCase) .map(String::getBytes);

¿Te resulta familiar? Ese es el punto! La implementación del funtor está más allá del alcance de este artículo y ni siquiera es importante. Basta con decir que estamos muy cerca de implementar CompletableFuture desde Java 8 y casi descubrimos Observable desde RxJava. Pero volvamos a functors. Promesa< Cliente> todavía no tiene un valor de Cliente. Promete tener tal valor en el futuro. Pero todavía podemos mapear sobre tal funtor, al igual que hicimos con FOptional y FList: la sintaxis y la semántica son exactamente las mismas. El comportamiento sigue lo que representa el funtor. Invocando al cliente.mapa (Cliente:: getAddress) produce Promesa<Dirección>, lo que significa que el mapa no es bloqueante. cliente.map() promete al cliente completarlo. En cambio, devuelve otra promesa, de un tipo diferente. Cuando se completa la promesa ascendente, la promesa descendente aplica una función pasada a map () y pasa el resultado descendente. De repente, nuestro funtor nos permite canalizar cálculos asíncronos de manera no bloqueante. Pero no tienes que entender o aprender eso, porque Promise es un funtor, debe seguir la sintaxis y las leyes.

Hay muchos otros grandes ejemplos de funtores, por ejemplo representando valor o error de una manera compositiva. Pero ya es hora de mirar a las mónadas.

De Funtores a Mónadas

Asumo que entiendes cómo funcionan los funtores y por qué son una abstracción útil. Pero los funtores no son tan universales como cabría esperar. ¿Qué sucede si su función de transformación (la que se pasa como argumento a map()) devuelve una instancia de funtor en lugar de un valor simple? Bueno, un funtor también es un valor, así que no pasa nada malo. Lo que se devolvió se coloca de nuevo en un funtor para que todo se comporte de manera consistente. Sin embargo, imagine que tiene este práctico método para analizar cadenas:

FOptional<Integer> tryParse(String s) { try { final int i = Integer.parseInt(s); return FOptional.of(i); } catch (NumberFormatException e) { return FOptional.empty(); }}

Las excepciones son efectos secundarios que socavan el sistema de tipos y la pureza funcional. En los lenguajes funcionales puros, no hay lugar para excepciones. Después de todo, nunca hemos oído hablar de excepciones de lanzamiento durante las clases de matemáticas, ¿verdad? Los errores y las condiciones ilegales se representan explícitamente utilizando valores y envoltorios. Por ejemplo, TryParse () toma una cadena pero no simplemente devuelve una int o lanza silenciosamente una excepción en tiempo de ejecución. Decimos explícitamente, a través del sistema de tipos, que TryParse() puede fallar, no hay nada excepcional o erróneo en tener una cadena mal formada. Este semi-fallo está representado por un resultado opcional. Curiosamente, Java ha comprobado las excepciones, las que deben declararse y manejarse, por lo que en cierto sentido, Java es más puro en ese sentido, no oculta los efectos secundarios. Pero para bien o para mal, las excepciones comprobadas a menudo se desaconsejan en Java, así que volvamos a TryParse (). Parece útil componer TryParse con Cuerda ya envuelta en FOptional:

FOptional<String> str = FOptional.of("42");FOptional<FOptional<Integer>> num = str.map(this::tryParse);

Eso no debería ser una sorpresa. Si tryParse() devuelve un int, obtendrásFOptional<Integer> num, pero debido a que map() la función devuelve FOptional<Integer>, se envuelve dos veces en torpe FOptional<FOptional<Integer>>. Por favor, mire cuidadosamente los tipos, debe entender por qué tenemos esta envoltura doble aquí. Además de verse horrible, tener un funtor en composición de ruinas de funtor y encadenamiento fluido:

FOptional<Integer> num1 = //...FOptional<FOptional<Integer>> num2 = //...FOptional<Date> date1 = num1.map(t -> new Date(t));//doesn't compile!FOptional<Date> date2 = num2.map(t -> new Date(t));

Aquí tratamos de mapear el contenido de FOptional convirtiendo int en +Fecha+. Teniendo una función de int -> Date podemos transformar fácilmente de Functor<Integer> a Functor<Date>, sabemos cómo funciona. Pero en el caso de num2 la situación se complica. Lo que num2.map()recibe como entrada ya no es un int sino un FOoption<Integer> y obviamentejava.util.Date no tiene tal constructor. Rompimos nuestro funtor envolviéndolo dos veces. Sin embargo, tener una función que devuelve un funtor en lugar de un valor simple es tan común (comotryParse()) que no podemos simplemente ignorar dicho requisito. Un enfoque es introducir un método especial sin parámetros join() que «aplana» los funtores anidados:

FOptional<Integer> num3 = num2.join()

Funciona, pero debido a que este patrón es tan común, se introdujo un método especial llamado flatMap(). flatMap() es muy similar a map pero espera que la función recibida como argumento devuelva un funtor o mónada para ser precisos:

interface Monad<T,M extends Monad<?,?>> extends Functor<T,M> { M flatMap(Function<T,M> f);}

Simplemente concluimos que flatMap es solo un azúcar sintáctico para permitir una mejor composición. Pero el métodoflatMap (a menudo llamado bind o >>= de Haskell) hace toda la diferencia, ya que permite que las transformaciones complejas se compongan en un estilo puro y funcional. Si FOptional fue una instancia de mónada, el análisis de repente funciona como se esperaba:

FOptional<String> num = FOptional.of("42");FOptional<Integer> answer = num.flatMap(this::tryParse);

Las mónadas no necesitan implementar map, se pueden implementar encima de flatMap() fácilmente. De hecho, flatMap es el operador esencial que permite un universo completamente nuevo de transformaciones. Obviamente, al igual que con los funtores, el cumplimiento sintáctico no es suficiente para llamar a una mónada de clase a, el operador flatMap() tiene que seguir las leyes de la mónada, pero son bastante intuitivas como la asociatividad de flatMap() y la identidad. Este último requiere que m(x).flatMap(f) sea lo mismo quef(x) para cualquier mónada que tenga un valor x y cualquier función f. No vamos a sumergirnos demasiado en la teoría de la mónada, en su lugar, centrémonos en las implicaciones prácticas. Las mónadas brillan cuando su estructura interna no es trivial, por ejemplo Promise mónada que tendrá un valor en el futuro. ¿Puede adivinar desde el sistema de tipos cómo se comportará Promise en el siguiente programa? En primer lugar, todos los métodos que pueden tardar algún tiempo en completarse devuelven un Promise:

import java.time.DayOfWeek;Promise<Customer> loadCustomer(int id) { //...}Promise<Basket> readBasket(Customer customer) { //...}Promise<BigDecimal> calculateDiscount(Basket basket, DayOfWeek dow) { //...}

Ahora podemos componer estas funciones como si estuvieran bloqueadas utilizando operadores monádicos:

Promise<BigDecimal> discount = loadCustomer(42) .flatMap(this::readBasket) .flatMap(b -> calculateDiscount(b, DayOfWeek.FRIDAY));

Esto se vuelve interesante. flatMap() debe conservar el tipo monádico, por lo que todos los objetos intermedios son Promise s. No se trata solo de mantener los tipos en orden, ¡el programa precedente de repente es completamente asíncrono! loadCustomer() devuelve un Promise por lo que no se bloquea. readBasket() toma lo que tiene (tendrá) Promise y aplica una función que devuelve otra Promise y así sucesivamente. Básicamente, construimos una canalización asincrónica de cómputo donde la finalización de un paso en segundo plano activa automáticamente el siguiente paso.

Exploring flatMap()

Es muy común tener dos mónadas y combinar el valor que encierran juntas. Sin embargo, tanto los funtores como las mónadas no permiten el acceso directo a sus componentes internos, que serían impuros. En cambio, debemos aplicar cuidadosamente la transformación sin escapar de la mónada. Imagina que tienes dos mónadas y quieres combinarlas:

import java.time.LocalDate;import java.time.Month;Monad<Month> month = //...Monad<Integer> dayOfMonth = //...Monad<LocalDate> date = month.flatMap((Month m) -> dayOfMonth .map((int d) -> LocalDate.of(2016, m, d)));

Por favor, tómese su tiempo para estudiar el pseudo-código anterior. No uso ninguna implementación de mónada real como Promise o List para enfatizar el concepto central. Tenemos dos mónadas independientes, una de tipo Month y la otra de tipo Integer. Para construir LocalDate a partir de ellos, debemos construir una transformación anidada que tenga acceso a las partes internas de ambas mónadas. Trabaje con los tipos, especialmente asegurándose de entender por qué usamos flatMap en un lugar ymap() en el otro. Piense en cómo estructuraría este código si tuviera un tercero Monad<Year> también. Este patrón de aplicar una función de dos argumentos (m y d en nuestro caso) es tan común que en Haskell hay una función auxiliar especial llamada liftM2 que hace exactamente esta transformación, implementada encima de map y flatMap. En la pseudo sintaxis de Java se vería algo así como esto:

Monad<R> liftM2(Monad<T1> t1, Monad<T2> t2, BiFunction<T1, T2, R> fun) { return t1.flatMap((T1 tv1) -> t2.map((T2 tv2) -> fun.apply(tv1, tv2)) );}

No es necesario implementar este método para cada mónada, flatMap() es suficiente, además, funciona de manera consistente para todas las mónadas. liftM2 es extremadamente útil cuando se considera cómo se puede usar con varias mónadas. Por ejemplo, listM2(list1, list2, function) se aplicará function a cada par de elementos posibles de list1 y list2 (producto cartesiano). Por otro lado, para los opcionales aplicará una función solo cuando ambos opcionales no estén vacíos. Aún mejor, para una mónada Promise , una función se ejecutará de forma asíncrona cuando se completen ambas mónadas Promise. Esto significa que acabamos de inventar un mecanismo de sincronización simple (join() en algoritmos de unión por bifurcación) de dos pasos asíncronos.

Otro operador útil que podemos construir fácilmente sobre flatMap() es filter(Predicate<T>) que toma lo que está dentro de una mónada y lo descarta por completo si no cumple con cierto predicado. En cierto modo, es similar a map, pero en lugar de la asignación de 1 a 1, tenemos 1 a 0 o 1. De nuevo, filter()tiene la misma semántica para cada mónada, pero una funcionalidad bastante sorprendente dependiendo de la mónada que usemos realmente. Obviamente, permite filtrar ciertos elementos de una lista:

FList<Customer> vips = customers.filter(c -> c.totalOrders > 1_000);

Pero funciona igual de bien, por ejemplo, para opcionales. En ese caso, podemos transformar un opcional no vacío en uno vacío si el contenido del opcional no cumple con algunos criterios. Los opcionales vacíos se dejan intactos.

De Lista de Mónadas a Mónadas de Lista

Otro operador útil que se origina en flatMap() es sequence(). Puede adivinar fácilmente lo que hace simplemente mirando la firma de tipo:

Monad<Iterable<T>> sequence(Iterable<Monad<T>> monads)

A menudo tenemos un montón de mónadas del mismo tipo y queremos tener una sola mónada de una lista de ese tipo. Esto puede sonar abstracto para usted, pero es impresionantemente útil. Imagine que quería cargar varios clientes de la base de datos simultáneamente por ID, por lo que utilizó el método loadCustomer(id) varias veces para diferentes ID, cada invocación devolviendo Promise<Customer>. Ahora tiene una lista de Promise s, pero lo que realmente desea es una lista de clientes, por ejemplo, que se muestre en el navegador web. El operador sequence() (en RxJava sequence() se llama concat() o merge(), dependiendo del caso de uso) está construido solo para eso:

FList<Promise<Customer>> custPromises = FList .of(1, 2, 3) .map(database::loadCustomer);Promise<FList<Customer>> customers = custPromises.sequence();customers.map((FList<Customer> c) -> ...);

Tener un FList<Integer> que representa ID de cliente map sobre él (¿ve cómo ayuda que FList sea un funtor?) llamando a database.loadCustomer(id) para cada ID. Esto conduce a una lista bastante incómoda de Promise s. sequence() salva el día, pero una vez más esto no es solo un azúcar sintáctico. El código anterior es totalmente no bloqueante. Para diferentes tipos de mónadas sequence() todavía tiene sentido, pero en un contexto computacional diferente. Por ejemplo, puede cambiar FList<FOptional<T>> a FOptional<FList<T>>. Y, por cierto, puede implementarsequence() (al igual que map()) sobre flatMap().

Esto es solo la punta del iceberg cuando se trata de la utilidad de flatMap() y las mónadas en general. A pesar de provenir de una teoría de categorías bastante oscura, las mónadas demostraron ser una abstracción extremadamente útil incluso en lenguajes de programación orientados a objetos como Java. Ser capaz de componer funciones que devuelven mónadas es tan universalmente útil que docenas de clases no relacionadas siguen un comportamiento monádico.

Además, una vez que encapsula datos dentro de una mónada, a menudo es difícil sacarlos explícitamente. Tal operación no es parte del comportamiento de la mónada y a menudo conduce a un código no idiomático. Por ejemplo, Promise.get() en Promise<T> puede devolver técnicamente T, pero solo bloqueando, mientras que todos los operadores basados en flatMap() no bloquean. Otro ejemplo es FOptional.get(), pero puede fallar porque FOptional puede estar vacío. Incluso FList.get(idx) que se asoma a un elemento particular de una lista suena incómodo porque puede reemplazar bucles for con map() con bastante frecuencia.

Espero que ahora entiendas por qué las mónadas son tan populares en estos días. Incluso en un lenguaje orientado a objetos (- ish) como Java, son una abstracción bastante útil.

Deja una respuesta

Tu dirección de correo electrónico no será publicada.