Programación funcional en Java, Parte 1: listas, lambdas y referencias de métodos

Aprenda a usar expresiones lambda para reducir en gran medida el desorden de código.

Las colecciones de números, cadenas y objetos se utilizan con tanta frecuencia en Java que eliminar incluso una pequeña cantidad de ceremonia al codificarlos puede reducir enormemente el desorden del código. En este artículo de dos partes, demuestro cómo usar expresiones lambda para aprovechar el estilo funcional de programación para crear un código más expresivo y conciso con menos mutabilidad y menos errores.

Después de leer este artículo, es posible que su código Java para manipular colecciones nunca vuelva a ser el mismo: será conciso, expresivo, elegante y más extensible que nunca.

Iterando a través de una lista

Iterar a través de una lista es una operación básica en una colección, pero a lo largo de los años esa operación ha experimentado algunos cambios significativos. Comenzaré con lo antiguo y desarrollaré un ejemplo, enumerando una lista de nombres, hasta el estilo elegante.

Puede crear fácilmente una colección inmutable de una lista de nombres con el siguiente código:


final List<String> friends =
Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

Esta es la forma habitual, pero no tan deseable, de iterar e imprimir cada uno de los elementos.


for(int i = 0; i < friends.size(); i++) { System.out.println(friends.get(i)); }

A este estilo lo llamo el patrón de heridas autoinfligidas: es verboso y propenso a errores. Tienes que detenerte y preguntarte: “¿Es 

i <
i <=
?” Esto es útil solo si necesita manipular elementos en un índice particular en la colección, pero incluso entonces, podría optar por usar un estilo funcional que favorezca la inmutabilidad, como se discutirá pronto.

Java ofrece una construcción que es un poco más civilizada que el viejo 

for
ciclo.


for(String name : friends) { System.out.println(name); }

Bajo el capó, esta forma de iteración usa la 

Iterator
interfaz y llama a sus métodos 
hasNext()
next()
.

Ambas versiones son iteradores externos , que mezclan cómo lo hace con lo que le gustaría lograr. Usted controla explícitamente la iteración con ellos, indicando dónde comenzar y dónde terminar; la segunda versión lo hace bajo el capó usando los 

Iterator
métodos. Con control explícito, las declaraciones 
break
continue
ayudan a administrar el flujo de control de la iteración.

La segunda construcción tiene menos ceremonia que la primera y es mejor que la primera si no tiene la intención de modificar la colección en un índice particular. Sin embargo, ambos estilos son imperativos y puede prescindir de ellos en Java moderno utilizando un enfoque funcional .

Hay bastantes razones para favorecer el cambio al estilo funcional.

  • for
     Los bucles son inherentemente secuenciales y son bastante difíciles de paralelizar.
  • Dichos bucles no son polimórficos: obtienes exactamente lo que pides. Pasó la colección a en 
    for
    lugar de invocar un método (una operación polimórfica) en la colección para realizar la tarea.
  • A nivel de diseño, el código falla en el principio de “Diga, no pregunte”. Solicita que se realice una iteración específica en lugar de dejar los detalles de la iteración a las bibliotecas subyacentes.

Es hora de cambiar el antiguo estilo imperativo por la versión de estilo funcional más elegante de la iteración interna . Con una iteración interna, voluntariamente pasa el cómo a la biblioteca subyacente para que pueda concentrarse en el qué esencial . La función subyacente se encargará de gestionar la iteración.

Aquí, utilizará un iterador interno para enumerar los nombres. La 

Iterable
interfaz se ha mejorado (comenzando en JDK 8) con un método especial llamado 
forEach()
, que acepta un parámetro de tipo 
Consumer
. Como su nombre indica, una instancia de 
Consumer
consumirá, a través de su 
accept()
método, que es lo que se le da. Utilice el 
forEach()
método con la sintaxis de clase interna anónima.


friends.forEach(new Consumer<String>() {
   public void accept(final String name) {
      System.out.println(name);
   }
});

Ha invocado 

forEach()
a la 
friends
colección y le ha pasado una instancia anónima 
Consumer
. El 
forEach()
método invocará el 
accept()
método dado 
Consumer
para cada elemento de la colección y realizará una acción específica. En este ejemplo, la acción simplemente imprime el valor dado, que es un nombre.

Mire el resultado de esta versión, que es el mismo que el resultado de las dos versiones anteriores.


Brian
Nate
Neal
Raju
Sara
Scott

Cambió simplemente una cosa, intercambiando el 

