Java Avanzado

Objetivo : Dominar las herramientas del Java moderno (Java 8–21) para escribir código más expresivo, seguro y mantenible.

1. Lambdas y referencias a métodos

¿Qué es una lambda?

Una lambda es una función anónima: un bloque de código que puede ser tratado como un valor, pasado como argumento, o retornado desde un método.

Sintaxis:

(parámetros) -> expresión
(parámetros) -> { bloque de código }

Antes vs ahora

// Java <7
Comparator<String> comparador = new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return a.compareTo(b);
    }
};
// Java > 8
 
Comparator<String> comparador = (a, b) -> a.compareTo(b);
 
Comparator<String> comparador = String::compareTo;

Estructura de una lambda

// Lambda con cero paraetros
Runnable tarea = () -> System.out.println("Hola mundo");
 
// Lambda con un parametro 
Consumer<String> imprimir = nombre -> System.out.println("Hola, " + nombre);
 
// Lambda con dos parametros
BiFunction<Integer, Integer, Integer> sumar = (a, b) -> a + b;
 
// Lambda con bloque de codigo (necesita return explícito)
Function<Integer, String> clasificar = numero -> {
    if (numero > 0) return "positivo";
    if (numero < 0) return "negativo";
    return "cero";
};

Referencias a métodos

Son atajos para lambdas que solo llaman a un método existente.

// Tipos de referencias a métodos:
 
// 1. Referencia a método estático:  Clase::métodoEstático
Function<String, Integer> parsear = Integer::parseInt;
// Equivale a: s -> Integer.parseInt(s)
 
// 2. Referencia a método de instancia de un objeto específico:  instancia::método
String prefijo = "Hola, ";
Function<String, String> saludar = prefijo::concat;
// Equivale a: s -> prefijo.concat(s)
 
// 3. Referencia a método de instancia de un tipo arbitrario:  Clase::método
Function<String, String> aUpperCase = String::toUpperCase;
// Equivale a: s -> s.toUpperCase()
 
// 4. Referencia a constructor:  Clase::new
Supplier<ArrayList<String>> crearLista = ArrayList::new;
// Equivale a: () -> new ArrayList<>()

Ejemplos de las referencias a métodos:

List<String> nombres = Arrays.asList("Ana", "Carlos", "Beatriz", "David");
 
// Imprimir cada elemento
nombres.forEach(System.out::println);
 
// Convertir a mayúsculas
List<String> mayusculas = nombres.stream()
    .map(String::toUpperCase)
    .collect(Collectors.toList());
 
// Parsear números
List<String> numerosStr = Arrays.asList("1", "2", "3", "4", "5");
List<Integer> numeros = numerosStr.stream()
    .map(Integer::parseInt)
    .collect(Collectors.toList());
 
// Constructor reference para crear objetos
record Persona(String nombre) {}
List<Persona> personas = nombres.stream()
    .map(Persona::new)
    .collect(Collectors.toList());

2. Interfaces funcionales

Una interface funcional es aquella que tiene exactamente un método abstracto. Se pueden anotar con @FunctionalInterface para validación en tiempo de compilación, pero sin la anotación son igualmente validas

Ejemplo:

@FunctionalInterface
public interface MiInterfazFuncional {
    int operar(int a, int b); // único método abstracto
    
    // Puede tener métodos default y static, ya que no cuenta como abstractos
    default void describir() {
        System.out.println("Soy una interfaz funcional");
    }
}

Java por defecto trae 4 interfaces funcionales que son suficientes para trabajar con programación funcional y streams

1. Function<T,R> - Transforma un valor

// T = tipo de entrada, R = tipo de salida
// Forma de uso: R apply(T t)
 
Function<String, Integer> longitud = String::length;
Function<Integer, String> aString = Object::toString;
 
System.out.println(longitud.apply("Hola"));  // 4
System.out.println(aString.apply(42));        // "42"
 
