Encapsulación, static y final en Java
En esta lección
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:
- El objeto siempre está en un estado válido. Las comprobaciones están en un único sitio.
- 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(). - El código se entiende mejor. Un método
prestar()dice mucho más quelibro.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
booleanse suele llamarisAlgo(isPrestado) en vez degetAlgo. - 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ónde | Significado |
|---|---|
| Variable local o parámetro | Solo se le puede asignar un valor una vez |
| Atributo | Se asigna en la declaración o en el constructor, y ya no cambia |
| Método | Las subclases no pueden sobrescribirlo |
| Clase | Nadie 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:
finalen una referencia impide cambiar a qué objeto apunta, no el contenido del objeto. Confinal List<String> generos = new ArrayList<>();puedes hacergeneros.add("Novela")sin problema; lo que no puedes esgeneros = 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:
- Declara la clase
final. - Haz todos los atributos
private final. - No escribas setters.
- 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 finalpara cada componente. - Un constructor con todos ellos.
- Métodos de acceso sin el prefijo get:
nombre()yanioNacimiento(). equals,hashCodeytoStringya 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 = paginasy 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 atributostaticsi de verdad es de la clase. - Usar
staticpara “que funcione”. Si conviertes todos los atributos enstaticpara evitar el error anterior, todos los libros compartirán el mismo título.staticsolo para datos que son realmente de la clase. - Creer que
finalhace inmutable un objeto. Solo congela la referencia. Para inmutabilidad real, sigue los cuatro pasos de arriba y no devuelvas colecciones internas modificables.
Resumen
| Concepto | Para qué sirve |
|---|---|
private + getters/setters | Controlar cómo se leen y cambian los datos |
| Validación en setters y constructor | Que el objeto nunca esté en un estado incorrecto |
static en atributos | Un dato compartido por toda la clase (contador) |
static en métodos | Métodos de utilidad que no dependen de un objeto |
Bloque static | Inicializar datos de la clase una sola vez |
static final | Constantes con nombre en MAYÚSCULAS |
final | Variable que no se reasigna, método que no se sobrescribe, clase que no se hereda |
| Clase inmutable | Objetos seguros de compartir: final, sin setters, con copias |
record | Clases 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
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.