Al cerrar la lección anterior quedó pendiente una pieza pobre del proyecto: las incidencias de un Prestamo son un String[] con textos sueltos como "Pagina 42 subrayada". Una incidencia real tiene tres datos —el día en que se detectó, el motivo y su gravedad— y una cadena no puede llevarlos sin degenerar en un formato frágil del tipo "12|Pagina subrayada|LEVE" que habría que partir cada vez. Necesitas un tipo. Pero, ¿una clase pública de primer nivel, en su propio fichero, para algo que solo existe dentro de un préstamo y que nadie usará por su cuenta?
Java ofrece una respuesta mejor: las clases anidadas. Son clases declaradas dentro de otra clase, y permiten que un tipo auxiliar viva exactamente donde tiene sentido, con la visibilidad justa. Son cuatro variantes con reglas distintas, y elegir mal tiene consecuencias reales: una de ellas es la causa clásica de fugas de memoria en aplicaciones Java de producción. Esta lección te enseña a distinguirlas, a elegir por defecto la correcta —que casi siempre es la misma— y a reconocer el patrón cuando lo veas en la biblioteca estándar, donde está por todas partes: Map.Entry, Thread.State, los nodos internos de LinkedList.
Contenido
- Las cuatro clases anidadas: panorama y tabla comparativa
- Clase interna de miembro (no estática)
- La referencia oculta
Externa.thisy su coste - Fugas de memoria: el caso real
- Clase anidada estática: la opción correcta casi siempre
- Clase local: una clase dentro de un método
- Por qué se exige que las variables capturadas sean efectivamente finales
- Visibilidad: la clase anidada
privatecomo detalle oculto - Los ficheros
.classgenerados y qué revelan - Casos de uso reales
- BiblioTech:
Prestamo.Incidencia - BiblioTech: un comparador anidado del catálogo
- Errores Comunes y Consejos
- Ejercicios
- Las cuatro clases anidadas: panorama y tabla comparativa
Una clase anidada es una clase declarada dentro del cuerpo de otra clase o de un método. Java ofrece cuatro variantes:
public class Externa {
private int campo = 10;
// 1. Clase interna de miembro (no estatica)
class Interna { }
// 2. Clase anidada estatica
static class Anidada { }
public void metodo() {
// 3. Clase local
class Local { }
// 4. Clase anonima (04-04)
Runnable r = new Runnable() {
@Override public void run() { }
};
}
}| Característica | Interna (miembro) | Anidada estática | Local | Anónima |
|---|---|---|---|---|
| Dónde se declara | Cuerpo de la clase | Cuerpo de la clase | Dentro de un método | En una expresión |
Palabra static |
No | Sí | No aplica | No aplica |
| Tiene nombre | Sí | Sí | Sí (local) | No |
| Referencia a la instancia externa | Sí (Externa.this) |
No | Sí, si el método no es estático | Sí, si el contexto no es estático |
| Accede a campos de instancia de la externa | Sí | Solo a los static |
Sí | Sí |
| Accede a variables locales | — | — | Sí, si son efect. finales | Sí, si son efect. finales |
Puede tener miembros static |
Sí (desde Java 16) | Sí | Sí (desde Java 16) | Solo constantes |
| Se instancia con | externa.new Interna() |
new Externa.Anidada() |
new Local() en el método |
new Tipo() { ... } |
| Modificadores de acceso | Los cuatro | Los cuatro | Ninguno | Ninguno |
| Riesgo de fuga de memoria | Sí | No | Bajo | Sí, si se guarda |
| Frecuencia real de uso | Baja | Muy alta | Baja | Media |
La conclusión práctica está en las dos últimas filas: la anidada estática es la opción por defecto, y la interna no estática solo se justifica cuando de verdad necesitas acceder al estado del objeto externo. Vamos a ver por qué.
- Clase interna de miembro (no estática)
Es una clase declarada como miembro de otra, sin static. Su rasgo definitorio: cada instancia suya está ligada a una instancia de la clase externa, y puede acceder a todos sus miembros, incluso los private.
public class Prestamo {
private final String referencia;
private int diasTranscurridos;
public Prestamo(String referencia, int diasTranscurridos) {
this.referencia = referencia;
this.diasTranscurridos = diasTranscurridos;
}
/** Clase interna NO estatica: cada aviso pertenece a UN prestamo concreto. */
public class AvisoInterno {
private final String texto;
public AvisoInterno(String texto) {
this.texto = texto;
}
public String formatear() {
// Accede DIRECTAMENTE a los campos privados del prestamo externo:
return "[" + referencia + " / dia " + diasTranscurridos + "] " + texto;
}
}
}Fíjate en formatear(): usa referencia y diasTranscurridos sin ningún prefijo, como si fueran suyos, y son campos private de Prestamo. Esa es la capacidad que distingue a la clase interna.
Y aquí viene la sintaxis que sorprende a todo el mundo: para crear un AvisoInterno necesitas primero un Prestamo.
Prestamo p = new Prestamo("PR-0001", 12);
// Sintaxis correcta: instancia externa PUNTO new
Prestamo.AvisoInterno aviso = p.new AvisoInterno("Retraso detectado");
System.out.println(aviso.formatear());
// new Prestamo.AvisoInterno("..."); // NO COMPILA: falta la instancia externaEse p.new AvisoInterno(...) es probablemente la sintaxis más rara de Java, y su rareza es informativa: te está diciendo que la clase interna no existe sin su objeto externo. Dentro de la propia clase externa no hace falta escribirla, porque this está implícito:
public void anotar(String texto) {
AvisoInterno aviso = new AvisoInterno(texto); // equivale a this.new AvisoInterno(texto)
System.out.println(aviso.formatear());
}
- La referencia oculta
Externa.this y su coste
Externa.this y su coste¿Cómo puede AvisoInterno leer los campos de Prestamo? Porque el compilador le añade un campo oculto con una referencia a la instancia externa. El código que realmente se genera es equivalente a esto:
// Version "desazucarada" de lo que hace el compilador:
public class Prestamo$AvisoInterno {
final Prestamo this$0; // CAMPO OCULTO
private final String texto;
Prestamo$AvisoInterno(Prestamo externa, String texto) {
this.this$0 = externa; // se guarda al construir
this.texto = texto;
}
public String formatear() {
return "[" + this$0.referencia + " / dia " + this$0.diasTranscurridos + "] " + texto;
}
}Ese campo se llama this$0 en el bytecode y tú puedes referirte a él con la sintaxis Externa.this, que resulta imprescindible cuando hay nombres que chocan:
public class Prestamo {
private String estado = "ACTIVO";
public class AvisoInterno {
private String estado = "PENDIENTE"; // mismo nombre
public String describir() {
String estado = "LOCAL"; // y una variable local tambien
return "local=" + estado // LOCAL
+ ", aviso=" + this.estado // PENDIENTE
+ ", prestamo=" + Prestamo.this.estado; // ACTIVO
}
}
}La regla de resolución es de dentro hacia fuera: variable local → campo de la interna (this) → campo de la externa (Externa.this).
Pero ese campo oculto tiene un coste real, y es el punto central de la lección:
| Coste | Consecuencia |
|---|---|
| Memoria | Cada instancia interna pesa una referencia más |
| Construcción | Siempre hace falta una instancia externa |
| Serialización | Serializar la interna arrastra a la externa entera (módulo 7) |
| Vida del objeto | Mientras viva la interna, la externa NO puede ser recolectada |
La última fila es la peligrosa.
- Fugas de memoria: el caso real
Una fuga de memoria en Java no es memoria que se pierde: es memoria que el recolector de basura no puede liberar porque algo sigue apuntando a ella. Y una clase interna no estática apunta a su objeto externo siempre, aunque no use ni uno solo de sus campos.
Veamos el escenario concreto. Imagina un CatalogoPesado que ocupa mucha memoria y del que solo queremos conservar un pequeño identificador:
public class CatalogoPesado {
private final Material[] materiales = new Material[100_000]; // MUY grande
private final String codigo;
public CatalogoPesado(String codigo) { this.codigo = codigo; }
/** Clase interna NO estatica: ojo. */
public class Etiqueta {
private final String texto;
public Etiqueta(String texto) { this.texto = texto; }
public String getTexto() { return texto; }
// No usa NADA de CatalogoPesado... pero lo retiene igual
}
public Etiqueta crearEtiqueta() {
return new Etiqueta("Catalogo " + codigo);
}
}public class FugaDemo {
private static CatalogoPesado.Etiqueta etiquetaGuardada; // vive todo el programa
public static void main(String[] args) {
CatalogoPesado catalogo = new CatalogoPesado("CAT-2026");
etiquetaGuardada = catalogo.crearEtiqueta();
catalogo = null; // "ya no necesito el catalogo"
System.gc(); // sugerencia al recolector
// El array de 100.000 posiciones SIGUE EN MEMORIA:
// etiquetaGuardada -> this$0 -> catalogo -> materiales
System.out.println(etiquetaGuardada.getTexto());
}
}flowchart LR
A["etiquetaGuardada (campo static, vivo)"] --> B["Etiqueta"]
B -->|"this$0 oculto"| C["CatalogoPesado"]
C --> D["Material[100000]"]
E["variable catalogo = null"] -.->|"ya no apunta"| C
Poner catalogo = null no sirve de nada: sigue habiendo un camino vivo hasta el array gigante, pasando por la referencia oculta. En una aplicación que crea miles de etiquetas y las guarda en una caché, esto es una fuga que crece hasta el OutOfMemoryError, y es de las más difíciles de diagnosticar porque el código de Etiqueta no menciona al catálogo por ninguna parte.
La solución es de una palabra:
/** Clase anidada ESTATICA: sin referencia oculta, sin fuga. */
public static class Etiqueta {
private final String texto;
public Etiqueta(String texto) { this.texto = texto; }
public String getTexto() { return texto; }
}Con static, la Etiqueta es un objeto independiente. Cuando catalogo deja de estar referenciado, el recolector se lleva el array de cien mil posiciones y la etiqueta se queda sola, pesando lo que pesa una cadena. Los detalles del recolector y de cómo diagnosticar estos casos se estudian en la lección 10-07.
- Clase anidada estática: la opción correcta casi siempre
Una clase anidada estática es un miembro static de la clase externa. Conceptualmente no es una clase interna: es una clase de primer nivel normal y corriente que, por conveniencia, vive dentro del espacio de nombres de otra.
public class Prestamo {
/** Clase anidada ESTATICA: independiente del prestamo concreto. */
public static class Incidencia {
private final int dia;
private final String motivo;
public Incidencia(int dia, String motivo) {
this.dia = dia;
this.motivo = motivo;
}
public int getDia() { return dia; }
public String getMotivo() { return motivo; }
}
}Se instancia sin ninguna ceremonia extra, cualificando con el nombre de la clase externa:
Prestamo.Incidencia i = new Prestamo.Incidencia(12, "Pagina 42 subrayada");
// O, con un import estatico del tipo anidado:
// import com.nexussoftware.bibliotech.dominio.Prestamo.Incidencia;
Incidencia i2 = new Incidencia(14, "Tapa desprendida");Sus reglas:
- No tiene referencia a ninguna instancia externa. Por tanto no puede acceder a campos ni métodos de instancia de la clase externa.
- Sí puede acceder a los miembros
staticde la externa, incluidos losprivate. - No provoca fugas, no complica la serialización y se construye sin nada previo.
¿Y qué gana entonces frente a una clase de primer nivel en su propio fichero? Tres cosas nada menores:
- Expresa pertenencia.
Prestamo.Incidenciadice, en su propio nombre, que una incidencia pertenece al mundo de los préstamos. - Permite ocultarla. Si la declaras
private, es invisible fuera dePrestamo(apartado 8). Una clase de primer nivel no puede serprivate. - Reduce el ruido del paquete. Un tipo auxiliar de quince líneas no merece su propio fichero.
El JDK la usa constantemente. Map.Entry es una interfaz anidada dentro de Map; Thread.State es un enum anidado; Character.UnicodeBlock es una clase anidada estática. Todos siguen la misma lógica: el tipo auxiliar vive donde tiene sentido.
Regla práctica: declara la clase anidada
staticpor defecto. Quítale elstaticsolo cuando compruebes que necesita acceder al estado de la instancia externa. Es exactamente el mismo criterio de los IDE modernos, que te avisan con "esta clase interna puede ser estática".
- Clase local: una clase dentro de un método
Una clase local se declara dentro del cuerpo de un método, un constructor o un bloque. Su ámbito es ese bloque: fuera de él no existe, ni siquiera se puede nombrar.
public class GeneradorInformes {
/** Agrupa materiales por gravedad usando un tipo que solo vive aqui. */
public String informeGravedad(Material[] materiales, int diasTranscurridos) {
// Clase LOCAL: nace y muere dentro de este metodo
class Contador {
private int leves;
private int graves;
void registrar(String gravedad) {
if ("LEVE".equals(gravedad)) { leves++; }
if ("GRAVE".equals(gravedad)) { graves++; }
}
String resumen() {
return String.format("A los %d dias: %d leves, %d graves",
diasTranscurridos, leves, graves);
}
}
Contador contador = new Contador();
for (Material m : materiales) {
contador.registrar(m.clasificarGravedad(diasTranscurridos));
}
return contador.resumen();
}
}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)
};
System.out.println(new GeneradorInformes().informeGravedad(catalogo, 20));Sus características:
- No admite modificadores de acceso.
public class Contadordentro de un método no compila: su visibilidad ya está fijada por el bloque. - Puede acceder a los campos de la clase externa si el método no es
static. - Puede capturar variables locales y parámetros del método, con una condición estricta que es el tema del siguiente apartado. Fíjate en
resumen(): usadiasTranscurridos, que es un parámetro del método.
Las clases locales son poco frecuentes. Si el tipo cabe en pocas líneas y solo implementa una interfaz, la lambda (04-05) es mejor; si tiene entidad, una clase anidada estática es más legible. Su nicho es este: un tipo auxiliar con varios campos y varios métodos que de verdad solo tiene sentido dentro de un algoritmo concreto.
- Por qué se exige que las variables capturadas sean efectivamente finales
Prueba a modificar una variable local capturada:
public String informe(Material[] materiales) {
int total = 0;
class Contador {
void sumar() {
total++; // ERROR DE COMPILACION
}
}
// ...
}Una variable es efectivamente final (effectively final, desde Java 8) cuando no se le asigna un valor después de su inicialización, aunque no lleve la palabra final. El compilador lo deduce solo.
¿Por qué esta restricción? Por el ciclo de vida de las variables locales, que ya conoces desde 03-02:
- Una variable local vive en la pila del hilo, dentro del marco de la llamada. Cuando el método termina, ese marco desaparece y la variable deja de existir.
- Un objeto vive en el montón (heap), y puede sobrevivir mucho más que el método que lo creó.
Si el objeto de la clase local sobrevive al método —porque lo devuelves, lo guardas en un campo o lo pasas a otro hilo—, ¿de dónde leería el valor de una variable local que ya no existe?
flowchart TD
A["El metodo se ejecuta: 'total' vive en la pila"] --> B["Se crea el objeto de la clase local en el heap"]
B --> C["El compilador COPIA el valor de 'total' en un campo oculto del objeto"]
C --> D["El metodo termina: el marco de pila desaparece"]
D --> E["El objeto sobrevive con SU COPIA del valor"]
La solución de Java es la captura por valor: el compilador copia el valor dentro del objeto. Y de ahí sale la restricción, porque si se permitiera modificar la variable habría dos copias descoordinadas:
- Modificar
totalen el método no cambiaría la copia del objeto. - Modificar
totaldentro del objeto no cambiaría la del método.
Un mismo nombre con dos valores distintos: una fuente inagotable de bugs. Java prefiere prohibirlo en compilación.
Otros lenguajes eligen lo contrario: JavaScript captura la variable por referencia (closures reales), y por eso allí sí puedes modificarla —a costa de sorpresas propias, como el clásico bucle cuyo contador acaba valiendo lo mismo en todos los callbacks—. Java optó por la seguridad. Volverá a ser relevante en el módulo 8, donde compartir estado mutable entre hilos es precisamente la fuente de los peores errores.
Alternativas cuando de verdad necesitas acumular:
// Opcion A (la mejor): un campo de la clase local o de la externa
class Contador {
private int total; // campo: vive en el heap, es mutable
void sumar() { total++; }
int getTotal() { return total; }
}
// Opcion B: un array de un elemento (truco antiguo, evitalo si puedes)
int[] total = {0}; // 'total' no cambia: cambia total[0]
class C { void sumar() { total[0]++; } }La opción A es la correcta: la mutabilidad pertenece a un objeto, no a una variable capturada. La B funciona porque la variable total (la referencia al array) nunca se reasigna, pero es un truco de legibilidad dudosa. La misma regla se aplicará palabra por palabra a las lambdas en 04-05.
- Visibilidad: la clase anidada
private como detalle oculto
private como detalle ocultoUna clase anidada admite los cuatro modificadores de acceso, algo imposible en una clase de primer nivel (que solo puede ser public o de paquete). Y la combinación más potente es private static:
public class Catalogo {
private final Material[] materiales;
private int tamano;
public Catalogo(int capacidad) {
this.materiales = new Material[Math.max(1, capacidad)];
this.tamano = 0;
}
/**
* Detalle de implementacion TOTALMENTE oculto: fuera de Catalogo
* este tipo no existe. Se puede cambiar o eliminar sin romper a nadie.
*/
private static class Estadisticas {
int disponibles;
int prestados;
double multaAcumulada;
}
private Estadisticas calcular(int diasTranscurridos) {
Estadisticas e = new Estadisticas();
for (int i = 0; i < tamano; i++) {
if (materiales[i].estaDisponible()) { e.disponibles++; }
else { e.prestados++; }
e.multaAcumulada += materiales[i].calcularMulta(diasTranscurridos);
}
return e;
}
/** API publica: no filtra el tipo interno. */
public String resumen(int diasTranscurridos) {
Estadisticas e = calcular(diasTranscurridos);
return String.format("%d disponibles, %d prestados, %.2f EUR acumulados",
e.disponibles, e.prestados, e.multaAcumulada);
}
}Este es el encapsulamiento de 03-07 llevado al nivel del tipo, no ya del campo. Estadisticas no aparece en ninguna firma pública, así que:
- Nadie fuera puede declarar una variable de ese tipo.
- Cambiar sus campos, renombrarla o sustituirla no rompe a ningún cliente.
- La API pública de
Catalogosigue siendo mínima (03-08).
Nota que sus campos no llevan modificador ni getters. Es aceptable precisamente porque la clase es private: su única "API pública" es el interior de Catalogo, y ahí la ceremonia no aporta nada. Ese relajamiento sería inaceptable en un tipo público.
- Los ficheros
.class generados y qué revelan
.class generados y qué revelanUn detalle que aclara mucho el modelo mental: la JVM no conoce las clases anidadas. Es un mecanismo del lenguaje que el compilador traduce a clases de primer nivel con nombres construidos.
Compila un Prestamo con una Incidencia anidada y una clase local Contador, y mira el directorio de salida:
| Fichero | Corresponde a |
|---|---|
Prestamo.class |
La clase externa |
Prestamo$Incidencia.class |
Clase anidada (estática o no); el nombre se conserva |
Prestamo$1Contador.class |
Clase local: nombre precedido de un número, porque puede haber varias Contador en métodos distintos |
Prestamo$1.class |
Clase anónima: solo un número, porque no tiene nombre (04-04) |
Tres conclusiones prácticas:
- Cada clase anidada es un fichero
.classmás. Cien clases anónimas son cien ficheros, con su coste de carga y de tamaño del.jar. En Android este recuento importó mucho durante años. - Puedes distinguir en el bytecode si una interna es estática: la no estática tiene el campo
this$0. Conjavap -p Prestamo\$Incidencialo verás listado. - El acceso a miembros
privateentre externa e interna no es magia. Hasta Java 10 el compilador generaba métodos puente sintéticos (access$000) para permitirlo; desde Java 11 se resuelve con nest-based access control, un mecanismo de la propia JVM. Por eso una clase interna puede leer un campoprivatede la externa sin violar el encapsulamiento: ambas pertenecen al mismo "nido".
- Casos de uso reales
Antes de aplicarlo al proyecto, conviene fijar los tres escenarios donde las clases anidadas son la respuesta estándar en código profesional.
Builders. Un constructor con ocho parámetros es ilegible (lo viste en 03-04). El patrón Builder ofrece una clase anidada estática que va acumulando valores y construye el objeto al final:
Libro libro = new Libro.Builder()
.titulo("Java Efectivo")
.autor("Joshua Bloch")
.isbn("978-0000000001")
.anio(2018)
.construir();El Builder va anidado dentro de Libro porque solo sirve para construir libros. Es el caso de uso número uno de las clases anidadas estáticas, y se formaliza en 12-02.
Nodos de una estructura de datos. Una lista enlazada necesita un nodo con un valor y un enlace al siguiente. Ese nodo es puro detalle interno:
public class ListaSimple {
private Nodo cabeza;
/** Detalle de implementacion: nadie fuera sabe que existe. */
private static class Nodo {
Material valor;
Nodo siguiente;
Nodo(Material valor) { this.valor = valor; }
}
}Así está escrita java.util.LinkedList en el JDK, con una private static class Node. Lo verás en la lección 05-04.
Agrupación de datos auxiliares. Cuando un método necesita devolver dos o tres valores relacionados, una clase anidada evita inventar un tipo público. Desde Java 16 hay una forma aún mejor para este caso —los record, y pueden ser anidados—, que verás en 04-07.
- BiblioTech:
Prestamo.Incidencia
Prestamo.IncidenciaHa llegado el momento de sustituir el String[] de incidencias. Una incidencia tiene día, motivo y gravedad, y solo tiene sentido dentro de un préstamo: clase anidada estática.
package com.nexussoftware.bibliotech.dominio;
import java.util.Arrays;
import java.util.Objects;
public class Prestamo {
// ============================================================
// Tipo anidado: una incidencia registrada durante el prestamo
// ============================================================
/**
* Incidencia detectada en un material durante un prestamo.
*
* <p>Es ESTATICA porque una incidencia no necesita acceder al estado del
* prestamo: se le pasa todo lo que necesita al construirla. Asi puede
* crearse, guardarse y compararse sin arrastrar el prestamo entero.</p>
*
* <p>Es INMUTABLE: una vez anotada, una incidencia no cambia.</p>
*/
public static class Incidencia {
private final int dia; // dia (entero) en que se detecto
private final String motivo;
private final String gravedad; // "LEVE" o "GRAVE"; en 04-07 sera un enum
public Incidencia(int dia, String motivo, String gravedad) {
this.dia = Math.max(0, dia);
this.motivo = (motivo == null || motivo.isBlank())
? "Sin especificar" : motivo.trim();
this.gravedad = "GRAVE".equalsIgnoreCase(gravedad) ? "GRAVE" : "LEVE";
}
/** Atajo para las incidencias corrientes. */
public Incidencia(int dia, String motivo) {
this(dia, motivo, "LEVE");
}
public int getDia() { return dia; }
public String getMotivo() { return motivo; }
public String getGravedad() { return gravedad; }
public boolean esGrave() { return "GRAVE".equals(gravedad); }
@Override
public String toString() {
return String.format("dia %d - %s [%s]", dia, motivo, gravedad);
}
@Override
public boolean equals(Object o) {
if (this == o) { return true; }
if (o == null || getClass() != o.getClass()) { return false; }
Incidencia otra = (Incidencia) o;
return dia == otra.dia && motivo.equals(otra.motivo);
}
@Override
public int hashCode() { return Objects.hash(dia, motivo); }
}
// ============================================================
// Prestamo
// ============================================================
private final String referencia;
private final Material material;
private final Empleado empleado;
private Incidencia[] incidencias; // antes: String[]
// ... resto de campos y constructor sin cambios; incidencias = new Incidencia[0] ...
/** Anota una incidencia con dia, motivo y gravedad. */
public void anotarIncidencia(int dia, String motivo, String gravedad) {
Incidencia nueva = new Incidencia(dia, motivo, gravedad);
Incidencia[] ampliado = Arrays.copyOf(incidencias, incidencias.length + 1);
ampliado[incidencias.length] = nueva;
this.incidencias = ampliado; // el modulo 5 hara esto mucho mejor
}
/** Atajo: incidencia leve en el dia actual del prestamo. */
public void anotarIncidencia(String motivo) {
anotarIncidencia(getDiasTranscurridos(), motivo, "LEVE");
}
/** @return copia defensiva (03-07): modificarla no afecta al prestamo. */
public Incidencia[] getIncidencias() {
return Arrays.copyOf(incidencias, incidencias.length);
}
/** Cuenta cuantas incidencias graves se han registrado. */
public int contarIncidenciasGraves() {
int graves = 0;
for (Incidencia i : incidencias) {
if (i.esGrave()) { graves++; }
}
return graves;
}
}Uso:
Libro javaEfectivo = new Libro("Java Efectivo", "Joshua Bloch", "978-0000000001", 2018);
Empleado marta = new Empleado("Marta Ruiz", "EMP-001");
Prestamo p = new Prestamo(javaEfectivo, marta, 100, 18);
p.anotarIncidencia(12, "Pagina 42 subrayada", "LEVE");
p.anotarIncidencia(15, "Tapa desprendida", "GRAVE");
p.anotarIncidencia("Marca de cafe en la contraportada");
System.out.println("Incidencias de " + p.getReferencia() + ":");
for (Prestamo.Incidencia i : p.getIncidencias()) {
System.out.println(" " + i);
}
System.out.println("Graves: " + p.contarIncidenciasGraves());Incidencias de PR-0001: dia 12 - Pagina 42 subrayada [LEVE] dia 15 - Tapa desprendida [GRAVE] dia 18 - Marca de cafe en la contraportada [LEVE] Graves: 1
Compara con la versión anterior. Antes: String[] con textos que había que leer con los ojos y donde contarIncidenciasGraves() habría exigido buscar subcadenas. Ahora: un tipo con tres datos, comportamiento propio (esGrave()), equals/hashCode correctos (03-09), inmutabilidad (03-07) y un nombre —Prestamo.Incidencia— que dice exactamente a qué mundo pertenece.
Y la decisión clave está en el static. Si Incidencia no fuera estática:
- No podrías crearla sin un
Prestamo(p.new Incidencia(...)). - Cada una arrastraría una referencia al préstamo completo, con su material y su empleado.
- Guardar un histórico de incidencias en memoria retendría todos los préstamos a los que pertenecen, aunque ya no los uses.
- BiblioTech: un comparador anidado del catálogo
Segundo caso de uso: ordenar el catálogo. Java ofrece Arrays.sort(array, comparador), donde el comparador es un objeto que implementa Comparator y responde a una pregunta: dados dos elementos, ¿cuál va antes?
package com.nexussoftware.bibliotech.servicio;
import com.nexussoftware.bibliotech.dominio.Material;
import java.util.Arrays;
import java.util.Comparator;
/** Operaciones de catalogo de BiblioTech. */
public class Catalogo {
private final Material[] materiales;
public Catalogo(Material[] materiales) {
this.materiales = Arrays.copyOf(materiales, materiales.length);
}
/**
* Comparador por titulo. Es una clase anidada ESTATICA: no necesita
* ningun dato del catalogo, solo los dos materiales que recibe.
*/
public static class PorTitulo implements Comparator<Material> {
@Override
public int compare(Material a, Material b) {
return a.getTitulo().compareToIgnoreCase(b.getTitulo());
}
}
/** Comparador por multa acumulada, de mayor a menor. */
public static class PorMultaDescendente implements Comparator<Material> {
private final int diasTranscurridos;
public PorMultaDescendente(int diasTranscurridos) {
this.diasTranscurridos = diasTranscurridos;
}
@Override
public int compare(Material a, Material b) {
return Double.compare(b.calcularMulta(diasTranscurridos),
a.calcularMulta(diasTranscurridos));
}
}
/** Devuelve una copia ordenada segun el criterio recibido. */
public Material[] ordenar(Comparator<Material> criterio) {
Material[] copia = Arrays.copyOf(materiales, materiales.length);
Arrays.sort(copia, criterio);
return copia;
}
}Sobre Comparator<Material>: los corchetes angulares indican el tipo con el que trabaja el comparador; Comparator<Material> es "un comparador de materiales", y gracias a eso el método compare recibe directamente Material sin conversiones. Aquí solo usas un tipo genérico ya existente; escribir tus propios tipos genéricos es la lección 10-01.
Uso:
Material[] catalogo = {
new Dvd("Refactorizacion en vivo", "DVD-0007", 95),
new Libro("Java Efectivo", "Joshua Bloch", "978-0000000001", 2018),
new Revista("Java Magazine", "REV-2024-03", 42, "Mensual"),
new Libro("Patrones de Diseno", "Erich Gamma", "978-0000000002", 1994)
};
Catalogo c = new Catalogo(catalogo);
System.out.println("--- Por titulo ---");
for (Material m : c.ordenar(new Catalogo.PorTitulo())) {
System.out.println(" " + m.describir());
}
System.out.println("--- Por multa a los 20 dias ---");
for (Material m : c.ordenar(new Catalogo.PorMultaDescendente(20))) {
System.out.printf(" %-24s %.2f EUR%n", m.getTitulo(), m.calcularMulta(20));
}--- Por titulo --- Libro "Java Efectivo" (ref. 978-0000000001) - Joshua Bloch, 2018 Revista "Java Magazine" (ref. REV-2024-03) - n.42 (Mensual) Libro "Patrones de Diseno" (ref. 978-0000000002) - Erich Gamma, 1994 DVD "Refactorizacion en vivo" (ref. DVD-0007) - 95 min --- Por multa a los 20 dias --- Refactorizacion en vivo 8,50 EUR Java Magazine 1,30 EUR Java Efectivo 1,25 EUR Patrones de Diseno 1,25 EUR
Observa el contraste entre los dos comparadores: PorTitulo no tiene estado y PorMultaDescendente sí (guarda diasTranscurridos). Ambos son estáticos, porque ninguno necesita nada del Catalogo; el segundo recibe lo que necesita por constructor. Esa es la forma correcta: pasar los datos, no capturarlos por la puerta de atrás.
En 04-04 verás que PorTitulo puede escribirse sin declarar la clase, en el mismo punto donde se usa. Y en 04-05 quedará en una sola línea.
Errores Comunes y Consejos
Olvidar el static. Es el error más común y el más caro. Una clase anidada sin static que no usa el estado externo paga una referencia oculta, complica la serialización y puede provocar fugas de memoria. Los IDE lo detectan; haz caso al aviso.
Intentar new Externa.Interna() con una interna no estática. No compila: necesita una instancia externa, con la sintaxis externa.new Interna(). Si esa sintaxis te resulta incómoda, es señal de que la clase debería ser static.
Declarar miembros static en una clase interna antes de Java 16. Hasta Java 15 una clase interna no estática solo admitía constantes static final. Desde Java 16 se permite cualquier miembro static. Con Java 17 como referencia del curso no es un problema, pero lo verás en código antiguo.
Confundir this con Externa.this. Dentro de una clase interna, this es la instancia interna. Para llegar a la externa hay que escribir Externa.this. Este malentendido reaparece amplificado en las clases anónimas (04-04).
Modificar una variable local capturada. No compila: debe ser final o efectivamente final. La solución correcta es un campo, no un array de un elemento.
Abusar de las clases anidadas. Si tu clase externa acumula quinientas líneas por culpa de cuatro tipos anidados, sácalos a ficheros propios. La anidación es para tipos pequeños y auxiliares; cuando un tipo tiene entidad propia, merece su fichero.
Consejo: sitúa las clases anidadas al principio o al final. Elige una convención y mantenla en todo el proyecto. Muchos equipos las ponen al final, tras los métodos; otros al principio, como declaración de tipos. Lo importante es no intercalarlas entre los métodos.
Consejo: private static class es el punto de partida. Empieza por la máxima restricción y relájala solo cuando haga falta. Es el mismo criterio que aplicaste a los campos en 03-07, ahora a los tipos.
Ejercicios
Ejercicio 1: Empleado.Historial
Añade a Empleado una clase anidada estática Historial con los campos prestamosTotales, devolucionesTotales y multaAcumulada (double), con getters y toString. Añade a Empleado un campo historial inicializado en el constructor y haz que registrarPrestamo() y registrarDevolucion() lo actualicen. Añade también registrarMulta(double). Justifica en un comentario por qué la clase es static.
Ejercicio 2: fuga de memoria demostrada
Escribe dos versiones de una clase CachePesada que contenga un int[] datos = new int[5_000_000] y una clase anidada Resumen con solo un String. En la versión A, Resumen es interna no estática; en la B, estática. En cada caso: crea la caché, obtén el resumen, pon la caché a null, llama a System.gc() y muestra la memoria usada con Runtime.getRuntime().totalMemory() - freeMemory(). Explica la diferencia.
Ejercicio 3: clase local para un informe agrupado
Escribe un método String informePorTipo(Material[] materiales, int diasTranscurridos) que use una clase local Grupo (con tipo, unidades y multaTotal) para agrupar los materiales por su getTipo() y devolver un informe con una línea por tipo. Recuerda: sin colecciones, usa arrays.
Soluciones
Solución 1
package com.nexussoftware.bibliotech.dominio;
public class Empleado {
public static final int MAX_PRESTAMOS_SIMULTANEOS = 3;
/**
* Historial acumulado de un empleado.
*
* Es ESTATICA porque solo guarda contadores propios: no necesita leer
* el nombre ni el identificador del empleado. Asi un historial puede
* copiarse, compararse o guardarse sin retener al empleado entero.
*/
public static class Historial {
private int prestamosTotales;
private int devolucionesTotales;
private double multaAcumulada;
public int getPrestamosTotales() { return prestamosTotales; }
public int getDevolucionesTotales() { return devolucionesTotales; }
public double getMultaAcumulada() { return multaAcumulada; }
/** Solo Empleado puede modificarlo: metodos de paquete, no publicos. */
void sumarPrestamo() { prestamosTotales++; }
void sumarDevolucion() { devolucionesTotales++; }
void sumarMulta(double importe) {
if (importe > 0) { multaAcumulada += importe; }
}
@Override
public String toString() {
return String.format("%d prestamos, %d devoluciones, %.2f EUR en multas",
prestamosTotales, devolucionesTotales, multaAcumulada);
}
}
private final String nombre;
private final String identificador;
private int prestamosAcumulados;
private final Historial historial;
public Empleado(String nombre, String identificador) {
this.nombre = (nombre == null || nombre.isBlank())
? "Empleado sin nombre" : nombre.trim();
this.identificador = (identificador == null || !identificador.startsWith("EMP-"))
? "EMP-000" : identificador.trim();
this.prestamosAcumulados = 0;
this.historial = new Historial();
}
public String getNombre() { return nombre; }
public String getIdentificador() { return identificador; }
public Historial getHistorial() { return historial; }
public boolean puedeTomarPrestado() {
return prestamosAcumulados < MAX_PRESTAMOS_SIMULTANEOS;
}
public boolean registrarPrestamo() {
if (!puedeTomarPrestado()) {
System.out.printf("AVISO: %s ya tiene %d prestamos (maximo %d).%n",
nombre, prestamosAcumulados, MAX_PRESTAMOS_SIMULTANEOS);
return false;
}
prestamosAcumulados++;
historial.sumarPrestamo();
return true;
}
public void registrarDevolucion() {
prestamosAcumulados = Math.max(0, prestamosAcumulados - 1);
historial.sumarDevolucion();
}
public void registrarMulta(double importe) {
historial.sumarMulta(importe);
}
}Empleado diego = new Empleado("Diego Alonso", "EMP-002");
diego.registrarPrestamo();
diego.registrarPrestamo();
diego.registrarDevolucion();
diego.registrarMulta(1.25);
diego.registrarMulta(8.50);
System.out.println(diego.getNombre() + ": " + diego.getHistorial());Detalle de diseño: los mutadores de Historial (sumarPrestamo, sumarMulta) no son public, sino de paquete. Así el historial es de solo lectura para el exterior —getHistorial() devuelve algo que nadie puede falsear desde otro paquete— pero Empleado sí puede actualizarlo. Es el encapsulamiento de 03-07 aprovechando la visibilidad de paquete que estudiaste allí.
Solución 2
package com.nexussoftware.bibliotech;
public class FugaDemo {
// ---------- Version A: clase interna NO estatica ----------
static class CachePesadaA {
private final int[] datos = new int[5_000_000]; // unos 20 MB
private final String codigo;
CachePesadaA(String codigo) {
this.codigo = codigo;
datos[0] = 1; // evita optimizaciones
}
/** NO estatica: guarda una referencia oculta a CachePesadaA. */
class Resumen {
private final String texto;
Resumen(String texto) { this.texto = texto; }
String getTexto() { return texto; }
}
Resumen resumir() { return new Resumen("Cache " + codigo); }
}
// ---------- Version B: clase anidada ESTATICA ----------
static class CachePesadaB {
private final int[] datos = new int[5_000_000];
private final String codigo;
CachePesadaB(String codigo) {
this.codigo = codigo;
datos[0] = 1;
}
/** ESTATICA: objeto independiente, sin referencia oculta. */
static class Resumen {
private final String texto;
Resumen(String texto) { this.texto = texto; }
String getTexto() { return texto; }
}
Resumen resumir() { return new Resumen("Cache " + codigo); }
}
private static long memoriaUsadaMb() {
Runtime r = Runtime.getRuntime();
return (r.totalMemory() - r.freeMemory()) / (1024 * 1024);
}
private static void reposar() {
System.gc();
// Sin try/catch (modulo 6) se evita Thread.sleep: basta con dos gc()
System.gc();
}
public static void main(String[] args) {
reposar();
System.out.println("Memoria inicial: " + memoriaUsadaMb() + " MB");
// --- A ---
CachePesadaA a = new CachePesadaA("CAT-A");
CachePesadaA.Resumen ra = a.resumir();
a = null;
reposar();
System.out.println("A (interna no estatica): " + memoriaUsadaMb()
+ " MB -> " + ra.getTexto());
// --- B ---
CachePesadaB b = new CachePesadaB("CAT-B");
CachePesadaB.Resumen rb = b.resumir();
b = null;
reposar();
System.out.println("B (anidada estatica): " + memoriaUsadaMb()
+ " MB -> " + rb.getTexto());
}
}Salida típica (los números varían según la JVM y la memoria disponible):
Memoria inicial: 2 MB A (interna no estatica): 21 MB -> Cache CAT-A B (anidada estatica): 21 MB -> Cache CAT-B
La lectura correcta de esos números: tras el bloque A, los ~20 MB siguen ahí aunque a valga null, porque ra mantiene viva la caché A a través de la referencia oculta this$0. En el bloque B se crean otros ~20 MB, pero el recolector puede llevarse la caché B en cuanto b = null, así que la memoria no sube otros 20: lo que ves es la caché A, que nunca se liberó.
Para observarlo aún más claro, repite el bloque en un bucle de cinco iteraciones guardando cada resumen en un array: con la versión A la memoria crece sin parar hasta el OutOfMemoryError; con la B se mantiene estable. Esta es, literalmente, la fuga que aparece en aplicaciones reales cuando un listener o una entrada de caché se implementa como clase interna no estática. Los detalles del recolector están en 10-07.
Solución 3
package com.nexussoftware.bibliotech.servicio;
import com.nexussoftware.bibliotech.dominio.Material;
public class InformeCatalogo {
/**
* Agrupa los materiales por tipo. La clase Grupo es LOCAL: solo tiene
* sentido dentro de este algoritmo y no debe aparecer en la API.
*/
public String informePorTipo(Material[] materiales, int diasTranscurridos) {
class Grupo {
private final String tipo;
private int unidades;
private double multaTotal;
Grupo(String tipo) { this.tipo = tipo; }
void anadir(Material m) {
unidades++;
// 'diasTranscurridos' es un parametro capturado: no se modifica
// en ningun punto del metodo, asi que es efectivamente final.
multaTotal += m.calcularMulta(diasTranscurridos);
}
String linea() {
return String.format(" %-12s %2d unidades %6.2f EUR%n",
tipo, unidades, multaTotal);
}
}
// Sin colecciones (modulo 5): arrays paralelos de tamano maximo
Grupo[] grupos = new Grupo[materiales.length];
int numGrupos = 0;
for (Material m : materiales) {
Grupo destino = null;
for (int i = 0; i < numGrupos; i++) {
if (grupos[i].tipo.equals(m.getTipo())) {
destino = grupos[i];
break;
}
}
if (destino == null) {
destino = new Grupo(m.getTipo());
grupos[numGrupos++] = destino;
}
destino.anadir(m);
}
StringBuilder sb = new StringBuilder();
sb.append(String.format("INFORME POR TIPO (a los %d dias)%n", diasTranscurridos));
for (int i = 0; i < numGrupos; i++) {
sb.append(grupos[i].linea());
}
return sb.toString();
}
}Material[] catalogo = {
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.print(new InformeCatalogo().informePorTipo(catalogo, 20));INFORME POR TIPO (a los 20 dias) Libro 3 unidades 3,75 EUR Revista 1 unidades 1,30 EUR DVD 1 unidades 8,50 EUR
Tres puntos didácticos de la solución:
Grupoes local y está bien que lo sea. No aparece en ninguna firma, no contamina el paquete y sus campos sin getters son aceptables porque su alcance es el método. Si mañana hiciera falta fuera, pasaría a ser una clase anidada estática o unrecord(04-07).- Captura de
diasTranscurridos. Es un parámetro del método y nunca se reasigna, así que es efectivamente final yanadirpuede usarlo. Si en algún punto escribierasdiasTranscurridos++, el método dejaría de compilar en la línea deGrupo, no en la del incremento. - La búsqueda del grupo es O(n²). Recorrer los grupos por cada material es ineficiente y aquí es inevitable, porque no tienes
HashMap. En el módulo 5 este método entero se reducirá a unas pocas líneas.
Conclusión
Ya sabes que Java permite declarar clases dentro de clases y conoces las cuatro variantes con sus reglas: la interna de miembro, ligada a una instancia externa; la anidada estática, independiente; la local, confinada a un método; y la anónima, que llega en la próxima lección. Tienes la tabla que las distingue y, sobre todo, el criterio para elegir: static por defecto, y quítalo solo cuando compruebes que necesitas el estado del objeto externo.
Has visto por dentro el mecanismo de la clase interna: el campo oculto this$0 que el compilador añade, la sintaxis externa.new Interna() que delata la dependencia, y Externa.this para desambiguar cuando los nombres chocan. Y has visto su coste real: mientras viva la instancia interna, la externa no puede ser recolectada. Esa es la causa de fugas de memoria auténticas en producción, difíciles de diagnosticar precisamente porque el código de la clase interna no menciona a la externa por ninguna parte. La solución es una palabra.
Entiendes la restricción de las variables efectivamente finales y —más importante— por qué existe: las variables locales viven en la pila y mueren con el método, mientras que los objetos viven en el montón y pueden sobrevivir; Java captura por valor, y permitir la modificación crearía dos copias descoordinadas del mismo nombre. Es una regla que reaparecerá idéntica en las lambdas. Sabes usar una clase anidada private static como detalle de implementación totalmente oculto —el encapsulamiento de 03-07 aplicado al tipo entero, no ya al campo— y puedes leer los ficheros Externa$Interna.class, Externa$1Local.class y Externa$1.class sabiendo qué revela cada nombre.
Y lo has aplicado. Las incidencias de BiblioTech han dejado de ser cadenas sueltas: Prestamo.Incidencia es una clase anidada estática, inmutable, con día, motivo y gravedad, con equals/hashCode correctos y con un nombre que declara a qué mundo pertenece. Y el catálogo ya se puede ordenar con Catalogo.PorTitulo y Catalogo.PorMultaDescendente, dos comparadores anidados que se pasan como parámetro a Arrays.sort para cambiar el criterio sin tocar el código que ordena.
Ese último ejemplo deja una pregunta en el aire. PorTitulo es una clase entera —declaración, @Override, cuerpo— para una sola línea de lógica que solo se usa en un sitio. Declararla, nombrarla y darle un fichero propio en el espacio de nombres de Catalogo es mucha ceremonia para tan poco. En la lección 04-04, Clases Anónimas, aprenderás a declarar e instanciar una implementación en el mismo punto donde la necesitas y sin ponerle nombre: verás su sintaxis completa —incluido el punto y coma final que todo el mundo olvida—, qué puede y qué no puede hacer, la trampa de this dentro de una anónima, y una tabla de decisión que te dirá, para cada situación, si toca clase con nombre, anidada, local, anónima o —lo que viene después— una lambda.
Curso de Programación en Java
Módulo 1: Introducción a Java
- Introducción a Java
- Configuración del Entorno de Desarrollo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Operadores
- Entrada y Salida por Consola
- Tu Primer Programa Completo: BiblioTech
Módulo 2: Flujo de Control
- Sentencias Condicionales
- Bucles
- Sentencias Switch
- Break y Continue
- Depuración y Trazas de Ejecución
- Proyecto: Menú Interactivo de BiblioTech
Módulo 3: Programación Orientada a Objetos
- Introducción a la POO
- Clases y Objetos
- Métodos
- Constructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- La Clase Object: equals, hashCode y toString
Módulo 4: Programación Orientada a Objetos Avanzada
- Interfaces
- Clases Abstractas
- Clases Internas
- Clases Anónimas
- Expresiones Lambda
- Interfaces Funcionales y Referencias a Métodos
- Enumeraciones y Registros
Módulo 5: Estructuras de Datos y Colecciones
- Arreglos
- El Framework de Colecciones
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cola y Deque
- Pila
- Ordenación y Búsqueda en Colecciones
Módulo 6: Manejo de Excepciones
- Introducción a las Excepciones
- Bloque Try-Catch
- Throw y Throws
- Excepciones Personalizadas
- Bloque Finally
- Try-with-resources y AutoCloseable
- Estrategias de Manejo de Errores y Logging
Módulo 7: Entrada/Salida de Archivos
- Lectura de Archivos
- Escritura de Archivos
- Flujos de Archivos
- BufferedReader y BufferedWriter
- Serialización
- La API NIO.2: Path y Files
- Formatos de Intercambio: CSV y Properties
Módulo 8: Multihilo y Concurrencia
- Introducción al Multihilo
- Creación de Hilos
- Ciclo de Vida de un Hilo
- Sincronización
- Utilidades de Concurrencia
- Colecciones Concurrentes y Variables Atómicas
- Tareas Asíncronas con CompletableFuture
Módulo 9: Redes
- Introducción a las Redes
- Sockets
- ServerSocket
- DatagramSocket y DatagramPacket
- URL y HttpURLConnection
- El Cliente HTTP Moderno
Módulo 10: Temas Avanzados
- Genéricos
- Anotaciones
- Reflexión
- Características de Java 8: Streams y Optional
- Fechas y Horas con java.time
- Java 9 y Más Allá
- Memoria, Recolección de Basura y Rendimiento
Módulo 11: Frameworks y Librerías de Java
- Introducción a los Frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Pruebas Avanzadas con Mockito
- Librerías Esenciales del Ecosistema