// Composición de funciones
Function<String, String> primerCaracter = s -> String.valueOf(s.charAt(0));
Function<String, String> enMayusculas = String::toUpperCase;
 
// andThen: primero f, luego g
Function<String, String> primeraEnMayus = primerCaracter.andThen(enMayusculas);
System.out.println(primeraEnMayus.apply("java"));  // "J"
 
// compose: primero g, luego f (orden inverso)
Function<String, String> mayusLuegoPrimera = primerCaracter.compose(enMayusculas);
System.out.println(mayusLuegoPrimera.apply("java"));  // "J"
 
// Variantes importantes:
// BiFunction<T, U, R>  — dos entradas, una salida
BiFunction<String, Integer, String> repetir = (s, n) -> s.repeat(n);
System.out.println(repetir.apply("ab", 3));  // "ababab"
 
// UnaryOperator<T>  — entrada y salida del mismo tipo
UnaryOperator<String> doblar = s -> s + s;
System.out.println(doblar.apply("hey"));  // "heyhey"
 
// BinaryOperator<T>  — dos entradas del mismo tipo, misma salida
BinaryOperator<Integer> max = (a, b) -> a > b ? a : b;
System.out.println(max.apply(5, 3));  // 5

2. Predicate → Evalúa una condición

// T = tipo a evaluar
// Método: boolean test(T t)
 
Predicate<String> esVacio = String::isEmpty;
Predicate<Integer> esPar = n -> n % 2 == 0;
Predicate<String> empiezaConA = s -> s.startsWith("A");
 
System.out.println(esVacio.test(""));      // true
System.out.println(esPar.test(4));         // true
System.out.println(empiezaConA.test("Ana")); // true
 
// Combinación lógica de predicados
Predicate<String> tieneContenido = Predicate.not(String::isEmpty);
Predicate<Integer> esImpar = esPar.negate();
Predicate<Integer> esParYPositivo = esPar.and(n -> n > 0);
Predicate<Integer> esParONegativo = esPar.or(n -> n < 0);
 
System.out.println(esImpar.test(3));           // true
System.out.println(esParYPositivo.test(4));    // true
System.out.println(esParONegativo.test(-3));   // true
 
// Uso con colecciones
List<Integer> numeros = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Predicate<Integer> esMayorQue5 = n -> n > 5;
 
List<Integer> mayoresQue5 = numeros.stream()
    .filter(esPar.and(esMayorQue5))
    .collect(Collectors.toList());
// [6, 8, 10]
 
// BiPredicate<T, U> — dos argumentos
BiPredicate<String, String> contiene = String::contains;
System.out.println(contiene.test("Hola mundo", "mundo"));  // true

3. Consumer → Consume un valor sin retorno

// T = tipo a consumir
// Método: void accept(T t)
 
Consumer<String> imprimir = System.out::println;
Consumer<List<Integer>> imprimirLista = lista -> lista.forEach(System.out::println);
 
imprimir.accept("Hola");
 
// Encadenar consumers
Consumer<String> enMayus = s -> System.out.print(s.toUpperCase());
Consumer<String> conSalto = s -> System.out.println();
 
Consumer<String> imprimirConSalto = enMayus.andThen(conSalto);
imprimirConSalto.accept("java");  // imprime "JAVA\n"
 
// Ejemplo práctico: procesar una lista de pedidos
record Pedido(String id, double total, boolean procesado) {}
 
Consumer<Pedido> registrar = p -> System.out.println("Registrado: " + p.id());
Consumer<Pedido> notificar = p -> System.out.println("Notificado: " + p.id());
Consumer<Pedido> procesar = registrar.andThen(notificar);
 
List<Pedido> pedidos = List.of(
    new Pedido("P001", 150.0, false),
    new Pedido("P002", 200.0, false)
);
pedidos.forEach(procesar);
 
// BiConsumer<T, U> — dos argumentos
BiConsumer<String, Integer> imprimirN = (s, n) -> System.out.println(s.repeat(n));
imprimirN.accept("Java! ", 3);  // "Java! Java! Java! "

