Tabla de Contenidos

15 - Programación funcional

La programación funcional es un paradigma de programación que se centra en tratar a las funciones como elementos de primer orden. En este enfoque, los programas se construyen principalmente a través de la composición y aplicación de funciones. A diferencia del paradigma imperativo, donde se enfatiza la ejecución de instrucciones que modifican el estado del programa, en la programación funcional se hace hincapié en la evaluación de expresiones y en la ausencia de efectos secundarios.

En la programación funcional, las funciones se tratan como objetos que pueden ser asignados a variables, pasadas como argumentos a otras funciones y devueltas como resultados de otras funciones. Además, se promueve el uso de funciones puras, que son aquellas que, dadas las mismas entradas, siempre producen los mismos resultados sin causar efectos secundarios observables.

Este enfoque ofrece ventajas como la capacidad de escribir código más conciso y legible, facilitando la comprensión y el mantenimiento del mismo. Además, fomenta la escritura de programas más robustos y menos propensos a errores, al minimizar los efectos secundarios y mejorar la modularidad del código. La programación funcional se ha vuelto cada vez más popular en los últimos años debido a estas ventajas y a la creciente demanda de sistemas más escalables y fiables.

Principios fundamentales

Existen unos principios fundamentales que son la base de la programación funcional y proporcionan una guía para escribir código más claro, modular y libre de efectos secundarios. Integrar estos principios en el diseño y la implementación de programas en Java puede conducir a sistemas más robustos y mantenibles:

Una de las herramientas más poderosas para implementar estos principios son las funciones lambda. Las funciones lambda permiten definir bloques de código de manera concisa y expresiva, lo que facilita la creación de funciones de orden superior y la composición de operaciones. En el siguiente apartado, exploraremos en detalle qué son las funciones lambda y cómo se utilizan en Java para escribir código más claro y funcional.

Funciones lambda

Las funciones lambda son una característica fundamental de la programación funcional que permite tratar a las funciones como objetos de primera clase. En esencia, una función lambda es una expresión anónima que representa una función. Estas funciones pueden ser pasadas como argumentos a otras funciones, retornadas como resultados de otras funciones y almacenadas en variables. Su sintaxis concisa y su flexibilidad las hacen especialmente útiles para operaciones de alto nivel, como la manipulación de colecciones de datos y la implementación de patrones de diseño funcional.

Las funciones lambda en Java se definen utilizando la siguiente sintaxis:

(parámetros) -> {implementación}

Por ejemplo, si queremos crear una función lambda para sumar dos enteros:

AddOperation addOperationFunctional = (a, b) -> a + b;

Las funciones lambda se utilizan comúnmente en combinación con interfaces funcionales, que son interfaces que contienen un solo método abstracto.

Interfaces funcionales

En Java, una interfaz funcional es una interfaz que contiene un solo método abstracto. Estas interfaces son la base de la programación funcional en Java y son utilizadas principalmente para definir el tipo de una función lambda.

Por ejemplo, podemos definir una interfaz funcional de la siguiente manera:

@FunctionalInterface
public interface AddOperation {

    Integer add(Integer a, Integer b);
}

En este ejemplo, AddOperation es una interfaz funcional que contiene un solo método abstracto llamado add, el cual toma dos parámetros enteros y devuelve un entero. La anotación @FunctionalInterface es opcional, pero es una buena práctica utilizarla para indicar que una interfaz es funcional y así evitar que se agreguen métodos abstractos adicionales en el futuro.

Las interfaces funcionales se utilizan principalmente en dos situaciones:

public class LambdaFunctions {

   // Without functional programming
   AddOperation addOperation = new AddOperation() {
      @Override
      public Integer add(Integer a, Integer b) {
         return a + b;
      }
   };

   // With functional programming
   AddOperation addOperationFunctional = (a, b) -> a + b;
}
}

   public Integer add(Integer a, Integer b, AddOperation addOperation) {
      return addOperation.add(a, b);
   }

    @Test
    void testAdd() {
        LambdaFunctions lambdaFunctions = new LambdaFunctions();
        assertEquals(3, lambdaFunctions.add(1, 2, lambdaFunctions.addOperation));
    }

