Programación funcional de Java con Vavr

Muchos han oído hablar de lenguajes funcionales como Haskell y Clojure. Pero hay idiomas como Scala, por ejemplo. Combina OOP y un enfoque funcional. ¿Qué pasa con el buen viejo Java? ¿Es posible escribir programas en un estilo funcional y cuánto puede doler? Sí, hay Java 8 y lambdas con secuencias. Este es un gran paso para el idioma, pero aún no es suficiente. ¿Es posible llegar a algo en esta situación? Resulta que si.



Para comenzar, tratemos de determinar qué significa la escritura de código en un estilo funcional. Primero, debemos operar no con variables y manipulaciones con ellas, sino con cadenas de algunos cálculos. En esencia, una secuencia de funciones. Además, debemos tener estructuras de datos especiales. Por ejemplo, las colecciones estándar de Java no son adecuadas. Pronto quedará claro por qué.

Consideremos las estructuras funcionales con más detalle. Cualquier estructura de este tipo debe satisfacer al menos dos condiciones:

  • inmutable : la estructura debe ser inmutable. Esto significa que arreglamos el estado del objeto en la etapa de creación y lo dejamos como tal hasta el final de su existencia. Un claro ejemplo de violación de una condición: ArrayList estándar.
  • persistente : la estructura debe almacenarse en la memoria el mayor tiempo posible. Si creamos algún objeto, en lugar de crear uno nuevo con el mismo estado, deberíamos usar el listo. Más formalmente, tales estructuras retienen todos sus estados anteriores tras la modificación. Las referencias a estas condiciones deben permanecer completamente operativas.

Obviamente, necesitamos algún tipo de solución de terceros. Y existe tal solución: la biblioteca Vavr . Hoy es la biblioteca Java más popular para trabajar en un estilo funcional. A continuación, describiré las características principales de la biblioteca. Muchos, pero no todos, ejemplos y descripciones fueron tomados de la documentación oficial.

Las principales estructuras de datos de la biblioteca vavr


Tupla


Una de las estructuras de datos funcionales más básicas y simples son las tuplas. Una tupla es un conjunto ordenado de longitud fija. A diferencia de las listas, una tupla puede contener datos de cualquier tipo.

Tuple tuple = Tuple.of(1, "blablabla", .0, 42L); // (1, blablabla, 0.0, 42) 

Obtener el artículo deseado proviene de llamar al campo con el número de artículo en la tupla.

 ((Tuple4) tuple)._1 // 1 

Tenga en cuenta: ¡la indexación de tuplas comienza en 1! Además, para obtener el elemento deseado, debemos convertir nuestro objeto al tipo deseado con el conjunto apropiado de métodos. En el ejemplo anterior, usamos una tupla de 4 elementos, lo que significa que la conversión debe ser del tipo Tuple4 . De hecho, nadie nos impide hacer inicialmente el tipo correcto.

 Tuple4 tuple = Tuple.of(1, "blablabla", .0, 42L); // (1, blablabla, 0.0, 42) System.out.println(tuple._1); // 1 

Las 3 mejores colecciones de vavr


Lista


Crear una lista con vavr es muy simple. Incluso más fácil que sin vavr .

 List.of(1, 2, 3) 

¿Qué podemos hacer con tal lista? Bueno, en primer lugar, podemos convertirlo en una lista estándar de Java .

 final boolean containThree = List.of(1, 2, 3) .asJava() .stream() .anyMatch(x -> x == 3); 

Pero, de hecho, esto no es muy necesario, porque podemos hacer, por ejemplo, así:

 final boolean containThree = List.of(1, 2, 3) .find(x -> x == 1) .isDefined(); 

En general, la lista de bibliotecas vavr estándar tiene muchos métodos útiles. Por ejemplo, hay una función de convolución bastante poderosa que le permite combinar una lista de valores por alguna regla y un elemento neutral.

 //   final int zero = 0; //   final BiFunction<Integer, Integer, Integer> combine = (x, y) -> x + y; //   final int sum = List.of(1, 2, 3) .fold(zero, combine); //   

Un punto importante debe señalarse aquí. Tenemos estructuras de datos funcionales, lo que significa que no podemos cambiar su estado. ¿Cómo se implementa nuestra lista? Las matrices simplemente no nos convienen.

Lista vinculada como la lista predeterminada

Hagamos una lista simplemente vinculada con objetos inmutables. Se verá más o menos así:

imagen

Ejemplo de código
 List list = List.of(1, 2, 3); 