4. Supplier → Provee un valor, sin entrada

// T = tipo a proveer
// Método: T get()
 
Supplier<String> saludo = () -> "Hola mundo";
Supplier<List<String>> nuevaLista = ArrayList::new;
Supplier<LocalDateTime> ahora = LocalDateTime::now;
 
System.out.println(saludo.get());  // "Hola mundo"
 
// Uso con lazy evaluation (evaluación diferida)
// El valor NO se calcula hasta que se llame .get()
Supplier<Double> calculoLento = () -> {
    // Simula un cálculo costoso
    return Math.random() * 1000;
};
 
// Uso clásico con Optional
Optional<String> nombre = Optional.empty();
String resultado = nombre.orElseGet(() -> "Nombre por defecto");
// La lambda solo se evalúa si Optional está vacío
 
// Ejemplo práctico: Factory pattern con Supplier
Map<String, Supplier<Animal>> fabrica = new HashMap<>();
fabrica.put("perro", Perro::new);
fabrica.put("gato", Gato::new);
 
Animal animal = fabrica.get("perro").get();
 
// Supplier para valores costosos (memoización simple)
class CacheSupplier<T> {
    private T valor;
    private final Supplier<T> fuente;
    
    CacheSupplier(Supplier<T> fuente) {
        this.fuente = fuente;
    }
    
    T get() {
        if (valor == null) {
            valor = fuente.get(); // solo se calcula una vez
        }
        return valor;
    }
}

Resumen

InterfazEntradaSalidaMétodoUso típico
Function<T,R>TRapply()Transformar/mapear
Predicate<T>Tbooleantest()Filtrar/validar
Consumer<T>Tvoidaccept()Efectos secundarios
Supplier<T>ningunaTget()Crear/proveer valores

3. Programación funcional

Principios clave:

  1. Inmutabilidad
//  Mutable — estado cambiante, difícil de razonar
class Contador {
    private int valor = 0;
    public void incrementar() { valor++; }
    public int getValor() { return valor; }
}
 
//  Inmutable — el estado no cambia, retorna nuevos objetos
record Contador(int valor) {
    Contador incrementar() {
        return new Contador(valor + 1);
    }
}
 
Contador c = new Contador(0);
Contador c2 = c.incrementar();  // c sigue siendo 0
  1. Funciones puras
//  Función impura — depende de estado externo, tiene efectos secundarios
private int multiplicador = 2;
public int duplicar(int n) {
    System.out.println("duplicando");  // efecto secundario
    return n * multiplicador;           // depende de estado externo
}
 
//  Función pura — mismo input = mismo output, sin efectos secundarios
public static int duplicar(int n) {
    return n * 2;
}
 
// Las funciones puras son:
// - Fáciles de testear
// - Fáciles de razonar
// - Seguras para concurrencia
// - Fáciles de cachear
  1. Composición de funciones
// Construir comportamientos complejos combinando funciones simples
Function<String, String> limpiar = String::trim;
Function<String, String> normalizar = String::toLowerCase;
Function<String, Boolean> esValido = s -> s.length() >= 3;
 
// Componer: limpiar, luego normalizar, luego verificar
Function<String, Boolean> validarNombre = limpiar
    .andThen(normalizar)
    .andThen(esValido::apply);
 
// Patrón común: pipeline de transformaciones
String input = "  JAVA  ";
String resultado = limpiar.andThen(normalizar).apply(input);
// "java"
 
// Composición de predicados para reglas de negocio
Predicate<String> noEsNulo = Objects::nonNull;
Predicate<String> noEsVacio = Predicate.not(String::isBlank);
Predicate<String> tieneEspacio = s -> s.contains(" ");
Predicate<String> esNombreCompleto = noEsNulo
    .and(noEsVacio)
    .and(tieneEspacio);
 