for
ciclo antiguo por el nuevo iterador interno 
forEach()
. En cuanto al beneficio, pasó de especificar cómo iterar a centrarse en lo que quiere hacer para cada elemento. La mala noticia es que el código parece mucho más detallado, tanto que puede acabar con cualquier entusiasmo por el nuevo estilo de programación. Afortunadamente, puedes solucionarlo rápidamente. Aquí es donde entran en juego las expresiones lambda y la magia del compilador. Hagamos un cambio, reemplazando la clase interna anónima con una expresión lambda.


friends.forEach((final String name) -> System.out.println(name));

Eso es mucho mejor, y no solo porque es más corto. El 

forEach()
es una función de orden superior que acepta una expresión lambda o bloque de código para ejecutar en el contexto de cada elemento de la lista. La variable 
name
está vinculada a cada elemento de la colección durante la llamada. La biblioteca subyacente toma el control de cómo se evalúan las expresiones lambda. Puede decidir realizarlas con pereza, en cualquier orden, y explotar el paralelismo como crea conveniente.

Esta versión produce el mismo resultado que las versiones anteriores. La versión del iterador interno es más concisa que las otras. Además, ayuda a centrar su atención en lo que desea lograr para cada elemento en lugar de en cómo secuenciar la iteración: es declarativo.

Sin embargo, la versión con mejor código tiene una limitación. Una vez 

forEach()
que se inicia el método, a diferencia de las otras dos versiones, no puede salir de la iteración. (Existen facilidades para manejar esta limitación). En consecuencia, este estilo es útil cuando desea procesar cada elemento de una colección. Más adelante mostraré otras funciones que ofrecen más control sobre la ruta de iteración.

La sintaxis estándar para las expresiones lambda espera que los parámetros estén entre paréntesis, con la información de tipo proporcionada y separados por comas. El compilador de Java también ofrece cierta indulgencia y puede inferir los tipos. Omitir el tipo es conveniente, requiere menos esfuerzo y es menos ruidoso. Aquí está el código anterior sin la información del tipo.


friends.forEach((name) -> System.out.println(name));

En este caso, el compilador de Java determina que el 

name
parámetro es un 
String
tipo, según el contexto. Busca la firma del método llamado 
forEach()
, en este ejemplo, y analiza la interfaz funcional que toma como parámetro. Luego analiza el método abstracto de esa interfaz para determinar el número esperado de parámetros y sus tipos. También puede usar la inferencia de tipo si una expresión lambda toma varios parámetros, pero en ese caso, debe omitir la información de tipo para todos los parámetros; debe especificar el tipo para ninguno o para todos los parámetros en una expresión lambda.

El compilador de Java trata las expresiones lambda de un solo parámetro como especiales. Puede omitir los paréntesis alrededor del parámetro si se infiere el tipo del parámetro.


friends.forEach(name -> System.out.println(name));

Hay una advertencia: los parámetros inferidos no son 

final
. El ejemplo anterior, que especificó explícitamente el tipo, también marcó el parámetro como 
final
. Esto evita modificar el parámetro dentro de la expresión lambda. En general, modificar parámetros es de mal gusto y conduce a errores, por lo que marcarlos 
final
es una buena práctica. Desafortunadamente, cuando favorece la inferencia de tipos, debe practicar una disciplina adicional para no modificar el parámetro, porque el compilador no lo protegerá.

Este ejemplo ha reducido bastante el código. Un último paso sacará otra onza de concisión.


friends.forEach(System.out::println);

Este código usa una referencia de método . Java le permite simplemente reemplazar el cuerpo del código con el nombre del método de su elección. Profundizaré en esto en la siguiente sección, pero por ahora reflexionemos sobre las sabias palabras de Antoine de Saint-Exupéry: “La perfección se logra no cuando no hay nada más que agregar, sino cuando no queda nada que quitar. “

Las expresiones lambda lo ayudaron a iterar de manera concisa sobre una colección. A continuación, verá cómo ayudan a eliminar la mutabilidad y hacen que el código sea aún más conciso cuando transforma colecciones.

Transformar una lista

Manipular una colección para producir otro resultado es tan fácil como iterar a través de los elementos de una colección. Suponga que la tarea consiste en convertir una lista de nombres en letras mayúsculas. ¿Cuáles son algunas opciones?

Java 

String
es inmutable, por lo que las instancias no se pueden cambiar. Puede crear nuevas cadenas en mayúsculas y reemplazar los elementos apropiados en la colección. Sin embargo, la colección original se perdería; Además, si la lista original es inmutable, como es cuando se creó con 
Arrays.asList()
, la lista no puede cambiar. También sería difícil paralelizar el trabajo.

