Interfaces Funcionales Integradas de Java
El paquete java.util.function: Function, Predicate, Consumer, Supplier y sus variantes especializadas.
El paquete java.util.function se incluyó en Java 8 para proporcionar al JDK — y a tu código — un vocabulario compartido para las lambdas. Sin él, cada método que aceptara una función tendría que definir su propia interfaz puntual (StringMapper, IntToBool, RowHandler, …), y las lambdas definidas para una no podrían reutilizarse en otra. El paquete resuelve esto con 43 interfaces pequeñas que cubren las formas que aparecen una y otra vez: "toma algo, devuelve otra cosa", "toma algo, decide sí o no", "toma algo, haz algo con ello", "dame algo".
Si solo aprendes cuatro interfaces de este paquete, aprende Function, Predicate, Consumer y Supplier. Casi todo lo demás es una variante de una de ellas: versiones con dos argumentos, especializaciones primitivas para evitar el boxing, o helpers de composición.
Las cuatro principales
Function<T, R> f = t -> ...; // takes a T, returns an R — r = f.apply(t)
Predicate<T> p = t -> ...; // takes a T, returns a boolean — boolean b = p.test(t)
Consumer<T> c = t -> { ... }; // takes a T, returns nothing — c.accept(t)
Supplier<T> s = () -> ...; // takes nothing, returns a T — t = s.get()Cada una está anotada con @FunctionalInterface y tiene un método abstracto de una sola palabra (apply, test, accept, get). Rara vez llamarás a esos métodos directamente cuando se trabaja con streams — stream().filter(predicate).map(function).forEach(consumer) realiza las llamadas por ti — pero conocer el nombre del método importa cuando escribes código que recibe un Function<T, R> como parámetro y necesita invocarlo.
Las formas se corresponden con preguntas comunes:
| Pregunta | Interfaz |
|---|---|
| "¿Transformar una X en una Y?" | Function<X, Y> |
| "¿Esta X es válida?" | Predicate<X> |
| "Hacer algo con esta X" | Consumer<X> |
| "Dame una X" | Supplier<X> |
Variantes con dos argumentos
Cuando la operación necesita dos entradas, añade el prefijo Bi:
BiFunction<T, U, R> f = (t, u) -> ...; // two ins, one out — apply
BiPredicate<T, U> p = (t, u) -> ...; // two ins, a boolean — test
BiConsumer<T, U> c = (t, u) -> { ... }; // two ins, no out — acceptNo existe BiSupplier — Supplier no toma argumentos por definición, por lo que un "supplier con dos argumentos" sería simplemente un BiFunction.
Las variantes Bi son exactamente lo que Map.forEach((k, v) -> ...), Map.merge y Map.compute esperan:
Map<String, Integer> scores = new HashMap<>();
scores.forEach((name, score) -> System.out.println(name + "=" + score)); // BiConsumer
scores.merge("alice", 1, Integer::sum); // BinaryOperator<Integer>BinaryOperator<T> es un BiFunction<T, T, T> — el mismo tipo para ambas entradas y la salida. UnaryOperator<T> es similarmente un Function<T, T>.
Especializaciones primitivas — evitando el costo del boxing
Function<Integer, Integer> funciona, pero cada llamada hace boxing de la entrada y hace boxing del resultado. En un bucle muy ajustado, ese es un costo real. Por eso el paquete ofrece versiones especializadas para primitivos:
IntFunction<R> f = i -> ...; // int in, R out
IntPredicate p = i -> ...; // int in, boolean out
IntConsumer c = i -> { ... }; // int in, void
IntSupplier s = () -> 42; // void in, int out
IntUnaryOperator u = i -> i * 2; // int in, int out
IntBinaryOperator b = (a, c2) -> a + c2;
ToIntFunction<T> f1 = t -> t.hashCode(); // T in, int out
ToIntBiFunction<T, U> f2 = (t, u) -> t.hashCode() + u.hashCode();
IntToLongFunction f3 = i -> (long) i * i; // int in, long out
IntToDoubleFunction f4 = i -> Math.sqrt(i);La misma familia existe para Long y Double. La convención de nombres se lee como una oración:
IntX— opera sobre unint.ToIntX— produce unint.IntToLongX—intde entrada,longde salida.
En el código de streams, mapToInt(...) devuelve un IntStream, cuyas operaciones terminales (sum, average, min, max) devuelven todos primitivos sin boxing — lo cual es uno de los mayores beneficios prácticos de las variantes primitivas.
Composición integrada en las interfaces
La mayoría de las interfaces vienen con métodos default que te permiten componer sin escribir nuevas lambdas:
// Function: andThen (left-to-right), compose (right-to-left)
Function<String, String> trim = String::trim;
Function<String, Integer> len = String::length;
Function<String, Integer> trimLen = trim.andThen(len); // trim, then length
Function<String, Integer> sameThing = len.compose(trim); // length applied after trim
// Predicate: and / or / negate
Predicate<String> notNull = Objects::nonNull;
Predicate<String> notBlank = s -> !s.trim().isEmpty();
Predicate<String> useful = notNull.and(notBlank);
Predicate<String> blank = notBlank.negate();
// Consumer: andThen (run two consumers in sequence)
Consumer<String> log = System.out::println;
Consumer<String> save = s -> writeToFile(s);
Consumer<String> both = log.andThen(save);
// Comparator (in java.util, not java.util.function, but the same idea):
Comparator<Person> byName = Comparator.comparing(Person::name);
Comparator<Person> ordered = byName.thenComparing(Person::age);También hay una útil fábrica estática: Predicate.not(p) es una forma abreviada de p.negate() y resulta más natural en el punto de llamada:
list.removeIf(Predicate.not(String::isBlank)); // remove all blank stringsFunction.identity y Predicate.isEqual — los pequeños estáticos útiles
Dos métodos de fábrica que verás en el código de streams y que debes reconocer:
Function<T, T> id = Function.identity(); // t -> t — useful as a no-op map
Predicate<Object> isFoo = Predicate.isEqual("foo"); // o -> Objects.equals(o, "foo")Function.identity() se usa con mayor frecuencia como mapeador de clave o valor en Collectors.toMap:
Map<String, Person> byName = people.stream()
.collect(Collectors.toMap(Person::name, Function.identity()));Predicate.isEqual raramente es más corto que s -> s.equals("foo"), pero compara de forma null-safe con Objects.equals, lo cual importa cuando el stream puede contener null.
Un ejemplo completo: las cuatro principales, composición y especialización primitiva
El programa a continuación usa Function, Predicate, Consumer y Supplier, compone algunos de ellos, y contrasta un Function<Integer, Integer> (con boxing) con un IntUnaryOperator (primitivo) sumando una lista pequeña.
Lo que hay que extraer de la ejecución:
- Las cuatro interfaces principales se mapean limpiamente en cuatro tipos de trabajo: transformar (
Function), probar (Predicate), actuar (Consumer), producir (Supplier). Sus nombres de métodos abstractos (apply,test,accept,get) merecen memorizarse. trim.andThen(length)ynotNull.and(notBlank)construyeron nuevos valores a partir de los anteriores sin declaraciones de métodos auxiliares. Esa es el álgebra de composición que las interfaces llevan como métodosdefault.- El
Function<Integer, Integer>con boxing es significativamente más lento que elIntUnaryOperatorprimitivo porque cada llamada asigna dos objetosInteger. En rutas de código con alto rendimiento — pipelines de streams que procesan millones de valores — las especializaciones primitivas justifican su existencia. Predicate.not(notBlank)resulta más natural quenotBlank.negate()en el punto de llamada deremoveIf. Ambas compilan al mismo resultado.
Qué sigue
Ya has visto el vocabulario estándar. La pregunta ergonómica restante sobre las lambdas es "cuando el cuerpo de la lambda simplemente delega en un método existente, ¿puedo escribirlo de forma más breve?" Sí — con referencias a métodos. El siguiente capítulo, Referencias a Métodos en Java, cubre el operador :: y sus cuatro formas (estático, instancia vinculada, instancia no vinculada, constructor), y explica cuándo una referencia a método es más clara que una lambda y cuándo es lo contrario.