System.out.println(esNombreCompleto.test("Ana García"));  // true
System.out.println(esNombreCompleto.test("Ana"));          // false
  1. Funciones de orden superior (Funciones que retornan funciones)
// Funciones que reciben o retornan otras funciones
 
// Recibe una función como argumento
public static <T, R> List<R> transformar(List<T> lista, Function<T, R> f) {
    return lista.stream().map(f).collect(Collectors.toList());
}
 
// Retorna una función
public static Predicate<Integer> mayorQue(int umbral) {
    return n -> n > umbral;
}
 
public static Function<Integer, Integer> multiplicadorDe(int factor) {
    return n -> n * factor;
}
 
// Uso:
List<String> nombres = List.of("Ana", "Carlos", "Beatriz");
List<Integer> longitudes = transformar(nombres, String::length);
// [3, 6, 7]
 
Predicate<Integer> mayorQue10 = mayorQue(10);
System.out.println(mayorQue10.test(15));  // true
 
Function<Integer, Integer> triple = multiplicadorDe(3);
System.out.println(triple.apply(5));  // 15

4. API Streams

¿Qué es un Stream?

Un Stream es una secuencia de elementos que soporta operaciones funcionales en pipeline. No almacena datos, no modifica la fuente, y es evaluado de forma lazy (perezosa). Fuente → [Operaciones intermedias] → Operación terminal

Características:

  • No almacena datos — fluye sobre la fuente
  • Lazy — las operaciones intermedias no se ejecutan hasta que hay una terminal
  • Consumible — solo se puede recorrer una vez
  • Puede ser infinito
// Desde colección
List<String> lista = List.of("a", "b", "c");
Stream<String> s1 = lista.stream();
Stream<String> s2 = lista.parallelStream();  // stream paralelo, util para operaciones que requieren de procesador
 
// Desde array
int[] array = {1, 2, 3, 4, 5};
IntStream s3 = Arrays.stream(array);
 
// Desde valores directos
Stream<String> s4 = Stream.of("x", "y", "z");
 
// Stream vacío
Stream<String> vacio = Stream.empty();
 
// Stream infinito con iterate
Stream<Integer> naturales = Stream.iterate(0, n -> n + 1);
// [0, 1, 2, 3, 4, ...]
 
Stream<Integer> pares = Stream.iterate(0, n -> n + 2).limit(5);
// [0, 2, 4, 6, 8]
 
// Stream infinito con generate
Stream<Double> aleatorios = Stream.generate(Math::random).limit(3);
 
// Desde rango
IntStream rango = IntStream.range(1, 6);     // [1, 2, 3, 4, 5]
IntStream rangoCerrado = IntStream.rangeClosed(1, 5);  // [1, 2, 3, 4, 5]
 
// Desde fichero (líneas)
try (Stream<String> lineas = Files.lines(Path.of("archivo.txt"))) {
    lineas.forEach(System.out::println);
}
 
// Stream de un Optional
Optional<String> opt = Optional.of("valor");
Stream<String> fromOptional = opt.stream();  // Java 9+

Las operaciones intermedias en los streams por lo general retornan otro stream, para seguir operando:

List<String> nombres = Arrays.asList(
    "Ana", "Carlos", "Beatriz", "David", "Ana", "Elena"
);
 
// --- filter(Predicate) — filtrar elementos ---
Stream<String> conB = nombres.stream()
    .filter(n -> n.startsWith("B"));
// ["Beatriz"]
 
// --- map(Function) — transformar elementos ---
Stream<Integer> longitudes = nombres.stream()
    .map(String::length);
// [3, 6, 7, 5, 3, 5]
 
// --- flatMap(Function) — aplanar streams anidados ---
List<List<Integer>> matrizNumeros = List.of(
    List.of(1, 2, 3),
    List.of(4, 5, 6),
    List.of(7, 8, 9)
);
List<Integer> aplanado = matrizNumeros.stream()
    .flatMap(Collection::stream)
    .collect(Collectors.toList());
// [1, 2, 3, 4, 5, 6, 7, 8, 9]
 
