Saltar al contenido
encapsulacion.java · devschool

Encapsulación, static y final en Java

Lección 14 de 26 · 12 min de lectura · Actualizado el

En esta lección
  1. Por qué ocultar el estado de un objeto
  2. Getters y setters con validación
  3. Atributos y métodos static
  4. Constantes con static final
  5. La palabra final en todos sus usos
  6. Clases inmutables
  7. Records: clases de datos en una línea
  8. Errores frecuentes
  9. Resumen

En la lección de clases y objetos viste lo básico: atributos private, un getter y un setter. Aquí vas a ir más allá. Aprenderás por qué conviene ocultar el estado de un objeto, cómo escribir clases que se defienden solas de datos incorrectos, qué significa que algo sea static o final y cómo los records te ahorran decenas de líneas en las clases que solo guardan datos.

Todo el tiempo trabajaremos con el mismo ejemplo: el programa de gestión de una biblioteca municipal, con libros, socios y autores.

Por qué ocultar el estado de un objeto

Imagina una clase Libro con los atributos públicos:

public class Libro {
    public String titulo;
    public int paginas;
    public boolean prestado;
}

Cualquier parte del programa puede escribir libro.paginas = -40; o libro.titulo = "";. El compilador no se queja, y el error aparece mucho después, cuando alguien calcula el tiempo medio de lectura y obtiene un número negativo. Encontrar quién puso ese valor en un proyecto de miles de líneas es muy difícil.

La encapsulación consiste en esconder los datos dentro del objeto y permitir cambiarlos solo a través de métodos de la propia clase. Tiene tres ventajas:

  1. El objeto siempre está en un estado válido. Las comprobaciones están en un único sitio.
  2. Puedes cambiar la implementación sin romper nada. Si mañana guardas las páginas en otro formato, el resto del programa sigue llamando a getPaginas().
  3. El código se entiende mejor. Un método prestar() dice mucho más que libro.prestado = true.

Getters y setters con validación

La versión encapsulada de Libro hace privados todos los atributos y comprueba cada valor antes de guardarlo:

public class Libro {
    private String titulo;
    private int paginas;
    private boolean prestado;

    public Libro(String titulo, int paginas) {
        setTitulo(titulo);   // reutilizamos la validación
        setPaginas(paginas);
    }

    public String getTitulo() { return titulo; }
    public int getPaginas() { return paginas; }
    public boolean isPrestado() { return prestado; }

    public void setTitulo(String titulo) {
        if (titulo == null || titulo.isBlank()) {
            throw new IllegalArgumentException("El título no puede estar vacío");
        }
        this.titulo = titulo.trim();
    }

    public void setPaginas(int paginas) {
        if (paginas <= 0 || paginas > 5000) {
            throw new IllegalArgumentException("Páginas fuera de rango: " + paginas);
        }
        this.paginas = paginas;
    }
}

Fíjate en varios detalles:

  • El constructor llama a los setters en lugar de asignar directamente. Así un libro no puede nacer ya con datos incorrectos.
  • El getter de un boolean se suele llamar isAlgo (isPrestado) en vez de getAlgo.
  • Si el dato no es válido, lanzamos una excepción y el valor anterior se conserva. Las verás a fondo en excepciones.
Libro libro = new Libro("  Don Quijote ", 1200);
System.out.println(libro.getTitulo());  // Don Quijote

try {
    libro.setPaginas(-3);
} catch (IllegalArgumentException e) {
    System.out.println(e.getMessage()); // Páginas fuera de rango: -3
}
System.out.println(libro.getPaginas()); // 1200

No siempre hace falta un setter

Un error típico es generar getter y setter para todos los atributos de forma automática. Piensa en prestado: no tiene sentido un setPrestado(true) que cualquiera pueda llamar. Es mejor ofrecer operaciones con significado que respeten las reglas del negocio:

public void prestar() {
    if (prestado) {
        throw new IllegalStateException("El libro ya está prestado");
    }
    prestado = true;
}

