La lección anterior terminó con una observación incómoda: cada comparador del catálogo de BiblioTech tiene una línea de lógica útil envuelta en cinco de ceremonia. Y esa ceremonia no aporta nada, porque toda ella es información que el compilador ya tiene: sabe qué interfaz esperas (lo dice el parámetro de Arrays.sort), sabe qué método hay que implementar (Comparator solo tiene uno) y sabe de qué tipo son los dos argumentos.

Las expresiones lambda, incorporadas en Java 8, permiten escribir solo lo que aporta información: los parámetros y el cuerpo. Fueron el mayor cambio en la sintaxis del lenguaje desde su creación y transformaron por completo la forma de escribir Java: ordenar colecciones, definir callbacks, lanzar tareas concurrentes y —sobre todo— procesar datos con la API de Streams. Esta lección te enseña qué son realmente (no lo que casi todo el mundo cree), toda su sintaxis, sus reglas de captura, la diferencia crítica de this frente a las clases anónimas, y cómo usarlas en BiblioTech para cambiar el comportamiento de una clase sin tocar su código.

Contenido

  1. De la clase anónima a la lambda, paso a paso
  2. Sintaxis completa y formas abreviadas
  3. Qué es realmente una lambda
  4. Inferencia de tipos y el tipo objetivo
  5. Captura de variables efectivamente finales
  6. Por qué una lambda no puede modificar una variable local
  7. this dentro de una lambda: el contraste con 04-04
  8. Dónde se usan hoy las lambdas
  9. Una interfaz funcional propia de BiblioTech
  10. Parametrizar el comportamiento: FiltroMaterial
  11. Legibilidad: cuándo una lambda debe ser un método
  12. Errores Comunes y Consejos
  13. Ejercicios

  1. De la clase anónima a la lambda, paso a paso

Partimos del comparador por título de 04-04. Vamos a quitarle ceremonia en cinco pasos, comprobando en cada uno qué información se pierde: ninguna.

Paso 0. La clase anónima completa.

Arrays.sort(catalogo, new Comparator<Material>() {
    @Override
    public int compare(Material a, Material b) {
        return a.getTitulo().compareToIgnoreCase(b.getTitulo());
    }
});

Paso 1. Fuera new Comparator<Material>(). El compilador ya sabe que Arrays.sort(T[], Comparator<? super T>) espera un Comparator<Material>: se lo dice el tipo del array. Repetirlo es redundante.

Arrays.sort(catalogo,
    public int compare(Material a, Material b) {
        return a.getTitulo().compareToIgnoreCase(b.getTitulo());
    }
);

Paso 2. Fuera @Override, public y el nombre del método. Comparator tiene un solo método abstracto: compare. No hay ambigüedad posible sobre cuál estás implementando, así que nombrarlo no aporta nada.

Arrays.sort(catalogo,
    (Material a, Material b) {
        return a.getTitulo().compareToIgnoreCase(b.getTitulo());
    }
);

Paso 3. Aparece la flecha ->. Java necesita un separador entre los parámetros y el cuerpo. Ese separador es el operador lambda.

Arrays.sort(catalogo, (Material a, Material b) -> {
    return a.getTitulo().compareToIgnoreCase(b.getTitulo());
});

Esto ya es una lambda válida y compila.

Paso 4. Fuera los tipos de los parámetros. El compilador sabe que compare recibe dos Material. Escribirlo es opcional.

Arrays.sort(catalogo, (a, b) -> {
    return a.getTitulo().compareToIgnoreCase(b.getTitulo());
});

Paso 5. Fuera las llaves y el return. Si el cuerpo es una sola expresión, su valor se devuelve automáticamente.

Arrays.sort(catalogo, (a, b) -> a.getTitulo().compareToIgnoreCase(b.getTitulo()));

De seis líneas a una. Y la línea final contiene exactamente la información que no era deducible: los dos parámetros y la operación.

Paso Qué se elimina Por qué se puede
1 new Comparator<Material>() El tipo lo impone el parámetro del método
2 @Override public int compare La interfaz tiene un único método abstracto
3 Se añade -> como separador
4 Tipos de los parámetros Se infieren de la firma del método
5 { } y return Cuerpo de una sola expresión
flowchart TD
    A["new Comparator&lt;Material&gt;() { @Override public int compare(Material a, Material b) { ... } }"] --> B["El tipo lo impone Arrays.sort: fuera new Comparator"]
    B --> C["La interfaz tiene un solo metodo abstracto: fuera el nombre y los modificadores"]
    C --> D["Se anade la flecha como separador: (Material a, Material b) -> { ... }"]
    D --> E["Los tipos se infieren: (a, b) -> { ... }"]
    E --> F["Cuerpo de una sola expresion: (a, b) -> a.getTitulo().compareToIgnoreCase(b.getTitulo())"]

  1. Sintaxis completa y formas abreviadas

La forma general es:

(parametros) -> cuerpo

Y admite varias abreviaturas según el número de parámetros y la forma del cuerpo:

Forma Ejemplo Cuándo se puede usar
Sin parámetros () -> System.out.println("Hola") Siempre que el método no reciba nada. Los paréntesis son obligatorios
Un parámetro con tipo (Material m) -> m.getTitulo() Siempre
Un parámetro sin tipo (m) -> m.getTitulo() Cuando el tipo se puede inferir
Un parámetro sin paréntesis m -> m.getTitulo() Solo con un parámetro y sin tipo declarado
Varios parámetros con tipo (Material a, Material b) -> ... Siempre
Varios parámetros sin tipo (a, b) -> ... Cuando se pueden inferir. Todo o nada
Cuerpo de expresión (a, b) -> a.getTitulo().compareTo(b.getTitulo()) Cuerpo de una sola expresión; devuelve su valor
Cuerpo de bloque (a, b) -> { ... return x; } Varias sentencias; el return es obligatorio si hay valor de retorno
Bloque sin retorno m -> { System.out.println(m); } Cuando el método devuelve void