// --- distinct() — eliminar duplicados ---
Stream<String> sinDuplicados = nombres.stream().distinct();
// ["Ana", "Carlos", "Beatriz", "David", "Elena"]
 
// --- sorted() / sorted(Comparator) — ordenar ---
Stream<String> ordenados = nombres.stream().sorted();
// ["Ana", "Ana", "Beatriz", "Carlos", "David", "Elena"]
 
Stream<String> porLongitud = nombres.stream()
    .sorted(Comparator.comparingInt(String::length).reversed());
// ["Beatriz", "Carlos", "David", "Elena", "Ana", "Ana"]
 
// --- limit(n) — tomar primeros n elementos ---
Stream<String> primerosTres = nombres.stream().limit(3);
// ["Ana", "Carlos", "Beatriz"]
 
// --- skip(n) — saltar primeros n elementos ---
Stream<String> sinPrimerosDos = nombres.stream().skip(2);
// ["Beatriz", "David", "Ana", "Elena"]
 
// --- peek(Consumer) — para depuración (no modifica) ---
Stream<String> debug = nombres.stream()
    .filter(n -> n.length() > 3)
    .peek(n -> System.out.println("Pasó filtro: " + n))
    .map(String::toUpperCase);
 
// --- mapToInt / mapToLong / mapToDouble — streams primitivos ---
IntStream longitudesInt = nombres.stream()
    .mapToInt(String::length);
 
// --- takeWhile(Predicate) — tomar mientras se cumple
List<Integer> nums = List.of(2, 4, 6, 7, 8, 10);
List<Integer> hastaImpar = nums.stream()
    .takeWhile(n -> n % 2 == 0)
    .collect(Collectors.toList());
// [2, 4, 6]
 
// --- dropWhile(Predicate) — saltar mientras se cumple
List<Integer> desdeImpar = nums.stream()
    .dropWhile(n -> n % 2 == 0)
    .collect(Collectors.toList());
// [7, 8, 10]

Y luego están las operaciones que consumen un stream, y por lo tanto, lo terminan.

List<Integer> numeros = Arrays.asList(3, 1, 4, 1, 5, 9, 2, 6, 5);
 
// --- collect()
List<Integer> lista = numeros.stream().collect(Collectors.toList());
Set<Integer> conjunto = numeros.stream().collect(Collectors.toSet());
String unido = Stream.of("a","b","c").collect(Collectors.joining(", ", "[", "]"));
// "[a, b, c]"
 
// --- forEach(Consumer) ---
numeros.stream().forEach(System.out::println);
 
// --- count() ---
long cantidad = numeros.stream().filter(n -> n > 3).count();  // 5
 
// --- findFirst() / findAny() — retornan Optional ---
Optional<Integer> primero = numeros.stream().filter(n -> n > 5).findFirst();
primero.ifPresent(System.out::println);  // 9
 
// --- anyMatch / allMatch / noneMatch ---
boolean alguno = numeros.stream().anyMatch(n -> n > 8);   // true (9)
boolean todos  = numeros.stream().allMatch(n -> n > 0);   // true
boolean ninguno = numeros.stream().noneMatch(n -> n < 0); // true
 
// --- min / max — retornan Optional ---
Optional<Integer> minimo = numeros.stream().min(Integer::compareTo);  // 1
Optional<Integer> maximo = numeros.stream().max(Integer::compareTo);  // 9
 
// --- sum / average / statistics (en IntStream) ---
int suma = numeros.stream().mapToInt(Integer::intValue).sum();          // 36
OptionalDouble promedio = numeros.stream().mapToInt(i -> i).average();  // 4.0
IntSummaryStatistics stats = numeros.stream().mapToInt(i -> i).summaryStatistics();
System.out.println(stats.getMin());    // 1
System.out.println(stats.getMax());    // 9
System.out.println(stats.getSum());    // 36
System.out.println(stats.getAverage()); // 4.0
 