Es mejor crear una nueva lista que tenga los elementos en mayúsculas.

Esa sugerencia puede parecer bastante ingenua al principio; el rendimiento es una preocupación obvia para todos. Sin embargo, es probable que descubra que el enfoque funcional a menudo produce un rendimiento sorprendentemente mejor que el enfoque imperativo. Comience creando una nueva colección de nombres en mayúsculas de la colección dada.


final List<String> uppercaseNames =
   new ArrayList<String>();
for(String name : friends) {
   uppercaseNames.add(name.toUpperCase());
}

En este estilo imperativo, este código creó una lista vacía y luego la llenó con nombres en mayúsculas, un elemento a la vez, mientras recorría la lista original. Como primer paso para avanzar hacia un estilo funcional, use el 

forEach()
método de iterador interno para reemplazar el 
for
bucle, de la siguiente manera:


final List<String> uppercaseNames =
   new ArrayList<String>();
friends.forEach(name ->
   uppercaseNames.add(name.toUpperCase()));
System.out.println(uppercaseNames);

Este código usaba el iterador interno, pero eso aún requería la lista vacía y el esfuerzo de agregarle elementos.

Yendo al siguiente paso, el 

map()
método de una nueva 
Stream
interfaz puede ayudar a evitar la mutabilidad y hacer que el código sea conciso. A 
Stream
es muy parecido a un iterador en una colección de objetos y proporciona algunas funciones fluidas y agradables . Al usar los métodos de esta interfaz, puede componer una secuencia de llamadas, de modo que el código se lea y fluya de la misma manera que enunciaría el problema, haciendo que sea más fácil de leer.

El 

map()
método de 
Stream
puede mapear o transformar una secuencia de entrada en una secuencia de salida. Esto se ajustará bastante bien a la tarea en cuestión.


friends.stream()
   .map(name -> name.toUpperCase())
   .forEach(name -> System.out.print(name + " "));
System.out.println();

El método stream () está disponible en todas las colecciones desde JDK 8 y envuelve la colección en una instancia de 

Stream
. El 
map()
método aplica la expresión lambda dada o el bloque de código entre paréntesis en cada elemento de 
Stream
. El 
map()
método es bastante diferente al 
forEach()
método, que simplemente ejecuta el bloque en el contexto de cada elemento de la colección. Además, el 
map()
método recopila el resultado de ejecutar la expresión lambda y devuelve la colección resultante. Finalmente, el código imprime los elementos en este resultado usando el 
forEach()
método. Los nombres de la nueva colección están en mayúsculas.


BRIAN NATE NEAL RAJU SARA SCOTT

El 

map()
método es muy útil para mapear o transformar una colección de entrada en una nueva colección de salida. Este método garantizará que exista el mismo número de elementos en la secuencia de entrada y salida. Sin embargo, los tipos de elementos en la entrada no tienen que coincidir con los tipos de elementos en la colección de salida.

En este ejemplo, tanto la entrada como la salida son una colección de cadenas. Podría haber pasado al 

map()
método un bloque de código que devolviera, por ejemplo, el número de caracteres en un nombre dado. En este caso, la entrada aún sería una secuencia de cadenas, pero la salida sería una secuencia de números, como en el siguiente ejemplo.


friends.stream()
   .map(name -> name.length())
   .forEach(count -> System.out.print(count + " "));

El resultado es un recuento del número de letras de cada nombre.


5 4 4 4 4 5

Las versiones que utilizan las expresiones lambda no tienen ninguna mutación explícita; son concisos. Estas versiones tampoco necesitaban ninguna colección vacía inicial o variable de basura; esa variable retrocedió silenciosamente en las sombras de la implementación subyacente.

Usar referencias de métodos

Puede hacer que el código sea un poco más conciso utilizando una función llamada referencia de método . El compilador de Java tomará una expresión lambda o una referencia a un método donde se espera una implementación de una interfaz funcional. Con esta característica, un corto 

String::toUpperCase
puede reemplazar 
name ->name.toUpperCase()
, de la siguiente manera:


friends.stream()
   .map(String::toUpperCase)
   .forEach(name -> System.out.println(name));

Java sabe invocar el 

String
método dado por la clase 
toUpperCase()
en el parámetro pasado al método sintetizado: la implementación del método abstracto de la interfaz funcional. Esa referencia de parámetro está implícita aquí. En situaciones simples como el ejemplo anterior, puede sustituir las referencias de método por expresiones lambda; Te lo explicaré en un momento.