public void devolver() {
    prestado = false;
}

Consejo: antes de escribir un setter, pregúntate si ese dato debería poder cambiar desde fuera. Si la respuesta es no, no lo escribas.

Atributos y métodos static

Hasta ahora cada atributo pertenecía a un objeto: cada libro tiene su título. Un atributo static pertenece a la clase: solo existe una copia, compartida por todos los objetos.

Un contador de instancias

Queremos saber cuántos libros se han creado en total. Ese dato no es de ningún libro concreto, sino de la clase:

public class Libro {
    private static int totalLibros = 0;   // una sola copia para toda la clase

    private String titulo;
    // ...

    public Libro(String titulo, int paginas) {
        setTitulo(titulo);
        setPaginas(paginas);
        totalLibros++;                     // cada nuevo libro suma uno
    }

    public static int getTotalLibros() {
        return totalLibros;
    }
}
new Libro("Don Quijote", 1200);
new Libro("La Regenta", 700);
System.out.println(Libro.getTotalLibros()); // 2

Los miembros static se usan con el nombre de la clase (Libro.getTotalLibros()), no con un objeto. Por eso el método main es static: Java lo llama sin haber creado ningún objeto.

Métodos de utilidad

Hay métodos que no necesitan datos de ningún objeto: reciben todo lo que necesitan por parámetro. Es el caso de Math.sqrt() o Integer.parseInt(), que ya conoces de Math y clases envoltorio. Puedes crear tus propias clases de utilidad:

public final class Utilidades {
    private Utilidades() { }   // nadie puede crear objetos de esta clase

    public static boolean esIsbnValido(String isbn) {
        return isbn != null && isbn.matches("\\d{13}");
    }

    public static String formatearTitulo(String titulo) {
        return titulo.toUpperCase();
    }
}
System.out.println(Utilidades.esIsbnValido("123"));        // false
System.out.println(Utilidades.formatearTitulo("el camino")); // EL CAMINO

El constructor privado impide escribir new Utilidades(), que no tendría ningún sentido.

Qué puede usar un método static

Un método static no tiene this, porque no se ejecuta sobre ningún objeto. Por eso no puede leer atributos normales:

public class Main {
    int x = 5;

    public static void main(String[] args) {
        System.out.println(x); // error: non-static variable x cannot be referenced from a static context
    }
}

La regla es sencilla: lo static solo ve lo static. Los métodos normales, en cambio, pueden usar ambos.

El bloque static

Si un atributo static necesita algo más que una asignación para inicializarse, puedes usar un bloque static. Se ejecuta una sola vez, la primera vez que se usa la clase:

class Configuracion {
    static final String NOMBRE_BIBLIOTECA;

    static {
        NOMBRE_BIBLIOTECA = "Biblioteca Municipal".toUpperCase();
        System.out.println("Cargando configuración...");
    }
}
System.out.println("Antes de usar Configuracion");
System.out.println(Configuracion.NOMBRE_BIBLIOTECA);
System.out.println(Configuracion.NOMBRE_BIBLIOTECA);
// Antes de usar Configuracion
// Cargando configuración...
// BIBLIOTECA MUNICIPAL
// BIBLIOTECA MUNICIPAL

Lo usarás poco, pero aparece en código real para cargar configuración.

Constantes con static final

Un número como 5000 escrito a mano dentro de un método es un número mágico: quien lee el código no sabe qué significa. Lo correcto es convertirlo en una constante:

public class Libro {
    public static final int MAX_PAGINAS = 5000;

    public void setPaginas(int paginas) {
        if (paginas <= 0 || paginas > MAX_PAGINAS) {
            throw new IllegalArgumentException("Páginas fuera de rango: " + paginas);
        }
        this.paginas = paginas;
    }
}
  • static: hay una sola copia para toda la clase.
  • final: no se puede cambiar.
  • Por convenio se escriben en MAYÚSCULAS con guiones bajos: MAX_PAGINAS, DIAS_PRESTAMO.