// --- reduce() — reducir a un solo valor ---
int producto = numeros.stream()
    .reduce(1, (acc, n) -> acc * n);  // con identidad
 
Optional<Integer> sumaOpt = numeros.stream()
    .reduce(Integer::sum);  // sin identidad, retorna Optional
 
// reduce más explícito:
int sumaManual = numeros.stream()
    .reduce(0, (acumulador, elemento) -> acumulador + elemento);
 
// --- toArray() ---
Integer[] array = numeros.stream().toArray(Integer[]::new);
 
// --- iterator() / spliterator() ---
Iterator<Integer> it = numeros.stream().iterator();

También es posible convertir una colección a otro tipo de colección usando streams

List<Persona> personas = List.of(
    new Persona("Ana", "Ingeniería", 28),
    new Persona("Carlos", "Marketing", 35),
    new Persona("Beatriz", "Ingeniería", 31),
    new Persona("David", "Marketing", 27),
    new Persona("Elena", "Ingeniería", 24)
);
 
// --- groupingBy — agrupar ---
Map<String, List<Persona>> porDepartamento = personas.stream()
    .collect(Collectors.groupingBy(Persona::departamento));
// {"Ingeniería": [...], "Marketing": [...]}
 
// groupingBy con downstream collector
Map<String, Long> cantidadPorDepto = personas.stream()
    .collect(Collectors.groupingBy(
        Persona::departamento,
        Collectors.counting()
    ));
// {"Ingeniería": 3, "Marketing": 2}
 
Map<String, Double> promedioPorDepto = personas.stream()
    .collect(Collectors.groupingBy(
        Persona::departamento,
        Collectors.averagingInt(Persona::edad)
    ));
 
Map<String, Optional<Persona>> masJovenPorDepto = personas.stream()
    .collect(Collectors.groupingBy(
        Persona::departamento,
        Collectors.minBy(Comparator.comparingInt(Persona::edad))
    ));
 
// --- partitioningBy — dividir en dos grupos ---
Map<Boolean, List<Persona>> mayoresMenores = personas.stream()
    .collect(Collectors.partitioningBy(p -> p.edad() >= 30));
// {true: [Carlos, Beatriz], false: [Ana, David, Elena]}
 
// --- toMap ---
Map<String, Integer> nombreEdad = personas.stream()
    .collect(Collectors.toMap(
        Persona::nombre,
        Persona::edad
    ));
 
// toMap con manejo de claves duplicadas
Map<String, String> deptoNombre = personas.stream()
    .collect(Collectors.toMap(
        Persona::departamento,
        Persona::nombre,
        (existente, nuevo) -> existente + ", " + nuevo  // resolver duplicados
    ));
 
// --- joining ---
String nombresStr = personas.stream()
    .map(Persona::nombre)
    .collect(Collectors.joining(", "));
// "Ana, Carlos, Beatriz, David, Elena"
 
// --- summarizingInt ---
IntSummaryStatistics edadStats = personas.stream()
    .collect(Collectors.summarizingInt(Persona::edad));
 
// --- Collectors.teeing (Java 12) — aplicar dos collectors a la vez ---
record Resultado(long count, double avg) {}
Resultado res = personas.stream()
    .collect(Collectors.teeing(
        Collectors.counting(),
        Collectors.averagingInt(Persona::edad),
        Resultado::new
    ));

Ahora, los streams paralelos ayudan a optimizar el rendimiento en operaciones que requieren de procesador.

// Un stream paralelo divide la carga entre múltiples hilos
List<Integer> numerosGrandes = IntStream.rangeClosed(1, 1_000_000)
    .boxed()
    .collect(Collectors.toList());
 
// Stream secuencial
long sumaSecuencial = numerosGrandes.stream()
    .mapToLong(Integer::longValue)
    .sum();
 
// Stream paralelo — puede ser más rápido con grandes volúmenes
long sumaParalela = numerosGrandes.parallelStream()
    .mapToLong(Integer::longValue)
    .sum();
 