Ejemplos equivalentes de la misma lambda, de más explícita a más concisa:

// 1. Todo explicito
Comparator<Material> c1 = (Material a, Material b) -> {
    return a.getTitulo().compareToIgnoreCase(b.getTitulo());
};

// 2. Sin tipos
Comparator<Material> c2 = (a, b) -> {
    return a.getTitulo().compareToIgnoreCase(b.getTitulo());
};

// 3. Cuerpo de expresion (la forma habitual)
Comparator<Material> c3 = (a, b) -> a.getTitulo().compareToIgnoreCase(b.getTitulo());

Y las reglas que hay que memorizar, con sus errores asociados:

// Con UN parametro, los parentesis son opcionales... si no declaras el tipo
m -> m.getTitulo()               // valido
(m) -> m.getTitulo()             // valido
(Material m) -> m.getTitulo()    // valido
// Material m -> m.getTitulo()   // NO VALIDO: con tipo, los parentesis son obligatorios

// Sin parametros, los parentesis SIEMPRE
() -> System.out.println("hola") // valido
// -> System.out.println("hola") // NO VALIDO

// Los tipos son TODO o NADA
(Material a, Material b) -> ...  // valido
(a, b) -> ...                    // valido
// (Material a, b) -> ...        // NO VALIDO: mezcla

// Cuerpo de bloque: return obligatorio si hay valor
(a, b) -> { return a.getTitulo().compareTo(b.getTitulo()); }   // valido
// (a, b) -> { a.getTitulo().compareTo(b.getTitulo()); }       // NO COMPILA: falta return

Hay una variante más, disponible desde Java 11: var en los parámetros, útil cuando quieres anotarlos.

Comparator<Material> c4 = (var a, var b) -> a.getTitulo().compareTo(b.getTitulo());

Es también todo o nada, y su único motivo real de existir es poder escribir (@NotNull var a, var b) -> .... En el día a día se usa poco.

  1. Qué es realmente una lambda

Aquí conviene ser preciso, porque hay un malentendido muy extendido.

Una lambda NO es un puntero a función. Una lambda ES una instancia de una interfaz funcional.

Una interfaz funcional es una interfaz con exactamente un método abstracto. Comparator lo es (compare), Runnable lo es (run), Prestable no lo es (tiene cuatro).

Cuando escribes:

Comparator<Material> porTitulo = (a, b) -> a.getTitulo().compareTo(b.getTitulo());

lo que obtienes es un objeto de un tipo que implementa Comparator<Material>, cuyo método compare ejecuta ese cuerpo. Puedes comprobarlo:

Comparator<Material> porTitulo = (a, b) -> a.getTitulo().compareTo(b.getTitulo());

System.out.println(porTitulo instanceof Comparator);      // true
System.out.println(porTitulo.getClass().getName());       // App$$Lambda$14/0x...
System.out.println(porTitulo.compare(libro1, libro2));    // se invoca como cualquier metodo

Las consecuencias prácticas de que sea un objeto son importantes:

  • Puedes guardarla en una variable, en un campo o en un array.
  • Puedes pasarla como argumento y devolverla desde un método.
  • Hay que invocar su método por su nombre: porTitulo.compare(a, b), no porTitulo(a, b).
  • Solo funciona con interfaces funcionales. Si la interfaz tiene dos métodos abstractos, no hay lambda posible: hace falta una clase anónima (04-04).

Ahora bien, hay una diferencia técnica con las clases anónimas que conviene conocer. El compilador no genera un fichero .class por cada lambda: emite una instrucción invokedynamic que crea la implementación en tiempo de ejecución. Por eso el nombre de clase que ves arriba es tan raro, y por eso miles de lambdas no encarecen el arranque como sí lo hacían miles de clases anónimas.

El catálogo de interfaces funcionales que trae el JDK (Function, Predicate, Consumer, Supplier...), la anotación @FunctionalInterface y las referencias a métodos son el contenido completo de la lección 04-06. Aquí trabajarás con Comparator y con interfaces funcionales propias.

  1. Inferencia de tipos y el tipo objetivo

Si en la lambda no escribes los tipos, ¿de dónde salen? Del tipo objetivo (target type): el tipo que el contexto espera en ese punto.

// Contexto 1: asignacion a una variable declarada
Comparator<Material> c = (a, b) -> a.getTitulo().compareTo(b.getTitulo());
//  tipo objetivo: Comparator<Material>  ->  a y b son Material

// Contexto 2: argumento de un metodo
Arrays.sort(catalogo, (a, b) -> a.getTitulo().compareTo(b.getTitulo()));
//  tipo objetivo: Comparator<? super Material>  ->  a y b son Material

// Contexto 3: valor de retorno
public Comparator<Material> criterioPorTitulo() {
    return (a, b) -> a.getTitulo().compareTo(b.getTitulo());
}

Y aquí llega la consecuencia que sorprende: la misma lambda puede significar cosas distintas según dónde la pongas.

public interface ValidadorTitulo {
    boolean validar(String titulo);
}

public interface FiltroTexto {
    boolean acepta(String texto);
}
ValidadorTitulo v = t -> t.length() > 3;   // implementa ValidadorTitulo.validar
FiltroTexto     f = t -> t.length() > 3;   // implementa FiltroTexto.acepta

System.out.println(v.validar("Java"));     // true
System.out.println(f.acepta("Java"));      // true
System.out.println(v.getClass() == f.getClass());   // false: son tipos distintos

El texto de la lambda es idéntico y los objetos son de tipos completamente distintos. Esto explica dos cosas:

Una lambda no tiene tipo propio. No existe "el tipo de t -> t.length() > 3". Su tipo lo determina el contexto. Por eso esto no compila:

// var f = t -> t.length() > 3;      // NO COMPILA: no hay tipo objetivo del que inferir
// Object o = t -> t.length() > 3;   // NO COMPILA: Object no es una interfaz funcional

Una lambda ambigua tampoco compila. Si un método está sobrecargado con dos interfaces funcionales de firma compatible, el compilador no puede elegir:

public void procesar(ValidadorTitulo v) { }
public void procesar(FiltroTexto f)     { }

// procesar(t -> t.length() > 3);              // NO COMPILA: referencia ambigua
procesar((ValidadorTitulo) t -> t.length() > 3);   // se desambigua con una conversion

  1. Captura de variables efectivamente finales

Una lambda puede leer:

  • Sus propios parámetros.
  • Los campos de la clase envolvente (incluso mutables).
  • Las variables locales del método, si son final o efectivamente finales.
public Material[] materialesCaros(Material[] materiales, double umbral, int dias) {

    // 'umbral' y 'dias' son parametros que no se reasignan: efectivamente finales
    Comparator<Material> porMulta =
        (a, b) -> Double.compare(b.calcularMulta(dias), a.calcularMulta(dias));

    Material[] copia = Arrays.copyOf(materiales, materiales.length);
    Arrays.sort(copia, porMulta);

    int cuantos = 0;
    for (Material m : copia) {
        if (m.calcularMulta(dias) >= umbral) { cuantos++; }
    }
    return Arrays.copyOf(copia, cuantos);
}

Es exactamente la misma regla que en clases locales (04-03) y anónimas (04-04), y por el mismo motivo: las variables locales viven en la pila y mueren con el método; el objeto lambda vive en el montón y puede sobrevivirle. Java copia el valor dentro del objeto.

Y la distinción crucial se mantiene: la restricción afecta a la variable, no al objeto.

Empleado marta = new Empleado("Marta Ruiz", "EMP-001");
StringBuilder registro = new StringBuilder();

Runnable tarea = () -> {
    marta.registrarPrestamo();               // PERMITIDO: se modifica el objeto
    registro.append("prestamo registrado");   // PERMITIDO: se modifica el objeto
    // marta = new Empleado(...);             // PROHIBIDO: se reasigna la variable
};

  1. Por qué una lambda no puede modificar una variable local

Este es el error que todo el mundo comete la primera semana:

public int contarVencidos(Material[] materiales, int dias) {

    int vencidos = 0;

    Consumidor tarea = m -> {
        if (m.calcularDiasRetraso(dias) > 0) {
            vencidos++;        // ERROR DE COMPILACION
        }
    };
    // ...
}
error: local variables referenced from a lambda expression must be final
       or effectively final

La causa, ya conocida, es la captura por valor: la lambda guarda una copia. Si pudieras modificarla, habría dos valores descoordinados con el mismo nombre. Java lo prohíbe en compilación.

Pero hay una segunda razón, específica de las lambdas y más profunda: una lambda puede ejecutarse en otro hilo. Si dos hilos incrementaran una misma variable de pila —cosa que además es físicamente imposible, porque cada hilo tiene su propia pila—, el resultado sería indefinido. La restricción cierra de raíz toda una categoría de errores de concurrencia que estudiarás en el módulo 8.

Las tres alternativas correctas, de mejor a peor:

// A. Un campo de la clase (vive en el heap, es legitimamente mutable)
public class ContadorVencidos {
    private int vencidos;                   // campo, no variable local
    public void procesar(Material[] materiales, int dias) {
        Consumidor tarea = m -> {
            if (m.calcularDiasRetraso(dias) > 0) { vencidos++; }   // PERMITIDO
        };
        for (Material m : materiales) { tarea.aceptar(m); }
    }
    public int getVencidos() { return vencidos; }
}

// B. Un bucle normal, sin lambda: casi siempre lo mas legible
int vencidos = 0;
for (Material m : materiales) {
    if (m.calcularDiasRetraso(dias) > 0) { vencidos++; }
}

// C. Un array de un elemento (truco antiguo: evitalo)
int[] vencidos = {0};
Consumidor tarea = m -> { if (m.calcularDiasRetraso(dias) > 0) { vencidos[0]++; } };

La opción B merece un comentario: acumular sobre una variable es precisamente lo que un bucle hace bien. No fuerces una lambda donde un for es más claro. Y para contar y sumar sobre colecciones existirá una forma mucho mejor: la API de Streams, que es la lección 10-04.

  1. this dentro de una lambda: el contraste con 04-04

Este apartado es el más importante de la lección para quien ya conoce las clases anónimas, porque el comportamiento es exactamente el contrario.

Dentro de una lambda, this es la instancia de la clase que la contiene. Una lambda no introduce un nuevo ámbito de this.

Se dice que la lambda tiene ámbito léxico: this, los nombres de variables y los de métodos significan dentro de ella lo mismo que significarían justo fuera.

package com.nexussoftware.bibliotech.servicio;

public class Auditor {

    private final String origen = "Auditor";

    public void comparar() {

        // --- CLASE ANONIMA ---
        Runnable conAnonima = new Runnable() {
            @Override
            public void run() {
                System.out.println("ANONIMA  -> this.getClass() = "
                                   + this.getClass().getSimpleName());
                System.out.println("ANONIMA  -> origen externo   = "
                                   + Auditor.this.origen);
            }
        };

        // --- LAMBDA ---
        Runnable conLambda = () -> {
            System.out.println("LAMBDA   -> this.getClass() = "
                               + this.getClass().getSimpleName());
            System.out.println("LAMBDA   -> origen           = " + origen);
            // System.out.println(Auditor.this.origen);   // valido, pero redundante
        };

        conAnonima.run();
        conLambda.run();
    }