Además de facilitar la implementación de funciones lambda, las interfaces funcionales en Java son fundamentales para trabajar con la clase Stream. Esta clase se introdujo en Java para permitir el trabajo con programación funcional, ofreciendo una forma más eficiente y concisa de procesar colecciones de datos. Los Streams en Java permiten operaciones intermedias y terminales, lo que brinda flexibilidad en la manipulación de datos de manera secuencial o paralela.

Interfaces funcionales comunes

Desde Java 8, se han agregado muchas interfaces funcionales predefinidas en el paquete java.util.function para facilitar el desarrollo de aplicaciones utilizando programación funcional.

Las interfaces funcionales se dividen en varios grupos principales según el propósito que cumplen. Los cuatro grupos principales son:

Consumer<String> print = s -> System.out.println(s);
print.accept("Hello, World!"); // Imprime: Hello, World!

Predicate<Integer> isPositive = i -> i > 0;
System.out.println(isPositive.test(1)); // Imprime: true

Function<Integer, Integer> square = i -> i * i;
System.out.println(square.apply(3)); // Imprime: 9

Supplier<String> hello = () -> "Hello, World!";
System.out.println(hello.get()); //Imprime: "Hello, World!"

Además de las cuatro interfaces funcionales principales (Consumer, Predicate, Function y Supplier), la librería java.util.function define muchas más interfaces para cubrir diferentes necesidades. Entre ellas se encuentran las variantes binarias como BiConsumer, BiPredicate y BiFunction, que aceptan dos argumentos. También hay interfaces especializadas para tipos primitivos, como IntConsumer, DoublePredicate, LongSupplier…. Estas interfaces adicionales permiten una mayor flexibilidad y eficiencia en la programación funcional en Java.

La clase Stream

En Java, un Stream representa una secuencia de elementos que pueden ser procesados de manera secuencial o paralela. La clase Stream fue introducida específicamente para habilitar el uso de programación funcional en Java, proporcionando una forma más concisa y expresiva de trabajar con colecciones de datos. Por lo tanto, los Streams desempeñan un papel crucial en la adopción de paradigmas de programación funcional en Java.

Los Streams tienen una serie de características básicas:

Pueden ser creados a partir de una variedad de fuentes de datos, lo que los hace extremadamente flexibles y poderosos para el procesamiento de datos en programación funcional.

Creación y colección

Los Streams pueden ser creados a partir de una variedad de fuentes de datos, lo que los hace extremadamente flexibles y poderosos para el procesamiento de datos en programación funcional.

List<Integer> numberList = List.of(1, 2, 3, 4, 5);
Stream<Integer> numberStream = numberList.stream();

Integer[] numberArray = {1, 2, 3, 4, 5};
Stream<Integer> numberStream = Arrays.stream(numberArray);

Stream<Integer> numberStream = Stream.of(1, 2, 3, 4, 5);

Estos son solo algunos ejemplos de cómo crear Streams en Java a partir de diferentes fuentes de datos. La versatilidad de los Streams permite procesar una amplia variedad de datos de manera funcional y eficiente.

Además, la clase Stream proporciona métodos como toList() y toArray() que facilitan la conversión de un Stream en colecciones tradicionales de Java. Estas operaciones de recopilación son útiles para interactuar con APIs que esperan tipos de colección estándar, proporcionando una forma fluida y eficiente de manipular y transformar datos en Java.

Métodos comunes

Los Streams en Java ofrecen una amplia variedad de métodos que permiten realizar operaciones sobre secuencias de datos. Estos métodos se dividen en dos tipos principales:

Esta combinación de operaciones ofrece flexibilidad y poder para manipular y procesar datos de manera eficiente en Java.

Operaciones intermedias más comunes

Filter

