Constructores, Modificadores de Acceso y Getters/Setters
En la lección anterior creabas objetos así:
Persona p1 = new Persona();
p1.nombre = "Laura";
p1.edad = 28;
Funciona, pero tiene un problema grave: entre la línea 1 y la línea 3 existe un objeto roto. Un Persona sin nombre y con edad 0 es un objeto que la clase permite crear pero que no representa a ninguna persona real. Y si alguien olvida la línea 2, ese objeto inválido va a circular por todo el programa hasta explotar mucho más lejos, en un lugar donde el error ya no se parece en nada a su causa.
Esta lección resuelve exactamente eso con dos herramientas que trabajan juntas:
- Constructores: garantizan que un objeto nazca completo y válido. No hay ventana de tiempo en la que exista a medio armar.
- Encapsulamiento: garantiza que, una vez nacido válido, nadie lo pueda dejar inválido desde afuera.
1. El constructor: el único momento en que un objeto nace
Un constructor es un bloque de código especial que la JVM ejecuta automáticamente durante new, y solo entonces. Se reconoce por dos reglas sintácticas:
- Se llama exactamente igual que la clase (mayúsculas incluidas).
- No declara tipo de retorno. Ni
void, niint, ni nada.
public class Producto {
private String nombre;
private double precio;
// Constructor: mismo nombre que la clase, sin tipo de retorno
public Producto(String nombre, double precio) {
this.nombre = nombre;
this.precio = precio;
}
}
Qué pasa realmente cuando ejecutás new
El constructor no es el primer paso de new, es el cuarto. Entender el orden completo explica por qué un atributo puede valer 0 incluso cuando en el constructor le asignás otra cosa.
new ejecuta cinco etapas. El constructor es la cuarta, no la primera: el objeto ya existe en memoria cuando tu código empieza a correr.La consecuencia práctica de la etapa 5 es enorme: si el constructor detecta datos inválidos, los corrige antes de terminar, asignando un valor por defecto seguro y avisando por consola. Ningún objeto sale de la etapa 5 con datos rotos: o tiene los valores que le pasaste, o tiene los valores por defecto que el propio constructor eligió. Esa es la diferencia entre validar en el constructor y validar después.
Más adelante vas a conocer una herramienta más robusta para rechazar datos inválidos —las excepciones—, en la lección Manejo de Excepciones y Robustez. Por ahora trabajamos solo con lo que ya sabés:
if, valores de retorno y valores por defecto.
2. El constructor por defecto y la trampa que esconde
Si vos no escribís ningún constructor, el compilador te regala uno vacío y sin parámetros:
public class Persona {
private String nombre;
// El compilador inserta implícitamente:
// public Persona() { }
}
Persona p = new Persona(); // Compila
Pero apenas escribís un solo constructor propio, ese regalo desaparece:
public class Persona {
private String nombre;
public Persona(String nombre) {
this.nombre = nombre;
}
}
Persona p = new Persona(); // ERROR de compilación
Persona q = new Persona("Laura"); // Correcto
Y esto no es un capricho del lenguaje: es intencional. Si declaraste que una Persona necesita un nombre para existir, el compilador te sostiene esa decisión y te impide crear una sin él. El constructor es un contrato, y el compilador es quien lo hace cumplir.
Si además querés seguir permitiendo
new Persona(), tenés que escribirlo vos mismo explícitamente. No lo escribas por costumbre: escribilo solo si un objeto sin datos tiene sentido real en tu dominio.
3. Sobrecarga de constructores y delegación con this(...)
Una clase puede tener varios constructores siempre que difieran en su lista de parámetros (cantidad, tipos u orden). Eso es sobrecarga, el mismo concepto que ya viste en métodos.
El error clásico es duplicar la lógica en cada uno:
// MAL: la validación de precio está copiada tres veces.
public Producto() {
this.nombre = "Sin nombre";
this.precio = 0;
}
public Producto(String nombre) {
this.nombre = nombre;
this.precio = 0;
}
public Producto(String nombre, double precio) {
this.nombre = nombre;
if (precio < 0) {
System.out.println("Precio inválido, se usó 0 por defecto.");
precio = 0;
}
this.precio = precio;
}
Si mañana agregás una regla nueva —que el nombre no pueda estar vacío— tenés que acordarte de tocar los tres. Vas a olvidarte de uno. Siempre pasa.
La solución es this(...): un constructor puede llamar a otro constructor de la misma clase y delegarle todo el trabajo. Se elige un único constructor canónico que concentra la validación, y el resto simplemente le pasa valores por defecto.
this(...): los constructores de conveniencia no repiten lógica, solo completan valores por defecto y llaman al canónico.public class Producto {
private String nombre;
private double precio;
public Producto() {
this("Sin nombre", 0); // delega
}
public Producto(String nombre) {
this(nombre, 0); // delega
}
// Constructor canónico: acá vive TODA la validación
public Producto(String nombre, double precio) {
if (!setNombre(nombre)) {
this.nombre = "Sin nombre";
System.out.println("Nombre inválido, se usó \"Sin nombre\" por defecto.");
}
if (!setPrecio(precio)) {
this.precio = 0;
System.out.println("Precio inválido, se usó 0 por defecto.");
}
}
// Setter validado: true si acepta el valor, false si lo rechaza (y no toca el atributo)
public boolean setNombre(String nombre) {
if (nombre == null || nombre.isBlank()) {
return false;
}
this.nombre = nombre;
return true;
}
public boolean setPrecio(double precio) {
if (precio < 0) {
return false;
}
this.precio = precio;
return true;
}
}
Dos reglas que el compilador te va a exigir con this(...):
- Tiene que ser la primera sentencia del constructor. No podés poner nada antes, ni siquiera un
System.out.println. - No puede haber ciclos. Si
A()llama aB()yB()llama aA(), es error de compilación, no un desbordamiento en tiempo de ejecución.
4. Encapsulamiento: qué problema resuelve de verdad
Encapsular no es “poné todo private y generá getters y setters con el IDE”. Eso es ritual, no diseño. Encapsular es esto:
El objeto es dueño de su propio estado y es el único responsable de mantenerlo consistente.
Mientras un atributo sea public, cualquier línea de cualquier archivo del proyecto puede dejarlo en un estado imposible, y no hay forma de impedirlo ni de saber quién lo hizo.
Prestá atención a la última línea de cada panel, porque ahí está todo. Con campos públicos, cuando encontrás un precio negativo en producción tenés que auditar el proyecto entero. Con un setter que valida, el rechazo ocurre en la línea exacta que lo causó: setPrecio devuelve false, el atributo queda intacto y quien llamó al método sabe inmediatamente que algo salió mal.
5. Los cuatro modificadores de acceso
Java define cuatro niveles de visibilidad, del más abierto al más cerrado. Pensalos como círculos concéntricos de confianza:
| Modificador | Misma clase | Mismo paquete | Subclase en otro paquete | Cualquier clase |
|---|---|---|---|---|
private | Sí | No | No | No |
| (sin modificador) | Sí | Sí | No | No |
protected | Sí | Sí | Sí | No |
public | Sí | Sí | Sí | Sí |
La regla operativa: atributos siempre private; métodos public solo si forman parte del contrato que la clase le ofrece al mundo. Todo lo demás, lo más cerrado posible. Abrir visibilidad después es trivial; cerrarla rompe todo el código que ya dependía de ella.
(Nota conceptual: en Java, los paquetes funcionan como namespaces (espacios de nombres) y a la vez como fronteras de encapsulamiento. Exploraremos en profundidad la teoría de namespaces, la resolución de colisiones y la arquitectura de paquetes en la lección Clases Abstractas, Interfaces y Organización del Código).
6. Getters y setters bien hechos
Un getter/setter generado automáticamente que no hace más que leer y escribir el atributo es, en la práctica, un campo público con más ceremonia:
// Esto NO encapsula nada. Es un campo public disfrazado.
public double getPrecio() { return precio; }
public void setPrecio(double precio) { this.precio = precio; }
Los accesores valen la pena cuando hacen algo: validan, transforman, calculan o directamente no existen.
public class CuentaBancaria {
private final String titular; // final: no cambia nunca después del constructor
private double saldo;
public CuentaBancaria(String titular, double saldoInicial) {
if (titular == null || titular.isBlank()) {
System.out.println("Titular inválido, se usó \"Cuenta sin titular\" por defecto.");
titular = "Cuenta sin titular";
}
if (saldoInicial < 0) {
System.out.println("Saldo inicial inválido, se usó 0 por defecto.");
saldoInicial = 0;
}
this.titular = titular;
this.saldo = saldoInicial;
}
// Getter: sí. Leer el saldo es parte del contrato público.
public double getSaldo() { return saldo; }
// Setter de saldo: NO. Nadie debería poder escribir el saldo directamente.
// En su lugar, operaciones del dominio que expresan la intención y devuelven
// un boolean: true si se aplicó, false si se rechazó.
public boolean depositar(double monto) {
if (monto <= 0) {
return false;
}
this.saldo += monto;
return true;
}
public boolean retirar(double monto) {
if (monto <= 0 || monto > saldo) {
return false;
}
this.saldo -= monto;
return true;
}
}
Compará las dos formas de escribir lo mismo:
cuenta.setSaldo(cuenta.getSaldo() - 5000); // ¿Qué está pasando acá? ¿Se validó algo?
cuenta.retirar(5000); // La intención es explícita y la regla se aplica.
La segunda versión no solo es más legible: es la única de las dos en la que la regla de fondos insuficientes puede existir. Los métodos deberían nombrar operaciones del dominio, no movimientos de datos.
7. La fuga de referencia: el error que rompe el encapsulamiento sin que lo notes
Este es el punto donde la mayoría de las clases “encapsuladas” se caen. Mirá:
public class Curso {
private static final int CAPACIDAD_MAXIMA = 30;
private final String[] alumnos = new String[CAPACIDAD_MAXIMA];
private int cantidad = 0;
public String[] getAlumnos() {
return alumnos; // ⚠️ Devolvemos la referencia interna
}
}
Todo parece correcto: el atributo es private, hay un getter. Pero:
Curso c = new Curso();
c.getAlumnos()[0] = "Intruso"; // Modificamos el estado interno desde afuera
java.util.Arrays.fill(c.getAlumnos(), null); // Y lo vaciamos entero
El getter entregó la dirección de memoria del array interno, no una copia. Quien la recibe tiene control total. El private no sirvió de nada, porque lo que protege el private es el campo, no el objeto al que apunta.
Hay dos soluciones, de menor a mayor rigidez:
// 1. Copia defensiva: quien llama recibe un array propio e independiente.
public String[] getAlumnos() {
String[] copia = new String[cantidad];
for (int i = 0; i < cantidad; i++) {
copia[i] = alumnos[i];
}
return copia;
}
// 2. No exponer la colección: exponer solo las operaciones que tienen sentido.
public boolean inscribir(String alumno) {
if (alumno == null || alumno.isBlank() || cantidad == alumnos.length) {
return false;
}
alumnos[cantidad] = alumno;
cantidad++;
return true;
}
public int cantidadInscriptos() { return cantidad; }
La segunda opción es casi siempre la mejor, y no por prolijidad: es la única que te deja agregar después una regla de negocio adicional (por ejemplo, rechazar nombres duplicados) sin cambiar la firma pública de la clase.
La misma trampa aplica a los constructores: si recibís un array por parámetro y lo asignás directo con
this.datos = datos, quien te lo pasó conserva una referencia viva a tu estado interno. Copialo al entrar también (por ejemplo, conArrays.copyOf, que ya conocés de la lección de arrays).
8. Inmutabilidad: el encapsulamiento llevado al extremo
Un objeto inmutable no cambia nunca después de nacer. Como no cambia, no puede quedar inconsistente, no necesita setters y es seguro compartirlo entre hilos sin sincronización alguna.
public final class Coordenada { // final: nadie puede heredar y romper las reglas
private final double latitud; // final: solo se asignan en el constructor
private final double longitud;
public Coordenada(double latitud, double longitud) {
if (latitud < -90 || latitud > 90) {
System.out.println("Latitud fuera de rango, se usó 0.0 por defecto.");
latitud = 0;
}
if (longitud < -180 || longitud > 180) {
System.out.println("Longitud fuera de rango, se usó 0.0 por defecto.");
longitud = 0;
}
this.latitud = latitud;
this.longitud = longitud;
}
public double getLatitud() { return latitud; }
public double getLongitud() { return longitud; }
// Para "modificar", se devuelve una instancia nueva
public Coordenada desplazar(double dLat, double dLon) {
return new Coordenada(latitud + dLat, longitud + dLon);
}
}
Desde Java 16, para este tipo de portadores de datos existe una forma corta, el record, que genera constructor, getters, equals, hashCode y toString automáticamente:
public record Coordenada(double latitud, double longitud) {
// Constructor compacto: solo escribís la validación
public Coordenada {
if (latitud < -90 || latitud > 90) {
System.out.println("Latitud fuera de rango, se usó 0.0 por defecto.");
latitud = 0;
}
}
}
Lo vas a ver mucho en código moderno. Por ahora quedate con la idea de fondo: cuanto menos pueda cambiar un objeto, menos formas hay de romperlo.
9. Errores frecuentes
| Error | Qué pasa | Cómo se arregla |
|---|---|---|
public void Producto(...) con tipo de retorno | Java lo compila como un método común llamado Producto, no como constructor. El objeto nunca se inicializa y no hay ningún aviso. | Borrar el tipo de retorno. |
Asignar sin this habiendo sombreado: nombre = nombre; | El parámetro se asigna a sí mismo. El atributo queda en null. Compila sin errores. | Usar this.nombre = nombre;. |
| Llamar a un método sobrescribible desde el constructor | La subclase ejecuta el método antes de que sus propios campos estén inicializados. | Que el constructor llame solo a métodos private o final. |
| Validar en el setter pero no en el constructor | El objeto puede nacer inválido y solo se protege después. | El constructor delega en el setter, o ambos delegan en un validador privado. |
| Generar getters y setters para todos los campos por reflejo | Encapsulamiento nominal: el estado queda tan expuesto como si fuera público. | Exponer solo lo que el contrato realmente necesita. |
10. Ejercicio práctico guiado
Desafío: clase Estudiante
Escribí una clase Estudiante que cumpla todas estas condiciones:
- Atributos
nombre(String) ypromedio(double), ambosprivate. Elnombreno debe poder cambiar nunca una vez creado el objeto. - Un constructor canónico que reciba nombre y promedio: si el nombre es nulo o está en blanco, usa
"Sin nombre"por defecto e informa por consola; el promedio se valida delegando en el setter y, si es inválido, usa0.0por defecto e informa por consola. - Un constructor de conveniencia que reciba solo el nombre y arranque con promedio
0.0, sin duplicar la validación. - Un setter
setPromedioque aplique la misma regla de rango que el constructor y devuelvaboolean:truesi acepta el nuevo valor,falsesi lo rechaza (y en ese caso conserva el promedio anterior). - Un método
aprobo()que devuelvatruesi el promedio es mayor o igual a6.0. - Un
mainque demuestre, usando los valores booleanos devueltos y los mensajes impresos, que el objeto nunca queda con un promedio fuera de rango, ni al construirse ni al modificarse.
Ver solución sugerida
public class Estudiante {
private final String nombre; // final: se fija en el constructor y no cambia más
private double promedio;
// Constructor de conveniencia: delega, no duplica
public Estudiante(String nombre) {
this(nombre, 0.0);
}
// Constructor canónico
public Estudiante(String nombre, double promedio) {
if (nombre == null || nombre.isBlank()) {
System.out.println("Nombre inválido, se usó \"Sin nombre\" por defecto.");
nombre = "Sin nombre";
}
this.nombre = nombre;
if (!setPromedio(promedio)) {
this.promedio = 0.0;
System.out.println("Promedio inválido, se usó 0.0 por defecto.");
}
}
public String getNombre() {
return nombre;
}
public double getPromedio() {
return promedio;
}
// true si acepta el nuevo promedio, false si lo rechaza (y conserva el anterior)
public boolean setPromedio(double promedio) {
if (promedio < 0.0 || promedio > 10.0) {
return false;
}
this.promedio = promedio;
return true;
}
public boolean aprobo() {
return promedio >= 6.0;
}
@Override
public String toString() {
return nombre + " — promedio " + promedio + (aprobo() ? " (aprobado)" : " (desaprobado)");
}
public static void main(String[] args) {
Estudiante e1 = new Estudiante("Laura Giménez", 8.4);
System.out.println(e1); // Laura Giménez — promedio 8.4 (aprobado)
Estudiante e2 = new Estudiante("Carlos Ruiz");
System.out.println(e2); // Carlos Ruiz — promedio 0.0 (desaprobado)
boolean aceptado = e2.setPromedio(7.2);
System.out.println("¿Se aceptó 7.2? " + aceptado);
System.out.println(e2); // Carlos Ruiz — promedio 7.2 (aprobado)
// El objeto se defiende al modificarse: el valor inválido se rechaza
// y el promedio anterior queda intacto.
boolean rechazado = e2.setPromedio(15.0);
System.out.println("¿Se aceptó 15.0? " + rechazado);
System.out.println(e2); // sigue en 7.2, no cambió
// Y también al construirse: nunca llega a existir con un promedio inválido
Estudiante invalido = new Estudiante("", 5.0);
System.out.println(invalido); // Sin nombre — promedio 5.0 (desaprobado)
}
}
Lo importante de esta solución no es que compile, sino que la regla del rango 0.0–10.0 está escrita una sola vez. El constructor canónico llama a setPromedio, y el constructor de conveniencia llama al canónico. Si mañana el rango pasa a ser 1.0–10.0, cambiás una línea y los tres caminos quedan corregidos.
Para llevarte
- El constructor es la única garantía de que un objeto nazca válido: si los datos son inválidos, sustituye un valor por defecto seguro antes de terminar, así que un objeto con datos rotos nunca llega a existir.
- Escribir un constructor elimina el que el compilador te regalaba. Eso es una función, no un bug.
this(...)concentra la validación en un constructor canónico y evita que las reglas se dupliquen.- Encapsular es hacer que el objeto sea responsable de su propia consistencia, no generar accesores en masa.
privatees el valor por defecto de todo atributo. Abrí solo lo que el contrato necesita.- Devolver un array u objeto interno sin copiar anula el encapsulamiento por más
privateque tenga el campo. - Lo que no puede cambiar, no puede romperse: preferí
finale inmutabilidad siempre que el dominio lo permita.