    public static void main(String[] args) {
        new Auditor().comparar();
    }
}
ANONIMA  -> this.getClass() = Auditor$1
ANONIMA  -> origen externo   = Auditor
LAMBDA   -> this.getClass() = Auditor
LAMBDA   -> origen           = Auditor

La tabla comparativa que debes recordar:

Aspecto Clase anónima Lambda
this La instancia anónima La instancia de la clase envolvente
this.getClass() Externa$1 Externa
Acceder al campo de la externa Externa.this.campo campo directamente
¿Puede tener campos propios? No
¿Puede ensombrecer nombres de la externa? (nuevo ámbito) No (ámbito léxico)
¿Genera un .class? Sí, uno por anónima No: invokedynamic
¿Puede implementar interfaces no funcionales? No

Ese "no puede ensombrecer" tiene una consecuencia concreta: dentro de una lambda no puedes declarar una variable con el mismo nombre que una del método envolvente.

public void ejemplo() {
    String titulo = "Java Efectivo";

    Runnable r1 = () -> {
        // String titulo = "Otro";      // NO COMPILA: 'titulo' ya esta definida
        System.out.println(titulo);
    };

    Runnable r2 = new Runnable() {
        @Override public void run() {
            String titulo = "Otro";      // SI COMPILA: nuevo ambito, lo ensombrece
            System.out.println(titulo);  // imprime "Otro"
        }
    };
}

Es el mismo motivo del cambio de this: la lambda no crea un ámbito nuevo, es código que vive en el mismo ámbito léxico. Y esto es la principal fuente de bugs al migrar código antiguo de anónimas a lambdas: si la anónima usaba this esperando referirse a sí misma, la lambda equivalente hará algo distinto sin dar ningún error de compilación.

  1. Dónde se usan hoy las lambdas

Tres escenarios que ya puedes usar con lo que sabes, más uno que llega después.

Comparadores. El caso que has visto:

Arrays.sort(catalogo, (a, b) -> a.getTitulo().compareToIgnoreCase(b.getTitulo()));
Arrays.sort(catalogo, (a, b) -> Integer.compare(a.getDiasPrestamo(), b.getDiasPrestamo()));
Arrays.sort(catalogo, (a, b) -> Double.compare(b.calcularMulta(20), a.calcularMulta(20)));

Tres criterios, tres líneas. Compáralo con las quince líneas por criterio de 04-04.

Runnable: una tarea sin argumentos ni resultado.

Runnable recordatorio = () -> System.out.println("Revisar los prestamos vencidos");
recordatorio.run();

Aquí solo lo ejecutas directamente. Su uso real —pasarlo a un hilo para que se ejecute en paralelo— es el módulo 8.

Callbacks propios. El OyenteDevolucion de 04-04 tiene un solo método abstracto, así que es una interfaz funcional y admite lambda:

// Antes (04-04): 6 lineas de clase anonima
gestor.setOyente(new OyenteDevolucion() {
    @Override
    public void alDevolver(Prestamo prestamo, double multa) {
        System.out.printf("RECIBO %s: %.2f EUR%n", prestamo.getReferencia(), multa);
    }
});

// Ahora: 1 linea
gestor.setOyente((prestamo, multa) ->
    System.out.printf("RECIBO %s: %.2f EUR%n", prestamo.getReferencia(), multa));

Y donde las lambdas brillan de verdad: la API de Streams. Operaciones como filtrar, transformar y agrupar colecciones enteras en una sola expresión encadenada. Es la lección 10-04, y no la usaremos antes.

  1. Una interfaz funcional propia de BiblioTech

Definir tus propias interfaces funcionales es lo que convierte las lambdas en una herramienta de diseño. Empecemos por una regla de tarifa configurable.

Hoy, la tarifa de un material está fijada en su clase: Libro cobra 0,25 €/día, siempre. Pero Nexus Software quiere aplicar campañas: mitad de tarifa en agosto, tarifa doble para materiales muy demandados, tarifa cero para becarios. Añadir un if por campaña dentro de Material sería exactamente el olor de diseño que aprendiste a evitar en 03-06.

La solución: hacer que la regla sea un parámetro.

package com.nexussoftware.bibliotech.dominio;

/**
 * Regla de calculo de la tarifa diaria aplicable a un material.
 *
 * <p>Es una interfaz funcional: tiene un unico metodo abstracto, asi que
 * cualquier lambda con la forma (Material, int) -> double la implementa.</p>
 */
@FunctionalInterface
public interface ReglaTarifa {

    /**
     * @param material material sobre el que se calcula
     * @param diasRetraso dias de retraso acumulados
     * @return euros por dia de retraso que deben aplicarse
     */
    double tarifaPara(Material material, int diasRetraso);
}

La anotación @FunctionalInterface hace que el compilador verifique que hay exactamente un método abstracto; si alguien añade un segundo, el error aparece aquí y no en los veinte sitios que usan lambdas. Su significado completo es 04-06.

Ahora un servicio que la usa:

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.Material;
import com.nexussoftware.bibliotech.dominio.ReglaTarifa;

/** Calcula multas aplicando una regla de tarifa configurable. */
public class CalculadoraMultas {

    public static final double MULTA_MAXIMA = 20.0;

    private final ReglaTarifa regla;

    /** La regla se inyecta: la calculadora no sabe cual es. */
    public CalculadoraMultas(ReglaTarifa regla) {
        this.regla = (regla != null)
                     ? regla
                     : (m, dias) -> m.getTarifaDiaria();   // regla por defecto, como lambda
    }

    public double calcular(Material material, int diasTranscurridos) {
        int    retraso = material.calcularDiasRetraso(diasTranscurridos);
        double tarifa  = regla.tarifaPara(material, retraso);
        return Math.min(retraso * tarifa, MULTA_MAXIMA);
    }
}