Filtra los elementos de un Stream según una función Predicate:

List<String> names = Arrays.asList("John", "Jane", "Jack", "Doe");
List<String> filteredNames = names.stream()
                                  .filter(name -> name.startsWith("J"))
                                  .toList();

Map

Transforma los elementos del Stream aplicando una función.

List<Integer> lengths = names.stream()
                             .map(String::length)
                             .toList();

String::length es equivalente a la función lambda s → s.length(), pero de una forma más concisa y legible. Esta optimización es posible en Java cuando la función lambda consiste en invocar un único método en el argumento de la función.
Sorted

Ordena los elementos del Stream de acuerdo a un comparador.

List<String> sortedNames = names.stream()
                                .sorted()
                                .toList();

Cuando se aplica el método sorted() a un Stream de objetos que implementan la interfaz Comparable (como String), el orden por defecto es el orden natural, que en el caso de las cadenas de texto (String) es el orden lexicográfico o alfabético.
distinct

Elimina los elementos duplicados del Stream.

List<Integer> numbers = Arrays.asList(1, 2, 2, 3, 4, 4, 5);
List<Integer> distinctNumbers = numbers.stream()
                                       .distinct()
                                       .toList();

Operaciones terminales más comunes

toList

Convierte los elementos del Stream en un List.

List<String> collectedNames = names.stream()
                                   .toList();

toArray

Convierte los elementos del Stream en un array.

String[] namesArray = names.stream()
                           .toArray(String[]::new);

reduce

Reduce los elementos del Stream a un único valor usando una operación de acumulación.

Integer sum = numbers.stream()
                     .reduce(0, Integer::sum);

También es posible calcular la suma de elementos en un Stream de enteros de manera más concisa usando el método reduce junto con una referencia de método. Por ejemplo, en lugar de especificar un valor inicial explícito como en en el ejemplo anterior, se puede usar reduce sin un valor inicial. Esto devuelve un Optional<Integer> que contiene el resultado de la operación de suma:

        return integers
                .stream()
                .reduce(Integer::sum)
                .orElse(0);

En el siguiente punto veremos la clase Optional con más detalle.

foreach

Realiza una acción para cada elemento del Stream.

names.stream()
     .forEach(System.out::println);

count

Devuelve el número de elementos en el Stream.

long count = names.stream()
                  .filter(name -> name.startsWith("J"))
                  .count();

findFirst y findAny

findFirst devuelve el primer elemento del Stream. findAny devuelve algún elemento del Stream, útil en Streams paralelos.

Optional<String> firstName = names.stream()
                                  .findFirst();

Optional

Los Optionals en Java son un contenedor que puede o no contener un valor. Esta clase fue introducida para abordar el problema de las referencias nulas (null) que pueden llevar a NullPointerExceptions en tiempo de ejecución. Los Optional ofrecen una forma más segura y explícita de representar valores que pueden estar ausentes.

Su principal utilidad radica en:

Los Optional son una herramienta esencial en Java para mejorar la robustez y la claridad del código al manejar la posibilidad de valores nulos de manera más segura y eficiente.

Creación

Para crear instancias de Optional en Java, existen varias formas dependiendo de si el valor que queremos encapsular puede ser nulo o no. A continuación, veremos las principales formas de crear Optionals en Java.

Optional.of(value)

Crea un Optional que contiene el valor especificado. Si el valor pasado como argumento es null, este método lanzará una NullPointerException.

String name = "John";
Optional<String> optionalName = Optional.of(name);

Optional.ofNullable(value)

Crea un Optional que contiene el valor especificado si no es nulo. Si el valor es null, devuelve un Optional vacío.

String nullableName = null;
Optional<String> optionalNullableName = Optional.ofNullable(nullableName);

Optional.empty()

Devuelve un Optional vacío, es decir, un Optional que no contiene ningún valor.

Optional<String> emptyOptional = Optional.empty();

