Guía de interfaces funcionales y expresiones lambda en Java

En esta extensa guía, adoptaremos una visión holística de la programación funcional en Java, qué son las interfaces funcionales y las expresiones lambda y las pondremos en práctica para probar objetos funcionalmente.

Introducción

Java es un lenguaje orientado a objetos, imperativo en su esencia (en contraste con la práctica declarativa que es la programación funcional). No obstante, era posible aplicar principios funcionales a los programas de Java antes de la versión 8; sin embargo, requería trabajo adicional para eludir la estructura innata del lenguaje y resultó en un código enrevesado. Java 8 trajo formas de aprovechar la verdadera eficacia y facilidad a la que aspira la programación funcional.

Esta guía tiene como objetivo proporcionar una visión holística de la programación funcional, un concepto que parece bastante esotérico para el desarrollador de la programación orientada a objetos. Debido a esto, el material muchas veces está disperso y escaso. Primero estableceremos una comprensión de los conceptos básicos de la programación funcional y las formas en que Java los implementa.

Debido a que hay muchos malentendidos con respecto a la Programación Funcional para aquellos con experiencia en programación orientada a objetos, comenzaremos con una introducción a la Programación Funcional y sus beneficios.

Luego, nos sumergiremos en Lambda Expressions como la implementación de Java de funciones de primera clase, así como también interfaces funcionales, seguido de una mirada rápida al paquete function de Java.

Introducción a la Programación Funcional

La programación funcional es un paradigma de programación que gira en torno a - bueno, funciones. Aunque la programación orientada a objetos también emplea funciones, los componentes básicos del programa son los objetos. Los objetos se utilizan para mediar en el estado y los patrones de comportamiento dentro del programa, mientras que las funciones se encargan del flujo de control.

La programación funcional separa el comportamiento de los objetos.

Las funciones tienen entonces la libertad de actuar como entidades de primera clase. Se pueden almacenar en variables y pueden ser argumentos o los valores de retorno de otras funciones sin necesidad de ir acompañados de un objeto. Estas entidades discretas se denominan funciones de primera clase, mientras que las funciones que las encierran se denominan funciones de orden superior.

La programación funcional también tiene un enfoque diferente hacia el estado del programa. En OOP, el resultado deseado de un algoritmo se logra manipulando el estado del programa. La práctica funcional se abstiene de causar cambios de estado por completo. Las funciones son generalmente puras, lo que significa que no causan ningún efecto secundario; no alteran las variables globales, no realizan operaciones de E/S ni lanzan excepciones.

Existen lenguajes puramente funcionales, algunos de los cuales imponen el uso de variables inmutables. También existen lenguajes puramente orientados a objetos. Java es un lenguaje multiparadigma; tiene la capacidad de oscilar entre diferentes estilos de programación y utilizar los beneficios de múltiples paradigmas en la misma base de código.