Y las campañas, cada una en una línea:

Material dvd = new Dvd("Refactorizacion en vivo", "DVD-0007", 95);

// 1. Regla estandar: la tarifa propia de cada material
CalculadoraMultas estandar = new CalculadoraMultas((m, dias) -> m.getTarifaDiaria());

// 2. Campana de agosto: mitad de tarifa
CalculadoraMultas agosto = new CalculadoraMultas((m, dias) -> m.getTarifaDiaria() / 2);

// 3. Tarifa progresiva: se duplica a partir del octavo dia de retraso
CalculadoraMultas progresiva = new CalculadoraMultas((m, dias) ->
    dias > 7 ? m.getTarifaDiaria() * 2 : m.getTarifaDiaria());

// 4. Amnistia: sin multas
CalculadoraMultas amnistia = new CalculadoraMultas((m, dias) -> 0.0);

System.out.printf("Estandar:   %.2f EUR%n", estandar.calcular(dvd, 20));
System.out.printf("Agosto:     %.2f EUR%n", agosto.calcular(dvd, 20));
System.out.printf("Progresiva: %.2f EUR%n", progresiva.calcular(dvd, 20));
System.out.printf("Amnistia:   %.2f EUR%n", amnistia.calcular(dvd, 20));
Estandar:   8,50 EUR
Agosto:     4,25 EUR
Progresiva: 17,00 EUR
Amnistia:   0,00 EUR

Lo que ha ocurrido merece pararse a mirarlo: cuatro políticas de multa distintas, y ni Material ni Dvd ni CalculadoraMultas han cambiado una sola línea. El comportamiento variable se pasa como si fuera un dato. Esto tiene nombre —el patrón Strategy— y lo formalizarás en 12-02; aquí lo has escrito casi sin darte cuenta.

  1. Parametrizar el comportamiento: FiltroMaterial

Segundo ejemplo, con el problema clásico de "dame los materiales que cumplen X".

Sin lambdas, cada criterio nuevo obliga a añadir un método al catálogo: buscarPorTitulo, buscarDisponibles, buscarPorTipo, buscarConMultaMayorQue... La clase crece sin fin y cada criterio nuevo la modifica.

package com.nexussoftware.bibliotech.dominio;

/** Criterio de seleccion de materiales. Interfaz funcional. */
@FunctionalInterface
public interface FiltroMaterial {

    /** @return true si el material cumple el criterio. */
    boolean acepta(Material material);
}
package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.FiltroMaterial;
import com.nexussoftware.bibliotech.dominio.Material;
import java.util.Arrays;

public class Catalogo {

    private final Material[] materiales;

    public Catalogo(Material[] materiales) {
        this.materiales = Arrays.copyOf(materiales, materiales.length);
    }

    /**
     * Un unico metodo de busqueda, valido para CUALQUIER criterio.
     * El comportamiento llega como parametro.
     */
    public Material[] buscar(FiltroMaterial filtro) {
        Material[] resultado = new Material[materiales.length];
        int n = 0;
        for (Material m : materiales) {
            if (filtro.acepta(m)) {
                resultado[n++] = m;
            }
        }
        return Arrays.copyOf(resultado, n);   // el modulo 5 lo hara mucho mejor
    }

    /** Cuenta cuantos cumplen el criterio, sin construir el array. */
    public int contar(FiltroMaterial filtro) {
        int n = 0;
        for (Material m : materiales) {
            if (filtro.acepta(m)) { n++; }
        }
        return n;
    }
}

Y ahora, criterios ilimitados sin tocar Catalogo:

Catalogo catalogo = new Catalogo(new Material[] {
    new Libro("Java Efectivo",      "Joshua Bloch",  "978-0000000001", 2018),
    new Libro("Patrones de Diseno", "Erich Gamma",   "978-0000000002", 1994),
    new Libro("Refactorizacion",    "Martin Fowler", "978-0000000003", 1999),
    new Revista("Java Magazine", "REV-2024-03", 42, "Mensual"),
    new Dvd("Refactorizacion en vivo", "DVD-0007", 95)
});

System.out.println("Disponibles: "
    + catalogo.contar(m -> m.estaDisponible()));

System.out.println("Solo libros: "
    + catalogo.contar(m -> m instanceof Libro));

System.out.println("Plazo corto (<= 7 dias): "
    + catalogo.contar(m -> m.getDiasPrestamo() <= 7));

System.out.println("Con 'Refactoriz' en el titulo:");
for (Material m : catalogo.buscar(m -> m.getTitulo().contains("Refactoriz"))) {
    System.out.println("  " + m.describir());
}

// Un filtro guardado en una variable y reutilizado
FiltroMaterial caros = m -> m.calcularMulta(20) > 5.0;
System.out.println("Multa alta a los 20 dias: " + catalogo.contar(caros));
Disponibles: 5
Solo libros: 3
Plazo corto (<= 7 dias): 2
Con 'Refactoriz' en el titulo:
  Libro "Refactorizacion" (ref. 978-0000000003) - Martin Fowler, 1999
  DVD "Refactorizacion en vivo" (ref. DVD-0007) - 95 min
Multa alta a los 20 dias: 1

Este es el cambio de mentalidad del módulo: el comportamiento es un dato. Antes pasabas números y cadenas; ahora pasas trozos de lógica. El JDK ya trae una interfaz idéntica a FiltroMaterial —se llama Predicate— y la verás en 04-06, junto con la forma de combinar filtros (and, or, negate).

  1. Legibilidad: cuándo una lambda debe ser un método

Las lambdas son concisas, y la concisión mal aplicada produce código ilegible. Tres criterios prácticos:

Regla de las tres líneas. Si el cuerpo de la lambda pasa de tres líneas, extráelo a un método con nombre.

// MAL: logica de negocio enterrada en una lambda larga
Material[] criticos = catalogo.buscar(m -> {
    int retraso = m.calcularDiasRetraso(20);
    if (retraso == 0) { return false; }
    double multa = m.calcularMulta(20);
    boolean caro = multa >= Material.MULTA_MAXIMA * 0.5;
    boolean tipoSensible = m.getDiasPrestamo() <= 3;
    return caro || (tipoSensible && retraso > 5);
});

// BIEN: la condicion tiene nombre y se puede probar por separado
Material[] criticos = catalogo.buscar(m -> esCritico(m, 20));

private static boolean esCritico(Material m, int dias) {
    int retraso = m.calcularDiasRetraso(dias);
    if (retraso == 0) { return false; }
    boolean caro         = m.calcularMulta(dias) >= Material.MULTA_MAXIMA * 0.5;
    boolean tipoSensible = m.getDiasPrestamo() <= 3;
    return caro || (tipoSensible && retraso > 5);
}

La versión buena gana tres cosas: la condición tiene nombre (esCritico explica la intención mejor que seis líneas de booleanos), es reutilizable en otros criterios, y es probable de forma aislada con JUnit en el módulo 11.

Si se repite, dale nombre. Una lambda usada en tres sitios debería ser una constante:

public static final FiltroMaterial DISPONIBLES  = m -> m.estaDisponible();
public static final FiltroMaterial SOLO_LIBROS  = m -> m instanceof Libro;
public static final Comparator<Material> POR_TITULO =
    (a, b) -> a.getTitulo().compareToIgnoreCase(b.getTitulo());

Nombra bien los parámetros. (a, b) está bien para un comparador genérico; m está bien para un material en un contexto obvio. Pero en una lambda de dos líneas, (prestamo, multa) se lee infinitamente mejor que (p, x). La concisión no justifica nombres crípticos.

Errores Comunes y Consejos

Creer que una lambda es una función suelta. Es un objeto que implementa una interfaz funcional. Por eso se invoca por el nombre del método (filtro.acepta(m), no filtro(m)) y por eso no puedes asignarla a var ni a Object.

Intentar una lambda sobre una interfaz no funcional. Prestable p = () -> true; no compila: Prestable tiene cuatro métodos abstractos. Solo interfaces con exactamente uno.

Olvidar el return en un cuerpo de bloque. Si abres llaves y el método devuelve algo, el return es obligatorio. (a, b) -> { a.compareTo(b); } no compila.

Poner punto y coma tras un cuerpo de expresión. m -> m.getTitulo(); dentro de una llamada a método es un error. Con cuerpo de expresión no hay ; interno; con cuerpo de bloque, cada sentencia lleva el suyo.

Modificar una variable local capturada. Prohibido. Usa un campo o un bucle. Y no recurras al array de un elemento salvo que no haya alternativa.

Confundir this. En una lambda, this es la clase envolvente; en una anónima, la anónima. Es el error número uno al convertir código antiguo, y no da error de compilación: simplemente hace otra cosa.

Mezclar tipos en los parámetros. (Material a, b) -> ... no compila. O todos con tipo o ninguno.

Lambdas kilométricas. Una lambda de veinte líneas dentro de una llamada a método es peor que la clase anónima que sustituye. Extrae un método.

Consejo: anota siempre @FunctionalInterface en tus interfaces de un solo método. Documenta la intención y evita que un compañero añada un segundo método abstracto y rompa todas las lambdas del proyecto.

Consejo: aprende a leer los errores de inferencia. Cuando una lambda no compila, el mensaje suele apuntar al tipo objetivo, no a la lambda. Si te pierdes, escribe temporalmente los tipos de los parámetros: el error se vuelve mucho más claro.

Consejo: guarda las lambdas reutilizadas como constantes static final. Se crea una sola instancia, el nombre documenta el criterio y las trazas de error son más legibles.

Ejercicios

Ejercicio 1: reescribir los comparadores de 04-04

Toma el método mostrarOrdenado del ejercicio 1 de la lección 04-04 y reescríbelo usando lambdas en lugar de clases anónimas. Cuenta las líneas antes y después. Añade un cuarto criterio, "tipo", que ordene por tipo y desempate por título.

Ejercicio 2: ReglaAviso, una interfaz funcional propia

Crea una interfaz funcional ReglaAviso con el método String redactar(Prestamo prestamo, double multa). Crea una clase ServicioAvisos con un método enviar(Prestamo, double, ReglaAviso) que imprima el texto redactado. Pásale tres lambdas distintas: un aviso formal, uno breve y uno que solo escriba algo si la multa supera 5 €. Explica qué ventaja tiene esto frente a tres métodos enviarFormal, enviarBreve y enviarSiAlta.

Ejercicio 3: captura y this

Escribe una clase Registrador con un campo private int avisos y un método procesar(Material[] materiales, int dias) que:

  1. Declare una variable local int contadorLocal = 0 e intente incrementarla dentro de una lambda. Comprueba el error y coméntalo.
  2. Incremente en su lugar el campo avisos desde la lambda.
  3. Imprima this.getClass().getSimpleName() dentro de la lambda y explique el resultado comparándolo con lo que devolvería una clase anónima.

Soluciones

Solución 1

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.*;
import java.util.Arrays;
import java.util.Comparator;

public class OrdenadorCatalogo {