Es importante entender que Optional.empty() en Java no es equivalente a null. Mientras null indica la ausencia total de valor y puede causar NullPointerExceptions si no se maneja correctamente, Optional.empty() representa explícitamente la ausencia de un valor dentro de un contenedor Optional, sin la posibilidad de generar NullPointerExceptions.

Una ventaja clave de usar Optional es que un método que devuelve Optional<valor> siempre garantiza devolver un objeto Optional, ya sea que contenga un valor o esté vacío. Esto promueve un diseño más seguro y claro en el flujo de datos de la aplicación, ya que obliga a los desarrolladores a manejar explícitamente la posible ausencia de valores sin recurrir a valores null no controlados.
Es una buena práctica utilizar Optional únicamente como tipo de retorno para métodos que pueden o no devolver un valor, pero no debe usarse como tipo de parámetro de entrada para métodos. Entre otras razones, la clase Optional fue diseñado principalmente para mejorar la seguridad y la claridad al manejar valores de retorno potencialmente nulos. Utilizarlo como parámetro de entrada no se alinea con este propósito y puede llevar a prácticas confusas o propensas a errores.

Métodos más comunes

A continuación, exploraremos algunos de los métodos más comunes proporcionados por la clase Optional en Java. Estos métodos permiten realizar operaciones seguras y convenientes sobre los valores encapsulados dentro de un Optional, facilitando la gestión de casos donde un valor puede estar presente o ausente de manera explícita y sin riesgo de NullPointerExceptions.

isPresent()

Verifica si el Optional contiene un valor.

Optional<String> optionalName = Optional.of("John");
if (optionalName.isPresent()) {
    System.out.println("El nombre es: " + optionalName.get());
}

orElse(T other)

Devuelve el valor encapsulado si está presente; de lo contrario, devuelve el valor proporcionado como argumento.

Optional<String> optionalName = Optional.empty();
String name = optionalName.orElse("Valor Predeterminado");

orElseGet(Supplier<? extends T> supplier)

Devuelve el valor encapsulado si está presente; de lo contrario, devuelve el resultado obtenido del Supplier proporcionado.

Optional<String> optionalName = Optional.empty();
String name = optionalName.orElseGet(() -> "Valor Generado");

orElseThrow(Supplier<? extends X> exceptionSupplier)

Devuelve el valor encapsulado si está presente; de lo contrario, lanza una excepción proporcionada por el Supplier.

Optional<String> optionalName = Optional.empty();
String name = optionalName.orElseThrow(() -> new NoSuchElementException("No se encontró el nombre"));

get()

Devuelve el valor encapsulado si está presente; de lo contrario, lanza una excepción NoSuchElementException.

Optional<String> optionalName = Optional.of("Jane");
String name = optionalName.get();

ifPresent(Consumer<? super T> consumer)

Ejecuta la acción proporcionada si el valor está presente.

Optional<String> optionalName = Optional.of("Jack");
optionalName.ifPresent(name -> System.out.println("El nombre es: " + name));

Ejercicios

Ejercicio 1

Dada una lista de enteros, usa programación funcional para calcular la suma de todos los elementos.

Ejercicio 2

Dada una lista de enteros, devuelve una lista con solo los números pares.

Ejercicio 3

Dada una lista de cadenas, convierte todas a mayúsculas usando programación funcional.

Ejercicio 4

Dada una lista de enteros, usa programación funcional para encontrar el número máximo.

Ejercicio 5

Dada una lista de enteros y un valor, cuenta cuántos elementos son mayores a ese valor.

Ejercicio 6

Dada una lista de cadenas, usa programación funcional para concatenar todas las cadenas en una sola.

Ejercicio 7

Dada una lista de enteros, devuelve una lista con los cuadrados de cada número.

Ejercicio 8

Dada una lista de enteros, elimina los duplicados usando programación funcional.

Ejercicio 9

Dada una lista de enteros, devuelve el primer número que sea mayor a un valor dado.

Ejercicio 10

Dada una lista de cadenas, cuenta cuántas empiezan con una letra específica.