Como no se puede modificar, no pasa nada por hacerla public: Libro.MAX_PAGINAS vale 5000 desde cualquier parte. Java usa este patrón en sus propias clases: Math.PI o Integer.MAX_VALUE.

La palabra final en todos sus usos

final significa “esto no puede cambiar”, pero lo que no cambia depende de dónde lo pongas.

DóndeSignificado
Variable local o parámetroSolo se le puede asignar un valor una vez
AtributoSe asigna en la declaración o en el constructor, y ya no cambia
MétodoLas subclases no pueden sobrescribirlo
ClaseNadie puede heredar de ella

Variables, parámetros y atributos

final int limite = 3;
limite = 4; // error: cannot assign a value to final variable limite

Un atributo final es ideal para datos que identifican al objeto y nunca deberían cambiar, como el ISBN de un libro:

public class Libro {
    private final String isbn;   // se asigna una vez, en el constructor

    public Libro(String isbn, String titulo, int paginas) {
        if (!Utilidades.esIsbnValido(isbn)) {
            throw new IllegalArgumentException("ISBN no válido: " + isbn);
        }
        this.isbn = isbn;
        // ...
    }

    public String getIsbn() { return isbn; }  // getter sí, setter no
}

Métodos y clases

Un método final no se puede redefinir en una subclase, y de una clase final no se puede heredar. Lo entenderás mejor en la lección de herencia. Por ejemplo, String es final, y nuestra clase Utilidades también:

class MasUtilidades extends Utilidades { }
// error: cannot inherit from final Utilidades

Cuidado: final en una referencia impide cambiar a qué objeto apunta, no el contenido del objeto. Con final List<String> generos = new ArrayList<>(); puedes hacer generos.add("Novela") sin problema; lo que no puedes es generos = new ArrayList<>();.

Clases inmutables

Un objeto inmutable es aquel que no puede cambiar después de crearse. String es el ejemplo más conocido: toUpperCase() no modifica la cadena, devuelve otra nueva.

Las clases inmutables son más seguras: puedes compartir el objeto con cualquier parte del programa sin miedo a que alguien lo altere. Para crear una:

  1. Declara la clase final.
  2. Haz todos los atributos private final.
  3. No escribas setters.
  4. Si un atributo es una colección u otro objeto mutable, copia lo que recibes.

Veamos un socio de la biblioteca con su historial de lecturas:

public final class Socio {
    private final String nombre;
    private final List<String> lecturas;

    public Socio(String nombre, List<String> lecturas) {
        this.nombre = nombre;
        this.lecturas = List.copyOf(lecturas);  // copia inmodificable
    }

    public String getNombre() { return nombre; }
    public List<String> getLecturas() { return lecturas; }

    // En lugar de modificar, devuelve un Socio nuevo
    public Socio conNuevaLectura(String titulo) {
        List<String> nuevas = new ArrayList<>(lecturas);
        nuevas.add(titulo);
        return new Socio(nombre, nuevas);
    }
}
List<String> lista = new ArrayList<>(List.of("Niebla"));
Socio marta = new Socio("Marta", lista);
lista.add("Trampa");                          // no afecta a marta
System.out.println(marta.getLecturas());      // [Niebla]

Socio marta2 = marta.conNuevaLectura("Fortunata y Jacinta");
System.out.println(marta.getLecturas());      // [Niebla]
System.out.println(marta2.getLecturas());     // [Niebla, Fortunata y Jacinta]

Sin List.copyOf, quien creó la lista podría seguir modificándola desde fuera y “colarse” dentro del objeto. Además, la lista devuelta no admite cambios: marta.getLecturas().add("X") lanza UnsupportedOperationException. Las listas las estudiarás en colecciones.

Records: clases de datos en una línea

Muchas clases solo sirven para agrupar datos: un autor con su nombre y año de nacimiento, un punto con x e y. Escribirlas a mano obliga a repetir constructor, getters, equals, hashCode y toString. Desde Java 16 existen los records:

public record Autor(String nombre, int anioNacimiento) { }

Esa línea crea automáticamente una clase final e inmutable con:

  • Atributos private final para cada componente.
  • Un constructor con todos ellos.
  • Métodos de acceso sin el prefijo get: nombre() y anioNacimiento().
  • equals, hashCode y toString ya escritos.
Autor a = new Autor("Benito Pérez Galdós", 1843);
Autor b = new Autor("Benito Pérez Galdós", 1843);

System.out.println(a);            // Autor[nombre=Benito Pérez Galdós, anioNacimiento=1843]
System.out.println(a.nombre());   // Benito Pérez Galdós
System.out.println(a.equals(b));  // true: mismos datos
System.out.println(a == b);       // false: son dos objetos distintos

Validar y añadir métodos a un record

Un record puede tener un constructor compacto para validar (sin paréntesis ni asignaciones: Java las hace por ti al final) y métodos propios:

public record Autor(String nombre, int anioNacimiento) {
    public Autor {
        if (anioNacimiento < 0) {
            throw new IllegalArgumentException("Año no válido");
        }
    }

    public String iniciales() {
        return nombre.substring(0, 1);
    }
}

Usa un record cuando la clase sea básicamente un “paquete de datos” que no cambia. Si necesitas modificar el estado (como prestar() en Libro), usa una clase normal.

Errores frecuentes

  • Poner setters a todo. Rompe la encapsulación igual que un atributo público. Ofrece solo las operaciones que tengan sentido.
  • Asignar en el constructor sin validar. Si el constructor hace this.paginas = paginas y el setter valida, puedes crear objetos inválidos. Llama al setter o repite la comprobación.
  • Usar un atributo normal desde un método static. Da el error non-static variable cannot be referenced from a static context. Crea un objeto o haz el atributo static si de verdad es de la clase.
  • Usar static para “que funcione”. Si conviertes todos los atributos en static para evitar el error anterior, todos los libros compartirán el mismo título. static solo para datos que son realmente de la clase.
  • Creer que final hace inmutable un objeto. Solo congela la referencia. Para inmutabilidad real, sigue los cuatro pasos de arriba y no devuelvas colecciones internas modificables.

Resumen

ConceptoPara qué sirve
private + getters/settersControlar cómo se leen y cambian los datos
Validación en setters y constructorQue el objeto nunca esté en un estado incorrecto
static en atributosUn dato compartido por toda la clase (contador)
static en métodosMétodos de utilidad que no dependen de un objeto
Bloque staticInicializar datos de la clase una sola vez
static finalConstantes con nombre en MAYÚSCULAS
finalVariable que no se reasigna, método que no se sobrescribe, clase que no se hereda
Clase inmutableObjetos seguros de compartir: final, sin setters, con copias
recordClases de datos inmutables en una línea (Java 16+)

En la siguiente lección verás cómo organizar todas estas clases en paquetes y qué otros modificadores de acceso existen además de public y private.

Pon a prueba lo que has aprendido

[Java] ¿Qué imprime este código?
class Contador {
    static int total = 0;
    int propio = 0;
    Contador() { total++; propio++; }
}

Contador c1 = new Contador();
Contador c2 = new Contador();
Contador c3 = new Contador();
System.out.println(Contador.total + " " + c3.propio);

[Java] ¿Qué ocurre con este código?
final List<String> generos = new ArrayList<>();
generos.add("Novela");
generos.add("Poesía");
System.out.println(generos.size());

[Java] ¿Qué imprime este código con un record?
record Punto(int x, int y) { }

Punto a = new Punto(1, 2);
Punto b = new Punto(1, 2);
System.out.println(a.equals(b) + " " + (a == b) + " " + a.x());

[Java] ¿Por qué no compila esta clase?
class Biblioteca {
    String nombre = "Central";

    static void mostrar() {
        System.out.println(nombre);
    }
}

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