    public static void mostrarOrdenado(Material[] materiales, String criterio, int dias) {

        Material[] copia = Arrays.copyOf(materiales, materiales.length);

        Comparator<Material> comparador = switch (criterio) {

            case "titulo" -> (a, b) -> a.getTitulo().compareToIgnoreCase(b.getTitulo());

            case "plazo"  -> (a, b) -> Integer.compare(a.getDiasPrestamo(),
                                                       b.getDiasPrestamo());

            case "multa"  -> (a, b) -> Double.compare(b.calcularMulta(dias),
                                                      a.calcularMulta(dias));

            // Criterio compuesto: cuerpo de bloque, porque hay una condicion intermedia
            case "tipo"   -> (a, b) -> {
                int porTipo = a.getTipo().compareTo(b.getTipo());
                if (porTipo != 0) {
                    return porTipo;
                }
                return a.getTitulo().compareToIgnoreCase(b.getTitulo());
            };

            default       -> (a, b) -> 0;
        };

        Arrays.sort(copia, comparador);

        System.out.println("--- Ordenado por " + criterio + " ---");
        for (Material m : copia) {
            System.out.printf("  %-10s %-24s plazo %2d  multa %5.2f EUR%n",
                              m.getTipo(), m.getTitulo(),
                              m.getDiasPrestamo(), m.calcularMulta(dias));
        }
    }
}
--- Ordenado por tipo ---
  DVD        Refactorizacion en vivo  plazo  3  multa  8,50 EUR
  Libro      Java Efectivo            plazo 15  multa  1,25 EUR
  Libro      Patrones de Diseno       plazo 15  multa  1,25 EUR
  Revista    Java Magazine            plazo  7  multa  1,30 EUR

El recuento: el switch con clases anónimas ocupaba 30 líneas para cuatro criterios; con lambdas ocupa 14 incluyendo el criterio compuesto, que es el único que necesita cuerpo de bloque. Y la ganancia no es solo de tamaño: el criterio de ordenación ahora se lee en una sola línea junto a su case, sin que la vista tenga que saltar por encima de @Override public int compare(...) cuatro veces.

Nota los dos estilos conviviendo: los tres primeros usan cuerpo de expresión (sin llaves, sin return) y el cuarto usa cuerpo de bloque porque tiene una condición intermedia. En 04-06 verás que incluso ese caso se puede escribir en una línea con Comparator.comparing(...).thenComparing(...).

Solución 2

package com.nexussoftware.bibliotech.dominio;

/** Redacta el texto de un aviso de devolucion. Interfaz funcional. */
@FunctionalInterface
public interface ReglaAviso {

    /**
     * @return texto a enviar, o null / cadena vacia si no procede avisar
     */
    String redactar(Prestamo prestamo, double multa);
}
package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.Prestamo;
import com.nexussoftware.bibliotech.dominio.ReglaAviso;

/** Envia avisos aplicando la regla de redaccion que se le indique. */
public class ServicioAvisos {

    private int enviados;

    public void enviar(Prestamo prestamo, double multa, ReglaAviso regla) {
        String texto = regla.redactar(prestamo, multa);
        if (texto == null || texto.isBlank()) {
            System.out.println("(sin aviso para " + prestamo.getReferencia() + ")");
            return;
        }
        enviados++;                 // campo: la lambda no lo toca, lo toca el servicio
        System.out.println(texto);
    }

    public int getEnviados() { return enviados; }
}
ServicioAvisos servicio = new ServicioAvisos();

// 1. Aviso formal
ReglaAviso formal = (p, multa) -> String.format(
        "Estimado/a %s:%n  El material \"%s\" (ref. %s) presenta %d dias de retraso.%n"
      + "  Importe a abonar: %.2f EUR.%n  Atentamente, BiblioTech - Nexus Software.",
        p.getNombreEmpleado(), p.getTituloMaterial(), p.getReferencia(),
        p.calcularDiasRetraso(), multa);

// 2. Aviso breve
ReglaAviso breve = (p, multa) ->
        String.format("[%s] %s: %.2f EUR", p.getReferencia(), p.getTituloMaterial(), multa);

// 3. Solo si la multa es alta: devuelve null y el servicio no envia nada
ReglaAviso soloAltas = (p, multa) -> multa > 5.0
        ? String.format("URGENTE %s: %.2f EUR pendientes", p.getReferencia(), multa)
        : null;

Empleado marta = new Empleado("Marta Ruiz", "EMP-001");
Prestamo p1 = new Prestamo(
        new Libro("Java Efectivo", "Joshua Bloch", "978-0000000001", 2018), marta, 100, 20);
Prestamo p2 = new Prestamo(
        new Dvd("Refactorizacion en vivo", "DVD-0007", 95), marta, 100, 20);

servicio.enviar(p1, 1.25, breve);
servicio.enviar(p1, 1.25, soloAltas);
servicio.enviar(p2, 8.50, soloAltas);
servicio.enviar(p2, 8.50, formal);

System.out.println("Avisos enviados: " + servicio.getEnviados());
[PR-0001] Java Efectivo: 1,25 EUR
(sin aviso para PR-0001)
URGENTE PR-0002: 8,50 EUR pendientes
Estimado/a Marta Ruiz:
  El material "Refactorizacion en vivo" (ref. PR-0002) presenta 17 dias de retraso.
  Importe a abonar: 8,50 EUR.
  Atentamente, BiblioTech - Nexus Software.
Avisos enviados: 3

Ventajas frente a tres métodos enviarFormal, enviarBreve y enviarSiAlta:

Aspecto Tres métodos Un método + ReglaAviso
Añadir un formato nuevo Modificar ServicioAvisos Escribir una lambda donde se use
Lógica de envío (contador, control de vacío) Duplicada tres veces Escrita una vez
Composición Imposible combinar formatos Una regla puede envolver a otra
Pruebas Hay que probar tres métodos Se prueba el envío una vez y las reglas por separado
Configuración externa Imposible La regla puede elegirse en tiempo de ejecución

Es el principio de abierto/cerrado: la clase queda abierta a extensión (reglas nuevas) y cerrada a modificación (su código no cambia). Lo formalizarás en 12-02.