Cada elemento de la lista tiene dos métodos principales: obtener el elemento head (head) y todos los demás (tail).

Ejemplo de código
 list.head(); // 1 list.tail(); // List(2, 3) 


Ahora, si queremos cambiar el primer elemento de la lista (de 1 a 0), entonces necesitamos crear una nueva lista con la reutilización de las partes terminadas.

imagen
Ejemplo de código
 final List tailList = list.tail(); //    tailList.prepend(0); //      


¡Y eso es todo! Dado que nuestros objetos en la hoja de trabajo son inmutables, obtenemos una colección reutilizable y segura para subprocesos. ¡Los elementos de nuestra lista se pueden aplicar en cualquier lugar de la aplicación y es completamente seguro!

Cola


Otra estructura de datos extremadamente útil es la cola. ¿Cómo hacer una cola para construir programas efectivos y confiables en un estilo funcional? Por ejemplo, podemos tomar estructuras de datos que ya conocemos: dos listas y una tupla.

imagen

Ejemplo de código
 Queue<Integer> queue = Queue.of(1, 2, 3) .enqueue(4) .enqueue(5); 


Cuando termina el primero, expandimos el segundo y lo usamos para leer.

imagen

imagen

Es importante recordar que la cola no debe cambiar, como todas las demás estructuras. Pero, ¿de qué sirve una cola que no cambia? De hecho, hay un truco. Como valor aceptado de la cola, obtenemos una tupla de dos elementos. Primero: el elemento de cola deseado, segundo: lo que sucedió a la cola sin este elemento.

 System.out.println(queue); // Queue(1, 2, 3, 4, 5) Tuple2<Integer, Queue<Integer>> tuple2 = queue.dequeue(); System.out.println(tuple2._1); // 1 System.out.println(tuple2._2); // Queue(2, 3, 4, 5) 

Corrientes


La siguiente estructura de datos importante es la secuencia. Una secuencia es una secuencia de ejecución de algunas acciones en un determinado conjunto de valores, a menudo abstracto.

Alguien puede decir que Java 8 ya tiene transmisiones completas y que no necesitamos nuevas. Es asi?

Para comenzar, asegurémonos de que Java Stream no sea una estructura de datos funcional. Verifique la estructura de mutabilidad. Para hacer esto, cree una secuencia tan pequeña:
 IntStream standardStream = IntStream.range(1, 10); 

Ordenaremos todos los elementos en la secuencia:

 standardStream.forEach(System.out::print); 

En respuesta, obtenemos la salida a la consola: 123456789 . Repitamos la operación de fuerza bruta:

 standardStream.forEach(System.out::print); 

Vaya, se produjo el siguiente error:

 java.lang.IllegalStateException: stream has already been operated upon or closed 

El hecho es que las transmisiones estándar son solo una especie de abstracción sobre un iterador. Aunque las transmisiones externas parecen extremadamente independientes y poderosas, las desventajas de los iteradores no han desaparecido.

Por ejemplo, la definición de una secuencia no dice nada acerca de limitar el número de elementos. Desafortunadamente, existe en el iterador, lo que significa que está en secuencias estándar.

Afortunadamente, la biblioteca vavr resuelve estos problemas. Asegúrate de esto:

 Stream stream = Stream.range(1, 10); stream.forEach(System.out::print); stream.forEach(System.out::print); 

En respuesta, obtenemos 123456789123456789 . Lo que significa que la primera operación no "estropeó" nuestra transmisión.

Intentemos crear una secuencia interminable:

Stream infiniteStream = Stream.from (1);
System.out.println (infiniteStream); // Stream (1 ,?)

Tenga en cuenta: al imprimir un objeto, no obtenemos una estructura infinita, sino el primer elemento y el signo de interrogación. El hecho es que cada elemento posterior en la secuencia se genera sobre la marcha. Este enfoque se llama inicialización perezosa. Es él quien le permite trabajar con seguridad con tales estructuras.

Si nunca ha trabajado con estructuras de datos infinitas, lo más probable es que esté pensando: ¿por qué es esto necesario? Pero pueden ser extremadamente convenientes. Escribimos una secuencia que devuelve un número arbitrario de números impares, los convierte en una cadena y agrega un espacio:

 Stream oddNumbers = Stream .from(1, 2) //  1   2 .map(x -> x + " "); //  //   oddNumbers.take(5) .forEach(System.out::print); // 1 3 5 7 9 oddNumbers.take(10) .forEach(System.out::print); // 1 3 5 7 9 11 13 15 17 19 

Tan simple

Estructura general de colecciones.