En el ejemplo anterior, la referencia al método era para un método de instancia. Las referencias a 

static
métodos también pueden referirse a métodos y métodos que toman parámetros. Mostraré ejemplos de estos más adelante.

Las expresiones lambda ayudaron a enumerar una colección y a transformarla en una nueva colección. Las lambdas también pueden ayudarlo a elegir de manera concisa un elemento de una colección, a continuación.

¿Cuándo debería utilizar referencias de métodos? Por lo general, uso expresiones lambda con mucha más frecuencia que referencias a métodos cuando programo en Java. Sin embargo, eso no significa que las referencias a métodos no sean importantes o menos útiles. Son buenos reemplazos cuando las expresiones lambda son cortas y hacen llamadas directas y simples a un método de instancia o un método estático. En otras palabras, si las expresiones lambda simplemente pasan sus parámetros, puede reemplazarlos con referencias a métodos.

Estas expresiones lambda candidatas son muy parecidas a las de Tom Smykowski, en la película Office Space , cuyo trabajo es “tomar especificaciones de los clientes y llevarlas a los ingenieros de software”. Por esta razón, llamo a la refactorización de lambdas a referencias de métodos al patrón de espacio de oficina .

Además de la concisión, con las referencias a métodos se obtiene la capacidad de utilizar más directamente los nombres ya elegidos para estos métodos.

Hay bastante magia de compilación bajo el capó con referencias de métodos. El objeto y los parámetros de destino de la referencia del método se derivan de los parámetros pasados ​​al método sintetizado. Esto hace que el código con referencias a métodos sea mucho más conciso que el código con expresiones lambda. Sin embargo, no puede usar esta conveniencia si la lógica de la aplicación requiere manipular parámetros antes de enviarlos como argumentos o modificar los resultados de la llamada antes de devolverlos.

Encontrar elementos en una colección

Los elegantes métodos utilizados para atravesar y transformar colecciones no le ayudarán directamente a elegir elementos de una colección. El 

filter()
método está diseñado para ese propósito.

Imagínese que a partir de una lista de nombres, es necesario escoger los que empiezan con la letra N . Debido a que puede haber cero nombres coincidentes en la lista, el resultado puede ser una lista vacía. Primero, aquí le mostramos cómo codificarlo usando el enfoque anterior.


final List<String> startsWithN =
   new ArrayList<String>();
for(String name : friends) {
   if(name.startsWith("N")) {
   startsWithN.add(name);
   }
}

Es un código hablador para una tarea sencilla; creó una variable y la inicializó en una colección vacía. Luego recorrió la colección en busca de un nombre que comience con la letra deseada. Si lo encuentra, agrega el elemento a la colección.

Aquí se explica cómo refactorizar este código para usar el 

filter()
método y ver cómo cambia las cosas.


final List<String> startsWithN =
   friends.stream()
      .filter(name -> name.startsWith("N"))
      .collect(Collectors.toList());

El 

filter()
método espera una expresión lambda que devuelva un resultado booleano. Si la expresión lambda regresa 
true
, el elemento en contexto mientras se ejecuta esa expresión lambda se agrega a una colección de resultados; de lo contrario, se omite. Finalmente, el método devuelve una secuencia con solo elementos para los que se produjo la expresión lambda 
true
. Al final, transformó el resultado en un 
List
usando el 
collect()
método.

A continuación, se explica cómo imprimir el número de elementos de la colección de resultados.


System.out.println(
   String.format(
      "Found %d names", startsWithN.size()));

A partir de la siguiente salida, está claro que el método recogió la cantidad adecuada de elementos de la colección de entrada:


Found 2 names

El 

filter()
método devuelve un iterador como lo hace el 
map()
método, pero la similitud termina ahí. Mientras que el 
map()
método devuelve una colección del mismo tamaño que la colección de entrada, es posible que el 
filter()
método no. Podría producir una colección de resultados con un número de elementos que van desde cero hasta el número máximo de elementos en la colección de entrada. Sin embargo, a diferencia de 
map()
los elementos de la colección de resultados que se 
filter()
devuelven, son un subconjunto de los elementos de la colección de entrada.

Conclusión

La concisión lograda mediante el uso de expresiones lambda hasta ahora es buena, pero la duplicación de código puede colarse rápidamente si no tiene cuidado. Abordaré esta preocupación en la segunda parte de este artículo, “Programación funcional en Java, Parte 2: reutilización de Lambda, alcance léxico y cierres, y reduce ()”.

Fuente: https://blogs.oracle.com/javamagazine/java-functional-programming-lambda-method-references