Módulo 11: Encapsulación, miembros estáticos y paquetes
Objetivos del módulo
- Comprender el principio de encapsulación y de ocultación de información
- Usar los modificadores de acceso
private, package-private,protectedypublic - Escribir getters y setters con validación y decidir cuándo no escribirlos
- Garantizar los invariantes de una clase
- Distinguir los miembros de clase (
static) de los miembros de instancia, y entender por quémainesstatic - Definir constantes con
static final, bloques static y clases de utilidades - Organizar el código en paquetes y usar
import,import staticeimport module - Trabajar con las clases envoltorio (
Integer,Double…), el autoboxing y sus trampas - Diseñar clases inmutables con copias defensivas y entender por qué
Stringlo es
1. El principio de encapsulación
1.1. El problema: objetos indefensos
En el módulo 10 los atributos no tenían ningún modificador, así que cualquier clase podía cambiarlos a su antojo:
// ===== Producto.java =====
// Versión del módulo 10: atributos accesibles desde fuera
public class Producto {
String nombre;
double precio;
int stock;
Producto(String nombre, double precio, int stock) {
this.nombre = nombre;
this.precio = precio;
this.stock = stock;
}
double valorStock() {
return precio * stock;
}
}
// ===== PruebaSinEncapsular.java =====
public class PruebaSinEncapsular {
public static void main(String[] args) {
Producto p = new Producto("Teclado", 29.99, 10);
// Nada impide dejar el objeto en un estado absurdo
p.precio = -50;
p.stock = -3;
p.nombre = "";
System.out.println("Nombre: '" + p.nombre + "'");
System.out.println("Valor del stock: " + p.valorStock() + " €");
}
}
Nombre: ''
Valor del stock: 150.0 €
Un producto sin nombre, con precio negativo y stock negativo, y un “valor del stock” de 150 € que no tiene sentido. El objeto no puede defenderse: cualquier línea de cualquier clase puede romperlo, y cuando aparezca el error tendrás que revisar todo el programa para averiguar quién lo hizo.
1.2. La solución: ocultar el estado y exponer operaciones
📘 Concepto: La encapsulación consiste en agrupar datos y operaciones en una clase y ocultar los detalles internos, de modo que el resto del programa solo pueda usar el objeto a través de su interfaz pública (sus métodos públicos). A la parte que se oculta se le llama implementación; ocultarla es el principio de ocultación de información.
Piensa en un cajero automático: puedes consultar el saldo, sacar dinero o hacer un ingreso pulsando botones (la interfaz pública), pero no puedes abrir la máquina y coger billetes del depósito (el estado interno). El cajero valida cada operación: no te da más dinero del que tienes. Así funciona un objeto bien encapsulado.
Figura 1. El estado (nombre, precio, stock) es privado. Las demás clases solo pueden llamar a los métodos públicos, que validan antes de modificar nada.
Ventajas de encapsular:
| Ventaja | Explicación |
|---|---|
| Integridad | El objeto valida cada cambio: nunca queda en un estado imposible |
| Localización de errores | Si el precio es incorrecto, el culpable está en los métodos de Producto, no en todo el programa |
| Libertad para cambiar la implementación | Puedes cambiar cómo se guardan los datos (por ejemplo, el precio en céntimos) sin tocar las clases que usan Producto |
| Facilidad de uso | Quien usa la clase solo necesita conocer su interfaz pública, no sus detalles |
2. Modificadores de acceso
Java tiene cuatro niveles de acceso para atributos, métodos y constructores. Tres se escriben con una palabra clave; el cuarto es la ausencia de modificador.
| Modificador | Misma clase | Mismo paquete | Subclase en otro paquete | Cualquier otra clase |
|---|---|---|---|---|
private | ✔ | ✘ | ✘ | ✘ |
| (ninguno): package-private | ✔ | ✔ | ✘ | ✘ |
protected | ✔ | ✔ | ✔ | ✘ |
public | ✔ | ✔ | ✔ | ✔ |
private: solo dentro de la propia clase. Es el nivel para los atributos, casi siempre.- package-private (sin modificador): visible para las clases del mismo paquete (sección 6). Es lo que usamos en el módulo 10 sin saberlo.
protected: como el anterior, y además accesible desde las subclases aunque estén en otro paquete. Cobrará sentido con la herencia en el módulo 12.public: accesible desde cualquier sitio. Es el nivel de la interfaz pública: constructores y métodos que quieres ofrecer.
Si intentas acceder a un miembro privado desde otra clase, el programa ni siquiera compila:
// ===== Producto.java =====
public class Producto {
private double precio;
public double getPrecio() {
return precio;
}
}
// ===== Prueba.java =====
public class Prueba {
public static void main(String[] args) {
Producto p = new Producto();
p.precio = -50; // intento de acceso directo
System.out.println(p.getPrecio());
}
}
Prueba.java:4: error: precio has private access in Producto
p.precio = -50; // intento de acceso directo
^
1 error
📘 Concepto: Las clases de nivel superior (las que no están dentro de otra) solo pueden ser
publico package-private. Una clasepublices utilizable desde cualquier paquete y debe estar en un fichero con su mismo nombre.
💡 Consejo: Regla de oro para empezar: atributos
private, métodos y constructorespublicsalvo los métodos auxiliares internos, que también seránprivate. Abre el acceso solo cuando tengas una razón.
3. Getters y setters
Si los atributos son privados, ¿cómo se leen o se cambian desde fuera? A través de métodos públicos que la clase decide ofrecer:
- Un getter (método de acceso) devuelve el valor de un atributo:
getPrecio(). - Un setter (método modificador) cambia el valor de un atributo validándolo antes:
setPrecio(double).
// ===== Producto.java =====
public class Producto {
// 1. Atributos PRIVADOS: solo esta clase puede leerlos o modificarlos
private String nombre;
private double precio;
private int stock;
// 2. El constructor valida reutilizando los setters
public Producto(String nombre, double precio, int stock) {
setNombre(nombre);
setPrecio(precio);
setStock(stock);
}
// 3. Getters: acceso de lectura controlado
public String getNombre() {
return nombre;
}
public double getPrecio() {
return precio;
}
public int getStock() {
return stock;
}
// 4. Setters: acceso de escritura con validación
public void setNombre(String nombre) {
if (nombre == null || nombre.isBlank()) {
throw new IllegalArgumentException("El nombre no puede estar vacío");
}
this.nombre = nombre.strip();
}
public void setPrecio(double precio) {
if (precio < 0) {
throw new IllegalArgumentException("Precio negativo: " + precio);
}
this.precio = precio;
}
public void setStock(int stock) {
if (stock < 0) {
throw new IllegalArgumentException("Stock negativo: " + stock);
}
this.stock = stock;
}
public double valorStock() {
return precio * stock;
}
}
// ===== PruebaEncapsulado.java =====
public class PruebaEncapsulado {
public static void main(String[] args) {
Producto p = new Producto(" Teclado ", 29.99, 10);
System.out.println("Nombre: '" + p.getNombre() + "'"); // espacios eliminados
p.setPrecio(24.5); // cambio válido
System.out.println("Nuevo precio: " + p.getPrecio());
System.out.println("Valor del stock: " + p.valorStock() + " €");
p.setStock(-3); // cambio NO válido
System.out.println("Esta línea no llega a ejecutarse");
}
}
Nombre: 'Teclado'
Nuevo precio: 24.5
Valor del stock: 245.0 €
Exception in thread "main" java.lang.IllegalArgumentException: Stock negativo: -3
at Producto.setStock(Producto.java:44)
at PruebaEncapsulado.main(PruebaEncapsulado.java:8)
Fíjate en varios detalles:
- El constructor llama a los setters, de modo que la validación se escribe una sola vez y se aplica tanto al crear el objeto como al modificarlo.
setNombrenormaliza el dato (strip()quita los espacios de los extremos): los setters también sirven para dejar los datos en un formato coherente.- Ante un valor no válido, el setter lanza una excepción con
throw new IllegalArgumentException("mensaje"). El programa se detiene mostrando el mensaje y la línea exacta, igual que con unaNullPointerException. Es la forma profesional de rechazar un argumento incorrecto; en el módulo 15 aprenderás a capturar estas excepciones para que el programa pueda continuar.
3.1. Convenciones de nombres
| Atributo | Getter | Setter |
|---|---|---|
String nombre | String getNombre() | void setNombre(String nombre) |
double precio | double getPrecio() | void setPrecio(double precio) |
boolean activo | boolean isActivo() | void setActivo(boolean activo) |
Estas convenciones (heredadas de la especificación JavaBeans) no son un capricho: muchas bibliotecas que usarás en el curso avanzado (Jackson, Hibernate, Spring) las utilizan para leer y escribir los objetos automáticamente.
💡 Consejo: En IntelliJ IDEA no hace falta escribirlos a mano: pulsa Alt+Insert (en macOS, ⌘N) dentro de la clase → Getter and Setter, selecciona los atributos y el IDE los genera. Después añade tú la validación a los setters.
3.2. No todo atributo necesita getter y setter
Generar automáticamente un getter y un setter para cada atributo no es encapsular: es poner una puerta automática a cada habitación. Piensa en cada caso:
- Sin setter si el dato no debe cambiar nunca tras la creación (el DNI de una persona, el IBAN de una cuenta). Márcalo además como
final. - Sin setter si el dato solo debe cambiar mediante operaciones con sentido: el saldo de una cuenta no se “fija”, se modifica con
ingresaryretirar. - Getters calculados: un getter puede devolver un dato que no está guardado como atributo (
isMayorDeEdad()a partir de la edad). - Sin getter para detalles internos que no interesan a nadie fuera de la clase.
4. Invariantes de clase
📘 Concepto: Un invariante de clase es una condición que debe cumplirse siempre para cualquier objeto de la clase, desde que termina su constructor hasta que deja de usarse. Por ejemplo: “el saldo nunca es negativo”, “el precio es mayor o igual que cero”, “la fecha es un día que existe”.
La encapsulación es lo que permite garantizar los invariantes: si los atributos son privados, solo los métodos de la clase pueden cambiarlos, y basta con revisar esos métodos para asegurarse de que ninguno rompe la regla.
// ===== CuentaBancaria.java =====
public class CuentaBancaria {
private final String iban; // no cambia nunca: final y sin setter
private String titular;
private double saldo; // INVARIANTE: saldo >= 0 siempre
public CuentaBancaria(String iban, String titular) {
this.iban = iban;
setTitular(titular);
// saldo empieza en 0: el invariante se cumple desde el nacimiento
}
public String getIban() {
return iban;
}
public String getTitular() {
return titular;
}
public void setTitular(String titular) {
if (titular == null || titular.isBlank()) {
throw new IllegalArgumentException("Titular obligatorio");
}
this.titular = titular;
}
public double getSaldo() {
return saldo;
}
// NO hay setSaldo: el saldo solo cambia mediante operaciones con sentido
public void ingresar(double cantidad) {
if (cantidad <= 0) {
throw new IllegalArgumentException("Cantidad no válida: " + cantidad);
}
saldo += cantidad;
}
// Quedarse sin fondos es una situación normal, no un error de programación:
// se informa con un boolean
public boolean retirar(double cantidad) {
if (cantidad <= 0) {
throw new IllegalArgumentException("Cantidad no válida: " + cantidad);
}
if (cantidad > saldo) {
return false;
}
saldo -= cantidad;
return true;
}
}
// ===== PruebaCuenta.java =====
public class PruebaCuenta {
public static void main(String[] args) {
CuentaBancaria c = new CuentaBancaria("ES91 2100 0418 4502 0005 1332", "Marta Ruiz");
c.ingresar(200);
System.out.println("¿Retirar 500? " + c.retirar(500));
System.out.println("¿Retirar 50? " + c.retirar(50));
System.out.println(c.getTitular() + " tiene " + c.getSaldo() + " €");
c.ingresar(-10);
}
}
¿Retirar 500? false
¿Retirar 50? true
Marta Ruiz tiene 150.0 €
Exception in thread "main" java.lang.IllegalArgumentException: Cantidad no válida: -10.0
at CuentaBancaria.ingresar(CuentaBancaria.java:35)
at PruebaCuenta.main(PruebaCuenta.java:8)
Para mantener un invariante:
- El constructor debe dejar el objeto en un estado que lo cumpla.
- Cada método modificador debe conservarlo: o el cambio es válido, o no se hace nada.
- Ningún método debe “filtrar” una referencia a un objeto interno mutable (por ejemplo, un array) que permita modificarlo desde fuera. Lo verás en la sección 9 con las copias defensivas.
💡 Consejo: Observa los dos tipos de respuesta ante un problema: una cantidad negativa es un error de programación del que llama (excepción), mientras que no tener fondos es una situación normal del negocio (se devuelve
false). Distinguir ambos casos es una habilidad de diseño muy valorada.
5. Miembros static: de la clase, no del objeto
5.1. Atributos de clase frente a atributos de instancia
Hasta ahora cada objeto tenía su propia copia de cada atributo. Pero a veces un dato pertenece a la clase en su conjunto: cuántos productos se han creado, el tipo de interés común a todas las cuentas, el IVA. Para eso existe static.
📘 Concepto: Un atributo
static(o atributo de clase) existe una sola vez, asociado a la clase, y lo comparten todos sus objetos. Un atributo sinstatic(o atributo de instancia) existe una vez por cada objeto.
// ===== Producto.java =====
public class Producto {
// Atributo de CLASE: una sola variable compartida por todos los productos
private static int contador = 0;
// Atributos de INSTANCIA: cada producto tiene los suyos
private final int id;
private String nombre;
public Producto(String nombre) {
contador++; // se incrementa la variable compartida
this.id = contador; // y cada objeto guarda su número
this.nombre = nombre;
}
public int getId() {
return id;
}
public String getNombre() {
return nombre;
}
// Método de CLASE: se llama con Producto.getContador()
public static int getContador() {
return contador;
}
}
// ===== PruebaContador.java =====
public class PruebaContador {
public static void main(String[] args) {
System.out.println("Productos creados: " + Producto.getContador()); // aún no hay objetos
Producto a = new Producto("Teclado");
Producto b = new Producto("Ratón");
Producto c = new Producto("Monitor");
System.out.println(a.getId() + " " + a.getNombre());
System.out.println(b.getId() + " " + b.getNombre());
System.out.println(c.getId() + " " + c.getNombre());
System.out.println("Productos creados: " + Producto.getContador());
}
}
Productos creados: 0
1 Teclado
2 Ratón
3 Monitor
Productos creados: 3
Figura 2. El atributo contador vive en la clase, que la JVM carga una sola vez. Cada objeto tiene sus propios id y nombre.
Observa que Producto.getContador() funciona antes de crear ningún objeto: los miembros static existen desde que se carga la clase.
⚠️ Importante: A los miembros
staticse accede con el nombre de la clase:Producto.getContador(),Math.PI. Java también permite escribira.getContador()a través de un objeto, pero es engañoso (parece que depende dea) y el IDE lo marca como aviso. No lo hagas.
5.2. Métodos static y sus limitaciones
Un método static pertenece a la clase y se ejecuta sin ningún objeto. Por eso no tiene this y no puede usar directamente atributos ni métodos de instancia: ¿de qué objeto serían?
public class Producto {
private String nombre;
private static int contador;
public static void mostrarResumen() {
System.out.println("Hay " + contador + " productos"); // correcto: contador es static
System.out.println("Último: " + nombre); // ERROR: ¿el nombre de qué objeto?
System.out.println(this.nombre); // ERROR: no hay this
}
}
Producto.java:7: error: non-static variable nombre cannot be referenced from a static context
System.out.println("Último: " + nombre); // ERROR: ¿el nombre de qué objeto?
^
Producto.java:8: error: non-static variable this cannot be referenced from a static context
System.out.println(this.nombre); // ERROR: no hay this
^
2 errors
| Desde… | ¿Puede usar miembros static? | ¿Puede usar miembros de instancia? |
|---|---|---|
| Un método de instancia | ✔ | ✔ (los de this) |
Un método static | ✔ | Solo a través de un objeto: p.getNombre() |
¿Cuándo hacer un método static? Cuando no necesita el estado de ningún objeto: cálculos que dependen solo de sus parámetros (Math.sqrt(x), Fecha.esBisiesto(anio)), métodos de utilidad o métodos que trabajan con datos static.
5.3. Por qué main es static
Ahora puedes entender la firma que llevas escribiendo desde el primer día:
public static void main(String[] args)
Cuando ejecutas java Tienda, todavía no existe ningún objeto: la JVM solo tiene la clase. Para poder llamar a main sin crear antes un objeto Tienda, el método tiene que ser static. Y por eso, desde main, para llamar a un método de instancia necesitas primero un objeto (new Producto(...)).
💡 Consejo: Con los ficheros compactos de Java 25 (módulo 01) puedes escribir
void main()sinstatic: en ese caso el lanzador crea automáticamente un objeto de la clase y llama amainsobre él. Es una comodidad para programas pequeños; la mecánica de fondo que acabas de aprender sigue siendo la misma.
5.4. Constantes: static final
Un atributo final no puede cambiar después de inicializarse. Combinado con static, define una constante de clase: un único valor, compartido y fijo.
// ===== Tarifas.java =====
public class Tarifas {
// Constantes de clase: public static final y nombre en MAYÚSCULAS
public static final double IVA_GENERAL = 0.21;
public static final double IVA_REDUCIDO = 0.10;
public static final double GASTOS_ENVIO = 4.95;
public static final double ENVIO_GRATIS_DESDE = 50.0;
public static double conIva(double base, double tipoIva) {
return base * (1 + tipoIva);
}
public static double gastosEnvio(double importe) {
return importe >= ENVIO_GRATIS_DESDE ? 0 : GASTOS_ENVIO;
}
}
// ===== PruebaTarifas.java =====
public class PruebaTarifas {
public static void main(String[] args) {
double base = 40;
double total = Tarifas.conIva(base, Tarifas.IVA_GENERAL);
System.out.printf("Base %.2f € + IVA %d%% = %.2f €%n", base, (int) (Tarifas.IVA_GENERAL * 100), total);
System.out.printf("Envío: %.2f €%n", Tarifas.gastosEnvio(total));
System.out.printf("Libro (IVA reducido): %.2f €%n", Tarifas.conIva(20, Tarifas.IVA_REDUCIDO));
System.out.println("Pi según Java: " + Math.PI); // otra constante de clase
}
}
Base 40.00 € + IVA 21% = 48.40 €
Envío: 4.95 €
Libro (IVA reducido): 22.00 €
Pi según Java: 3.141592653589793
Si intentas reasignar una constante, el compilador lo impide:
public class PruebaFinal {
public static final double IVA_GENERAL = 0.21;
public static void main(String[] args) {
IVA_GENERAL = 0.18; // una constante no se puede reasignar
}
}
PruebaFinal.java:5: error: cannot assign a value to static final variable IVA_GENERAL
IVA_GENERAL = 0.18; // una constante no se puede reasignar
^
1 error
| Declaración | Significado |
|---|---|
private final String dni; | Cada objeto tiene el suyo; se asigna una vez (en el constructor) y no cambia |
private static int contador; | Uno para toda la clase; puede cambiar |
public static final double IVA_GENERAL = 0.21; | Uno para toda la clase; no cambia: constante |
⚠️ Importante: Las constantes se escriben en
MAYÚSCULAS_CON_GUIONES_BAJOS. Usa constantes con nombre en lugar de “números mágicos”:importe >= ENVIO_GRATIS_DESDEse entiende mucho mejor queimporte >= 50, y si la política cambia, solo hay que tocar una línea.
5.5. Bloques static
Cuando inicializar un dato static requiere más de una expresión (leer un fichero, rellenar un array, hacer cálculos), se usa un bloque de inicialización static. Se ejecuta una sola vez, cuando la clase se usa por primera vez:
// ===== Provincias.java =====
public class Provincias {
private static final String[] NOMBRES;
private static int consultas; // atributo de clase sin inicializar: vale 0
// Bloque static: se ejecuta UNA vez, cuando la clase se usa por primera vez
static {
System.out.println(" [bloque static: cargando provincias]");
NOMBRES = new String[]{"Álava", "Albacete", "Alicante", "Almería", "Asturias"};
}
public static String nombre(int codigo) {
consultas++;
return NOMBRES[codigo - 1];
}
public static int getConsultas() {
return consultas;
}
}
// ===== PruebaProvincias.java =====
public class PruebaProvincias {
public static void main(String[] args) {
System.out.println("Inicio de main");
System.out.println("Provincia 3: " + Provincias.nombre(3)); // aquí se carga la clase
System.out.println("Provincia 5: " + Provincias.nombre(5)); // el bloque ya no se repite
System.out.println("Consultas: " + Provincias.getConsultas());
}
}
Inicio de main
[bloque static: cargando provincias]
Provincia 3: Alicante
Provincia 5: Asturias
Consultas: 2
Fíjate en el orden de la salida: el bloque no se ejecuta al arrancar el programa, sino en el momento en que se usa Provincias por primera vez, y no se repite.
5.6. Clases de utilidades
Algunas clases no representan ninguna entidad: solo agrupan funciones relacionadas. Es el caso de Math o Arrays. Se diseñan así:
- Todos sus métodos son
static. - El constructor es privado, para que nadie cree objetos inútiles.
- La clase se declara
final(no se puede heredar de ella; módulo 12).
// ===== Validador.java =====
// Clase de utilidades: final, constructor privado y solo miembros static
public final class Validador {
private static final String LETRAS_DNI = "TRWAGMYFPDXBNJZSQVHLCKE";
private Validador() {
// nadie puede crear objetos Validador
}
public static boolean esDniValido(String dni) {
if (dni == null || !dni.matches("\\d{8}[A-Za-z]")) {
return false;
}
int numero = Integer.parseInt(dni.substring(0, 8));
char letra = Character.toUpperCase(dni.charAt(8));
return LETRAS_DNI.charAt(numero % 23) == letra;
}
public static boolean esEmailValido(String email) {
return email != null && email.matches("[\\w.+-]+@[\\w-]+(\\.[\\w-]+)+");
}
public static boolean estaEntre(int valor, int min, int max) {
return valor >= min && valor <= max;
}
}
// ===== PruebaValidador.java =====
public class PruebaValidador {
public static void main(String[] args) {
String[] dnis = {"12345678Z", "12345678A", "1234Z", "00000000t"};
for (String d : dnis) {
System.out.println(d + " -> " + Validador.esDniValido(d));
}
System.out.println("ana@correo.es -> " + Validador.esEmailValido("ana@correo.es"));
System.out.println("ana@correo -> " + Validador.esEmailValido("ana@correo"));
System.out.println("¿15 entre 0 y 10? " + Validador.estaEntre(15, 0, 10));
}
}
12345678Z -> true
12345678A -> false
1234Z -> false
00000000t -> true
ana@correo.es -> true
ana@correo -> false
¿15 entre 0 y 10? false
Y si alguien intenta crear un Validador:
Prueba.java:3: error: Validador() has private access in Validador
Validador v = new Validador();
^
1 error
⚠️ Importante: No abuses de
static. Si conviertes todo enstatic, vuelves a la programación estructurada del módulo 07 con otra sintaxis. Usastaticpara lo que de verdad pertenece a la clase (contadores, constantes, utilidades) y objetos para todo lo demás.
6. Paquetes
6.1. Qué son y para qué sirven
Cuando un proyecto tiene decenas o cientos de clases, meterlas todas en la misma carpeta es inmanejable. Un paquete agrupa clases relacionadas y cumple tres funciones:
- Organizar el código, como las carpetas organizan tus documentos.
- Evitar conflictos de nombres: puede haber una clase
Productoenes.tienda.modeloy otra encom.proveedor.catalogo. El nombre completo de una clase incluye su paquete. - Controlar el acceso: lo package-private solo es visible dentro del paquete.
La biblioteca de Java está organizada así: java.lang (lo básico: String, Math, System), java.util (Scanner, Arrays, colecciones), java.time (fechas), java.nio.file (ficheros)…
📘 Concepto: Por convención, los paquetes se nombran en minúsculas con el dominio de la organización al revés: si tu empresa es
tienda.es, sus paquetes empiezan pores.tienda. Así se garantiza que no coincidan con los de otras organizaciones.
6.2. Declarar un paquete y su estructura de carpetas
La primera línea de un fichero (antes de los import) declara su paquete, y la carpeta debe coincidir con el nombre del paquete: la clase es.tienda.modelo.Producto va en es/tienda/modelo/Producto.java.
Figura 3. Cada paquete es una carpeta. es.tienda.app importa clases de es.tienda.modelo y hace un import static de es.tienda.util.
package es.tienda.modelo;
import es.tienda.util.Formato;
public class Producto {
private static int contador = 0;
private final String codigo;
private final String nombre;
private final double precio;
private int stock;
public Producto(String nombre, double precio, int stock) {
contador++;
this.codigo = String.format("P%03d", contador); // P001, P002...
this.nombre = nombre;
this.precio = precio;
this.stock = stock;
}
public String getCodigo() {
return codigo;
}
public int getStock() {
return stock;
}
// Sin modificador (package-private): solo lo usan las clases de es.tienda.modelo
void retirarStock(int cantidad) {
stock -= cantidad;
}
@Override
public String toString() {
return codigo + " " + nombre + " " + Formato.euros(precio) + " (" + stock + " uds.)";
}
}
// ===== Almacen.java =====
package es.tienda.modelo;
public class Almacen {
private final Producto[] productos = new Producto[20];
private int numProductos;
public void agregar(Producto p) {
productos[numProductos++] = p;
}
public boolean vender(String codigo, int cantidad) {
for (int i = 0; i < numProductos; i++) {
Producto p = productos[i];
if (p.getCodigo().equals(codigo) && p.getStock() >= cantidad) {
p.retirarStock(cantidad); // permitido: mismo paquete
return true;
}
}
return false;
}
public void listar() {
for (int i = 0; i < numProductos; i++) {
System.out.println(" " + productos[i]);
}
}
}
// ===== Formato.java =====
package es.tienda.util;
public final class Formato {
private Formato() {
}
public static String euros(double cantidad) {
return String.format("%.2f €", cantidad);
}
}
package es.tienda.app;
import es.tienda.modelo.Almacen;
import es.tienda.modelo.Producto;
import static es.tienda.util.Formato.euros; // import static: se usa euros(...) sin prefijo
public class Tienda {
public static void main(String[] args) {
Almacen almacen = new Almacen();
almacen.agregar(new Producto("Teclado", 29.99, 10));
almacen.agregar(new Producto("Ratón", 15.5, 25));
System.out.println("¿Vendidos 3 P002? " + almacen.vender("P002", 3));
System.out.println("¿Vendidos 50 P001? " + almacen.vender("P001", 50));
almacen.listar();
System.out.println("Total de ejemplo: " + euros(45.49));
}
}
6.3. Compilar y ejecutar con paquetes
Desde la terminal, en la carpeta raíz del código fuente (src), se compila indicando con -d dónde dejar los .class y se ejecuta usando el nombre completo de la clase:
$ find . -name "*.java"
./es/tienda/app/Tienda.java
./es/tienda/modelo/Almacen.java
./es/tienda/modelo/Producto.java
./es/tienda/util/Formato.java
$ javac -d out $(find . -name "*.java")
$ java -cp out es.tienda.app.Tienda
¿Vendidos 3 P002? true
¿Vendidos 50 P001? false
P001 Teclado 29.99 € (10 uds.)
P002 Ratón 15.50 € (22 uds.)
Total de ejemplo: 45.49 €
$ find out -name "*.class"
out/es/tienda/app/Tienda.class
out/es/tienda/modelo/Almacen.class
out/es/tienda/modelo/Producto.class
out/es/tienda/util/Formato.class
Observa que javac reproduce la estructura de carpetas de los paquetes dentro de out. En IntelliJ IDEA basta con clic derecho en src → New → Package (es.tienda.modelo) y crear las clases dentro; el IDE escribe la línea package y gestiona las carpetas.
6.4. El acceso package-private entre paquetes
retirarStock no tiene modificador. Almacen, que está en es.tienda.modelo, puede usarlo; Tienda, en otro paquete, no:
package es.tienda.app;
import es.tienda.modelo.Producto;
public class Tienda {
public static void main(String[] args) {
Producto p = new Producto();
p.retirarStock(3); // otro paquete: no es accesible
}
}
es/tienda/app/Tienda.java:8: error: retirarStock(int) is not public in Producto; cannot be accessed from outside package
p.retirarStock(3); // otro paquete: no es accesible
^
1 error
Esto es muy útil para diseñar: el paquete modelo ofrece hacia fuera una interfaz pública (vender) y se reserva para uso interno las operaciones delicadas (retirarStock), que solo usan clases de confianza del mismo paquete.
6.5. Formas de importar
import java.time.LocalDate; // importación de un tipo concreto
import java.util.*; // importación de todo el paquete java.util
import static java.lang.Math.*; // importación estática: sqrt, max, PI...
public class Imports {
public static void main(String[] args) {
LocalDate fecha = LocalDate.of(2026, 10, 4);
System.out.println("Fecha: " + fecha + ", día de la semana: " + fecha.getDayOfWeek());
String[] frutas = {"pera", "manzana", "kiwi"};
Arrays.sort(frutas); // Arrays está en java.util
System.out.println("Ordenadas: " + Arrays.toString(frutas));
System.out.println("sqrt(16) = " + sqrt(16) + ", max(3, 7) = " + max(3, 7) + ", PI = " + PI);
// Nombre completamente cualificado: sin import, escribiendo el paquete
java.util.Random r = new java.util.Random(42);
System.out.println("Aleatorio con semilla 42: " + r.nextInt(100));
// String, Math, System, Integer... son de java.lang: nunca hace falta importarlos
System.out.println(String.valueOf(Integer.MAX_VALUE).length() + " cifras tiene Integer.MAX_VALUE");
}
}
Fecha: 2026-10-04, día de la semana: SUNDAY
Ordenadas: [kiwi, manzana, pera]
sqrt(16) = 4.0, max(3, 7) = 7, PI = 3.141592653589793
Aleatorio con semilla 42: 30
10 cifras tiene Integer.MAX_VALUE
| Forma | Ejemplo | Qué importa |
|---|---|---|
| Un tipo concreto | import java.time.LocalDate; | Solo esa clase. Es la forma recomendada |
| Paquete completo | import java.util.*; | Todas las clases públicas de ese paquete (no de sus subpaquetes) |
| Estático | import static java.lang.Math.*; | Los miembros static de la clase, para usarlos sin prefijo |
| Nombre completo | java.util.Random r = ... | Nada: escribes el paquete en cada uso. Útil si hay dos clases con el mismo nombre |
| Módulo (Java 25) | import module java.base; | Todos los paquetes exportados por un módulo |
- El paquete
java.langse importa siempre automáticamente: por eso nunca has importadoString,Math,SystemniInteger. - Si dos paquetes importados con
*tienen una clase con el mismo nombre (por ejemplo,java.util.Dateyjava.sql.Date), el compilador da un error de ambigüedad al usarla. Se resuelve con un import concreto o con el nombre completo. - Los
importno copian código ni hacen el programa más lento: solo permiten escribir el nombre corto de la clase.
Desde Java 25 (JEP 511) existe además la importación de módulos, que trae de golpe todos los paquetes de un módulo de la plataforma:
import module java.base; // Java 25: importa todos los paquetes públicos del módulo java.base
public class ImportModulo {
public static void main(String[] args) {
int[] numeros = {5, 3, 8};
Arrays.sort(numeros); // java.util
Path ruta = Path.of("datos", "clientes.csv"); // java.nio.file
LocalDate hoy = LocalDate.of(2026, 10, 4); // java.time
System.out.println(Arrays.toString(numeros) + " " + ruta + " " + hoy);
}
}
[3, 5, 8] datos/clientes.csv 2026-10-04
💡 Consejo:
import module java.base;es cómodo para programas pequeños y es lo que los ficheros compactos de Java 25 hacen implícitamente. En proyectos reales se siguen prefiriendo los imports de tipos concretos, que dejan claro qué usa cada clase (y el IDE los gestiona por ti con Optimize Imports, Ctrl+Alt+O).
7. Clases envoltorio
7.1. Un objeto para cada tipo primitivo
Los tipos primitivos no son objetos: no tienen métodos ni pueden valer null. Pero hay situaciones en las que se necesita un objeto (las colecciones del módulo 16, por ejemplo, solo guardan objetos). Para eso, cada primitivo tiene su clase envoltorio (wrapper) en java.lang:
| Primitivo | Envoltorio | Ejemplos de utilidades |
|---|---|---|
byte | Byte | Byte.parseByte("12") |
short | Short | Short.MAX_VALUE |
int | Integer | Integer.parseInt, MAX_VALUE, toBinaryString |
long | Long | Long.parseLong("9000000000") |
float | Float | Float.parseFloat("2.5") |
double | Double | Double.parseDouble, Double.compare, isNaN |
char | Character | isDigit, isLetter, toUpperCase |
boolean | Boolean | Boolean.parseBoolean("true") |
public class Envoltorios {
public static void main(String[] args) {
// Conversión de texto a número (muy usado al leer datos)
int edad = Integer.parseInt("42");
double precio = Double.parseDouble("19.95");
System.out.println(edad + 1 + " | " + (precio * 2));
// Constantes y utilidades
System.out.println("int va de " + Integer.MIN_VALUE + " a " + Integer.MAX_VALUE);
System.out.println("42 en binario: " + Integer.toBinaryString(42) + ", en hexadecimal: " + Integer.toHexString(255));
System.out.println("¿'7' es dígito? " + Character.isDigit('7') + ", ¿'a' es letra? " + Character.isLetter('a'));
System.out.println("Comparar 2.5 y 1.0: " + Double.compare(2.5, 1.0));
// Autoboxing: int -> Integer automáticamente
Integer cantidad = 15; // equivale a Integer.valueOf(15)
// Unboxing: Integer -> int automáticamente
int doble = cantidad * 2; // equivale a cantidad.intValue() * 2
System.out.println("cantidad = " + cantidad + ", doble = " + doble);
// Un envoltorio puede ser null (un primitivo no)
Integer descuento = null; // "sin descuento definido"
System.out.println("descuento = " + descuento);
}
}
43 | 39.9
int va de -2147483648 a 2147483647
42 en binario: 101010, en hexadecimal: ff
¿'7' es dígito? true, ¿'a' es letra? true
Comparar 2.5 y 1.0: 1
cantidad = 15, doble = 30
descuento = null
7.2. Autoboxing y unboxing
Desde Java 5, el compilador convierte automáticamente entre primitivos y envoltorios:
- Autoboxing: de primitivo a objeto.
Integer cantidad = 15;se traduce aInteger.valueOf(15). - Unboxing: de objeto a primitivo.
int doble = cantidad * 2;se traduce acantidad.intValue() * 2.
Es cómodo, pero esconde dos trampas que aparecen en exámenes y en errores reales.
Trampa 1: unboxing de null. Si el envoltorio vale null, la conversión automática provoca una NullPointerException, y en una línea que no tiene ningún punto a la vista:
public class UnboxingNull {
public static void main(String[] args) {
Integer stockEnAlmacen = null; // dato desconocido
int total = stockEnAlmacen + 5; // unboxing de null
System.out.println(total);
}
}
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because "stockEnAlmacen" is null
at UnboxingNull.main(UnboxingNull.java:4)
Trampa 2: comparar envoltorios con ==. Los envoltorios son objetos, así que == compara referencias. Para complicarlo más, Integer.valueOf reutiliza objetos ya creados para los valores entre -128 y 127 (la caché de Integer), así que == “funciona” con números pequeños y falla con los grandes:
public class CacheInteger {
public static void main(String[] args) {
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println("a == b : " + (a == b)); // mismo objeto de la caché
System.out.println("c == d : " + (c == d)); // dos objetos distintos
System.out.println("c.equals(d) : " + c.equals(d)); // compara el valor
System.out.println("c == 128 : " + (c == 128)); // unboxing: compara int con int
}
}
a == b : true
c == d : false
c.equals(d) : true
c == 128 : true
Figura 4. Con 127 se reutiliza el objeto de la caché; con 128 cada autoboxing crea un objeto nuevo. c == 128 es true porque al comparar con un int se hace unboxing.
⚠️ Importante: Compara siempre los envoltorios con
equals, nunca con==. Un programa que comparaIntegercon==puede pasar todas tus pruebas con números pequeños y fallar en producción con el pedido número 128. (La caché de -128 a 127 la exige la especificación; el límite superior se puede ampliar con una opción de la JVM, otra razón para no depender de ella).
7.3. No uses los constructores de los envoltorios
En código antiguo verás new Integer(5). Esos constructores están obsoletos (deprecated) desde Java 9: usa Integer.valueOf(5) o el autoboxing. El compilador te avisa:
Note: Obsoleto.java uses or overrides a deprecated API.
Note: Recompile with -Xlint:deprecation for details.
5
Y con todos los avisos activados (-Xlint:all) te dice exactamente qué es lo obsoleto:
$ javac -Xlint:all Obsoleto.java
Obsoleto.java:3: warning: [deprecation] Integer(int) in Integer has been deprecated
Integer x = new Integer(5); // constructor obsoleto
^
1 warning
💡 Consejo: Usa primitivos siempre que puedas (son más rápidos y nunca son
null) y envoltorios solo cuando los necesites: colecciones, valores que pueden faltar (null) o métodos de utilidad. UnLong sumaacumulando en un bucle de un millón de vueltas crea un millón de objetos; unlong suma, ninguno.
8. Clases inmutables
8.1. Qué es un objeto inmutable
📘 Concepto: Un objeto es inmutable si su estado no puede cambiar después de crearse. Las operaciones que “modificarían” el objeto devuelven en su lugar un objeto nuevo con el resultado.
Ya conoces uno: String. Ventajas de la inmutabilidad:
- Seguridad al compartir: puedes pasar el objeto a cualquier método o guardarlo en varios sitios (alias) sin miedo a que alguien lo cambie.
- Invariantes garantizados para siempre: se validan una vez, en el constructor.
- Sencillez: un objeto que no cambia es más fácil de razonar, de depurar y de usar desde varios hilos a la vez (lo estudiarás en el curso avanzado).
8.2. La receta
- Declara la clase
final(para que nadie cree una subclase que añada estado mutable; módulo 12). - Todos los atributos
private final. - Sin setters ni métodos que cambien atributos.
- El constructor valida todos los datos.
- Los métodos que “cambian” algo devuelven un objeto nuevo.
- Si algún atributo es un objeto mutable (un array, por ejemplo), haz copias defensivas al recibirlo y al devolverlo (sección 9).
8.3. Ejemplo: una fecha propia inmutable
Java ya tiene una excelente clase de fechas inmutable (java.time.LocalDate, que usarás en proyectos reales), pero construir la nuestra es un ejercicio perfecto que combina todo el módulo: validación en el constructor, métodos static de ayuda, final y métodos que devuelven objetos nuevos.
// ===== Fecha.java =====
// Clase INMUTABLE: una vez creada, una fecha no cambia nunca
public final class Fecha {
private final int dia;
private final int mes;
private final int anio;
public Fecha(int dia, int mes, int anio) {
if (mes < 1 || mes > 12) {
throw new IllegalArgumentException("Mes no válido: " + mes);
}
if (dia < 1 || dia > diasDelMes(mes, anio)) {
throw new IllegalArgumentException("Día no válido: " + dia + "/" + mes + "/" + anio);
}
this.dia = dia;
this.mes = mes;
this.anio = anio;
}
// Métodos static de ayuda: no dependen de ningún objeto concreto
public static boolean esBisiesto(int anio) {
return (anio % 4 == 0 && anio % 100 != 0) || anio % 400 == 0;
}
public static int diasDelMes(int mes, int anio) {
return switch (mes) {
case 2 -> esBisiesto(anio) ? 29 : 28;
case 4, 6, 9, 11 -> 30;
default -> 31;
};
}
public int getDia() { return dia; }
public int getMes() { return mes; }
public int getAnio() { return anio; }
// En lugar de modificar this, devuelve una fecha NUEVA
public Fecha diaSiguiente() {
if (dia < diasDelMes(mes, anio)) {
return new Fecha(dia + 1, mes, anio);
}
if (mes < 12) {
return new Fecha(1, mes + 1, anio);
}
return new Fecha(1, 1, anio + 1);
}
public Fecha sumarDias(int n) {
Fecha f = this;
for (int i = 0; i < n; i++) {
f = f.diaSiguiente();
}
return f;
}
public boolean esAnteriorA(Fecha otra) {
if (anio != otra.anio) return anio < otra.anio;
if (mes != otra.mes) return mes < otra.mes;
return dia < otra.dia;
}
@Override
public String toString() {
return String.format("%02d/%02d/%04d", dia, mes, anio);
}
}
// ===== PruebaFecha.java =====
public class PruebaFecha {
public static void main(String[] args) {
Fecha prestamo = new Fecha(27, 2, 2028);
Fecha devolucion = prestamo.sumarDias(15); // prestamo NO cambia
System.out.println("Préstamo: " + prestamo);
System.out.println("Devolución: " + devolucion);
System.out.println("Fin de año: " + new Fecha(31, 12, 2026).diaSiguiente());
System.out.println("¿2100 bisiesto? " + Fecha.esBisiesto(2100) + "; ¿2000? " + Fecha.esBisiesto(2000));
System.out.println("¿Préstamo antes que devolución? " + prestamo.esAnteriorA(devolucion));
new Fecha(29, 2, 2027); // 2027 no es bisiesto
}
}
Préstamo: 27/02/2028
Devolución: 13/03/2028
Fin de año: 01/01/2027
¿2100 bisiesto? false; ¿2000? true
¿Préstamo antes que devolución? true
Exception in thread "main" java.lang.IllegalArgumentException: Día no válido: 29/2/2027
at Fecha.<init>(Fecha.java:12)
at PruebaFecha.main(PruebaFecha.java:10)
Observa que prestamo.sumarDias(15) no modifica prestamo: devuelve otra fecha. Y que es imposible crear una fecha inválida como el 29/02/2027: el invariante “la fecha existe” se cumple siempre.
💡 Consejo: La
switchcon flechas (case 2 -> ...) dediasDelMeses la switch expression de Java 14 que viste en el módulo 05. Combinada con métodosstaticpequeños queda un código muy legible.
8.4. Por qué String es inmutable
public class StringInmutable {
public static void main(String[] args) {
String s = "hola";
s.toUpperCase(); // crea "HOLA"... y se pierde
System.out.println(s); // hola: s no ha cambiado
String t = s.toUpperCase(); // hay que guardar el resultado
System.out.println(s + " / " + t);
String alias = s;
s = s + " mundo"; // s apunta a un String NUEVO
System.out.println("s = " + s + " | alias = " + alias);
}
}
hola
hola / HOLA
s = hola mundo | alias = hola
Todos los métodos de String que parecen modificarlo (toUpperCase, replace, strip, concat…) devuelven un String nuevo. Java lo diseñó así por varias razones:
- El pool de cadenas (módulo 09): los literales iguales comparten el mismo objeto. Si se pudieran modificar, cambiar
"hola"en un sitio lo cambiaría en todo el programa. - Seguridad: los
Stringse usan para rutas de ficheros, URL, nombres de clases o contraseñas. Si un método valida una ruta, nadie puede cambiarla después de la validación. - Código hash cacheado: como no cambia,
Stringpuede calcular su hash una vez y reutilizarlo, lo que hace muy rápidos los mapas con clavesString(módulo 16). - Uso seguro desde varios hilos sin sincronización.
El precio es que concatenar muchas veces en un bucle crea muchos objetos intermedios; para eso existe StringBuilder (módulo 09), que es la versión mutable.
📘 Concepto: En el módulo 14 conocerás los records (Java 16), una forma concisa de declarar clases inmutables de datos:
record Punto(double x, double y) {}genera automáticamente los atributosprivate final, el constructor, los métodos de acceso,equals,hashCodeytoString.
9. Copias defensivas
Hay una forma sutil de romper la encapsulación aunque todos los atributos sean private final: compartir referencias a objetos internos mutables. final impide reasignar la referencia, pero no impide modificar el objeto al que apunta.
// ===== Alumno.java =====
// Versión con una FUGA de encapsulación
public class Alumno {
private final String nombre;
private final double[] notas;
public Alumno(String nombre, double[] notas) {
for (double n : notas) {
if (n < 0 || n > 10) {
throw new IllegalArgumentException("Nota fuera de rango: " + n);
}
}
this.nombre = nombre;
this.notas = notas; // guarda la MISMA referencia que le pasan
}
public double[] getNotas() {
return notas; // entrega la referencia a su array interno
}
public double media() {
double s = 0;
for (double n : notas) s += n;
return s / notas.length;
}
}
// ===== AlumnoSeguro.java =====
import java.util.Arrays;
// Versión correcta con COPIAS DEFENSIVAS
public final class AlumnoSeguro {
private final String nombre;
private final double[] notas;
public AlumnoSeguro(String nombre, double[] notas) {
double[] copia = Arrays.copyOf(notas, notas.length); // 1. copiar ANTES de validar
for (double n : copia) {
if (n < 0 || n > 10) {
throw new IllegalArgumentException("Nota fuera de rango: " + n);
}
}
this.nombre = nombre;
this.notas = copia;
}
public double[] getNotas() {
return Arrays.copyOf(notas, notas.length); // 2. devolver una copia
}
public double media() {
double s = 0;
for (double n : notas) s += n;
return s / notas.length;
}
}
// ===== PruebaFuga.java =====
public class PruebaFuga {
public static void main(String[] args) {
double[] misNotas = {7, 8, 9};
Alumno a = new Alumno("Lucía", misNotas);
misNotas[0] = 50; // ataque 1: modificar el array original
a.getNotas()[1] = -20; // ataque 2: modificar lo que devuelve el getter
System.out.println("Alumno: media = " + a.media());
double[] otras = {7, 8, 9};
AlumnoSeguro s = new AlumnoSeguro("Pablo", otras);
otras[0] = 50;
s.getNotas()[1] = -20;
System.out.println("AlumnoSeguro: media = " + s.media());
}
}
Alumno: media = 13.0
AlumnoSeguro: media = 8.0
¡El Alumno “seguro” con atributos private final y validación en el constructor ha acabado con una nota de 50 y otra de -20 y una media de 13! Hubo dos fugas:
- El constructor guardó la referencia que le pasaron: quien la tenía (
misNotas) puede seguir cambiando el array. - El getter devolvió la referencia a su array interno: quien lo llama puede modificarlo.
Figura 5. A la izquierda, el objeto comparte su array con el exterior. A la derecha, AlumnoSeguro copia el array al recibirlo y entrega copias al devolverlo.
⚠️ Importante: Haz la copia antes de validar (como en
AlumnoSeguro): si validas el array original y luego lo copias, alguien podría cambiarlo justo entre ambas operaciones. Los objetos inmutables (String,Integer,Fecha,LocalDate) no necesitan copias: se pueden compartir sin peligro. Esa es otra gran ventaja de la inmutabilidad.
10. Ejemplo completo: Persona con validaciones
Una clase que reúne el módulo: atributo final sin setter (DNI), setters con validación que se reutilizan en el constructor y en otros métodos, un atributo opcional que admite null, un getter calculado con prefijo is, una constante privada y una clase de utilidades:
public class Persona {
private static final int EDAD_MAXIMA = 150;
private final String dni; // identifica a la persona: no cambia
private String nombre;
private int edad;
private String email; // opcional: puede ser null
public Persona(String dni, String nombre, int edad) {
if (!Validador.esDniValido(dni)) {
throw new IllegalArgumentException("DNI no válido: " + dni);
}
this.dni = dni.toUpperCase();
setNombre(nombre);
setEdad(edad);
}
public String getDni() { return dni; }
public String getNombre() { return nombre; }
public int getEdad() { return edad; }
public String getEmail() { return email; }
public boolean isMayorDeEdad() { // getter de un dato calculado (boolean: prefijo is)
return edad >= 18;
}
public void setNombre(String nombre) {
if (nombre == null || nombre.isBlank()) {
throw new IllegalArgumentException("Nombre obligatorio");
}
this.nombre = nombre.strip();
}
public void setEdad(int edad) {
if (!Validador.estaEntre(edad, 0, EDAD_MAXIMA)) {
throw new IllegalArgumentException("Edad no válida: " + edad);
}
this.edad = edad;
}
public void setEmail(String email) {
if (email != null && !Validador.esEmailValido(email)) {
throw new IllegalArgumentException("Email no válido: " + email);
}
this.email = email; // null significa "sin email"
}
public void cumplirAnios() {
setEdad(edad + 1); // reutiliza la validación
}
@Override
public String toString() {
return nombre + " (" + dni + ", " + edad + " años" + (email != null ? ", " + email : "") + ")";
}
}
public class PruebaPersona {
public static void main(String[] args) {
Persona p = new Persona("12345678z", " Ana López ", 17);
System.out.println(p + " ¿mayor de edad? " + p.isMayorDeEdad());
p.cumplirAnios();
p.setEmail("ana.lopez@correo.es");
System.out.println(p + " ¿mayor de edad? " + p.isMayorDeEdad());
p.setEmail("ana.lopez(at)correo");
}
}
Ana López (12345678Z, 17 años) ¿mayor de edad? false
Ana López (12345678Z, 18 años, ana.lopez@correo.es) ¿mayor de edad? true
Exception in thread "main" java.lang.IllegalArgumentException: Email no válido: ana.lopez(at)correo
at Persona.setEmail(Persona.java:43)
at PruebaPersona.main(PruebaPersona.java:8)
Persona usa la clase Validador de la sección 5.6 (en la misma carpeta). Fíjate en que cumplirAnios() no suma directamente: llama a setEdad, así que también respeta el límite de 150 años.
11. Errores comunes
| Error | Síntoma | Solución |
|---|---|---|
Atributos sin private | Cualquier clase deja el objeto en estado inválido | private + métodos con validación |
| Setter para todo, sin validar | La encapsulación es solo aparente | Valida y elimina los setters que no tengan sentido |
| Validar en el setter pero no en el constructor | Se pueden crear objetos inválidos con new | El constructor llama a los setters o a la misma validación |
Usar un atributo de instancia en un método static | non-static variable … cannot be referenced from a static context | Hacer el método de instancia o usar un objeto |
Llamar a métodos static a través de un objeto | Código confuso; aviso del IDE | NombreClase.metodo() |
| Números mágicos repetidos | Cambiar una política obliga a buscar por todo el código | Constantes static final |
Carpeta que no coincide con el package | Errores de compilación o ClassNotFoundException | Carpeta = paquete; usa el IDE para crear paquetes |
| Olvidar el nombre completo al ejecutar | Could not find or load main class Tienda | java -cp out es.tienda.app.Tienda |
Comparar Integer con == | Funciona con 100, falla con 1000 | equals |
Unboxing de null | NullPointerException en una suma o comparación | Comprobar null o usar primitivos |
| Getter que devuelve un array interno | El exterior modifica el estado privado | Copias defensivas (Arrays.copyOf) |
Olvidar recoger el resultado de un método de String | s.toUpperCase(); no hace nada visible | s = s.toUpperCase(); |
12. Ejercicios
Ejercicios resueltos
Ejercicio 1: Termostato con invariante
Crea una clase Termostato con una temperatura objetivo que siempre debe estar entre 5 y 30 grados (constantes MINIMO y MAXIMO) y un estado encendido/apagado. Incluye setObjetivo (lanza IllegalArgumentException si el valor está fuera de rango), subir() y bajar() (de 0,5 en 0,5 sin salirse nunca del rango), alternar() y debeCalentar(double ambiente).
Ver solución
// ===== Termostato.java =====
public class Termostato {
public static final double MINIMO = 5.0;
public static final double MAXIMO = 30.0;
private double objetivo = 21.0; // INVARIANTE: MINIMO <= objetivo <= MAXIMO
private boolean encendido;
public double getObjetivo() {
return objetivo;
}
public boolean isEncendido() {
return encendido;
}
public void setObjetivo(double grados) {
if (grados < MINIMO || grados > MAXIMO) {
throw new IllegalArgumentException("Temperatura fuera de rango: " + grados);
}
objetivo = grados;
}
public void subir() {
objetivo = Math.min(objetivo + 0.5, MAXIMO); // nunca sale del rango
}
public void bajar() {
objetivo = Math.max(objetivo - 0.5, MINIMO);
}
public void alternar() {
encendido = !encendido;
}
// ¿Debe calentar? Depende de la temperatura ambiente
public boolean debeCalentar(double ambiente) {
return encendido && ambiente < objetivo;
}
}
// ===== PruebaTermostato.java =====
public class PruebaTermostato {
public static void main(String[] args) {
Termostato t = new Termostato();
t.alternar();
t.setObjetivo(29.5);
t.subir();
t.subir(); // no pasa de 30
System.out.println("Objetivo: " + t.getObjetivo());
System.out.println("¿Calentar con 25 º? " + t.debeCalentar(25));
t.alternar();
System.out.println("¿Calentar apagado? " + t.debeCalentar(25));
t.setObjetivo(40);
}
}
Objetivo: 30.0
¿Calentar con 25 º? true
¿Calentar apagado? false
Exception in thread "main" java.lang.IllegalArgumentException: Temperatura fuera de rango: 40.0
at Termostato.setObjetivo(Termostato.java:18)
at PruebaTermostato.main(PruebaTermostato.java:12)
El invariante se protege de dos formas distintas según la operación: setObjetivo recibe un valor arbitrario y lo rechaza si no es válido, mientras que subir y bajar saturan con Math.min/Math.max porque pulsar una vez de más el botón de subir no es un error. No hay setEncendido: el único modo de cambiarlo es alternar(), igual que el botón físico del aparato.
Ejercicio 2: Cuentas con numeración automática e interés común
Crea una clase Cuenta cuyo número se asigne automáticamente (1001, 1002…), con un tipo de interés anual común a todas las cuentas que se pueda cambiar con un método static (entre 0 y 0,2), un contador del total de cuentas creadas y un método aplicarIntereses().
Ver solución
// ===== Cuenta.java =====
public class Cuenta {
private static int ultimoNumero = 1000; // compartido: último número asignado
private static double interesAnual = 0.02; // compartido: igual para todas las cuentas
private static int totalCuentas;
private final int numero;
private final String titular;
private double saldo;
public Cuenta(String titular, double saldoInicial) {
if (saldoInicial < 0) {
throw new IllegalArgumentException("Saldo inicial negativo");
}
ultimoNumero++;
totalCuentas++;
this.numero = ultimoNumero;
this.titular = titular;
this.saldo = saldoInicial;
}
public static void setInteresAnual(double interes) {
if (interes < 0 || interes > 0.2) {
throw new IllegalArgumentException("Interés no válido");
}
interesAnual = interes;
}
public static double getInteresAnual() {
return interesAnual;
}
public static int getTotalCuentas() {
return totalCuentas;
}
public void aplicarIntereses() { // método de instancia que usa un dato static
saldo += saldo * interesAnual;
}
public double getSaldo() {
return saldo;
}
@Override
public String toString() {
return String.format("Cuenta %d (%s): %.2f €", numero, titular, saldo);
}
}
// ===== PruebaCuentas.java =====
public class PruebaCuentas {
public static void main(String[] args) {
Cuenta a = new Cuenta("Marta", 1000);
Cuenta b = new Cuenta("Javier", 2500);
Cuenta.setInteresAnual(0.05); // afecta a TODAS las cuentas
a.aplicarIntereses();
b.aplicarIntereses();
System.out.println(a);
System.out.println(b);
System.out.println("Cuentas: " + Cuenta.getTotalCuentas() + ", interés: " + Cuenta.getInteresAnual());
}
}
Cuenta 1001 (Marta): 1050.00 €
Cuenta 1002 (Javier): 2625.00 €
Cuentas: 2, interés: 0.05
ultimoNumero, interesAnual y totalCuentas son static porque describen al conjunto de cuentas, no a una en concreto. numero es final de instancia: cada cuenta tiene el suyo y no cambia. aplicarIntereses() es un método de instancia que usa un dato static, lo cual es perfectamente válido; lo contrario (un método static usando saldo) no lo sería.
Ejercicio 3: Clase de utilidades estadísticas
Crea una clase de utilidades Estadistica (no instanciable) con los métodos media, maximo y desviacionTipica para arrays de double. Si el array es null o está vacío, deben lanzar IllegalArgumentException. Usa un método privado para no repetir la comprobación.
Ver solución
// ===== Estadistica.java =====
public final class Estadistica {
private Estadistica() {
}
public static double media(double[] datos) {
comprobar(datos);
double suma = 0;
for (double d : datos) suma += d;
return suma / datos.length;
}
public static double maximo(double[] datos) {
comprobar(datos);
double max = datos[0];
for (double d : datos) max = Math.max(max, d);
return max;
}
public static double desviacionTipica(double[] datos) {
double m = media(datos);
double suma = 0;
for (double d : datos) suma += (d - m) * (d - m);
return Math.sqrt(suma / datos.length);
}
// Método privado de ayuda: también static
private static void comprobar(double[] datos) {
if (datos == null || datos.length == 0) {
throw new IllegalArgumentException("Se necesita al menos un dato");
}
}
}
// ===== PruebaEstadistica.java =====
import static java.lang.Math.round;
public class PruebaEstadistica {
public static void main(String[] args) {
double[] ventas = {120.5, 98.0, 143.25, 110.0, 87.5};
System.out.println("Media: " + Estadistica.media(ventas));
System.out.println("Máximo: " + Estadistica.maximo(ventas));
System.out.println("Desviación: " + round(Estadistica.desviacionTipica(ventas) * 100) / 100.0);
}
}
Media: 111.85
Máximo: 143.25
Desviación: 19.23
La clase es final y su constructor es privado. comprobar es private static: un detalle interno que no forma parte de la interfaz pública. En la prueba se usa import static java.lang.Math.round para escribir round(...) sin el prefijo Math.; la desviación se redondea a dos decimales multiplicando por 100, redondeando y dividiendo entre 100.0.
Ejercicio 4: Traza con envoltorios
Sin ejecutarlo, indica qué imprime cada línea y por qué:
public class TrazaEnvoltorios {
public static void main(String[] args) {
Integer a = 100, b = 100;
Integer c = 1000, d = 1000;
int e = 1000;
Long f = 100L;
System.out.println(a == b);
System.out.println(c == d);
System.out.println(c == e);
System.out.println(c.equals(d));
System.out.println(a.equals(f));
System.out.println(f.equals(100));
System.out.println(f.equals(100L));
}
}
Ver solución
true
false
true
true
false
false
true
a == b→true: 100 está en la caché,aybson el mismo objeto.c == d→false: 1000 está fuera de la caché; son dos objetos distintos.c == e→true: al comparar unIntegercon unintse hace unboxing y se comparan valores.c.equals(d)→true:equalscompara valores.a.equals(f)→false:aes unIntegeryfunLong.equalsde los envoltorios exige que el tipo también coincida.f.equals(100)→false: el literal100es uninty se convierte enInteger, no enLong.f.equals(100L)→true: ahora el argumento es unLongcon el mismo valor.
Ejercicio 5: Dinero inmutable
Los double no son adecuados para importes (0.1 + 0.2 no da exactamente 0.3). Crea una clase inmutable Dinero que guarde los céntimos en un long, con métodos de fábrica static (deEuros, deCentimos), una constante CERO, y operaciones sumar, multiplicar, porcentaje y esMayorQue que devuelvan objetos nuevos. toString debe mostrar el formato español (29,99 €).
Ver solución
// ===== Dinero.java =====
// Importe en euros guardado en CÉNTIMOS (long) para evitar errores de redondeo de double
public final class Dinero {
public static final Dinero CERO = new Dinero(0);
private final long centimos;
private Dinero(long centimos) { // constructor privado
this.centimos = centimos;
}
// "Métodos de fábrica" static para crear objetos
public static Dinero deEuros(double euros) {
return new Dinero(Math.round(euros * 100));
}
public static Dinero deCentimos(long centimos) {
return new Dinero(centimos);
}
public Dinero sumar(Dinero otro) {
return new Dinero(centimos + otro.centimos);
}
public Dinero multiplicar(int veces) {
return new Dinero(centimos * veces);
}
public Dinero porcentaje(double pct) {
return new Dinero(Math.round(centimos * pct / 100));
}
public boolean esMayorQue(Dinero otro) {
return centimos > otro.centimos;
}
@Override
public String toString() {
return String.format("%d,%02d €", centimos / 100, Math.abs(centimos % 100));
}
}
// ===== PruebaDinero.java =====
public class PruebaDinero {
public static void main(String[] args) {
System.out.println("Con double: " + (0.1 + 0.2));
Dinero a = Dinero.deEuros(0.1).sumar(Dinero.deEuros(0.2));
System.out.println("Con Dinero: " + a);
Dinero teclado = Dinero.deEuros(29.99);
Dinero total = teclado.multiplicar(3);
Dinero iva = total.porcentaje(21);
System.out.println("3 teclados: " + total + " + IVA " + iva + " = " + total.sumar(iva));
System.out.println("teclado sigue valiendo " + teclado);
System.out.println("¿Total > 0? " + total.esMayorQue(Dinero.CERO));
}
}
Con double: 0.30000000000000004
Con Dinero: 0,30 €
3 teclados: 89,97 € + IVA 18,89 € = 108,86 €
teclado sigue valiendo 29,99 €
¿Total > 0? true
El constructor es privado y los objetos se crean con métodos de fábrica static (Dinero.deEuros(29.99)), que tienen nombres más expresivos que un constructor y permiten elegir la unidad. Como Dinero es inmutable, la constante CERO se puede compartir sin peligro y teclado sigue valiendo lo mismo tras multiplicar. Este es el diseño que siguen las bibliotecas profesionales de importes (Java también ofrece BigDecimal para cálculos decimales exactos).
Ejercicio 6: Biblioteca en paquetes
Organiza una pequeña biblioteca en dos paquetes: es.biblioteca.modelo (clases Libro y Biblioteca) y es.biblioteca.app (clase App con el main). El método que marca un libro como prestado debe ser accesible para Biblioteca pero no para App. Compila y ejecuta desde la terminal.
Ver solución
src/
└── es/
└── biblioteca/
├── app/
│ └── App.java
└── modelo/
├── Biblioteca.java
└── Libro.java
package es.biblioteca.modelo;
public class Libro {
private final String titulo;
private boolean prestado;
public Libro(String titulo) {
this.titulo = titulo;
}
public String getTitulo() {
return titulo;
}
public boolean isPrestado() {
return prestado;
}
// package-private: solo Biblioteca (mismo paquete) puede cambiar el estado
void marcarPrestado(boolean valor) {
prestado = valor;
}
}
// ===== Biblioteca.java =====
package es.biblioteca.modelo;
public class Biblioteca {
private final Libro[] libros = new Libro[100];
private int numLibros;
public void agregar(Libro l) {
libros[numLibros++] = l;
}
public boolean prestar(String titulo) {
for (int i = 0; i < numLibros; i++) {
if (libros[i].getTitulo().equalsIgnoreCase(titulo) && !libros[i].isPrestado()) {
libros[i].marcarPrestado(true);
return true;
}
}
return false;
}
public int disponibles() {
int n = 0;
for (int i = 0; i < numLibros; i++) {
if (!libros[i].isPrestado()) n++;
}
return n;
}
}
// ===== App.java =====
package es.biblioteca.app;
import es.biblioteca.modelo.Biblioteca;
import es.biblioteca.modelo.Libro;
public class App {
public static void main(String[] args) {
Biblioteca b = new Biblioteca();
b.agregar(new Libro("Niebla"));
b.agregar(new Libro("La Regenta"));
System.out.println("Prestar Niebla: " + b.prestar("niebla"));
System.out.println("Prestar Niebla otra vez: " + b.prestar("Niebla"));
System.out.println("Disponibles: " + b.disponibles());
}
}
$ javac -d out $(find . -name "*.java")
$ java -cp out es.biblioteca.app.App
Prestar Niebla: true
Prestar Niebla otra vez: false
Disponibles: 1
marcarPrestado es package-private: Biblioteca (mismo paquete) lo usa, pero desde App solo se puede prestar a través de Biblioteca.prestar, que comprueba que el libro esté disponible. Así nadie puede marcar como prestado un libro saltándose las reglas.
Ejercicios propuestos
Ejercicio 7: Hora inmutable
Convierte la clase Hora del ejercicio 3 del módulo 10 en inmutable: atributos private final, clase final, constructor que lance IllegalArgumentException con valores fuera de rango y un método mas(int segundos) que devuelva una hora nueva. Añade constantes MEDIANOCHE y MEDIODIA.
Ejercicio 8: Códigos de producto
Modifica el Producto de la tienda para que el código se genere automáticamente con el formato P0001, P0002… y añade un método static reiniciarNumeracion() (pensado para pruebas). ¿Qué problema tendría hacerlo public en una aplicación real? ¿Qué modificador sería más adecuado?
Ejercicio 9: Conversor de unidades
Crea una clase de utilidades Conversor con constantes (KM_POR_MILLA, LITROS_POR_GALON…) y métodos static para convertir temperaturas (Celsius, Fahrenheit, Kelvin), distancias y volúmenes. Haz que no se puedan crear objetos y pruébala con import static.
Ejercicio 10: Matrícula validada
Crea una clase Matricula inmutable que solo admita el formato español actual (4 dígitos y 3 consonantes sin vocales ni Ñ ni Q, por ejemplo 1234-BCD o 1234BCD) y la guarde normalizada (1234 BCD). Usa una expresión regular (módulo 09). Añade la matrícula como atributo final del Coche del módulo 10.
Ejercicio 11: Contraseña con política
Crea una clase Usuario con nombre de usuario (inmutable) y contraseña. setPassword debe exigir al menos 8 caracteres, una mayúscula, una minúscula y un dígito (usa los métodos de Character). No debe existir getPassword: añade en su lugar boolean comprobarPassword(String intento).
Ejercicio 12: La tienda en paquetes
Reorganiza la tienda del módulo 10 (Producto, Cliente, LineaPedido, Pedido) en los paquetes es.tienda.modelo, es.tienda.util y es.tienda.app, con todos los atributos privados. Decide qué métodos deben ser public y cuáles package-private (por ejemplo, retirarStock solo debería usarlo Pedido). Haz que LineaPedido sea inmutable.
Resumen
| Concepto | Sintaxis / ejemplo | Idea clave |
|---|---|---|
| Encapsulación | atributos private + métodos public | El objeto protege su estado |
private | private double precio; | Solo la propia clase |
| package-private | void retirarStock(int c) | Clases del mismo paquete |
protected | protected String nombre; | Paquete + subclases (módulo 12) |
public | public double getPrecio() | Cualquier clase |
| Getter / setter | getPrecio(), isActivo(), setPrecio(double) | Acceso controlado; el setter valida |
| Rechazar un valor | throw new IllegalArgumentException("...") | Detiene el programa con un mensaje claro |
| Invariante | “saldo >= 0” | Se cumple siempre gracias a la encapsulación |
| Atributo de clase | private static int contador; | Uno compartido por todos los objetos |
| Método de clase | public static int getContador() | Sin this; se llama con la clase |
| Constante | public static final double IVA = 0.21; | Valor único y fijo, en MAYÚSCULAS |
| Bloque static | static { ... } | Se ejecuta una vez al cargar la clase |
| Clase de utilidades | final + constructor private + static | Agrupa funciones (Math, Arrays) |
| Paquete | package es.tienda.modelo; | Carpeta + espacio de nombres + acceso |
| Import | import, import static, import module | Usar nombres cortos; java.lang es implícito |
| Envoltorio | Integer, Double, Character… | Objeto para un primitivo; puede ser null |
| Autoboxing / unboxing | Integer x = 5; int y = x; | Automático; cuidado con null y con == |
| Inmutable | final class, private final, sin setters | No cambia nunca; se comparte sin riesgo |
| Copia defensiva | Arrays.copyOf(notas, notas.length) | No compartir objetos internos mutables |