// CUIDADO con el orden en paralelos:
List<Integer> listaOrdenada = numerosGrandes.parallelStream()
    .filter(n -> n % 2 == 0)
    .sorted()
    .limit(10)
    .collect(Collectors.toList());  // el sorted() anula ventaja del paralelo
 
//   Usar paralelos cuando:
// - La operación es CPU-intensiva
// - El volumen de datos es grande (>10.000 elementos típicamente)
// - Las operaciones son independientes entre sí
// - El orden no importa
 
//   Evitar paralelos cuando:
// - El volumen es pequeño
// - Hay efectos secundarios (escritura a BD, logs, etc.)
// - El orden es importante
// - Hay operaciones de I/O

Ejercicio

// Sistema de Biblioteca — implementar lo siguiente:
/*
Funcionalidades a implementar en BibliotecaService
1. Libro más reciente por autor
Dado un autor, retorna el Optional<Libro> con el año más alto. Usar filter + max.
2. Libros por género con filtro de rango de años
Retorna un Map<Genero, List<Libro>> con los libros ordenados por año, filtrando solo los publicados entre anioDesde y anioHasta.
3. Estadísticas por usuario
Retorna un Map<String, EstadisticaUsuario> que incluya: total de préstamos, préstamos activos, préstamos vencidos, promedio de días (solo devueltos) e ISBNs distintos prestados. 
El límite de días para considerar un préstamo vencido debe variar según la membresía: 14 días para BASICO, 30 para PREMIUM, 60 para INVESTIGADOR.
4. Préstamos vencidos con detalle
Retorna una List<PrestamoVencido> (record anidado con usuarioId, libroIsbn y diasVencido), ordenada de mayor a menor antigüedad. 
5. Reporte enriquecido por género
Retorna un Map<Genero, ReporteGenero> donde cada entrada incluye: cantidad de libros, lista de títulos ordenada por año descendente, año más antiguo y año más reciente del género.
6. Disponibilidad de copias en tiempo real
Retorna un Map<String, Integer> de ISBN → copias disponibles, calculado como copiasTotales - préstamos activos. Usa groupingBy + counting.
7. Top N autores más prestados
Dado un número N, retorna los N autores con más préstamos históricos (incluyendo devueltos). Requiere hacer un join entre la lista de libros y la de préstamos mediante un Map intermedio.
//  MODELOS
*/
enum Genero { FICCION, NO_FICCION, CIENCIA, HISTORIA, TECNOLOGIA }
 
record Libro(String isbn, String titulo, String autor, int anio, Genero genero, int copiasTotales) {
}
 
record Usuario(String id, String nombre, String email, NivelMembresia nivel) {}
 
enum NivelMembresia { BASICO, PREMIUM, INVESTIGADOR }
 
record Prestamo(String libroIsbn, String usuarioId, LocalDate fechaInicio, LocalDate fechaDevolucion, boolean devuelto) {
}
//  ESTADÍSTICAS (Value Objects inmutables)
record EstadisticaUsuario(
    String usuarioId,
    long totalPrestamos,
    long prestamosActivos,
    long prestamosVencidos,
    OptionalDouble promedioDias,
    List<String> isbnsPrestados
) {}
 
record ReporteGenero(
    Genero genero,
    long cantidadLibros,
    List<String> titulos,
    int anioMasAntiguo,
    int anioMasReciente
) {}
//  EXCEPCIONES DE DOMINIO
 
class LibroNoDisponibleException extends RuntimeException {
    LibroNoDisponibleException(String isbn) {
        super("No hay copias disponibles del libro: " + isbn);
    }
}
 
class UsuarioNoEncontradoException extends RuntimeException {
    UsuarioNoEncontradoException(String id) {
        super("Usuario no encontrado: " + id);
    }
}
 
//  SERVICIO PRINCIPAL
// Acá empiezan a desarrollar su solución:
public class BibliotecaService{
	/**/
}