Saltar al contenido
clases-internas.java · devschool

Clases internas y anónimas en Java

Lección 23 de 39 · 15 min de lectura · Actualizado el

En esta lección
  1. Para qué sirve una clase dentro de otra
  2. Clases estáticas anidadas
  3. Clases internas
  4. Clases locales
  5. Clases anónimas
  6. De las clases anónimas a las lambdas
  7. Qué genera el compilador
  8. ¿Cuál uso?
  9. Errores frecuentes
  10. Resumen

Hasta ahora cada clase vivía en su propio archivo. Pero Java permite declarar una clase dentro de otra, e incluso dentro de un método o en mitad de una expresión, sin darle nombre. Estas clases anidadas sirven para agrupar piezas que solo tienen sentido juntas y para esconder detalles que nadie más necesita ver. En esta lección conocerás los cuatro tipos (estáticas anidadas, internas, locales y anónimas), sabrás cuándo conviene cada uno y entenderás por qué las clases anónimas fueron el origen de las lambdas.

Para qué sirve una clase dentro de otra

Piensa en una tienda online. Un carrito tiene líneas (producto, unidades y precio). Una línea no tiene sentido fuera de un carrito, así que un archivo Linea.java suelto desordena el proyecto y sugiere que se puede usar en cualquier sitio.

Declararla dentro de Carrito deja claro que es una pieza del carrito. Las clases anidadas aportan:

  • Organización: lo que va junto, está junto. Se nota en el nombre: Carrito.Linea.
  • Encapsulación: una clase anidada puede ser private y quedar invisible para el resto del programa.
  • Acceso privilegiado: la clase de dentro puede usar los miembros private de la de fuera, y al revés.

Hay cuatro tipos:

TipoDónde se declara¿Necesita un objeto de la clase externa?
Estática anidadaDentro de la clase, con staticNo
InternaDentro de la clase, sin staticSí
LocalDentro de un métodoSí, si el método no es static
AnónimaEn la propia expresión new, sin nombreSí, si se crea fuera de un contexto static

A la clase que contiene a las demás se la llama clase externa (outer class).

Clases estáticas anidadas

Una clase estática anidada se declara dentro de otra con la palabra static. Es, a todos los efectos, una clase normal que vive dentro del “espacio de nombres” de la externa:

import java.util.ArrayList;
import java.util.List;

public class Carrito {
    private final List<Linea> lineas = new ArrayList<>();

    public static class Linea {
        private final String producto;
        private final int unidades;
        private final double precio;

        public Linea(String producto, int unidades, double precio) {
            this.producto = producto;
            this.unidades = unidades;
            this.precio = precio;
        }

        public double subtotal() { return unidades * precio; }

        @Override
        public String toString() {
            return unidades + " x " + producto + " = " + subtotal() + " €";
        }
    }

    public void anadir(String producto, int unidades, double precio) {
        lineas.add(new Linea(producto, unidades, precio));
    }

    public double total() {
        double suma = 0;
        for (Linea l : lineas) suma += l.subtotal();
        return suma;
    }

    public List<Linea> getLineas() { return List.copyOf(lineas); }
}

Dentro de Carrito se escribe simplemente Linea. Desde fuera, con el nombre completo Carrito.Linea:

Carrito c = new Carrito();
c.anadir("Camiseta", 2, 15.0);
c.anadir("Gorra", 1, 12.5);

for (Carrito.Linea l : c.getLineas()) {
    System.out.println(l);
}
System.out.println("Total: " + c.total() + " €");
// 2 x Camiseta = 30.0 €
// 1 x Gorra = 12.5 €
// Total: 42.5 €

Carrito.Linea suelta = new Carrito.Linea("Calcetines", 3, 4.0);
System.out.println(suelta); // 3 x Calcetines = 12.0 €

Como es static, una Linea no está ligada a ningún carrito concreto. Por eso no puede usar los atributos normales de Carrito:

public static class Linea {
    void mostrar() { System.out.println(lineas); }
    // error: non-static variable lineas cannot be referenced from a static context
}

Es la misma regla que ya conoces de los métodos static: lo estático no tiene un this de la clase externa.

Nota: si Linea fuera private, nadie fuera de Carrito podría usarla. Aquí es public porque getLineas() la devuelve; si solo la usara el carrito por dentro, private sería lo correcto.

Un uso muy común: el patrón Builder

Cuando un objeto tiene muchos datos opcionales, un constructor con siete parámetros es ilegible: new Pizza("familiar", true, false, ...). El patrón Builder usa una clase estática anidada que va recogiendo los datos paso a paso y al final crea el objeto:

import java.util.ArrayList;
import java.util.List;

public class Pizza {
    private final String tamano;
    private final List<String> ingredientes;

    private Pizza(Builder b) {           // privado: solo el Builder crea pizzas
        this.tamano = b.tamano;
        this.ingredientes = List.copyOf(b.ingredientes);
    }

    @Override
    public String toString() { return "Pizza " + tamano + " con " + ingredientes; }

    public static class Builder {
        private String tamano = "mediana";
        private final List<String> ingredientes = new ArrayList<>();

        public Builder tamano(String t) { tamano = t; return this; }
        public Builder con(String ingrediente) { ingredientes.add(ingrediente); return this; }
        public Pizza build() { return new Pizza(this); }
    }
}
Pizza p = new Pizza.Builder()
        .tamano("familiar")
        .con("tomate").con("mozzarella").con("albahaca")
        .build();
System.out.println(p);  // Pizza familiar con [tomate, mozzarella, albahaca]

System.out.println(new Pizza.Builder().con("jamón").build()); // Pizza mediana con [jamón]

Cada método del Builder devuelve this, y por eso se pueden encadenar. Fíjate en que el constructor de Pizza lee b.tamano, un atributo private del Builder: entre una clase y sus anidadas no hay secretos.

Clases internas

Si quitas el static, tienes una clase interna (inner class). La diferencia es importante: cada objeto de la clase interna está ligado a un objeto concreto de la clase externa y puede usar todos sus atributos, incluidos los privados.

El uso típico es crear un “ayudante” que trabaja sobre los datos de su objeto externo. Por ejemplo, una lista de reproducción que se puede recorrer con un for-each. Para eso tiene que implementar Iterable y devolver un iterador, un objeto que sabe ir dando los elementos uno a uno:

import java.util.Iterator;
import java.util.NoSuchElementException;

public class Playlist implements Iterable<String> {
    private final String nombre;
    private final String[] canciones = new String[50];
    private int total = 0;

    public Playlist(String nombre) { this.nombre = nombre; }

    public void anadir(String cancion) {
        canciones[total++] = cancion;
    }

    // Clase interna: cada Recorrido pertenece a una Playlist concreta
    private class Recorrido implements Iterator<String> {
        private int posicion = 0;

        @Override
        public boolean hasNext() {
            return posicion < total;          // atributo privado de Playlist
        }

        @Override
        public String next() {
            if (!hasNext()) throw new NoSuchElementException();
            return canciones[posicion++];     // otro atributo privado de Playlist
        }
    }

    @Override
    public Iterator<String> iterator() {
        return new Recorrido();
    }
}
Playlist rock = new Playlist("Rock nacional");
rock.anadir("Insurrección");
rock.anadir("Maldito duende");
rock.anadir("La chica de ayer");

for (String cancion : rock) {
    System.out.println(cancion);
}
// Insurrección
// Maldito duende
// La chica de ayer

Recorrido lee total y canciones como si fueran suyos. Eso es posible porque, al crearse con new Recorrido() dentro de iterator(), guarda una referencia oculta a la Playlist que lo creó. Si hubiera dos playlists, cada recorrido leería las canciones de la suya. Además, Recorrido es private: quien usa la playlist ni sabe que existe.

Crear objetos internos desde fuera y Externa.this

Como un objeto interno necesita uno externo, no basta con new. Si añadimos a Playlist una clase interna pública:

public class Reproductor {
    private String nombre = "Reproductor";

    public void info() {
        System.out.println(nombre);                // el del Reproductor
        System.out.println(Playlist.this.nombre);  // el de la Playlist
    }
}

Desde fuera hay que crearla a partir de un objeto de Playlist, con la sintaxis objeto.new Interna():

Playlist.Reproductor r = rock.new Reproductor();
r.info();
// Reproductor
// Rock nacional

Playlist.Reproductor mal = new Playlist.Reproductor();
// error: an enclosing instance that contains Playlist.Reproductor is required

Dentro de la clase interna, this es el objeto interno. Para referirte al objeto externo (por ejemplo, cuando un atributo tiene el mismo nombre en las dos) se escribe Externa.this, como en Playlist.this.nombre.

Consejo: si tu clase anidada no usa nada del objeto externo, declárala static. Una clase interna guarda siempre una referencia a su objeto externo; eso gasta memoria y puede impedir que el objeto externo se libere mientras el interno siga vivo.

Clases locales

Una clase local se declara dentro de un método y solo existe ahí. Es útil cuando necesitas una pequeña estructura auxiliar para un único cálculo:

static void informe(List<String> nombres, int minimo) {
    class Estadistica {
        int cortos, largos;

        void contar(String n) {
            if (n.length() < minimo) cortos++; else largos++;
        }
    }

    Estadistica e = new Estadistica();
    for (String n : nombres) e.contar(n);
    System.out.println("Cortos: " + e.cortos + ", largos: " + e.largos);
}
informe(List.of("Ana", "Bartolomé", "Eva", "Guillermo", "Iker"), 5);
// Cortos: 3, largos: 2

La clase Estadistica usa minimo, que es un parámetro del método. Las clases locales (y las anónimas) pueden capturar variables locales, pero solo si son final o efectivamente finales: que nunca se reasignan (lo viste en static y final). Si después de declarar minimo hicieras minimo = 3;, no compilaría.

En la práctica, las clases locales se ven poco. Si solo necesitas agrupar unos datos dentro de un método, un record local suele ser más cómodo.

Clases anónimas

Una clase anónima se declara y se instancia a la vez, en la misma expresión, y no tiene nombre. Sirve para crear un único objeto que implementa una interfaz o extiende una clase, cuando no merece la pena escribir una clase aparte.

Partimos de una interfaz para los descuentos de la tienda:

interface Descuento {
    double aplicar(double precio);
}

Sin clase anónima, tendrías que escribir class DescuentoRebajas implements Descuento { ... } en otro sitio. Con ella:

Descuento rebajas = new Descuento() {
    @Override
    public double aplicar(double precio) {
        return precio * 0.8;
    }
};

System.out.println(rebajas.aplicar(50)); // 40.0

Lee new Descuento() { ... } como “crea un objeto de una clase sin nombre que implementa Descuento con este cuerpo”. No estás haciendo new de una interfaz (eso es imposible): estás creando un objeto de una clase nueva cuyo código va entre las llaves. Fíjate en el ; final, porque todo es una única instrucción de asignación.

Igual que las locales, las anónimas pueden capturar variables efectivamente finales:

double porcentaje = 15;
Descuento socio = new Descuento() {
    @Override
    public double aplicar(double precio) {
        return precio - precio * porcentaje / 100;
    }
};
System.out.println(socio.aplicar(40)); // 34.0

Si porcentaje se reasignara en algún momento, el compilador diría: local variables referenced from an inner class must be final or effectively final.

Anónimas que extienden una clase abstracta

Una clase anónima también puede heredar de una clase abstracta, implementar varios métodos y tener sus propios atributos:

abstract class Notificacion {
    abstract void enviar(String texto);

    void enviarVarias(String... textos) {
        for (String t : textos) enviar(t);
    }
}
Notificacion porConsola = new Notificacion() {
    private int enviadas = 0;

    @Override
    void enviar(String texto) {
        enviadas++;
        System.out.println("[" + enviadas + "] " + texto);
    }
};

porConsola.enviarVarias("Pedido recibido", "Pedido enviado");
// [1] Pedido recibido
// [2] Pedido enviado

Una clase anónima no puede tener constructor (no tiene nombre con el que llamarlo), pero sí atributos inicializados, como enviadas.

De las clases anónimas a las lambdas

Antes de Java 8, las clases anónimas eran la forma habitual de pasar “un trozo de comportamiento” a un método, por ejemplo para ordenar una lista:

List<String> productos = new ArrayList<>(List.of("Mochila", "Gorra", "Cantimplora"));

productos.sort(new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return Integer.compare(a.length(), b.length());
    }
});
System.out.println(productos); // [Gorra, Mochila, Cantimplora]

Seis líneas para decir “compara por longitud”. Cuando la interfaz tiene un solo método abstracto (una interfaz funcional, como Comparator o nuestro Descuento), Java 8 permite escribir lo mismo con una lambda:

productos.sort((a, b) -> Integer.compare(b.length(), a.length()));
System.out.println(productos); // [Cantimplora, Mochila, Gorra]

Descuento lambda = precio -> precio - precio * porcentaje / 100;
System.out.println(lambda.aplicar(40)); // 34.0

La lambda es, conceptualmente, una clase anónima reducida a lo esencial: los parámetros y el cuerpo. La sintaxis completa la tienes en lambdas y streams. Pero no son exactamente lo mismo:

Clase anónimaLambda
Con qué funcionaInterfaces con cualquier número de métodos y clases (también abstractas)Solo interfaces funcionales (un método abstracto)
Atributos propiosSíNo
Qué es this dentroEl objeto anónimoEl objeto de la clase que la contiene
Variables capturadasEfectivamente finalesEfectivamente finales
Archivo .classGenera uno propio (Tienda$1.class)No genera archivo propio

