06 - Programación Funcional
La programación funcional es un paradigma 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 secuencial de instrucciones que modifican el estado de la aplicación, en la programación funcional se prima la evaluación de expresiones y la ausencia de efectos secundarios.
1. Principios Fundamentales
Los principios que sustentan la programación funcional proporcionan una guía para escribir código modular, expresivo y menos propenso a errores:
- Funciones como Ciudadanos de Primera Clase: Las funciones se tratan como cualquier otro tipo de dato. Pueden asignarse a variables, pasarse como argumentos a otros métodos y devolverse como resultado.
- Inmutabilidad: Una vez creado un objeto o estructura de datos, su estado no debe modificarse. En lugar de alterar el objeto existente, se generan nuevas instancias con los valores actualizados. Esto previene efectos colaterales y facilita la concurrencia.
- Pureza de las Funciones: Una función pura produce siempre el mismo resultado para las mismas entradas y no genera efectos secundarios observables (como modificar variables globales o alterar archivos).
- Composición y Funciones de Orden Superior: Las funciones de orden superior son aquellas que reciben otras funciones por parámetro o las devuelven como resultado, permitiendo construir abstracciones complejas combinando bloques simples.
2. Funciones Lambda e Interfaces Funcionales
Una función lambda es una expresión anónima que representa una implementación concisa de una interfaz funcional.
2.1 Sintaxis de las Expresiones Lambda
La sintaxis básica de una función lambda en Java consta de tres partes:
- Parámetros: Entre paréntesis y separados por comas. Si el tipo puede ser inferido por el compilador, se puede omitir el tipo implícito. Si solo hay un parámetro, se pueden omitir los paréntesis.
- Operador Flecha (
→): Separa la declaración de parámetros del cuerpo de la función.
- Cuerpo: Si contiene una única expresión, no requiere llaves
{}ni la palabra clavereturn.
//Consumer<String> printer = (phrase) -> IO.println(phrase);
Consumer<String> printer = IO::println;
Supplier<Integer> randomValue = () -> (int) (Math.random() * 100);
Function<Integer, String> printNumber = (number) -> {
if (number % 2 == 0) {
return number + " es par";
} else {
return number + " es impar";
}
};
::).En lugar de escribir la estructura completa de la función lambda
(mensaje) → IO.println(mensaje), podemos hacer referencia directa al método mediante IO::println (o IO::println a partir de Java 23+).
Esta sintaxis no ejecuta el método inmediatamente, sino que le indica al compilador que reutilice el método existente para implementar la interfaz funcional correspondiente, logrando un código más limpio y legible.
2.2 Interfaces Funcionales
En Java, una interfaz funcional es aquella que contiene un único método abstracto. Constituye el tipo de dato subyacente para cualquier expresión lambda.
Para garantizar que una interfaz mantenga este contrato, se utiliza la anotación @FunctionalInterface:
@FunctionalInterface
public interface IntegerOperation {
int operation(int a, int b);
}
public class IntegerOperationExample {
static void main() {
IntegerOperation integerOperation = Integer::sum;
int result = integerOperation.operation(5, 10);
IO.println("Resultado de la suma: " + result); // Imprime "Resultado de la suma: 15"
integerOperation = Integer::compare;
result = integerOperation.operation(5, 10);
IO.println("Resultado de la comparación: " + result); // Imprime "Resultado de la comparación: -1"
integerOperation = Integer::max;
result = integerOperation.operation(5, 10);
IO.println("Resultado de la maximización: " + result); // Imprime "Resultado de la maximización: 10"
}
}
2.3 Interfaces Funcionales Estándar (java.util.function)
Java proporciona un conjunto de interfaces funcionales predefinidas en el paquete java.util.function para cubrir los casos de uso habituales:
| Interfaz | Firma del Método | Propósito | Ejemplo |
|---|---|---|---|
Consumer<T> | void accept(T t) | Recibe un valor y realiza una acción sin devolver nada (efecto secundario). | s → IO.println(s) |
Predicate<T> | boolean test(T t) | Evalúa una condición sobre el argumento y devuelve un booleano. | i → i > 0 |
Function<T, R> | R apply(T t) | Transforma un argumento de tipo T en un resultado de tipo R. | s → s.length() |
Supplier<T> | T get() | No recibe argumentos y genera/devuelve un valor de tipo T. | () → “Hello World” |
public class FunctionalStandard {
static void main() {
Consumer<String> printer = IO::println;
printer.accept("Hello, World!"); // Imprime Hello, World!
Predicate<Integer> isPositive = (number) -> number > 0;
IO.println("¿Es 5 positivo? " + isPositive.test(5)); // Devuelve true
IO.println("¿Es -3 positivo? " + isPositive.test(-3)); // Devuelve false
Function<Integer, String> printNumber = (number) -> {
if (number % 2 == 0) {
return number + " es par";
} else {
return number + " es impar";
}
};
IO.println(printNumber.apply(42)); // Devuelve "42 es par"
IO.println(printNumber.apply(43)); // Devuelve "43 es impar"
Supplier<String> greeting = () -> "¡Hola, Mundo!";
IO.println(greeting.get()); // Imprime "¡Hola, Mundo!"
}
}
Existen además variantes binarias como BiConsumer<T, U>, BiPredicate<T, U> o BiFunction<T, R U,> para operar sobre dos argumentos simultáneamente.
3. La API Stream (java.util.stream)
Un Stream representa una secuencia de elementos que permite la ejecución de operaciones de procesamiento de datos de forma declarativa, secuencial o paralela.
3.1 Ciclo de Vida y Características
- No almacena datos e Inmutabilidad: Un
Streamno es una estructura de datos ni modifica la fuente original (colección,array, etc.). Aplica transformaciones de forma inmutable, generando una nueva secuencia de datos en cada paso.
- No es iterable: La interfaz
Streamno extiende deIterable, por lo que no se puede recorrer con un buclefor-eachtradicional (for (T item : stream)). El procesamiento se realiza exclusivamente a través de sus propios métodos.
- Encadenamiento de Operaciones: Permite encadenar múltiples operaciones intermedias de forma continua, formando una canalización (pipeline) de procesamiento.
- Evaluación Perezosa: Las operaciones intermedias no ejecutan ningún cálculo en el momento de su definición. Solo se procesan los datos cuando se invoca obligatoriamente una operación terminal.
- Consumo Único: Un
Streamsolo puede recorrerse una vez. Tras ejecutar una operación terminal, elStreamqueda cerrado y no se puede reutilizar.
3.2 Creación de Streams
//1. A partir de de colecciones
List<Integer> numberList = List.of(1, 2, 3, 4, 5);
Stream<Integer> numberStream = numberList.stream();
// 2. A partir de un Array
String[] nameArray = {"Alice", "Bob", "Charlie"};
Stream streamFromArray = Arrays.stream(nameArray);
// 3. A partir de valores directos
Stream streamFromValues = 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.
public class StreamExample {
static void main() {
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Stream<Integer> evenNumbers = numbers.stream()
.filter(n -> n % 2 == 0);
/* Error: La clase Stream no es iterable
for (Integer evenNumber : evenNumbers) {
IO.println("Even number: " + evenNumber);
}*/
//
List<Integer> evenNumbersList = numbers.stream()
.filter(n -> n % 2 == 0)
.toList();
for (Integer evenNumber : evenNumbersList) {
IO.println("Even number: " + evenNumber);
}
}
}
3.3 Operaciones Intermedias y Terminales
Operaciones Intermedias
Transforman el Stream en otro Stream diferente y se pueden encadenar indefinidamente. Al ser perezosas, no procesan la información de inmediato. Algunas de las más comunes:
filter(Predicate<T>): Retiene únicamente los elementos que cumplen la condición.
map(Function<T, R>): Transforma cada elemento aplicando una función.
sorted() / sorted(Comparator<T>): Ordena los elementos según su orden natural o un comparador.
distinct(): Elimina duplicados basándose enequals().
peek(Consumer): Realiza una acción sobre cada elemento a medida que se procesa (muy útil para depurar o hacer logging) sin modificar la secuencia delStream.
List<String>names = List.of("John", "Jane", "Bob", "Jack", "Doe");
List<String> filteredNames = names.stream()
.filter(name -> name.startsWith("J"))
.peek(name -> IO.println("Nombre filtrado: " + name)) // Muestra los nombres filtrados
.toList(); // Filtra los nombres que empiezan por J
List<Integer> lenghts = names.stream()
.map(String::length)
.peek(length -> IO.println("Longitud del nombre: " + length)) // Muestra la longitud de cada nombre
.toList(); // Mapea los nombres a su longitud
List<String> sortedNames = names.stream()
.sorted()
.peek(name -> IO.println("Nombre ordenado: " + name)) // Muestra los nombres ordenados
.toList(); // Ordena los nombres alfabéticamente
List<Integer> numbers = List.of(1, 2, 2, 3, 4, 4, 5);
List<Integer> uniqueNumbers = numbers.stream()
.distinct()
.peek(number -> IO.println("Número único: " + number)) // Muestra los números únicos
.toList(); // Obtiene los números únicos
Operaciones Terminales
Inician el procesamiento de los datos y producen un resultado final o un efecto secundario. Algunas de las más comunes:
toList()/collect(Collector): Recoge los elementos delStreamen una colección:
toList()(desde Java 16): Forma directa y moderna de obtener una lista inmutable.
collect(Collectors.toSet())/Collectors.toList(): Permite recolectar en colecciones específicas (Set,Mapo listas mutables tradicionales).
collect(Collector): Transforma los elementos delStreamen una colección personalizada (comoSetoMap) u otra estructura mediante la claseCollectors.
forEach(Consumer): Ejecuta una acción para cada elemento de la secuencia.
reduce(…): Combina todos los elementos en un único valor mediante una operación de acumulación.
count(): Devuelve el número total de elementos procesados como un valor de tipolong.
max(Comparator) / min(Comparator): Devuelve unOptionalcon el elemento máximo o mínimo según el comparador proporcionado.
findFirst() / findAny(): Devuelve unOptionalcon el primer elemento o con cualquier elemento que cumpla la condición.
anyMatch() / allMatch() / noneMatch(Predicate): Evalúan una condición y devuelven un valor booleano con el resultado.\
List<Integer> numbers = List.of(1, 19, 45, 19, 34, 99, 86);
// Crea una lista con los números pares
List<Integer> evenNumbers = numbers.stream()
.filter(n -> n % 2 == 0)
.toList();
// Imprimir cada número incrementado en 5
numbers.forEach(n -> IO.println(n + 5));
// Sumar todos los números usando reduce
int sum = numbers.stream()
.reduce(0, Integer::sum);
// Filtrar números pares y contarlos
long countEven = numbers.stream()
.filter(n -> n % 2 == 0)
.count();
// Obtener el número máximo o lanza una excepción si la lista está vacía
int max = numbers.stream()
.max(Integer::compare)
.orElseThrow(() -> new RuntimeException("Lista vacía"));
List<Integer> emptyList = List.of();
int max2 = emptyList.stream()
.max(Integer::compare)
.orElseThrow(() -> new RuntimeException("Lista vacía"));
// Obtiene el primer elemento que es mayor que 20
Optional<Integer> firstGreaterThan20 = numbers.stream()
.filter(n -> n > 20)
.findFirst();
// Obtiene cualquier elemento que es mayor que 20
Optional<Integer> anyGreaterThan20 = numbers.stream()
.filter(n -> n > 20)
.findAny();
// Comprueba si AL MENOS existe un elemento mayor que 20
boolean hasGreaterThan20 = numbers.stream()
.anyMatch(n -> n > 20);
// Comprueba si TODOS los elementos son mayor que 20
boolean allGreaterThan20 = numbers.stream()
.allMatch(n -> n > 20);
// Comprueba si NINGÚN elemento es mayor que 20
boolean noneGreaterThan20 = numbers.stream()
.noneMatch(n -> n > 20);
4. La Clase Optional<T>
Optional<T> es un contenedor que puede o no albergar un valor no nulo. Su objetivo principal es actuar como tipo de retorno en métodos para representar la ausencia de un resultado sin lanzar excepciones NullPointerException.
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:
- Evitar
NullPointerExceptions: Al encapsular un valor,Optionalpermite realizar operaciones de manera segura sin preocuparse por valores nulos no deseados.
- Claridad y Documentación del Código: Al usar
Optional, se hace explícito en la firma del método que un valor puede estar presente o ausente, mejorando la claridad y la documentación del código.
- Mejor Práctica de Diseño: Fomenta el diseño de métodos que devuelven o manipulan valores opcionales, promoviendo un código más robusto y fácil de mantener.
- API más Expresiva: Proporciona métodos útiles para trabajar con valores opcionales, como
orElse,orElseGet,orElseThrow,ifPresent, entre otros, que facilitan manejar casos de valores nulos de manera más elegante.
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.
4.1 Creación de Instancias
Optional<Integer> opt = Optional.of(5); // Permite valores nulos (si es null, devuelve un Optional vacío) Optional<Integer> optNullable = Optional.ofNullable(nullableValue); // Instancia explícitamente vacía Optional<Integer> optEmpty = Optional.empty();
4.2 Métodos Principales
isPresent: Verifica si el Optional contiene un valor.
orElse(T other): Devuelve el valor encapsulado si está presente; de lo contrario, devuelve el valor proporcionado como argumento.
orElseGet(Supplier<? extends T> supplier): Devuelve el valor encapsulado si está presente; de lo contrario, devuelve el resultado obtenido delSupplierproporcionado.
orElseThrow(Supplier<? extends X> exceptionSupplier): Devuelve el valor encapsulado si está presente; de lo contrario, lanza una excepción proporcionada por elSupplier.
get(): Devuelve el valor encapsulado si está presente; de lo contrario, lanza una excepciónNoSuchElementException.
ifPresent(Consumer<? super T> consumer): Ejecuta la acción proporcionada si el valor está presente.
Optional<String> optionalString = Optional.of("Hola, mundo!");
Optional<String> optionalStringEmpty = Optional.empty();
if(optionalString.isPresent()) {
IO.println(optionalString.get()); // Imprime "Hola, mundo!"
}
if (optionalStringEmpty.isPresent()) {
IO.println(optionalStringEmpty.get());
} else {
IO.println("El valor no está presente"); // Imprime "El valor no está presente"
}
optionalString.orElse("Valor por defecto"); // Devuelve "Hola, mundo!"
optionalStringEmpty.orElse("Valor por defecto"); // Devuelve "Valor por defecto"
optionalString.orElseGet(() -> "Valor por defecto"); // Devuelve "Hola, mundo!"
optionalStringEmpty.orElseGet(() -> "Valor por defecto"); // Devuelve "Valor por defecto"
optionalString.orElseThrow(() -> new RuntimeException("Valor no presente")); // Devuelve "Hola, mundo!"
optionalStringEmpty.orElseThrow(() -> new RuntimeException("Valor no presente")); // Lanza una excepción
optionalString.ifPresent(IO::println); // Imprime "Hola, mundo!"
optionalStringEmpty.ifPresent(IO::println); // No imprime nada
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.
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.
Ejercicios
Ejercicio 1
Dada una lista de enteros List<Integer>, calcula la suma de todos sus elementos utilizando la API Stream.
Ejercicio 2
Dada una lista de enteros List<Integer>, devuelve una nueva lista que contenga únicamente los números pares.
Ejercicio 3
Dada una lista de cadenas de texto List<String>, devuelve una nueva lista con todas las cadenas convertidas a mayúsculas.
Ejercicio 4
Dada una lista de enteros List<Integer>, encuentra el número máximo utilizando Streams y Optional.
Ejercicio 5
Dada una lista de enteros List<Integer> y un valor límite int limit, cuenta cuántos elementos de la lista son estrictamente mayores que dicho valor.
Ejercicio 6
Dada una lista de cadenas List<String>, concatena todos sus elementos en una única cadena separada por comas utilizando Streams.
Ejercicio 7
Dada una lista de enteros List<Integer>, devuelve una lista con el cuadrado de cada uno de sus números.
Ejercicio 8
Dada una lista de enteros List<Integer> que contiene valores repetidos, devuelve una nueva lista sin duplicados.
Ejercicio 9
Dada una lista de enteros List<Integer> y un valor de referencia int threshold, devuelve el primer número de la lista que sea mayor que dicho valor envuelto en un Optional.
Ejercicio 10
Dada una lista de cadenas List<String> y una letra de inicio String prefix, cuenta cuántas cadenas de la lista empiezan por dicha letra (sin importar mayúsculas o minúsculas).