Clases Inmutables en Java
Diseña clases inmutables en Java con campos final, copias defensivas y sin setters.
Una clase inmutable es aquella cuyas instancias no pueden cambiar tras su construcción. String, Integer, LocalDate, BigDecimal, UUID — la biblioteca estándar de Java está llena de ellas, y no es casualidad. Los objetos inmutables son seguros para compartir entre hilos, seguros para usar como claves de HashMap, seguros para cachear y fáciles de razonar: una vez que has visto uno, conoces su estado para el resto de su vida.
Hacer una clase inmutable no se trata de añadir una sola palabra clave — se trata de seguir un conjunto de reglas conjuntamente. Si omites una, obtienes una clase que parece inmutable pero no lo es.
Las cinco reglas
Para hacer una clase genuinamente inmutable:
- Declara la clase
final(o utiliza solo constructores privados). De lo contrario, una subclase puede romper el contrato. - Haz que cada campo sea
private final.finalevita la reasignación tras la construcción;privateimpide que los llamadores los toquen directamente. - No expongas setters. Cualquier método de mutación (
add,set,clear,reset) está fuera. - Copia defensivamente las entradas mutables en el constructor. Si el llamador pasa un
Dateo unaList, cópialo — de lo contrario, pueden mutarlo desde fuera y tu objeto "inmutable" cambia por debajo de ti. - Copia defensivamente las devoluciones mutables en los getters — por la misma razón pero en sentido inverso.
Una clase que cumple las cinco es profundamente inmutable. Si omites cualquiera, la garantía se filtra.
final por sí solo no es inmutabilidad. Un campo final no puede reasignarse, pero si apunta a un objeto mutable — una List, un array, un Date — ese objeto aún puede cambiar. final List<String> tags significa que no puedes cambiar la lista por otra diferente, no que el contenido de la lista esté congelado. Las reglas 4 y 5 existen precisamente para cerrar esa brecha. Consulta Java final keyword para saber qué promete y qué no promete final.
El ejemplo mínimo
Para una clase cuyos campos son todos primitivos o ya inmutables, las reglas se reducen a casi nada:
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int x() { return x; }
public int y() { return y; }
}int es un primitivo, así que no hay nada que copiar defensivamente. La clase es final, los campos son private final, no existen setters. Listo.
Los campos mutables necesitan copias defensivas
El problema comienza cuando un campo es en sí mismo mutable — un array, un Date, un ArrayList. Si almacenas la referencia del llamador directamente, este mantiene un acceso a ella y puede mutar tus internos:
// Broken: the array is shared
public final class Trajectory {
private final double[] points;
public Trajectory(double[] points) { this.points = points; }
public double[] points() { return points; }
}
double[] arr = {1.0, 2.0, 3.0};
Trajectory t = new Trajectory(arr);
arr[0] = 999; // mutates the "immutable" object!
System.out.println(t.points()[0]); // 999La solución es copiar al entrar y al salir:
public final class Trajectory {
private final double[] points;
public Trajectory(double[] points) {
this.points = points.clone(); // copy in
}
public double[] points() {
return points.clone(); // copy out
}
}Para las colecciones, el equivalente es List.copyOf(other) (devuelve una lista no modificable respaldada por una copia):
public final class Recipe {
private final String name;
private final List<String> steps;
public Recipe(String name, List<String> steps) {
this.name = name;
this.steps = List.copyOf(steps); // copy + unmodifiable view
}
public List<String> steps() { return steps; } // already unmodifiable
}Observa la asimetría con el ejemplo del array: el clone() de un array produce una copia mutable, por lo que debes copiar de nuevo al salir. List.copyOf produce una lista no modificable, así que el getter puede entregarla directamente — cualquier llamador que intente mutarla recibe una UnsupportedOperationException. Prefiere tipos de colecciones inmutables cuando puedas; eliminan toda una clase de errores de copia-al-salir.
Las "modificaciones" devuelven nuevas instancias
Una clase inmutable puede seguir soportando cambios — devolviendo una nueva instancia:
public final class Money {
private final long cents;
public Money plus(Money other) { return new Money(cents + other.cents); }
public Money times(int factor) { return new Money(cents * factor); }
// constructor + accessors omitted
}La convención nombra el método with... cuando produce una copia con un campo cambiado: point.withX(5), user.withEmail("..."). La API de fecha/hora de Java utiliza este patrón de forma consistente — LocalDate.plusDays(7), LocalDate.withYear(2026).
Por qué importa esto
Los objetos inmutables te proporcionan:
- Seguridad de hilos de forma gratuita. Sin bloqueos, sin
volatile, sin sorpresas de visibilidad — no hay nada que sincronizar porque el estado no puede cambiar. - Compartición y caché seguros. Dos llamadores que mantienen el mismo
Money(2000, "USD")no pueden interferir entre sí. - Claves de hash confiables. Dado que los campos usados en
hashCodeno pueden cambiar, el bucket del objeto nunca queda obsoleto. Una clave mutable cuyo hash cambia después de almacenarse en unHashMapse pierde efectivamente — consulta Java equals and hashCode. - Razonamiento más sencillo. Una vez que has visto un objeto inmutable, sabes lo que hará el resto de su vida. Sin arqueología de "¿dónde se mutó esto?".
El coste es asignar nuevas instancias para cada "modificación". Para objetos pequeños y de uso frecuente (String, Integer), esto rara vez es un problema; la JVM es muy buena con las asignaciones de corta duración. Para casos genuinamente costosos existen técnicas específicas (string builders, estructuras de datos persistentes) — pero recurre a ellas solo cuando el profiling muestre un problema real.
Los records hacen la mayor parte del trabajo
Un record es implícitamente final, tiene campos private final, genera accessors sin setters, y te proporciona equals/hashCode/toString de forma gratuita:
public record Point(int x, int y) {}Eso es profundamente inmutable siempre que los componentes en sí sean inmutables. Para records que contienen un componente mutable (una List, un array), aún necesitas un constructor compacto que copie defensivamente:
public record Recipe(String name, List<String> steps) {
public Recipe {
steps = List.copyOf(steps);
}
}Cuando los records encajan, son el camino más corto hacia una clase inmutable correcta.
Un ejemplo trabajado
Qué viene después
Las clases inmutables tratan de controlar el cambio. El capítulo final de la Parte 6 trata de controlar la cantidad — una clase diseñada para que solo exista una instancia. Continúa en Java singleton pattern.