La diferencia de this se ve en este ejemplo:

public class Tienda {
    private String nombre = "Tienda Norte";

    void probar() {
        Runnable anonima = new Runnable() {
            @Override
            public void run() {
                System.out.println(this.getClass().getName());
            }
        };
        Runnable lambda = () -> System.out.println(this.nombre);

        anonima.run(); // Tienda$1
        lambda.run();  // Tienda Norte
    }
}

En la anónima, this es el objeto anónimo, cuya clase el compilador ha llamado Tienda$1. En la lambda, this sigue siendo la Tienda.

¿Cuándo sigue teniendo sentido una clase anónima? Cuando la interfaz tiene más de un método, cuando extiendes una clase (abstracta o no), o cuando el objeto necesita guardar estado propio, como el contador de Notificacion. Para todo lo demás, usa una lambda o una referencia a método (Comparator.comparingInt(String::length)).

Qué genera el compilador

La máquina virtual no conoce las clases anidadas: el compilador crea un .class aparte para cada una, con un $ en el nombre. Verás archivos como Carrito$Linea.class, Playlist$Recorrido.class o Tienda$1.class (las anónimas se numeran por orden). Conviene reconocerlos cuando aparecen en un error o en la carpeta de compilación.

¿Cuál uso?

SituaciónElección
Una clase auxiliar que no necesita datos del objeto externo (línea de carrito, Builder)Estática anidada
Un ayudante que trabaja con los datos de un objeto concreto (iterador)Interna
Una estructura auxiliar para un único métodoRecord local o clase local
Implementar una interfaz funcional una sola vezLambda
Implementar una interfaz de varios métodos o extender una clase una sola vezAnónima
Una clase que se usa en muchos sitiosClase normal en su propio archivo

Errores frecuentes

  • Crear un objeto interno desde main con new Interna(). Da non-static variable this cannot be referenced from a static context: falta el objeto externo. Haz la clase static si no lo necesita, o usa externo.new Interna().
  • Usar atributos de la clase externa desde una estática anidada. No tiene objeto externo. Pásale los datos por parámetro o quita el static si de verdad los necesita.
  • Modificar una variable local que usa una anónima o una lambda. Solo pueden capturar variables efectivamente finales. Usa una variable nueva o un atributo.
  • Olvidar el ; después de una clase anónima. La anónima forma parte de una instrucción, que termina en punto y coma.
  • Confundir this en una clase anónima. Es el objeto anónimo, no el de la clase que lo contiene. Usa Externa.this o una lambda.
  • Hacer interna una clase que no usa nada del objeto externo. Gasta memoria sin necesidad. Por defecto, anidada static.

Resumen

TipoSintaxisIdea clave
Estática anidadastatic class Linea { } dentro de CarritoClase normal agrupada; se usa como Carrito.Linea
Internaclass Recorrido { } dentro de PlaylistLigada a un objeto externo; ve sus atributos privados
Crear interna desde fuerarock.new Reproductor()Necesita un objeto de la clase externa
Externa.thisPlaylist.this.nombreReferencia al objeto externo
Localclass Estadistica { } dentro de un métodoSolo existe en ese método
Anónimanew Descuento() { ... };Se declara y se crea a la vez, sin nombre
Lambdaprecio -> precio * 0.8Sustituye a la anónima en interfaces funcionales

En la siguiente lección verás un tipo especial de clase para representar conjuntos fijos de valores: los enums.

Pon a prueba lo que has aprendido

[Java] En main escribes new Playlist.Reproductor(), siendo Reproductor una clase interna (no static) de Playlist, y no compila. ¿Cómo se crea correctamente?

[Java] ¿Qué ocurre al compilar este código?
double porcentaje = 15;
Descuento d = new Descuento() {
    public double aplicar(double precio) {
        return precio - precio * porcentaje / 100;
    }
};
porcentaje = 20;

[Java] En la clase Tienda, ¿qué imprime anonima.run() y qué imprime lambda.run()?
public class Tienda {
    private String nombre = "Tienda Norte";
    void probar() {
        Runnable anonima = new Runnable() {
            public void run() { System.out.println(this.getClass().getName()); }
        };
        Runnable lambda = () -> System.out.println(this.nombre);
        anonima.run();
        lambda.run();
    }
}

[Java] Una clase Linea dentro de Carrito solo guarda producto, unidades y precio, y no usa ningún atributo del carrito. ¿Qué tipo de clase anidada es lo más adecuado?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.