Los beneficios de la programación funcional {#los beneficios de la programación funcional}

La programación funcional, entre todo lo demás, ofrece flexibilidad. Podemos crear capas de generalización. Podemos armar patrones de comportamiento y personalizarlos pasando instrucciones adicionales cuando sea necesario.

La programación orientada a objetos también tiene formas de crear estos patrones, aunque dependen del uso de los objetos. Las interfaces, por ejemplo, se pueden usar para crear un andamio, y cada clase que implementa la interfaz puede adaptar el comportamiento definido a su manera. Por otra parte, siempre debe haber un objeto para llevar las variantes. La programación funcional proporciona una forma más elegante.

Además, la programación funcional utiliza funciones puras. Dado que las funciones puras no pueden alterar estados fuera de su alcance, no tienen el poder de afectarse entre sí; cada función es totalmente independiente. Esto brinda a los programadores la capacidad de deshacerse de las funciones cuando ya no se necesitan, alterar el orden de ejecución a voluntad o ejecutar funciones en paralelo.

Dado que las funciones puras no dependen de valores externos, volver a ejecutar el código con los mismos argumentos dará como resultado el mismo resultado cada vez. Esto admite la técnica de optimización llamada memoización (no "memorización"), el proceso de almacenar en caché los resultados de una costosa secuencia de ejecución para recuperarlos cuando se necesiten en otra parte del programa.

Además, la capacidad de tratar funciones como entidades de primera clase permite currying: la técnica de subdividir la secuencia de ejecución de una función para realizarla en momentos separados. Una función con múltiples parámetros se puede ejecutar parcialmente en el punto donde se proporciona un parámetro, y el resto de la operación se puede almacenar y retrasar hasta que se proporcione el siguiente parámetro.

Expresiones Lambda en Java

Interfaces funcionales y expresiones lambda {# interfaces funcionales y expresiones lambda}

Java implementa el bloque básico de la programación funcional, las funciones puras de primera clase, en forma de expresiones lambda.

Las expresiones Lambda son los mensajeros a través de los cuales Java se mueve alrededor de un conjunto de comportamientos.

Las expresiones lambda, en general, tienen la estructura de:

1
(optional list of parameters) -> {behavior}

Por otra parte, esta estructura está sujeta a cambios. Primero, veamos las lambdas en acción y luego desarrollemos las versiones adaptadas de su sintaxis. Comenzaremos definiendo una interfaz funcional:

1
2
3
public interface StringConcat{
    String concat(String a, String b);
}

{.icon aria-hidden=“true”}

Una interfaz funcional es una interfaz que tiene exactamente un método abstracto.

Entonces podemos implementar el método de esta interfaz, a través de una expresión lambda:

1
StringConcat lambdaConcat = (String a, String b) -> {return a + " " + b;};

Con esta implementación, el método concat() ahora tiene un cuerpo y se puede usar más adelante:

1
2
3
4
5
String string1 = "german";
String string2 = "shepherd";

String concatenatedString = lambdaConcat.concat(string1, string2);
System.out.println(concatenatedString);

Demos un paso atrás y despeguemos lo que acabamos de hacer. La interfaz StringConcat contiene un único método abstracto (concat()) que toma dos parámetros de cadena y se espera que devuelva un valor de cadena.

StringConcat es una interfaz y no se puede instanciar. En el lado derecho de la asignación, el compilador espera encontrar una instanciación de una clase que implemente StringConcat, no una función. Sin embargo, el código funciona a la perfección.

{.icon aria-hidden=“true”}

Java está inherentemente orientado a objetos. Todo es un objeto en Java (más exactamente, todo se extiende a una clase de objeto), incluidas las expresiones lambda.

Aunque tratamos a las lambdas como funciones de primera clase, Java las interpreta como objetos. Intrínseco en eso, la expresión lambda asignada para ser del tipo StringConcat es esencialmente una clase de implementación y, por lo tanto, tiene que definir el comportamiento para el método de StringConcat.

El método concat() se puede llamar de la misma manera que se llama a los métodos de objetos (lambdaConcat.concat()), y se comporta como lo define la expresión lambda:

Al final de la ejecución del programa, la consola debería leer:

1
german shepherd

Lambdas como argumentos

Las lambdas brillan más cuando se pasan como argumentos a los métodos, en lugar de usarse como clases de utilidad. Implementemos una función que filtre a través de una lista de personas para encontrar un conjunto estadísticamente probable que sea "agradable" según algún estándar establecido.

{.icon aria-hidden=“true”}

Nota: Nuestro estándar de "simpatía" se establecerá solo con fines ilustrativos y no refleja ninguna investigación real o análisis estadístico.

La función aceptará una masa y un sesgo para filtrar la masa y terminar con un grupo de personas que, según la opinión aplicada, son "buenas personas":

1
2
3
4
filter(mass, bias){
    //filter the mass according to bias
    return nicePeople
}

El sesgo en la lista de parámetros será una función, una expresión lambda, a la que se refiere la función de orden superior para decidir el atractivo de cada persona en la masa.

Empecemos por crear una clase Person para representar a una persona:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
enum PetPreference {
    DOGPERSON, CATPERSON, HASAPETSNAKE
}

public class Person {
    private String name;
    private int age;
    private boolean extrovert;
    private PetPreference petPreference;
    private List<String> hobbies;

    // Constructor, getters, setters and toString()
}

A la clase Persona se le asignan varios campos para delinear cada uno de sus personajes. Cada ‘Persona’ tiene un nombre, una edad, un indicador de sociabilidad, una preferencia de mascota seleccionada entre un conjunto de constantes y una lista de pasatiempos.

Con una clase Person, sigamos adelante, definiendo una interfaz funcional Bias con una función test(). La función test() será, naturalmente, abstracta y sin implementación por defecto:

1
2
3
public interface Bias {
    boolean test(Person p);
}

Una vez que lo implementemos, la función test() probará a una persona para determinar su simpatía, de acuerdo con un conjunto de sesgos. Avancemos y definamos también la función filter(), que acepta una lista de personas y un Bias para filtrar:

1
2
3
4
5
6
7
8
9
public static List<Person> filter(List<Person> people, Bias bias) {
    List<Person> filteredPeople = new ArrayList<>();
    for (Person p : people) {
        if (bias.test(p)) {
            filteredPeople.add(p);
        }
    }
    return filteredPeople;
}

Basándonos en el resultado de la función test(), agregamos o saltamos la adición de una persona a la lista de personas filtradas, que es, bueno, cómo funcionan los filtros. Tenga en cuenta que la implementación real de la función test() todavía no existe, y solo obtendrá cuerpo después de que definamos su cuerpo como una función lambda.

Dado que el método filter() acepta la interfaz funcional Bias, podemos crear de forma anónima la función lambda en la llamada filter():

1
2
3
4
5
6
7
8
9
Person p1 = new Person("David", 35, true, PetPreference.DOGPERSON, "neuroscience", "languages", "travelling", "reading");
Person p2 = new Person("Marry", 35, true, PetPreference.CATPERSON, "archery", "neurology");
Person p3 = new Person("Jane", 15, false, PetPreference.DOGPERSON, "neurology", "anatomy", "biology");
Person p4 = new Person("Mariah", 27, true, PetPreference.HASAPETSNAKE, "hiking");
Person p5 = new Person("Kevin", 55, false, PetPreference.CATPERSON, "traveling", "swimming", "weightlifting");

List<Person> people = Arrays.asList(p1, p2, p3, p4, p5);

System.out.println(filter(people, p -> p.isExtrovert()));

Finalmente, aquí es donde todo se une: hemos definido el cuerpo de la interfaz funcional a través de una expresión lambda:

1
p -> p.isExtrovert()

La expresión lambda se evalúa y compara con la firma del método test() de Bias y este cuerpo se usa luego como verificación del método test() y devuelve un true o false basado en el valor del método isExtrovert().

{.icon aria-hidden=“true”}

Tenga en cuenta que podríamos haber usado cualquier cuerpo aquí, ya que Bias es una interfaz funcional "plug-and-play".

La capacidad de crear un método que pueda ajustar su enfoque de esta manera es una delicadeza de la programación funcional.

El método filter() es una función de grado superior que toma otra función como su parámetro según el cual altera su comportamiento, donde la otra función es completamente fluida.

Existen innumerables formas en las que podemos seleccionar una ‘Persona’ para pasar el rato. Dejando a un lado la ética de filtrar de esta manera, podemos optar por pasar el rato con personas de cierta edad, preferir a los extrovertidos, o podemos estar desesperados por encontrar a alguien que vaya al gimnasio con nosotros pero que no esté dispuesto a compartir su opinión. historias de gatos

También se pueden encadenar varios criterios de selección.

Por supuesto, es posible crear diferentes métodos para cada escenario, pero ¿tiene sentido comprar diferentes taladros para usar en diferentes materiales cuando simplemente puede cambiar las brocas?

El método filter() proporciona flexibilidad. Define el comportamiento principal, seleccionar. Más tarde, en el programa, podemos usar este método para cualquier selección y simplemente pasar "cómo".

{.icon aria-hidden=“true”}

Vale la pena señalar que el método filter() comienza creando una nueva ArrayList, ya que la práctica funcional se abstiene de cambiar el estado del programa. En lugar de operar y manipular la lista original, comenzamos con una lista vacía que luego completamos con las ‘Personas’ deseadas.

La lista que contiene solo a los extrovertidos se pasa a list() para que se muestre en la consola:

1
2
3
4
5
[
Person{name='David', age=35, extrovert=true, petPreference=DOGPERSON, hobbies=[neuroscience, languages, travelling, reading]}, 
Person{name='Marry', age=35, extrovert=true, petPreference=CATPERSON, hobbies=[archery, neurology]}, 
Person{name='Mariah', age=27, extrovert=true, petPreference=HASAPETSNAKE, hobbies=[hiking]}
]

Este ejemplo muestra la flexibilidad y liquidez de las interfaces funcionales y sus cuerpos creados por lambda.

Lambdas e interfaces

Hasta ahora, las expresiones lambda se adscribían a una interfaz. Esta será la norma cada vez que queramos implementar funciones de primera clase en Java.

Considere la implementación de arreglos. Cuando los elementos de una matriz se necesitan en alguna parte del código, llamamos a la matriz por su nombre asignado y accedemos a sus elementos a través de ese nombre en lugar de mover el conjunto real de datos. Y como hemos declarado que es un arreglo de un tipo, cada vez que queremos operar sobre él, el compilador sabe que el nombre de la variable se refiere a un arreglo y que este arreglo almacena objetos de un tipo significativo. El compilador puede decidir las capacidades de esta variable y las acciones que puede realizar.

Java es un lenguaje de tipo estático - requiere este conocimiento para cada variable.

Cada variable debe indicar su nombre y su tipo antes de que pueda usarse (esto se llama declarar una variable). Las expresiones lambda no son una excepción a esta regla.

Cuando queremos usar expresiones lambda, debemos informar al compilador sobre la naturaleza del comportamiento encapsulado. Las interfaces que vinculamos a las expresiones lambda están ahí para proporcionar esta información; actúan como notas a pie de página a las que el compilador puede hacer referencia.

Podríamos incluir el nombre y la información de tipo junto con la propia expresión lambda. Sin embargo, la mayoría de las veces, usaremos el mismo tipo de lambdas para crear una variedad de comportamientos particulares.

Es una buena práctica evitar la redundancia en el código; escribir la misma información muchas veces solo hará que nuestro código sea propenso a errores y nuestros dedos se cansen.

Sintaxis de expresiones lambda

Las lambdas vienen en muchos sabores. Si bien el operador lambda (->) se establece firme, los paréntesis y las declaraciones de tipo se pueden eliminar en algunas circunstancias.

Lambda toma su forma más simple cuando solo existe un parámetro y una operación para realizar dentro del cuerpo de la función.

1
c -> c.isExtrovert()

Ya no necesitamos paréntesis alrededor del parámetro, no se necesita una declaración de tipo, no se necesitan corchetes que encierran la declaración y no se requiere usar la palabra clave return.

La expresión lambda puede tomar más de un parámetro o no tomar ninguno. En esos casos, estamos obligados a incluir paréntesis:

1
2
() -> System.out.println("Hello World!")
(a, b) -> System.out.println(a + b)

Si el cuerpo de la función incluye más de una instrucción, también se requieren llaves y, si el tipo de retorno no es nulo, la palabra clave return:

1
2
3
4
(a, b) -> {
String c = a + b;
return c;
}

La declaración de tipo para los parámetros se puede omitir por completo. Aunque si un parámetro entre muchos tiene su tipo declarado, se requiere que otros sigan sus pasos:

1
2
(a, b) -> System.out.println(a + b)
(String a, String b -> System.out.println(a + b)

Las dos afirmaciones anteriores son válidas. Sin embargo, el compilador se quejaría si el programa usara la siguiente expresión:

1
(String a, b) -> System.out.println(a + b)

Interfaces funcionales

@Interfaz funcional

Cualquier interfaz con un solo método abstracto califica para ser una interfaz funcional; no hay ningún requisito adicional. Sin embargo, puede ser necesaria una distinción para grandes bases de código.

Tomemos la interfaz Bias de Lambdas como argumentos, y agreguemos otro método abstracto:

1
2
3
4
public interface Bias {
    boolean test(Person p);
    boolean concat(String a, String b);
}

La interfaz Bias se conectó a una expresión lambda, pero el compilador no se queja si agregamos otro método a la interfaz, lo que la convierte de una interfaz funcional a una normal.

El compilador no tiene forma de saber que se suponía que Bias era una interfaz funcional hasta que encuentra la expresión lambda vinculada a ella. Dado que una interfaz normal puede tener muchos métodos abstractos (y dado que no hay indicios de que esta interfaz no sea como cualquier otra), el compilador culpará a la expresión lambda por intentar vincularse a una interfaz no funcional. .

Para evitar esto, Java proporciona una forma de marcar las interfaces que sirven expresiones lambda, explícitamente:

1
2
3
4
@FunctionalInterface
public interface Bias {
    boolean test(Person p);
}

La anotación @FunctionalInterface le permitirá al compilador saber que esta interfaz está destinada a ser funcional y, por lo tanto, cualquier método abstracto adicional no es bienvenido aquí.

El compilador ahora puede interferir en el lugar cuando alguien comete el error de agregar otro método a esta interfaz, aunque las posibilidades de que se reduzcan una vez más por la marca @FunctionalInterface.

Métodos predeterminados y estáticos

Hasta Java 8, las interfaces se limitaban a tener constantes y métodos abstractos. Junto con el soporte de programación funcional vino la adición de métodos predeterminados y estáticos a las definiciones de interfaz.

Un método abstracto define un esqueleto para implementar el método. Un método predeterminado, por otro lado, no es un mero esqueleto; se define explícitamente. Sin embargo, una clase de implementación tiene la opción de anular los métodos predeterminados. Si no es así, se activa la implementación predeterminada:

1
2
3
4
5
public interface Doggo {
    default void bark(){
        System.out.println("Woof woof");
    }
}

Implementemos esta interfaz sin implementar el método bark():

1
static class GermanShepherd implements Doggo {}

Ahora, ejemplifiquemos y echemos un vistazo a la implementación predeterminada:

1
2
GermanShepherd rinTinTin = new GermanShepherd();
rinTinTin.bark();
1
Woof woof

Un método estático de una interfaz, por otro lado, es propiedad privada de esa interfaz. Solo se puede llamar a través del nombre de la interfaz y no puede ser anulado por las clases de implementación:

1
2
3
4
5
6
7
8
public interface Doggo {
    default void bark(){
        System.out.println("Woof woof");
    }
    static void howl(){
        System.out.println("owooooo");
    }
}

Implementemos la interfaz:

1
static class GermanShepherd implements Doggo {}

Y crea una instancia de GermanSheperd:

1
2
3
GermanShepherd rinTinTin = new GermanShepherd();
rinTinTin.bark();
Doggo.howl();

Esto resulta en:

1
2
Woof woof
owooooo

El paquete java.util.function

El alcance de la información que proporcionan las interfaces funcionales es limitado. Las definiciones de métodos se pueden generalizar fácilmente para cubrir casos de uso comunes y pueden ser bastante flexibles en sus implementaciones.

El tipo de retorno del método abstracto puede ser cualquiera de los tipos primitivos (entero, cadena, doble, etc.) o puede ser nulo. Cualquier clase que se defina dentro del programa también se puede declarar como el tipo de retorno, aunque el tipo genérico cubriría todo.

La misma lógica se aplica a los tipos de parámetros. Aunque la cantidad de parámetros de un método aún puede variar, existe un límite lógico por el bien de la calidad del código. La lista de nombres que se pueden asignar a una función también es ilimitada, aunque rara vez importa.

Al final, nos quedamos con un puñado de permutaciones que pueden cubrir la mayoría de los casos de uso comunes.

Java emplea 43 interfaces funcionales predefinidas, en el paquete java.util.function, para atender estos escenarios. Podemos agruparlos en cinco grupos:

1
2
3
4
5
Function<E,F>: Takes an object, operates on it, returns an object.
Predicate<E>: Takes an object, performs a test, returns a Boolean. 
Consumer<E>: Takes an object, consumes it, returns void.
Supplier<E>: Does not take any data, returns an object.
Operator<E>: Takes an object, operates on it, returns the same type of object.

En sus guías individuales, cubriremos cada uno de estos grupos por separado.

Conclusión

En esta guía, hemos dado un vistazo holístico a la Programación Funcional en Java y su implementación. Hemos cubierto las interfaces funcionales, así como las expresiones Lambda como componentes básicos para el código funcional. .

Licensed under CC BY-NC-SA 4.0