Solución 3

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.FiltroMaterial;
import com.nexussoftware.bibliotech.dominio.Material;

public class Registrador {

    private int avisos;                       // CAMPO: vive en el heap, es mutable

    public void procesar(Material[] materiales, int dias) {

        int contadorLocal = 0;                // VARIABLE LOCAL: vive en la pila

        FiltroMaterial esVencido = m -> {
            // contadorLocal++;               // (1) NO COMPILA:
            //   "local variables referenced from a lambda expression
            //    must be final or effectively final"
            //
            // La lambda guarda una COPIA del valor. Si pudiera modificarla,
            // habria dos valores distintos con el mismo nombre: el del metodo
            // y el del objeto lambda. Ademas, la lambda podria ejecutarse en
            // otro hilo, que tiene su propia pila (modulo 8).

            if (m.calcularDiasRetraso(dias) > 0) {
                avisos++;                     // (2) SI COMPILA: es un campo de la clase
                return true;
            }
            return false;
        };

        for (Material m : materiales) {
            esVencido.acepta(m);
        }

        // (3) this dentro de la lambda
        Runnable informe = () -> System.out.printf(
                "%s ha registrado %d avisos. this = %s%n",
                getClass().getSimpleName(), avisos,
                this.getClass().getSimpleName());
        informe.run();

        System.out.println("contadorLocal sigue valiendo " + contadorLocal);
    }

    public int getAvisos() { return avisos; }

    public static void main(String[] args) {
        Material[] catalogo = {
            new Libro("Java Efectivo",   "Joshua Bloch",  "978-0000000001", 2018),
            new Revista("Java Magazine", "REV-2024-03", 42, "Mensual"),
            new Dvd("Refactorizacion en vivo", "DVD-0007", 95)
        };
        new Registrador().procesar(catalogo, 20);
    }
}
Registrador ha registrado 3 avisos. this = Registrador
contadorLocal sigue valiendo 0

Las tres respuestas:

  1. contadorLocal++ no compila. La lambda captura el valor por copia porque el objeto puede sobrevivir al método, y modificar la copia produciría dos valores descoordinados. El compilador lo detiene antes de que el problema exista.
  2. avisos++ sí compila. avisos es un campo de instancia: vive en el montón junto al objeto Registrador, y la lambda accede a él a través de this, que sí tiene capturado. La mutabilidad pertenece al objeto, no a la variable capturada. (Con varios hilos esto dejaría de ser seguro sin sincronización: módulo 8.)
  3. this.getClass() devuelve Registrador. La lambda no crea un ámbito propio de this: su this es el del método envolvente. Con una clase anónima, la misma línea habría impreso Registrador$1. Y fíjate en que dentro de la lambda getClass() sin prefijo funciona igual que this.getClass(), algo que en una anónima habría requerido Registrador.this.getClass().

Conclusión

Has hecho el recorrido completo de la clase anónima a la lambda, quitando ceremonia paso a paso y comprobando que en ningún momento se pierde información: el tipo lo impone el contexto, el nombre del método es único, los tipos de los parámetros se infieren y el return sobra cuando el cuerpo es una expresión. De seis líneas a una, y la línea que queda contiene exactamente lo que no era deducible.

Dominas la sintaxis completa con todas sus formas: paréntesis obligatorios cuando no hay parámetros o cuando se declaran los tipos, opcionales con un único parámetro sin tipo; tipos todo o nada; cuerpo de expresión sin llaves ni return frente a cuerpo de bloque con return obligatorio. Y sabes qué es realmente una lambda: no un puntero a función, sino una instancia de una interfaz funcional —una interfaz con exactamente un método abstracto—, un objeto con todas las de la ley que puedes guardar, pasar y devolver, y cuyo método hay que invocar por su nombre.

Entiendes la inferencia por tipo objetivo y su consecuencia más llamativa: la misma lambda escrita palabra por palabra puede producir objetos de tipos completamente distintos según dónde la coloques, y por eso no existe "el tipo de una lambda" ni se puede asignar a var u Object. Conoces la regla de captura de variables efectivamente finales, su motivo doble —la copia por valor y la posibilidad de ejecutarse en otro hilo— y las alternativas correctas cuando de verdad necesitas acumular: un campo, o simplemente un bucle. Y tienes muy clara la diferencia que más caro se paga al migrar código antiguo: en una lambda, this es la clase envolvente, justo al revés que en una clase anónima, y ese cambio no produce ningún error de compilación.

Sobre todo, has cambiado de mentalidad: el comportamiento es un dato. CalculadoraMultas aplica cuatro políticas de tarifa distintas sin que Material ni ella misma cambien una línea, y Catalogo responde a cualquier criterio de búsqueda imaginable con un único método buscar(FiltroMaterial). Eso es lo que las lambdas aportan de verdad: clases abiertas a extensión y cerradas a modificación.

Pero has escrito dos interfaces funcionales propias —FiltroMaterial y ReglaAviso— que se parecen sospechosamente a algo que debería ser estándar. Y lo es: el JDK trae un catálogo completo de interfaces funcionales en el paquete java.util.function, con Predicate para filtrar, Function para transformar, Consumer para consumir y Supplier para producir, además de formas de combinarlas (and, or, negate, andThen) que convierten dos criterios en uno. Y hay algo más: cuando una lambda se limita a llamar a un método que ya existe —m -> m.getTitulo()— hasta esa línea es demasiada ceremonia. En la lección 04-06, Interfaces Funcionales y Referencias a Métodos, verás el catálogo entero, la anotación @FunctionalInterface y qué comprueba exactamente, la composición de funciones y comparadores, y las cuatro formas de referencia a método que reducen m -> m.getTitulo() a Material::getTitulo.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados