1. Estilo imperativo vs. funcional
Vamos a empezar con una pregunta sencilla: ¿para qué sirve ese «estilo funcional»? ¿En qué es mejor que el enfoque habitual basado en bucles? ¿Y qué significa «estilo funcional» en Java?
Estilo imperativo
El estilo imperativo consiste en decirle al ordenador cómo hacer algo, paso a paso. Por ejemplo, si necesitas, a partir de una lista de cadenas, obtener sus longitudes, quedarte solo con las impares y ordenarlas de mayor a menor, escribirías algo así:
List<String> words = Arrays.asList("gato", "elefante", "rinoceronte", "tigre", "ratón");
List<Integer> lengths = new ArrayList<>();
for (String word : words) {
int len = word.length();
if (len % 2 != 0) {
lengths.add(len);
}
}
lengths.sort(Comparator.reverseOrder());
System.out.println(lengths); // [7, 5, 3]
Aquí creamos explícitamente una lista intermedia, añadimos elementos a mano, ordenamos — todo por pasos.
Estilo funcional
El estilo funcional consiste en describir qué quieres obtener, no cómo se hace. En Java esto se materializa mediante Stream API:
List<String> words = Arrays.asList("gato", "elefante", "rinoceronte", "tigre", "ratón");
List<Integer> result = words.stream()
.map(String::length)
.filter(len -> len % 2 != 0)
.sorted(Comparator.reverseOrder())
.toList();
System.out.println(result); // [7, 5, 3]
Aquí estamos construyendo una especie de «cadena» de procesamiento de datos: primero convertimos las palabras en sus longitudes (map), luego filtramos las impares (filter), y después ordenamos (sorted). Todo en una sola cadena, sin colecciones intermedias explícitas ni bucles.
Comparación: estilo iterativo vs. funcional
En el enfoque imperativo habitual escribimos un bucle en cuyo interior, paso a paso, explicamos al ordenador qué hacer: recorrer cada elemento, comprobar la condición, añadir a una nueva lista o mostrar por pantalla. El código funciona, pero ocupa más líneas y, cuanto más compleja es la tarea, más difícil resulta leerlo y mantenerlo.
El estilo funcional permite describir no el proceso en sí, sino el resultado que queremos obtener. En lugar de un bucle largo construimos una cadena de operaciones: filtrar, transformar, recolectar el resultado. Es más corto, más claro y reduce el riesgo de errores porque hay menos «trabajo manual» con colecciones mutables.
También tiene su lado menos favorable. Para alguien que empieza, una cadena con varias lambdas seguidas puede resultar enrevesada: a veces se lee peor que un bucle simple. Por eso, el estilo funcional gana en concisión y expresividad, pero requiere práctica y familiaridad.
2. Operaciones básicas de Stream API
Stream API no es simplemente «un nuevo tipo de bucle», sino un conjunto completo de herramientas para procesar colecciones en estilo funcional. Veamos las operaciones básicas.
¿Cómo obtener un Stream?
List<String> list = List.of("a", "bb", "ccc");
Stream<String> stream = list.stream();
Operaciones intermedias
- map — convierte los elementos del stream
- filter — filtra elementos según una condición
- flatMap — convierte cada elemento en un stream y los «aplana»
- sorted — ordenación
- distinct — elimina duplicados
- limit / skip — limitar/omitir elementos
Operaciones terminales
- forEach — ejecutar una acción para cada elemento
- collect — recolectar el resultado en una colección
- reduce — reducir el stream a un único valor (por ejemplo, una suma)
- count — contar el número de elementos
- anyMatch, allMatch, noneMatch — comprobaciones de condiciones
Ejemplo: cadena de procesamiento
List<String> names = List.of("Anna", "Boris", "Vika", "Gleb", "Dasha");
List<String> filtered = names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.sorted()
.toList();
System.out.println(filtered); // [BORIS, VIKA, DASHA]
Esquema visual de la «cadena»:
[Anna, Boris, Vika, Gleb, Dasha]
| filter (length>3)
[Boris, Vika, Dasha]
| map (toUpperCase)
[BORIS, VIKA, DASHA]
| sorted
[BORIS, VIKA, DASHA]
| toList
Cada operación no modifica la colección original: se crea un nuevo stream.
3. Ejecución en flujo y evaluación perezosa
Operaciones intermedias y terminales
Los streams tienen dos tipos de operaciones. Las primeras son intermedias, como map, filter o sorted. Devuelven un nuevo stream y es como si prometieran hacer algo, pero en realidad aún no ejecutan nada. Las segundas son terminales, por ejemplo forEach, collect o reduce. Esas sí ponen en marcha todo el procesamiento. El punto clave es que, hasta que no llames a una operación terminal, el stream permanece «perezoso»: no comienzan los cálculos.
Ejemplo:
Stream<String> stream = List.of("a", "bb", "ccc").stream()
.map(s -> {
System.out.println("map: " + s);
return s.toUpperCase();
});
System.out.println("Antes de forEach");
stream.forEach(System.out::println);
Salida:
Antes de forEach
map: a
A
map: bb
BB
map: ccc
CCC
Se ve que map no se ejecuta hasta que comienza forEach.
¿Por qué es genial?
Este enfoque permite construir cadenas de transformaciones todo lo largas que quieras sin costes innecesarios. El stream empezará a trabajar solo cuando sea realmente necesario. Gracias a ello se pueden procesar sin problemas volúmenes enormes de datos o incluso secuencias infinitas. Además, la pereza ayuda a ahorrar memoria y a ejecutar los cálculos de forma más eficiente.
4. Práctica: tarea «Cadenas → longitudes → impares → en orden descendente»
Resolvamos paso a paso la tarea: «A partir de una lista de cadenas obtener la lista de longitudes, quedarse solo con las impares y ordenar de mayor a menor.»
Solución imperativa
List<String> words = Arrays.asList("gato", "elefante", "rinoceronte", "tigre", "ratón");
List<Integer> lengths = new ArrayList<>();
for (String word : words) {
int len = word.length();
if (len % 2 != 0) {
lengths.add(len);
}
}
lengths.sort(Comparator.reverseOrder());
System.out.println(lengths); // [7, 5, 3]
Solución funcional con Stream API
List<String> words = Arrays.asList("gato", "elefante", "rinoceronte", "tigre", "ratón");
List<Integer> result = words.stream()
.map(String::length) // transformamos las cadenas en sus longitudes
.filter(len -> len % 2 != 0) // nos quedamos solo con las longitudes impares
.sorted(Comparator.reverseOrder()) // ordenamos de mayor a menor
.toList(); // recolectamos en un List (Java 16+)
System.out.println(result); // [7, 5, 3]
Explicaciones:
- map(String::length) — para cada cadena obtenemos su longitud.
- filter(len -> len % 2 != 0) — dejamos solo las longitudes impares.
- sorted(Comparator.reverseOrder()) — ordenamos de mayor a menor.
- toList() — recolectamos el stream en una lista nueva.
Analogía
Es como si construyeras una «cinta» en una fábrica: en cada etapa las piezas se procesan de una forma nueva y solo al final todo se mete en la caja.
5. Más ejemplos: map, filter, forEach, collect
Ejemplo 1: Filtrado e impresión
List<String> names = List.of("Anna", "Boris", "Vika", "Gleb", "Dasha");
names.stream()
.filter(name -> name.contains("a"))
.forEach(System.out::println);
// Imprimirá: Anna, Dasha
Ejemplo 2: Transformación y recolección en Set
Set<String> upperNames = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toSet());
System.out.println(upperNames); // [ANNA, BORIS, VIKA, GLEB, DASHA]
Ejemplo 3: Obtener la suma de las longitudes de todas las cadenas
int totalLength = names.stream()
.mapToInt(String::length)
.sum();
System.out.println("Longitud total: " + totalLength);
Ejemplo 4: Uso de Predicate y Function
Predicate<String> longName = name -> name.length() > 4;
Function<String, String> greet = name -> "Hola, " + name + "!";
names.stream()
.filter(longName)
.map(greet)
.forEach(System.out::println);
// Hola, Boris!
// Hola, Dasha!
6. Particularidades del estilo funcional con Stream API
Ausencia de estado mutable
El estilo de Stream API fomenta las funciones «puras», sin efectos secundarios. Esto significa que es mejor no modificar variables externas dentro de las lambdas.
Malo:
List<String> result = new ArrayList<>();
names.stream()
.filter(name -> name.startsWith("A"))
.forEach(result::add); // side-effect!
Mejor:
List<String> result = names.stream()
.filter(name -> name.startsWith("A"))
.toList();
Composición de operaciones
Puedes construir cadenas muy largas combinando map, filter, sorted y otros métodos. La clave es no pasarse: si la cadena ya no cabe en pantalla, quizá convenga dividirla en partes.
Evaluación perezosa
Stream API no hace nada hasta que llega a una operación terminal. Esto permite ahorrar recursos y construir canalizaciones de procesamiento eficientes.
Inmutabilidad de las colecciones de origen
Stream no modifica la colección original. ¡Todas las transformaciones devuelven un nuevo stream/colección!
7. Cuándo usar Stream API
Stream API es ideal cuando:
- Necesitas procesar una colección rápidamente (filtrado, transformación, ordenación).
- Quieres un código conciso y legible.
- No quieres crear colecciones intermedias manualmente.
- Necesitas añadir paralelismo fácilmente (mediante parallelStream()).
El estilo imperativo a veces es preferible si:
- Hace falta una lógica compleja con varios bucles anidados y condiciones.
- Es crucial mantener el máximo rendimiento en partes críticas (Stream API a veces es ligeramente más lento).
- Hay que trabajar con estado mutable (por ejemplo, actualizar elementos «in situ»).
8. Errores típicos al trabajar con Stream API
Error n.º 1: Usar forEach para recolectar una colección. Muchos principiantes usan forEach para añadir elementos a una colección nueva. ¡Eso no es estilo funcional! En su lugar usa collect o toList().
Error n.º 2: Optimización prematura. No intentes usar parallelStream() desde el principio: el paralelismo solo tiene sentido para colecciones realmente grandes y tareas intensivas en CPU.
Error n.º 3: Mezclar Stream API y colecciones mutables. Stream API presupone trabajo con datos inmutables. No conviene modificar elementos de la colección dentro de lambdas.
Error n.º 4: «Perder» el resultado. Si olvidas llamar a una operación terminal, no ocurrirá nada.
Error n.º 5: Lambdas demasiado complejas. Si una expresión lambda tiene más de una o dos líneas, extráela a un método con un nombre claro.
GO TO FULL VERSION