Java
Tutorial Java: Encapsulación
Java encapsulación: técnicas y ejemplos. Domina las técnicas de encapsulación en Java con ejemplos prácticos y detallados.
Aprende Java y certifícateFundamentos: modificadores de acceso (public, private, protected)
La encapsulación permite ocultar los detalles internos de implementación de una clase y exponer solo lo necesario. En Java, este concepto se implementa principalmente a través de los modificadores de acceso, que determinan la visibilidad y accesibilidad de clases, atributos y métodos.
Java proporciona cuatro niveles de control de acceso, cada uno con un propósito específico. Estos modificadores se aplican directamente a la declaración de elementos como variables, métodos y clases.
Modificador public
El modificador public
otorga el nivel más amplio de acceso. Cuando se declara un elemento como público, se puede acceder a él desde cualquier parte del programa, sin restricciones.
public class Usuario {
public String nombre;
public void mostrarNombre() {
System.out.println("Nombre: " + nombre);
}
}
En este ejemplo, tanto la clase Usuario
como su atributo nombre
y el método mostrarNombre()
son accesibles desde cualquier otra clase del proyecto. Se utiliza public
cuando se necesita que un elemento sea completamente visible y accesible.
Sin embargo, hacer todo público va en contra del principio de encapsulación. Se recomienda usar este modificador principalmente para:
- Clases que representan una API pública
- Métodos que forman parte de la interfaz de la clase
- Constantes que deben ser accesibles globalmente
Modificador private
El modificador private
representa el nivel más restrictivo de acceso. Los elementos marcados como privados solo son accesibles dentro de la propia clase donde se declaran.
public class CuentaBancaria {
private double saldo;
private String numeroCuenta;
public CuentaBancaria(String numeroCuenta, double saldoInicial) {
this.numeroCuenta = numeroCuenta;
this.saldo = saldoInicial;
}
private void validarCantidad(double cantidad) {
if (cantidad <= 0) {
throw new IllegalArgumentException("La cantidad debe ser positiva");
}
}
public void depositar(double cantidad) {
validarCantidad(cantidad);
saldo += cantidad;
}
}
En este ejemplo, los atributos saldo
y numeroCuenta
son privados, lo que significa que no se puede acceder directamente a ellos desde fuera de la clase. El método validarCantidad()
también es privado, ya que es un método auxiliar interno que no forma parte de la interfaz pública de la clase.
El modificador private
se utiliza para:
- Atributos que representan el estado interno de un objeto
- Métodos auxiliares que no deben ser parte de la API pública
- Implementaciones internas que podrían cambiar en el futuro
Modificador protected
El modificador protected
proporciona un nivel intermedio de acceso. Los elementos marcados como protegidos son accesibles:
- Dentro de la misma clase
- Dentro de clases del mismo paquete
- Dentro de clases que heredan (subclases), incluso si están en paquetes diferentes
package animales;
public class Animal {
protected String nombre;
protected int edad;
protected void respirar() {
System.out.println("El animal está respirando");
}
}
package mascotas;
import animales.Animal;
public class Perro extends Animal {
public void ladrar() {
// Puede acceder a los miembros protected de Animal
System.out.println(nombre + " está ladrando");
respirar();
}
}
En este ejemplo, la clase Perro
puede acceder a los atributos nombre
y edad
, así como al método respirar()
de la clase Animal
, a pesar de estar en un paquete diferente, porque Perro
extiende a Animal
.
El modificador protected
se utiliza principalmente para:
- Atributos y métodos que deben ser accesibles para las subclases
- Elementos que forman parte de la implementación de una jerarquía de clases
- Cuando se quiere permitir la extensión y personalización en subclases
Modificador por defecto (package-private)
Cuando no se especifica ningún modificador, se aplica el nivel de acceso por defecto o package-private. Los elementos con este nivel de acceso son visibles solo dentro del mismo paquete.
package utilidades;
class Calculadora {
int sumar(int a, int b) {
return a + b;
}
}
package utilidades;
public class Matematicas {
void operacion() {
Calculadora calc = new Calculadora();
int resultado = calc.sumar(5, 3); // Acceso permitido (mismo paquete)
}
}
package aplicacion;
import utilidades.Calculadora; // Error: Calculadora no es visible
public class Aplicacion {
void iniciar() {
Calculadora calc = new Calculadora(); // Error de compilación
}
}
En este ejemplo, la clase Calculadora
y su método sumar()
tienen acceso por defecto, por lo que solo son accesibles desde otras clases del paquete utilidades
.
Este nivel de acceso se utiliza para:
- Clases auxiliares que solo se necesitan dentro de un paquete
- Implementaciones internas que no deben ser parte de la API pública
- Cuando se quiere agrupar funcionalidades relacionadas en un paquete
Tabla comparativa de modificadores de acceso
Para entender mejor las diferencias entre los modificadores, se puede visualizar su alcance:
Modificador | Misma clase | Mismo paquete | Subclase (diferente paquete) | Cualquier clase |
---|---|---|---|---|
private | ✓ | ✗ | ✗ | ✗ |
(default) | ✓ | ✓ | ✗ | ✗ |
protected | ✓ | ✓ | ✓ | ✗ |
public | ✓ | ✓ | ✓ | ✓ |
Aplicación práctica de los modificadores
Veamos un ejemplo completo que ilustra el uso adecuado de los diferentes modificadores:
package banco;
public class CuentaBancaria {
private String titular;
private double saldo;
protected String numeroCuenta;
public static final double TASA_INTERES = 0.05;
public CuentaBancaria(String titular, String numeroCuenta) {
this.titular = titular;
this.numeroCuenta = numeroCuenta;
this.saldo = 0.0;
}
public double consultarSaldo() {
return saldo;
}
public void depositar(double cantidad) {
validarCantidad(cantidad);
saldo += cantidad;
registrarMovimiento("Depósito", cantidad);
}
public boolean retirar(double cantidad) {
validarCantidad(cantidad);
if (saldo >= cantidad) {
saldo -= cantidad;
registrarMovimiento("Retiro", cantidad);
return true;
}
return false;
}
private void validarCantidad(double cantidad) {
if (cantidad <= 0) {
throw new IllegalArgumentException("La cantidad debe ser positiva");
}
}
protected void registrarMovimiento(String tipo, double cantidad) {
System.out.println(tipo + " de " + cantidad + " en cuenta " + numeroCuenta);
}
}
En este ejemplo:
- Los atributos
titular
ysaldo
son privados para proteger la información sensible. - El atributo
numeroCuenta
es protegido para permitir que las subclases puedan acceder a él. - La constante
TASA_INTERES
es pública porque es información que puede ser útil para cualquier clase. - Los métodos
consultarSaldo()
,depositar()
yretirar()
son públicos porque forman parte de la interfaz de la clase. - El método
validarCantidad()
es privado porque es un método auxiliar interno. - El método
registrarMovimiento()
es protegido para permitir que las subclases puedan personalizarlo.
Consideraciones importantes
Al trabajar con modificadores de acceso, se deben tener en cuenta algunas consideraciones:
- Se recomienda usar el principio de menor privilegio: proporcionar el nivel de acceso más restrictivo posible que permita que el código funcione.
- Los atributos generalmente deben ser
private
para mantener el encapsulamiento. - Los métodos que forman parte de la API pública deben ser
public
. - Los métodos auxiliares internos deben ser
private
. - El modificador
protected
se debe usar con precaución, ya que crea una dependencia entre la clase base y sus subclases.
Getters y setters: implementación y buenas prácticas
Los getters y setters son métodos especiales que permiten acceder y modificar los atributos privados de una clase, manteniendo el principio de encapsulación. Estos métodos actúan como una interfaz controlada entre los atributos privados de un objeto y el código externo que necesita interactuar con ellos.
En Java, se implementan siguiendo una convención de nomenclatura específica que facilita su identificación y uso. Para un atributo llamado nombreAtributo
, los métodos correspondientes serían:
private TipoDato nombreAtributo;
public TipoDato getNombreAtributo() {
return nombreAtributo;
}
public void setNombreAtributo(TipoDato nombreAtributo) {
this.nombreAtributo = nombreAtributo;
}
Implementación básica de getters y setters
Veamos un ejemplo práctico con una clase Empleado
:
public class Empleado {
private String nombre;
private double salario;
private int edad;
// Constructor
public Empleado(String nombre, double salario, int edad) {
this.nombre = nombre;
this.salario = salario;
this.edad = edad;
}
// Getter para nombre
public String getNombre() {
return nombre;
}
// Setter para nombre
public void setNombre(String nombre) {
this.nombre = nombre;
}
// Getter para salario
public double getSalario() {
return salario;
}
// Setter para salario
public void setSalario(double salario) {
this.salario = salario;
}
// Getter para edad
public int getEdad() {
return edad;
}
// Setter para edad
public void setEdad(int edad) {
this.edad = edad;
}
}
Con esta implementación, se puede acceder y modificar los atributos de manera controlada:
Empleado empleado = new Empleado("Ana García", 45000.0, 28);
// Acceder a los atributos mediante getters
System.out.println("Nombre: " + empleado.getNombre());
System.out.println("Salario: " + empleado.getSalario());
// Modificar atributos mediante setters
empleado.setSalario(48000.0);
empleado.setEdad(29);
Validación en setters
Una de las ventajas principales de los setters es que permiten validar los datos antes de asignarlos a los atributos. Esto ayuda a mantener la integridad de los objetos:
public class Producto {
private String nombre;
private double precio;
private int stock;
// Constructor
public Producto(String nombre, double precio, int stock) {
setNombre(nombre);
setPrecio(precio);
setStock(stock);
}
public String getNombre() {
return nombre;
}
public void setNombre(String nombre) {
if (nombre == null || nombre.trim().isEmpty()) {
throw new IllegalArgumentException("El nombre no puede estar vacío");
}
this.nombre = nombre;
}
public double getPrecio() {
return precio;
}
public void setPrecio(double precio) {
if (precio < 0) {
throw new IllegalArgumentException("El precio no puede ser negativo");
}
this.precio = precio;
}
public int getStock() {
return stock;
}
public void setStock(int stock) {
if (stock < 0) {
throw new IllegalArgumentException("El stock no puede ser negativo");
}
this.stock = stock;
}
}
Los setters validan los datos de entrada, asegurando que los objetos Producto
siempre mantengan un estado válido. Además, se reutilizan los setters en el constructor para evitar duplicar la lógica de validación.
Getters con lógica adicional
Los getters también pueden incluir lógica adicional, no limitándose a simplemente devolver el valor del atributo:
public class Empleado {
private String nombre;
private String apellido;
private double salarioBase;
private double bonificacion;
// Otros métodos y constructor...
public String getNombreCompleto() {
return nombre + " " + apellido;
}
public double getSalarioTotal() {
return salarioBase + bonificacion;
}
// Getters y setters básicos para los atributos...
}
En este ejemplo, getNombreCompleto()
y getSalarioTotal()
son getters que calculan valores derivados a partir de los atributos básicos.
Buenas prácticas para getters y setters
Mantener la simplicidad
Los getters y setters deben ser concisos y enfocados. Un getter debe devolver el valor y un setter debe establecerlo, realizando solo las validaciones necesarias:
// Bien
public void setEdad(int edad) {
if (edad < 0 || edad > 120) {
throw new IllegalArgumentException("Edad fuera de rango válido");
}
this.edad = edad;
}
// Mal - Hace demasiadas cosas
public void setEdad(int edad) {
if (edad < 0) {
throw new IllegalArgumentException("Edad negativa");
}
this.edad = edad;
System.out.println("Edad actualizada a: " + edad);
actualizarBaseDeDatos();
notificarCambio();
}
Usar nombres consistentes
Sigue la convención de nomenclatura de Java para getters y setters:
// Para atributos booleanos
private boolean activo;
public boolean isActivo() { return activo; }
public void setActivo(boolean activo) { this.activo = activo; }
// Para atributos no booleanos
private String nombre;
public String getNombre() { return nombre; }
public void setNombre(String nombre) { this.nombre = nombre; }
No exponer todos los atributos automáticamente
No todos los atributos necesitan getters y setters. Evalúa cada caso:
public class Usuario {
private String nombre;
private String contraseña; // No debería tener getter
private boolean administrador;
// Getter para nombre
public String getNombre() {
return nombre;
}
// No incluir getter para contraseña por seguridad
// Setter para contraseña con validación
public void setContraseña(String contraseña) {
if (contraseña == null || contraseña.length() < 8) {
throw new IllegalArgumentException("La contraseña debe tener al menos 8 caracteres");
}
this.contraseña = contraseña;
}
// Solo getter para administrador (inmutable después de creación)
public boolean isAdministrador() {
return administrador;
}
}
Considerar métodos específicos en lugar de getters/setters genéricos
A veces, un método con nombre descriptivo es mejor que un getter o setter genérico:
// En lugar de:
usuario.setActivo(false);
// Mejor usar:
usuario.desactivar();
// En lugar de:
carrito.setItems(nuevaLista);
// Mejor usar:
carrito.agregarItem(item);
carrito.eliminarItem(item);
carrito.vaciar();
Evitar efectos secundarios en los getters
Los getters no deberían modificar el estado del objeto:
// Mal - tiene efecto secundario
public int getContadorAccesos() {
return contadorAccesos++; // Incrementa el contador cada vez que se accede
}
// Bien - sin efectos secundarios
public int getContadorAccesos() {
return contadorAccesos;
}
// Método separado para incrementar
public void incrementarContador() {
contadorAccesos++;
}
Usar getters en los métodos internos de la clase
Para mantener la consistencia, es recomendable usar los getters incluso dentro de la propia clase, especialmente si contienen lógica adicional:
public class Factura {
private List<LineaFactura> lineas;
private double descuento;
public double getSubtotal() {
double subtotal = 0;
for (LineaFactura linea : lineas) {
subtotal += linea.getImporte();
}
return subtotal;
}
public double getTotal() {
// Usa el getter en lugar de calcular de nuevo
return getSubtotal() * (1 - descuento);
}
}
Generación automática de getters y setters
La mayoría de los IDEs modernos como IntelliJ IDEA, Eclipse o Visual Studio Code (con extensiones) ofrecen herramientas para generar automáticamente getters y setters:
// Definimos los atributos
public class Cliente {
private String id;
private String nombre;
private String email;
private LocalDate fechaRegistro;
// El IDE puede generar automáticamente todos los getters y setters
// con validaciones básicas si es necesario
}
Getters y setters en clases con relaciones
Cuando trabajamos con relaciones entre clases, debemos tener cuidado con los getters que devuelven colecciones:
public class Departamento {
private String nombre;
private List<Empleado> empleados = new ArrayList<>();
// Getter que devuelve una copia defensiva
public List<Empleado> getEmpleados() {
return new ArrayList<>(empleados);
}
// Mejor enfoque: métodos específicos
public void agregarEmpleado(Empleado empleado) {
empleados.add(empleado);
}
public void eliminarEmpleado(Empleado empleado) {
empleados.remove(empleado);
}
public int getCantidadEmpleados() {
return empleados.size();
}
}
En este ejemplo, getEmpleados()
devuelve una copia de la lista para evitar que el código externo modifique directamente la colección interna. Alternativamente, se pueden proporcionar métodos específicos para manipular la colección de manera controlada.
Encapsulación en el mundo real: ejemplos prácticos
En entornos de producción, la encapsulación adecuada marca la diferencia entre un código mantenible y uno propenso a errores. Veamos cómo se aplica este principio en situaciones reales.
Sistemas de gestión de usuarios
Un caso típico donde la encapsulación resulta crucial es en los sistemas que manejan información sensible de usuarios:
public class Usuario {
private String nombre;
private String email;
private String contraseña;
private boolean cuentaVerificada;
private LocalDate fechaRegistro;
private List<String> rolesAsignados;
// Constructor
public Usuario(String nombre, String email, String contraseña) {
this.nombre = nombre;
this.email = validarEmail(email);
this.contraseña = encriptarContraseña(contraseña);
this.cuentaVerificada = false;
this.fechaRegistro = LocalDate.now();
this.rolesAsignados = new ArrayList<>();
}
// Getters controlados
public String getNombre() {
return nombre;
}
public String getEmail() {
return email;
}
// No exponemos la contraseña
public boolean isCuentaVerificada() {
return cuentaVerificada;
}
public LocalDate getFechaRegistro() {
return fechaRegistro;
}
public List<String> getRolesAsignados() {
return Collections.unmodifiableList(rolesAsignados);
}
// Métodos específicos en lugar de setters genéricos
public void cambiarNombre(String nuevoNombre) {
if (nuevoNombre == null || nuevoNombre.trim().isEmpty()) {
throw new IllegalArgumentException("El nombre no puede estar vacío");
}
this.nombre = nuevoNombre;
}
public void cambiarEmail(String nuevoEmail) {
this.email = validarEmail(nuevoEmail);
}
public boolean verificarContraseña(String contraseñaIntroducida) {
return this.contraseña.equals(encriptarContraseña(contraseñaIntroducida));
}
public void cambiarContraseña(String contraseñaActual, String nuevaContraseña) {
if (!verificarContraseña(contraseñaActual)) {
throw new SecurityException("Contraseña actual incorrecta");
}
this.contraseña = encriptarContraseña(nuevaContraseña);
}
public void verificarCuenta() {
this.cuentaVerificada = true;
}
public void asignarRol(String rol) {
if (!rolesAsignados.contains(rol)) {
rolesAsignados.add(rol);
}
}
public void revocarRol(String rol) {
rolesAsignados.remove(rol);
}
// Métodos auxiliares privados
private String validarEmail(String email) {
if (email == null || !email.matches("^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$")) {
throw new IllegalArgumentException("Email inválido");
}
return email;
}
private String encriptarContraseña(String contraseña) {
// En un caso real, usaríamos un algoritmo de hash seguro
return contraseña.hashCode() + "";
}
}
Observa cómo esta clase:
- Oculta completamente la contraseña (sin getter)
- Proporciona un método específico para verificar la contraseña sin exponerla
- Devuelve una lista inmutable de roles para evitar modificaciones no controladas
- Usa métodos específicos en lugar de setters genéricos para operaciones como asignar o revocar roles
Sistemas de transacciones financieras
En aplicaciones financieras, la encapsulación es fundamental para mantener la integridad de las operaciones:
public class CuentaBancaria {
private final String numeroCuenta;
private final String titular;
private double saldo;
private boolean bloqueada;
private final List<Transaccion> historialTransacciones;
public CuentaBancaria(String numeroCuenta, String titular, double saldoInicial) {
this.numeroCuenta = numeroCuenta;
this.titular = titular;
this.saldo = Math.max(0, saldoInicial);
this.bloqueada = false;
this.historialTransacciones = new ArrayList<>();
if (saldoInicial > 0) {
registrarTransaccion(TipoTransaccion.DEPOSITO, saldoInicial, "Saldo inicial");
}
}
// Getters esenciales
public String getNumeroCuenta() {
return numeroCuenta;
}
public String getTitular() {
return titular;
}
public double getSaldo() {
return saldo;
}
public boolean isBloqueada() {
return bloqueada;
}
public List<Transaccion> getHistorialTransacciones() {
return Collections.unmodifiableList(historialTransacciones);
}
// Operaciones específicas
public boolean depositar(double cantidad, String concepto) {
if (bloqueada) {
return false;
}
if (cantidad <= 0) {
throw new IllegalArgumentException("La cantidad debe ser positiva");
}
saldo += cantidad;
registrarTransaccion(TipoTransaccion.DEPOSITO, cantidad, concepto);
return true;
}
public boolean retirar(double cantidad, String concepto) {
if (bloqueada || cantidad > saldo || cantidad <= 0) {
return false;
}
saldo -= cantidad;
registrarTransaccion(TipoTransaccion.RETIRO, cantidad, concepto);
return true;
}
public boolean transferir(CuentaBancaria destino, double cantidad, String concepto) {
if (bloqueada || destino.bloqueada || cantidad > saldo || cantidad <= 0) {
return false;
}
saldo -= cantidad;
destino.saldo += cantidad;
registrarTransaccion(TipoTransaccion.TRANSFERENCIA_SALIENTE,
cantidad,
concepto + " → " + destino.getNumeroCuenta());
destino.registrarTransaccion(TipoTransaccion.TRANSFERENCIA_ENTRANTE,
cantidad,
concepto + " ← " + this.getNumeroCuenta());
return true;
}
public void bloquearCuenta() {
this.bloqueada = true;
registrarTransaccion(TipoTransaccion.BLOQUEO, 0, "Bloqueo de cuenta");
}
public void desbloquearCuenta() {
this.bloqueada = false;
registrarTransaccion(TipoTransaccion.DESBLOQUEO, 0, "Desbloqueo de cuenta");
}
// Método privado para registro interno
private void registrarTransaccion(TipoTransaccion tipo, double cantidad, String concepto) {
Transaccion transaccion = new Transaccion(
tipo,
cantidad,
concepto,
LocalDateTime.now()
);
historialTransacciones.add(transaccion);
}
// Clase interna para representar transacciones
public static class Transaccion {
private final TipoTransaccion tipo;
private final double cantidad;
private final String concepto;
private final LocalDateTime fecha;
private Transaccion(TipoTransaccion tipo, double cantidad, String concepto, LocalDateTime fecha) {
this.tipo = tipo;
this.cantidad = cantidad;
this.concepto = concepto;
this.fecha = fecha;
}
public TipoTransaccion getTipo() {
return tipo;
}
public double getCantidad() {
return cantidad;
}
public String getConcepto() {
return concepto;
}
public LocalDateTime getFecha() {
return fecha;
}
}
// Enum para tipos de transacción
public enum TipoTransaccion {
DEPOSITO, RETIRO, TRANSFERENCIA_ENTRANTE, TRANSFERENCIA_SALIENTE, BLOQUEO, DESBLOQUEO
}
}
Este ejemplo muestra cómo:
- Se encapsula el estado interno de la cuenta (saldo, estado de bloqueo)
- Se proporcionan operaciones específicas (depositar, retirar, transferir) en lugar de setters directos
- Se mantiene un historial inmutable de transacciones
- Se usa una clase interna para representar las transacciones, también inmutable
Sistemas de comercio electrónico
En aplicaciones de e-commerce, la encapsulación ayuda a mantener la consistencia del carrito de compras:
public class CarritoCompra {
private final String idCliente;
private final Map<Producto, Integer> items;
private boolean finalizado;
public CarritoCompra(String idCliente) {
this.idCliente = idCliente;
this.items = new HashMap<>();
this.finalizado = false;
}
public String getIdCliente() {
return idCliente;
}
public boolean isFinalizado() {
return finalizado;
}
public Map<Producto, Integer> getItems() {
return Collections.unmodifiableMap(items);
}
public int getCantidadItems() {
return items.values().stream().mapToInt(Integer::intValue).sum();
}
public double getTotal() {
return items.entrySet().stream()
.mapToDouble(entry -> entry.getKey().getPrecio() * entry.getValue())
.sum();
}
public void agregarProducto(Producto producto, int cantidad) {
if (finalizado) {
throw new IllegalStateException("No se puede modificar un carrito finalizado");
}
if (cantidad <= 0) {
throw new IllegalArgumentException("La cantidad debe ser positiva");
}
items.merge(producto, cantidad, Integer::sum);
}
public void eliminarProducto(Producto producto) {
if (finalizado) {
throw new IllegalStateException("No se puede modificar un carrito finalizado");
}
items.remove(producto);
}
public void actualizarCantidad(Producto producto, int nuevaCantidad) {
if (finalizado) {
throw new IllegalStateException("No se puede modificar un carrito finalizado");
}
if (nuevaCantidad <= 0) {
eliminarProducto(producto);
} else if (items.containsKey(producto)) {
items.put(producto, nuevaCantidad);
}
}
public void vaciar() {
if (finalizado) {
throw new IllegalStateException("No se puede modificar un carrito finalizado");
}
items.clear();
}
public Pedido finalizarCompra() {
if (finalizado) {
throw new IllegalStateException("El carrito ya ha sido finalizado");
}
if (items.isEmpty()) {
throw new IllegalStateException("No se puede finalizar un carrito vacío");
}
finalizado = true;
return new Pedido(this);
}
}
En este ejemplo:
- El mapa de items se devuelve como inmutable para evitar modificaciones no controladas
- Se proporcionan métodos específicos para cada operación (agregar, eliminar, actualizar)
- Se mantiene la consistencia verificando el estado del carrito antes de cada operación
- Se encapsula la lógica de creación de pedidos dentro del método
finalizarCompra()
Aplicaciones de monitoreo y sensores
En sistemas IoT o de monitoreo, la encapsulación permite abstraer los detalles de implementación:
public class SensorTemperatura {
private final String id;
private final String ubicacion;
private double temperaturaActual;
private double temperaturaMaxima;
private double temperaturaMinima;
private LocalDateTime ultimaLectura;
private final List<Observer> observadores;
public SensorTemperatura(String id, String ubicacion) {
this.id = id;
this.ubicacion = ubicacion;
this.temperaturaActual = 0;
this.temperaturaMaxima = Double.MIN_VALUE;
this.temperaturaMinima = Double.MAX_VALUE;
this.observadores = new ArrayList<>();
}
// Getters para información básica
public String getId() {
return id;
}
public String getUbicacion() {
return ubicacion;
}
public double getTemperaturaActual() {
return temperaturaActual;
}
public double getTemperaturaMaxima() {
return temperaturaMaxima;
}
public double getTemperaturaMinima() {
return temperaturaMinima;
}
public LocalDateTime getUltimaLectura() {
return ultimaLectura;
}
// Método para registrar una nueva lectura
public void registrarLectura(double temperatura) {
this.temperaturaActual = temperatura;
this.ultimaLectura = LocalDateTime.now();
if (temperatura > temperaturaMaxima) {
this.temperaturaMaxima = temperatura;
}
if (temperatura < temperaturaMinima) {
this.temperaturaMinima = temperatura;
}
notificarObservadores();
}
// Métodos para el patrón Observer
public void agregarObservador(Observer observador) {
if (!observadores.contains(observador)) {
observadores.add(observador);
}
}
public void eliminarObservador(Observer observador) {
observadores.remove(observador);
}
private void notificarObservadores() {
for (Observer observador : observadores) {
observador.actualizar(this);
}
}
// Interfaz para el patrón Observer
public interface Observer {
void actualizar(SensorTemperatura sensor);
}
}
Este ejemplo muestra:
- Encapsulación de la lógica de actualización de temperaturas máximas y mínimas
- Implementación del patrón Observer para notificar cambios sin exponer detalles internos
- Inmutabilidad de propiedades como ID y ubicación
Aplicación en frameworks y bibliotecas
Los frameworks modernos de Java hacen un uso extensivo de la encapsulación. Por ejemplo, en Spring Framework:
@Entity
public class Producto {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String nombre;
private String descripcion;
@Column(nullable = false)
private BigDecimal precio;
@Column(nullable = false)
private int stock;
@ManyToOne
@JoinColumn(name = "categoria_id")
private Categoria categoria;
@Version
private Long version;
// Getters y setters controlados
public boolean disponible() {
return stock > 0;
}
public boolean reservar(int cantidad) {
if (cantidad <= 0 || cantidad > stock) {
return false;
}
stock -= cantidad;
return true;
}
public void reponer(int cantidad) {
if (cantidad <= 0) {
throw new IllegalArgumentException("La cantidad debe ser positiva");
}
stock += cantidad;
}
}
En este ejemplo de entidad JPA:
- Los campos están encapsulados como privados
- Se proporcionan métodos específicos para operaciones de negocio (reservar, reponer)
- Se usa
@Version
para control de concurrencia optimista, encapsulando la lógica de bloqueo
Inmutabilidad y sus beneficios
La inmutabilidad representa un concepto avanzado de encapsulación donde, una vez creado un objeto, su estado no puede ser modificado. En Java, esto significa diseñar clases cuyos objetos mantienen los mismos valores durante toda su vida útil. Este enfoque, aunque inicialmente puede parecer restrictivo, ofrece ventajas en términos de seguridad, rendimiento y simplicidad del código.
Fundamentos de la inmutabilidad
Para crear una clase inmutable en Java, se deben seguir varios principios clave:
public final class Coordenada {
private final double x;
private final double y;
public Coordenada(double x, double y) {
this.x = x;
this.y = y;
}
public double getX() {
return x;
}
public double getY() {
return y;
}
public Coordenada trasladar(double deltaX, double deltaY) {
return new Coordenada(this.x + deltaX, this.y + deltaY);
}
@Override
public String toString() {
return "(" + x + ", " + y + ")";
}
}
En este ejemplo se aplican los principios fundamentales de inmutabilidad:
- La clase se declara como
final
para evitar que sea extendida - Todos los campos son
private
yfinal
- No se proporcionan métodos que modifiquen el estado (setters)
- Las operaciones que "modificarían" el objeto retornan una nueva instancia
Para usar esta clase inmutable:
Coordenada punto = new Coordenada(5, 10);
System.out.println("Punto original: " + punto);
// La operación no modifica el objeto original
Coordenada puntoDespazado = punto.trasladar(3, -2);
System.out.println("Punto original (sin cambios): " + punto);
System.out.println("Nuevo punto: " + puntoDespazado);
Inmutabilidad con colecciones
Cuando una clase inmutable contiene colecciones, se requiere especial cuidado para evitar que estas referencias puedan ser modificadas:
public final class Estudiante {
private final String id;
private final String nombre;
private final List<String> cursos;
public Estudiante(String id, String nombre, List<String> cursos) {
this.id = id;
this.nombre = nombre;
// Copia defensiva en el constructor
this.cursos = new ArrayList<>(cursos);
}
public String getId() {
return id;
}
public String getNombre() {
return nombre;
}
public List<String> getCursos() {
// Copia defensiva en el getter
return Collections.unmodifiableList(cursos);
}
public Estudiante agregarCurso(String curso) {
List<String> nuevosCursos = new ArrayList<>(this.cursos);
nuevosCursos.add(curso);
return new Estudiante(this.id, this.nombre, nuevosCursos);
}
}
Observa las técnicas defensivas empleadas:
- Se crea una copia de la colección en el constructor
- Se devuelve una vista no modificable en el getter
- Para "añadir" un curso, se crea un nuevo objeto con la lista actualizada
Beneficios de la inmutabilidad
Seguridad en entornos multihilo
Uno de los beneficios más importantes de la inmutabilidad es la seguridad inherente en entornos concurrentes:
public final class ConfiguracionAplicacion {
private final String nombreServidor;
private final int puerto;
private final Map<String, String> parametros;
public ConfiguracionAplicacion(String nombreServidor, int puerto,
Map<String, String> parametros) {
this.nombreServidor = nombreServidor;
this.puerto = puerto;
this.parametros = Collections.unmodifiableMap(new HashMap<>(parametros));
}
// Getters (sin setters)
public ConfiguracionAplicacion conPuerto(int nuevoPuerto) {
return new ConfiguracionAplicacion(this.nombreServidor, nuevoPuerto, this.parametros);
}
}
Este objeto de configuración puede compartirse con seguridad entre múltiples hilos sin necesidad de sincronización, ya que ningún hilo puede modificar su estado.
Simplificación del razonamiento sobre el código
La inmutabilidad simplifica el razonamiento sobre el comportamiento del código:
public void procesarTransaccion(Transaccion transaccion) {
// Podemos estar seguros de que la transacción no cambiará durante el procesamiento
validarTransaccion(transaccion);
registrarTransaccion(transaccion);
notificarCliente(transaccion);
}
Al saber que transaccion
es inmutable, no necesitamos preocuparnos por posibles cambios durante su procesamiento, eliminando toda una categoría de errores potenciales.
Hashcode consistente
Los objetos inmutables son ideales para usar como claves en mapas o elementos en conjuntos:
public final class ProductoId {
private final String codigo;
private final String paisOrigen;
// Constructor y getters
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ProductoId that = (ProductoId) o;
return Objects.equals(codigo, that.codigo) &&
Objects.equals(paisOrigen, that.paisOrigen);
}
@Override
public int hashCode() {
return Objects.hash(codigo, paisOrigen);
}
}
// Uso en un mapa
Map<ProductoId, Inventario> inventarioPorProducto = new HashMap<>();
Como el estado de ProductoId
nunca cambia, su hashcode permanece constante, garantizando el correcto funcionamiento en estructuras basadas en hash.
Facilita el patrón Builder
La inmutabilidad se complementa perfectamente con el patrón Builder para crear objetos complejos:
public final class Email {
private final String destinatario;
private final String remitente;
private final String asunto;
private final String cuerpo;
private final List<String> adjuntos;
private final boolean prioritario;
private Email(Builder builder) {
this.destinatario = builder.destinatario;
this.remitente = builder.remitente;
this.asunto = builder.asunto;
this.cuerpo = builder.cuerpo;
this.adjuntos = Collections.unmodifiableList(new ArrayList<>(builder.adjuntos));
this.prioritario = builder.prioritario;
}
// Getters (sin setters)
public static class Builder {
private String destinatario;
private String remitente;
private String asunto;
private String cuerpo;
private List<String> adjuntos = new ArrayList<>();
private boolean prioritario = false;
public Builder destinatario(String destinatario) {
this.destinatario = destinatario;
return this;
}
public Builder remitente(String remitente) {
this.remitente = remitente;
return this;
}
// Otros métodos del builder
public Builder adjunto(String adjunto) {
this.adjuntos.add(adjunto);
return this;
}
public Email build() {
// Validaciones
if (destinatario == null || remitente == null) {
throw new IllegalStateException("Email requiere destinatario y remitente");
}
return new Email(this);
}
}
}
El uso del patrón Builder permite crear objetos inmutables complejos de manera fácil:
Email email = new Email.Builder()
.destinatario("cliente@ejemplo.com")
.remitente("soporte@miempresa.com")
.asunto("Confirmación de pedido")
.cuerpo("Su pedido ha sido procesado correctamente.")
.adjunto("factura.pdf")
.prioritario(true)
.build();
Mejora del rendimiento en ciertos escenarios
La inmutabilidad puede mejorar el rendimiento al permitir:
- Caching seguro: Los resultados de operaciones costosas pueden cachearse sin riesgo
- Compartición de instancias: Objetos idénticos pueden compartirse con seguridad
public final class BigDecimalRange {
private final BigDecimal min;
private final BigDecimal max;
// Constructor y getters
// Cache para rangos comunes
private static final Map<String, BigDecimalRange> CACHE = new ConcurrentHashMap<>();
public static BigDecimalRange of(BigDecimal min, BigDecimal max) {
String key = min + ":" + max;
return CACHE.computeIfAbsent(key, k -> new BigDecimalRange(min, max));
}
}
Inmutabilidad en las APIs estándar de Java
Java incorpora numerosas clases inmutables en sus APIs estándar:
String
: Quizás el ejemplo más conocidoBigInteger
yBigDecimal
: Para cálculos de precisión arbitrariaLocalDate
,LocalTime
yLocalDateTime
: Para representación de fechas y horas- Clases de colecciones inmutables como las proporcionadas por
Collections.unmodifiableXXX()
Patrones para trabajar con objetos inmutables
Patrón de copia con modificación
Cuando necesitamos "modificar" un objeto inmutable, creamos una copia con los cambios deseados:
public final class Pedido {
private final String id;
private final Cliente cliente;
private final List<LineaPedido> lineas;
private final EstadoPedido estado;
// Constructor y getters
public Pedido conEstado(EstadoPedido nuevoEstado) {
return new Pedido(this.id, this.cliente, this.lineas, nuevoEstado);
}
public Pedido agregarLinea(LineaPedido nuevaLinea) {
List<LineaPedido> nuevasLineas = new ArrayList<>(this.lineas);
nuevasLineas.add(nuevaLinea);
return new Pedido(this.id, this.cliente, nuevasLineas, this.estado);
}
}
Uso de Record (Java 16+)
A partir de Java 16, los records proporcionan una forma concisa de definir clases inmutables:
public record Punto3D(double x, double y, double z) {
// Los getters, equals, hashCode y toString se generan automáticamente
public Punto3D desplazar(double dx, double dy, double dz) {
return new Punto3D(x + dx, y + dy, z + dz);
}
public double distancia(Punto3D otro) {
double dx = this.x - otro.x();
double dy = this.y - otro.y();
double dz = this.z - otro.z();
return Math.sqrt(dx*dx + dy*dy + dz*dz);
}
}
Los records simplifican enormemente la creación de clases inmutables al generar automáticamente constructores, getters y métodos equals()
, hashCode()
y toString()
.
Consideraciones prácticas
Aunque la inmutabilidad ofrece numerosos beneficios, también presenta algunos desafíos:
- Consumo de memoria: Crear nuevos objetos para cada "modificación" puede aumentar la presión sobre el recolector de basura
- Complejidad en ciertos escenarios: Algunos algoritmos que requieren muchas modificaciones pueden volverse más complejos
- Integración con frameworks: Algunos frameworks pueden requerir objetos mutables (aunque esto es cada vez menos común)
Para mitigar estos problemas, se pueden aplicar estrategias como:
- Usar inmutabilidad selectiva para las partes críticas del modelo
- Implementar estructuras de datos persistentes que optimizan la creación de copias
- Utilizar patrones como Builder para la construcción de objetos complejos
Ejemplo completo: Sistema de gestión de documentos
Veamos un ejemplo más completo que ilustra los beneficios de la inmutabilidad en un sistema real:
public final class Documento {
private final String id;
private final String titulo;
private final String contenido;
private final Usuario autor;
private final LocalDateTime fechaCreacion;
private final LocalDateTime ultimaModificacion;
private final Set<String> etiquetas;
private final EstadoDocumento estado;
// Constructor privado usado por el Builder
private Documento(Builder builder) {
this.id = builder.id;
this.titulo = builder.titulo;
this.contenido = builder.contenido;
this.autor = builder.autor;
this.fechaCreacion = builder.fechaCreacion;
this.ultimaModificacion = builder.ultimaModificacion;
this.etiquetas = Collections.unmodifiableSet(new HashSet<>(builder.etiquetas));
this.estado = builder.estado;
}
// Getters (sin setters)
// Métodos que devuelven nuevas instancias
public Documento conTitulo(String nuevoTitulo) {
return toBuilder().titulo(nuevoTitulo)
.ultimaModificacion(LocalDateTime.now())
.build();
}
public Documento conContenido(String nuevoContenido) {
return toBuilder().contenido(nuevoContenido)
.ultimaModificacion(LocalDateTime.now())
.build();
}
public Documento agregarEtiqueta(String etiqueta) {
Builder builder = toBuilder();
builder.etiquetas.add(etiqueta);
return builder.ultimaModificacion(LocalDateTime.now()).build();
}
public Documento cambiarEstado(EstadoDocumento nuevoEstado) {
return toBuilder().estado(nuevoEstado)
.ultimaModificacion(LocalDateTime.now())
.build();
}
// Método para crear un Builder pre-configurado con los valores actuales
public Builder toBuilder() {
Builder builder = new Builder();
builder.id = this.id;
builder.titulo = this.titulo;
builder.contenido = this.contenido;
builder.autor = this.autor;
builder.fechaCreacion = this.fechaCreacion;
builder.ultimaModificacion = this.ultimaModificacion;
builder.etiquetas = new HashSet<>(this.etiquetas);
builder.estado = this.estado;
return builder;
}
public static class Builder {
private String id;
private String titulo;
private String contenido;
private Usuario autor;
private LocalDateTime fechaCreacion;
private LocalDateTime ultimaModificacion;
private Set<String> etiquetas = new HashSet<>();
private EstadoDocumento estado = EstadoDocumento.BORRADOR;
public Builder id(String id) {
this.id = id;
return this;
}
// Otros métodos del builder
public Builder titulo(String titulo) {
this.titulo = titulo;
return this;
}
public Builder contenido(String contenido) {
this.contenido = contenido;
return this;
}
public Builder ultimaModificacion(LocalDateTime ultimaModificacion) {
this.ultimaModificacion = ultimaModificacion;
return this;
}
public Builder estado(EstadoDocumento estado) {
this.estado = estado;
return this;
}
public Documento build() {
if (id == null) {
id = UUID.randomUUID().toString();
}
if (fechaCreacion == null) {
fechaCreacion = LocalDateTime.now();
}
if (ultimaModificacion == null) {
ultimaModificacion = fechaCreacion;
}
// Validaciones
if (titulo == null || contenido == null || autor == null) {
throw new IllegalStateException("Documento requiere título, contenido y autor");
}
return new Documento(this);
}
}
public enum EstadoDocumento {
BORRADOR, REVISIÓN, PUBLICADO, ARCHIVADO
}
}
Este ejemplo demuestra:
- Inmutabilidad completa con todos los campos finales
- Patrón Builder para facilitar la creación
- Método toBuilder() para simplificar la creación de copias modificadas
- Métodos específicos para cada tipo de "modificación"
- Actualización automática de la fecha de modificación
Otras lecciones de Java
Accede a todas las lecciones de Java y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.
Instalación De Java
Introducción Y Entorno
Configuración De Entorno Java
Introducción Y Entorno
Tipos De Datos
Sintaxis
Variables
Sintaxis
Operadores
Sintaxis
Estructuras De Control
Sintaxis
Funciones
Sintaxis
Recursión
Sintaxis
Arrays Y Matrices
Sintaxis
Excepciones
Programación Orientada A Objetos
Clases Y Objetos
Programación Orientada A Objetos
Encapsulación
Programación Orientada A Objetos
Herencia
Programación Orientada A Objetos
Clases Abstractas
Programación Orientada A Objetos
Interfaces
Programación Orientada A Objetos
Sobrecarga De Métodos
Programación Orientada A Objetos
Polimorfismo
Programación Orientada A Objetos
La Clase Scanner
Programación Orientada A Objetos
Métodos De La Clase String
Programación Orientada A Objetos
Excepciones
Programación Orientada A Objetos
Records
Programación Orientada A Objetos
Pattern Matching
Programación Orientada A Objetos
Inferencia De Tipos Con Var
Programación Orientada A Objetos
Enumeraciones Enums
Programación Orientada A Objetos
Generics
Programación Orientada A Objetos
Clases Sealed
Programación Orientada A Objetos
Listas
Framework Collections
Conjuntos
Framework Collections
Mapas
Framework Collections
Funciones Lambda
Programación Funcional
Interfaz Funcional Consumer
Programación Funcional
Interfaz Funcional Predicate
Programación Funcional
Interfaz Funcional Supplier
Programación Funcional
Interfaz Funcional Function
Programación Funcional
Métodos Referenciados
Programación Funcional
Creación De Streams
Programación Funcional
Operaciones Intermedias Con Streams: Map()
Programación Funcional
Operaciones Intermedias Con Streams: Filter()
Programación Funcional
Operaciones Intermedias Con Streams: Distinct()
Programación Funcional
Operaciones Finales Con Streams: Collect()
Programación Funcional
Operaciones Finales Con Streams: Min Max
Programación Funcional
Operaciones Intermedias Con Streams: Flatmap()
Programación Funcional
Operaciones Intermedias Con Streams: Sorted()
Programación Funcional
Operaciones Finales Con Streams: Reduce()
Programación Funcional
Operaciones Finales Con Streams: Foreach()
Programación Funcional
Operaciones Finales Con Streams: Count()
Programación Funcional
Operaciones Finales Con Streams: Match
Programación Funcional
Api Optional
Programación Funcional
Transformación
Programación Funcional
Reducción Y Acumulación
Programación Funcional
Mapeo
Programación Funcional
Streams Paralelos
Programación Funcional
Agrupación Y Partición
Programación Funcional
Filtrado Y Búsqueda
Programación Funcional
Api Java.nio 2
Entrada Y Salida Io
Fundamentos De Io
Entrada Y Salida Io
Leer Y Escribir Archivos
Entrada Y Salida Io
Httpclient Moderno
Entrada Y Salida Io
Clases De Nio2
Entrada Y Salida Io
Api Java.time
Api Java.time
Localtime
Api Java.time
Localdatetime
Api Java.time
Localdate
Api Java.time
Executorservice
Concurrencia
Virtual Threads (Project Loom)
Concurrencia
Future Y Completablefuture
Concurrencia
Spring Framework
Frameworks Para Java
Micronaut
Frameworks Para Java
Maven
Frameworks Para Java
Gradle
Frameworks Para Java
Lombok Para Java
Frameworks Para Java
Quarkus
Frameworks Para Java
Ecosistema Jakarta Ee De Java
Frameworks Para Java
Introducción A Junit 5
Testing
Ejercicios de programación de Java
Evalúa tus conocimientos de esta lección Encapsulación con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.
Streams: match
Gestión de errores y excepciones
CRUD en Java de modelo Customer sobre un ArrayList
Clases abstractas
Listas
Métodos de la clase String
Streams: reduce()
API java.nio 2
Polimorfismo
Pattern Matching
Streams: flatMap()
Llamada y sobrecarga de funciones
Métodos referenciados
Métodos de la clase String
Representación de Fecha
Operadores lógicos
Inferencia de tipos con var
Tipos de datos
Estructuras de iteración
Streams: forEach()
Objetos
Funciones lambda
Uso de Scanner
Tipos de variables
Streams: collect()
Operadores aritméticos
Arrays y matrices
Clases y objetos
Interfaz funcional Consumer
CRUD en Java de modelo Customer sobre un HashMap
Interfaces
Enumeraciones Enums
API Optional
Interfaz funcional Function
Encapsulación
Interfaces
Uso de API Optional
Representación de Hora
Herencia básica
Clases y objetos
Interfaz funcional Supplier
HashMap
Sobrecarga de métodos
Polimorfismo de tiempo de ejecución
OOP en Java
Sobrecarga de métodos
CRUD de productos en Java
Clases sealed
Creación de Streams
Records
Encapsulación
Streams: min max
Herencia
Métodos avanzados de la clase String
Funciones
Polimorfismo de tiempo de compilación
Reto sintaxis Java
Conjuntos
Estructuras de control
Recursión
Excepciones
Herencia avanzada
Estructuras de selección
Uso de interfaces
Operadores
Variables
HashSet
Objeto Scanner
Streams: filter()
Operaciones de Streams
Interfaz funcional Predicate
Streams: sorted()
Configuración de entorno
Uso de variables
Clases
Streams: distinct()
Streams: count()
ArrayList
Mapas
Datos de referencia
Interfaces funcionales
Métodos básicos de la clase String
Tipos de datos
Clases abstractas
Instalación
Funciones
Excepciones
Estructuras de control
Herencia de clases
La clase Scanner
Generics
Streams: map()
Funciones y encapsulamiento
Certificados de superación de Java
Supera todos los ejercicios de programación del curso de Java y obtén certificados de superación para mejorar tu currículum y tu empleabilidad.
En esta lección
Objetivos de aprendizaje de esta lección
- Comprender el concepto y la importancia de la encapsulación en la programación orientada a objetos
- Aprender a usar modificadores de acceso (
private
,public
,protected
) en Java - Aprender a implementar la encapsulación en Java mediante la creación de atributos privados y métodos getter y setter
- Entender cómo la encapsulación contribuye a la seguridad, la flexibilidad y la mantenibilidad del código
- Aprender a usar la encapsulación para controlar el acceso a los datos y garantizar la integridad de los mismos