Programación funcional en Java
Una visión general de los conceptos de programación funcional en Java — funciones de primera clase, inmutabilidad, funciones puras y composición.
Programación funcional en Java
La parte anterior — Collections Framework — trataba de contenedores: estructuras de datos que contienen elementos y las operaciones (add, remove, iterate, sort, binarySearch) que actúan sobre ellos. Esta parte trata de otra capa del mismo problema. En lugar de dónde viven los datos, nos centraremos en cómo expresar transformaciones sobre ellos — con claridad, de forma composicional, sin bucles redundantes ni variables acumuladoras.
Ese cambio tiene un nombre. La programación funcional es el estilo en el que el cómputo se expresa como la aplicación de funciones a valores, en el que las funciones mismas son valores de primera clase, y en el que los datos suelen tratarse como inmutables. Java no se diseñó como un lenguaje funcional — las clases, la mutación y los bucles explícitos están en su núcleo — pero desde Java 8 todo programa Java moderno toma prestado abundantemente de la caja de herramientas funcional. Ya escribió algo de esto en la parte de colecciones: list.sort(Comparator.comparing(Person::name)), map.getOrDefault(k, 0), List.copyOf(source). Lo que hace la Parte 12 es nombrar el estilo explícitamente y darle el resto de las herramientas — lambdas, interfaces funcionales, los tipos java.util.function, referencias a métodos, Optional y la API Stream — para que los patrones que ha estado imitando se conviertan en movimientos de primera clase.
Cuatro ideas que definen el estilo
Los lenguajes puramente funcionales (Haskell, Erlang, F#) llevan las cuatro al límite. Java las aplica con moderación. Las cuatro ideas:
- Las funciones son valores de primera clase. Puede pasar una función como argumento, devolver una de un método, almacenar una en un campo o construir una en tiempo de ejecución.
- Funciones puras. Una función pura depende solo de sus entradas y no cambia nada observable del mundo. Dada la misma entrada, devuelve la misma salida. Sin E/S, sin mutación de campos, sin ramificación dependiente del tiempo.
- Inmutabilidad por defecto. Las estructuras de datos no se modifican en el sitio; las transformaciones devuelven valores nuevos. Las referencias antiguas siguen siendo válidas.
- Composición. Las funciones más grandes se construyen combinando otras más pequeñas (
f.andThen(g),pred.and(other),cmp.thenComparing(...)), no editándolas.
Ninguna de estas es específica de Java. Son una forma de pensar que el lenguaje ahora admite mediante lambdas, referencias a métodos, la API Stream y las colecciones inmutables que acaba de conocer.
1. Funciones como valores
Antes de Java 8, no podía tener una variable cuyo valor fuera una función. Podía pasar un objeto cuya clase resultara tener un método — eso eran Runnable, Comparator y ActionListener — pero la sintaxis era torpe:
list.sort(new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.length() - b.length();
}
});El método único iba envuelto en una declaración de clase anónima. Java 8 introdujo las expresiones lambda como sintaxis concisa para la misma idea:
list.sort((a, b) -> a.length() - b.length());La lambda es el valor. Se compila en una instancia de la interfaz funcional que se necesite en el punto de llamada (aquí, Comparator<String>). El siguiente capítulo trata por completo de la sintaxis; por ahora lo importante es que las funciones en Java ahora son valores que puede nombrar, almacenar y hacer circular.
2. Funciones puras
Una función pura es aquella cuyo valor de retorno depende solo de sus argumentos y cuya ejecución no tiene efectos secundarios observables. Math.sqrt(2) es pura. System.currentTimeMillis() no lo es — devuelve valores distintos en cada llamada. list.add(x) no lo es — muta list.
Las funciones puras son valiosas porque:
- Son fáciles de probar — sin preparación, sin mocks, solo
assertEquals(expected, f(input)). - Son fáciles de paralelizar — dos llamadas puras pueden ejecutarse en hilos distintos sin sincronización.
- Son fáciles de cachear — memorice una vez, devuelva la misma respuesta para siempre.
- Se componen sin sorpresas —
f(g(x))hace lo que sugiere su lectura.
La mayoría de los programas reales útiles no son puros al 100 % (alguien tiene que escribir en una base de datos). La disciplina funcional consiste en hacer puro el cómputo central y empujar las partes impuras — E/S, tiempo, aleatoriedad, mutación — hacia los bordes. Los streams animan a esto: una tubería de operaciones puras es correcta por construcción; una impura (stream().peek(x -> counter++)...) es un imán de errores.
3. Inmutabilidad
La conoció en el último capítulo. List.of(...), Set.of(...), Map.of(...) y List.copyOf(...) producen colecciones que no se pueden modificar. Los records (que se tratan más adelante) le dan clases de datos inmutables:
record Point(double x, double y) {
Point translated(double dx, double dy) {
return new Point(x + dx, y + dy); // returns a NEW Point — does not mutate this
}
}Los valores inmutables son intrínsecamente seguros para hilos. Nunca presentan un estado intermedio «roto». Se pueden compartir libremente sin copia defensiva. Y hacen prácticas las funciones puras — si los valores no pueden cambiar, una función que devuelve uno tiene garantizado ser determinista para esa parte del mundo.
4. Composición
La composición es «construir una función grande a partir de otras pequeñas». En Java, Function, Predicate y Comparator proporcionan todos operadores composicionales:
Function<String, String> trim = String::trim;
Function<String, String> upper = String::toUpperCase;
Function<String, String> clean = trim.andThen(upper); // trim, then upper
Predicate<Integer> positive = n -> n > 0;
Predicate<Integer> even = n -> n % 2 == 0;
Predicate<Integer> posEven = positive.and(even);
Comparator<String> byLength = Comparator.comparingInt(String::length);
Comparator<String> lengthThenA = byLength.thenComparing(Comparator.naturalOrder());La API composicional es parte del valor. No escribe un ayudante que tome dos predicados y los una con && — escribe a.and(b). El estilo escala: una transformación de seis pasos puede leerse de arriba abajo como una sola expresión en lugar de seis bucles anidados con acumuladores intermedios.
Qué conserva Java del lado imperativo
Java es multiparadigma. Las características funcionales añadidas en Java 8+ conviven con las imperativas que existen desde la 1.0. Algunas cosas siguen siendo imperativas a propósito:
- Sentencias y flujo de control.
if,for,while,trysiguen siendo los bloques básicos; las lambdas no los reemplazan, reemplazan el código repetitivo de las clases anónimas. - Variables locales mutables. Dentro del cuerpo de un método,
int sum = 0; for (int x : xs) sum += x;sigue siendo idiomático. - Campos mutables donde tienen sentido. Los builders, las cachés y los componentes de UI con estado siguen mutando.
El principio: use el estilo funcional donde haga el código más claro, no como dogma. Un stream().mapToInt(Integer::intValue).sum() puro es más claro que un bucle hecho a mano. Una tubería de composición de lambdas de seis pasos que nadie en su equipo puede leer no lo es.
Un ejemplo resuelto: imperativo vs. funcional, lado a lado
El programa de abajo calcula la longitud media de las cadenas no vacías de una lista, dos veces. La primera versión es imperativa — un acumulador mutable, un bucle explícito, una guarda contra la división entre cero. La segunda versión es funcional — una tubería de stream de operaciones puras que se lee de arriba abajo. El tercer fragmento construye valores compuestos Predicate y Function a partir de otros más pequeños, mostrando la composición en acción.
Qué llevarse de la ejecución:
- Ambas versiones calculan la misma media. La imperativa declara dos contadores mutables y un cuerpo de bucle; la funcional encadena cinco operaciones nombradas que describen cada una el qué, no el cómo.
Predicate.andconstruyó una prueba compuesta (notNull.and(notBlank)) a partir de dos predicados más pequeños — sin necesidad de un nuevo método ayudante. Esa es la composición en acción.Function.andThenhizo lo mismo para una tubería que produce un valor:trimluegolength, expresados como una solaFunction<String, Integer>compuesta.- Cada operación del stream es pura:
String::trim, la lambdas -> !s.isEmpty(),String::length— ninguna muta estado. Llamar atrimmedLen.apply(" hi ")dos veces produjo la misma respuesta; esa es la garantía de determinismo que hace seguras las funciones puras para memorizar y paralelizar.
Qué sigue
El modelo mental está en su sitio: las funciones son valores, las transformaciones puras se componen, la inmutabilidad lo libera de una clase de errores. El siguiente capítulo, Expresiones lambda de Java, introduce la sintaxis concreta — (params) -> body — que hace ergonómico este estilo en Java, además de las reglas sobre captura de variables, tipado objetivo y dónde puede aparecer una lambda.
Practice
A 'pure' function in the functional-programming sense is one that...