6. Lambdas e interfaces funcionales
Ejemplos completos: lessons/l06.
Sin tipos delegado: interfaces con un solo método
Sección titulada «Sin tipos delegado: interfaces con un solo método»C# tiene tipos delegado: Func<string, int> es un tipo por derecho propio, y desde C# 10 una lambda tiene incluso un tipo natural. Java no tiene ningún tipo función. Una lambda es una implementación de una interfaz funcional, una interfaz con exactamente un método abstracto, y el compilador necesita saber de qué interfaz se trata a partir del contexto: el tipo destino (target type).
El JDK ofrece las formas habituales en java.util.function:
| C# | Java | Método que se llama |
|---|---|---|
Func<T, R> |
Function<T, R> |
apply |
Func<T1, T2, R> |
BiFunction<T, U, R> |
apply |
Func<R> |
Supplier<T> |
get |
Action<T> |
Consumer<T> |
accept |
Action |
Runnable |
run |
Predicate<T> |
Predicate<T> |
test |
Func<T, T> |
UnaryOperator<T> |
apply |
Func<T, T, T> |
BinaryOperator<T> |
apply |
Func<string, int> sin boxing |
ToIntFunction<String>, IntBinaryOperator, IntPredicate… |
applyAsInt, test… |
No hay Function3: a partir de dos parámetros, declaras tu propia interfaz. El nombre del método también cambia con la interfaz, así que no puedes llamar a una variable lambda como a un método (length("abc") en C#):
// Func<string, int>, Func<int, int, int>, Func<string>, Action<string>, Predicate<string>Function<String, Integer> length = s -> s.length();BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;Supplier<String> greeting = () -> "hello";Consumer<String> print = s -> System.out.println("print: " + s);Predicate<String> isEmpty = s -> s.isEmpty();
// Cada interfaz tiene su propio nombre de método: apply, get, accept, test.System.out.println(length.apply("lambda") + " " + add.apply(2, 3) + " " + greeting.get());print.accept("consumer");System.out.println(isEmpty.test(""));
// Las especializaciones primitivas evitan el boxing.ToIntFunction<String> fastLength = String::length;IntBinaryOperator multiply = (a, b) -> a * b;System.out.println(fastLength.applyAsInt("abc") + " " + multiply.applyAsInt(6, 7));6 5 helloprint: consumertrue3 42Sin tipo destino, una lambda no tiene tipo. var no puede inferir uno, y Object no es una interfaz funcional:
class Increments { static void run() { var increment = (int x) -> x + 1; }}LambdaWithoutTarget.java:3: error: cannot infer type for local variable increment var increment = (int x) -> x + 1; ^ (lambda expression needs an explicit target-type)1 errorclass References { static void run() { Object length = String::length; }}MethodRefWithoutInterface.java:3: error: incompatible types: Object is not a functional interface Object length = String::length; ^1 errorEl lado C# imprime Func`2 para (string s) => s.Length.
Tus propias interfaces funcionales
Sección titulada «Tus propias interfaces funcionales»Cualquier interfaz con un único método abstracto sirve como destino de una lambda, incluidas las antiguas como Runnable, Comparator o Callable. Los métodos default y estáticos no cuentan. La anotación @FunctionalInterface es opcional, como @Override: le pide al compilador que compruebe la regla.
@FunctionalInterfaceinterface Handler { void handle(String message);
void close();}NotFunctional.java:1: error: Unexpected @FunctionalInterface annotation@FunctionalInterface^ Handler is not a functional interface multiple non-overriding abstract methods found in interface Handler1 errorComo las lambdas toman el tipo de su destino, dos sobrecargas que reciben interfaces funcionales distintas con la misma forma hacen ambigua una llamada. C# tiene el mismo problema con los delegados, pero las interfaces estándar de Java se solapan mucho: Supplier<String> y Callable<String> no reciben nada y devuelven un String.
import java.util.concurrent.Callable;import java.util.function.Supplier;
class Scheduler { static void schedule(Supplier<String> job) {}
static void schedule(Callable<String> job) {}
static void run() { schedule(() -> "report"); }}AmbiguousOverload.java:10: error: reference to schedule is ambiguous schedule(() -> "report"); ^ both method schedule(Supplier<String>) in Scheduler and method schedule(Callable<String>) in Scheduler match1 errorUn cast como schedule((Supplier<String>) () -> "report") elige una. La mejor solución es dar nombres distintos a las sobrecargas, como hace el JDK con comparingInt y comparingLong.
Referencias a métodos
Sección titulada «Referencias a métodos»Una referencia a método es el equivalente de un grupo de métodos de C#. La forma :: existe en cuatro variantes:
// Las cuatro variantes de referencia a método.Function<String, Integer> parse = Integer::parseInt; // método estáticoSet<String> jvmLanguages = Set.of("java", "kotlin", "scala");Predicate<String> isJvmLanguage = jvmLanguages::contains; // ligada: jvmLanguages.contains(s)Function<String, String> upper = String::toUpperCase; // no ligada: s.toUpperCase()Supplier<List<String>> newList = ArrayList::new; // constructorSystem.out.println(parse.apply("42") + " " + isJvmLanguage.test("kotlin") + " " + upper.apply("java"));System.out.println(newList.get().size());42 true JAVA0La variante no ligada (unbound) no tiene equivalente en C#: String::toUpperCase convierte el método de instancia en una función cuyo primer parámetro es el receptor. C# necesita una lambda, s => s.ToUpper().
Captura: valores, no variables
Sección titulada «Captura: valores, no variables»Un cierre (closure) de C# captura la variable. La lambda puede modificarla y ve los cambios posteriores. Una lambda de Java solo puede usar variables locales que sean finales o efectivamente finales (effectively final, nunca reasignadas), así que en la práctica captura sus valores.
class Clicks { static int count() { int clicks = 0; Runnable click = () -> clicks++; click.run(); return clicks; }}CaptureMutable.java:4: error: local variables referenced from a lambda expression must be final or effectively final Runnable click = () -> clicks++; ^1 errorLa regla descarta un error clásico de C#. En C#, un bucle for tiene una sola variable para todas las iteraciones, así que las lambdas creadas en el bucle ven todas su valor final. foreach tiene una variable nueva en cada iteración desde C# 5. El lado C# imprime:
3 3 30 1 2En Java, la versión con bucle for no compila, y el bucle for mejorado funciona porque la variable de cada iteración es efectivamente final:
import java.util.ArrayList;import java.util.List;import java.util.function.Supplier;
class Loop { static List<Supplier<Integer>> suppliers() { List<Supplier<Integer>> result = new ArrayList<>(); for (int i = 0; i < 3; i++) { result.add(() -> i); } return result; }}CaptureLoopIndex.java:9: error: local variables referenced from a lambda expression must be final or effectively final result.add(() -> i); ^1 error// Cada iteración de un bucle for mejorado tiene su propia variable efectivamente final.List<Supplier<Integer>> suppliers = new ArrayList<>();for (int value : new int[] {0, 1, 2}) { suppliers.add(() -> value);}System.out.println(suppliers.stream().map(Supplier::get).toList());[0, 1, 2]Cuando una lambda de verdad tiene que actualizar un estado, captura un objeto mutable en lugar de una variable local. AtomicInteger es la opción habitual, y además es segura entre hilos. La regla solo se aplica a las variables locales: los campos se pueden leer y escribir libremente, porque la lambda captura this.
// Las lambdas capturan valores, no variables: el estado mutable necesita un objeto.AtomicInteger clicks = new AtomicInteger();Runnable click = clicks::incrementAndGet;click.run();click.run();System.out.println("clicks: " + clicks.get());clicks: 2A menudo la necesidad desaparece del todo: contar o sumar dentro de una lambda suele ser un pipeline de stream disfrazado (ejercicio 2 y lección 7).
this en una lambda
Sección titulada «this en una lambda»Antes de Java 8, el equivalente de una lambda era una clase anónima, y todavía las encontrarás en código antiguo. Las dos difieren en this: en una lambda es la instancia contenedora, como en C#; en una clase anónima es el propio objeto anónimo.
private final String name = "outer";
void showThis() { Runnable lambda = () -> System.out.println("lambda this: " + this.name); Runnable anonymous = new Runnable() { private final String name = "anonymous";
@Override public void run() { System.out.println("anonymous this: " + this.name); } }; lambda.run(); anonymous.run();}lambda this: outeranonymous this: anonymousLa composición es cosa de la biblioteca
Sección titulada «La composición es cosa de la biblioteca»C# compone delegados con + (multicast) y no tiene un Compose integrado. Las interfaces funcionales de Java no admiten operadores, y + entre dos Runnable es un error:
class Combine { static void run() { Runnable hello = () -> System.out.print("hello "); Runnable world = () -> System.out.println("world"); Runnable both = hello + world; }}CombineRunnables.java:5: error: bad operand types for binary operator '+' Runnable both = hello + world; ^ first type: Runnable second type: Runnable1 errorA cambio, las interfaces estándar llevan métodos default para componer funciones: Function.andThen y compose, Predicate.and, or, negate y Predicate.not, y todo un builder en Comparator:
// La composición es cosa de la biblioteca: métodos default en las interfaces.UnaryOperator<String> trim = String::strip;Function<String, Integer> trimmedLength = trim.andThen(String::length);System.out.println(trimmedLength.apply(" padded "));Predicate<String> notBlank = Predicate.not(String::isBlank);System.out.println(notBlank.and(isJvmLanguage.negate()).test("csharp"));
var people = new ArrayList<>(List.of(new Person("Ada", 36), new Person("Alan", 41), new Person("Grace", 36)));people.sort(Comparator.comparingInt(Person::age).reversed().thenComparing(Person::name));System.out.println(people);6true[Person[name=Alan, age=41], Person[name=Ada, age=36], Person[name=Grace, age=36]]La cadena de Comparator es el OrderByDescending(p => p.Age).ThenBy(p => p.Name) de LINQ, aplicado a una lista in situ.
Sin eventos: listas de listeners
Sección titulada «Sin eventos: listas de listeners»Java no tiene palabra clave event ni delegados multicast. Las bibliotecas mantienen una lista de listeners (oyentes), normalmente Consumer, y exponen métodos para añadirlos y quitarlos:
static class PriceFeed { private final List<Consumer<Double>> listeners = new ArrayList<>();
void addListener(Consumer<Double> listener) { listeners.add(listener); }
boolean removeListener(Consumer<Double> listener) { return listeners.remove(listener); }
void publish(double price) { listeners.forEach(listener -> listener.accept(price)); }}La trampa está en la eliminación. En C#, feed.PriceChanged -= display.OnPrice funciona porque dos delegados con el mismo destino y el mismo método son iguales. En Java, cada evaluación de display::onPrice crea un objeto nuevo, y las lambdas no redefinen equals:
feed.addListener(display::onPrice);feed.publish(10.5);
// Cada evaluación de display::onPrice crea un objeto nuevo, y las lambdas no redefinen equals.System.out.println("removed: " + feed.removeListener(display::onPrice));Consumer<Double> first = display::onPrice;Consumer<Double> second = display::onPrice;System.out.println("equal: " + first.equals(second));feed.publish(11.0);
// Guarda la referencia que registraste si quieres quitarla.Consumer<Double> listener = display::onPrice;var other = new PriceFeed();other.addListener(listener);System.out.println("removed: " + other.removeListener(listener));other.publish(12.0);display: 10.5removed: falseequal: falsedisplay: 11.0removed: trueEl lado C# imprime equal: True, y el evento se queda sin listener después del -=. En Java, guarda la referencia, o devuelve un objeto de suscripción desde el método que añade (ejercicio 3). La Java Language Specification deja deliberadamente sin especificar la identidad de los objetos lambda, así que tampoco te fíes de ==.
Puntos clave
Sección titulada «Puntos clave»- Una lambda implementa una interfaz funcional elegida por el tipo destino; no hay tipos función, y cada interfaz tiene su propio nombre de método.
java.util.functioncubre las formas habituales, con especializaciones primitivas para evitar el boxing.- Las referencias a métodos existen en cuatro variantes; la no ligada (
String::length) no tiene equivalente en C#. - Las lambdas solo pueden usar variables locales efectivamente finales: capturan valores, lo que elimina el error de captura en el bucle
forde C#. - La composición pasa por métodos default (
andThen,negate,Comparator.comparing), no por operadores. - Los listeners sustituyen a los eventos; una referencia a método evaluada dos veces da dos objetos distintos.
Ejercicios
Sección titulada «Ejercicios»- Escribe
pipeline(List<UnaryOperator<String>> steps), que devuelva unUnaryOperator<String>que aplique los pasos en orden, y la identidad para una lista vacía. ¿Por qué no compilareduce(UnaryOperator.identity(), (f, g) -> f.andThen(g))?
Solución
static UnaryOperator<String> pipeline(List<UnaryOperator<String>> steps) { return steps.stream().reduce(UnaryOperator.identity(), (f, g) -> s -> g.apply(f.apply(s)));}
UnaryOperator<String> clean = pipeline(List.of(String::strip, String::toLowerCase, s -> s.replace(' ', '-')));clean.apply(" Hello Java World "); // "hello-java-world"andThen se hereda de Function y devuelve una Function<String, V>, no un UnaryOperator<String>. reduce necesita que su acumulador devuelva el tipo del elemento, y javac informa de «bad return type in lambda expression». Escribir la composición como una lambda hace que su tipo destino sea UnaryOperator<String>.
- Traduce este código C# sin un
AtomicInteger:
int longWords = 0;words.ForEach(w => { if (w.Length > 3) longWords++; });Solución
long longWords = words.stream().filter(w -> w.length() > 3).count();La versión C# solo necesita una variable capturada mutable porque cuenta a mano. Un stream expresa el recuento directamente, y no hay nada que capturar. count() devuelve un long. Para List.of("a", "lambda", "is", "not", "a", "delegate") el resultado es 2.
- Modifica
PriceFeedpara quesubscribe(Consumer<Double>)devuelva un objeto cuyoclose()quite el listener, y úsalo en un try-with-resources. ¿Por qué declarar una interfaz nueva en lugar de devolverAutoCloseable?
Solución
interface Subscription extends AutoCloseable { @Override void close(); // sin excepción comprobada, a diferencia de AutoCloseable.close()}
static class PriceFeed { private final List<Consumer<Double>> listeners = new ArrayList<>();
Subscription subscribe(Consumer<Double> listener) { listeners.add(listener); return () -> listeners.remove(listener); }
void publish(double price) { List.copyOf(listeners).forEach(listener -> listener.accept(price)); }}
try (Subscription subscription = feed.subscribe(received::add)) { feed.publish(1.0);}feed.publish(2.0); // received vale [1.0]La lambda captura exactamente el objeto que se añadió, así que remove lo encuentra. AutoCloseable.close() declara throws Exception, lo que obligaría a quien llama a capturar Exception (lección 5); redefinir close() sin la cláusula throws lo evita. Subscription es a su vez una interfaz funcional, por eso funciona la lambda. publish recorre una copia para que un listener pueda cancelar su suscripción mientras se le notifica. Es el patrón IDisposable del Subscribe de Rx.