Después de discutir las estructuras básicas, es hora de mirar la arquitectura general de las colecciones funcionales vavr :



Cada elemento de la estructura se puede usar como iterable:

 StringBuilder builder = new StringBuilder(); for (String word : List.of("one", "two", "tree")) { if (builder.length() > 0) { builder.append(", "); } builder.append(word); } System.out.println(builder.toString()); // one, two, tree 

Pero debes pensarlo dos veces y ver el muelle antes de usarlo. La biblioteca le permite facilitar las cosas familiares.

 System.out.println(List.of("one", "two", "tree").mkString(", ")); // one, two, tree 

Trabajar con funciones


La biblioteca tiene varias funciones (8 piezas) y métodos útiles para trabajar con ellas. Son interfaces funcionales ordinarias con muchos métodos interesantes. El nombre de las funciones depende del número de argumentos aceptados (de 0 a 8). Por ejemplo, Function0 no toma argumentos, Function1 toma un argumento, Function2 toma dos, etc.

 Function2<String, String, String> combineName = (lastName, firstName) -> firstName + " " + lastName; System.out.println(combineName.apply("Griffin", "Peter")); // Peter Griffin 

En las funciones de la biblioteca vavr, podemos hacer muchas cosas interesantes. En términos de funcionalidad, van muy por delante de la función estándar, BiFunction, etc. Por ejemplo, curry. Curry es la construcción de funciones en partes. Veamos un ejemplo:

 //    Function2<String, String, String> combineName = (lastName, firstName) -> firstName + " " + lastName; //           Function1<String, String> makeGriffinName = combineName .curried() .apply("Griffin"); //      System.out.println(makeGriffinName.apply("Peter")); // Peter Griffin System.out.println(makeGriffinName.apply("Lois")); // Lois Griffin 

Como puede ver, muy sucintamente. El método curry es extremadamente simple, pero puede ser muy útil.

Implementación del método curry
 @Override default Function1<T1, Function1<T2, R>> curried() { return t1 -> t2 -> apply(t1, t2); } 


Hay muchos más métodos útiles en el conjunto de funciones . Por ejemplo, puede almacenar en caché el resultado devuelto de una función:

 Function0<Double> hashCache = Function0.of(Math::random).memoized(); double randomValue1 = hashCache.apply(); double randomValue2 = hashCache.apply(); System.out.println(randomValue1 == randomValue2); // true 


Lucha contra las excepciones.


Como dijimos anteriormente, el proceso de programación debe ser seguro. Para hacer esto, es necesario evitar varios efectos extraños. Las excepciones son sus generadores explícitos.

Puede usar la clase Try para manejar de manera segura las excepciones en un estilo funcional. De hecho, esta es una mónada típica. Para profundizar en la teoría del uso no es necesario. Solo mira un ejemplo simple:

 Try.of(() -> 4 / 0) .onFailure(System.out::println) .onSuccess(System.out::println); 

Como puede ver en el ejemplo, todo es bastante simple. Simplemente colgamos el evento en un error potencial y no lo llevamos más allá de los límites de la computación.

Coincidencia de patrones


A menudo surge una situación en la que necesitamos verificar el valor de una variable y modelar el comportamiento del programa dependiendo del resultado. Solo en tales situaciones, un maravilloso motor de búsqueda de plantillas viene al rescate. Ya no tiene que escribir un montón de, si no , simplemente configure toda la lógica en un solo lugar.

 import static io.vavr.API.*; import static io.vavr.Predicates.*; public class PatternMatchingDemo { public static void main(String[] args) { String s = Match(1993).of( Case($(42), () -> "one"), Case($(anyOf(isIn(1990, 1991, 1992), is(1993))), "two"), Case($(), "?") ); System.out.println(s); // two } } 

Tenga en cuenta que mayúscula y minúscula case es una palabra clave y ya está en uso.

Conclusión


En mi opinión, la biblioteca es muy buena, pero vale la pena usarla con mucho cuidado. Ella puede hacerlo muy bien en el desarrollo impulsado por eventos . Sin embargo, su uso excesivo e irreflexivo en la programación imperativa estándar basada en un grupo de subprocesos puede causar mucho dolor de cabeza. Además, a menudo en nuestros proyectos usamos Spring e Hibernate, que no siempre están listos para tal aplicación. Antes de importar una biblioteca a su proyecto, necesita una comprensión clara de cómo y por qué se utilizará. De lo que hablaré en uno de mis próximos artículos.

Source: https://habr.com/ru/post/es421839/


All Articles