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, protected y public
  • 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é main es static
  • Definir constantes con static final, bloques static y clases de utilidades
  • Organizar el código en paquetes y usar import, import static e import module
  • Trabajar con las clases envoltorio (Integer, Double…), el autoboxing y sus trampas
  • Diseñar clases inmutables con copias defensivas y entender por qué String lo 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.

El código cliente solo puede llamar a los métodos públicos del objeto Producto; el acceso directo al estado privado está bloqueado

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 public o package-private. Una clase public es 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 constructores public salvo los métodos auxiliares internos, que también serán private. 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:

  1. 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.
  2. setNombre normaliza el dato (strip() quita los espacios de los extremos): los setters también sirven para dejar los datos en un formato coherente.
  3. 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 una NullPointerException. 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 ingresar y retirar.
  • 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:

  1. El constructor debe dejar el objeto en un estado que lo cumpla.
  2. Cada método modificador debe conservarlo: o el cambio es válido, o no se hace nada.
  3. 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 sin static (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

La clase Producto contiene un único contador static igual a 3; en el montón hay tres objetos, cada uno con su id y su nombre, y todos apuntan al mismo contador

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 static se accede con el nombre de la clase: Producto.getContador(), Math.PI. Java también permite escribir a.getContador() a través de un objeto, pero es engañoso (parece que depende de a) 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() sin static: en ese caso el lanzador crea automáticamente un objeto de la clase y llama a main sobre é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_DESDE se entiende mucho mejor que importe >= 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 en static, vuelves a la programación estructurada del módulo 07 con otra sintaxis. Usa static para 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:

  1. Organizar el código, como las carpetas organizan tus documentos.
  2. Evitar conflictos de nombres: puede haber una clase Producto en es.tienda.modelo y otra en com.proveedor.catalogo. El nombre completo de una clase incluye su paquete.
  3. 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 por es.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.

A la izquierda, el árbol de carpetas src/es/tienda con app, modelo y util; a la derecha, los paquetes como cajas UML con sus clases y flechas de import

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.lang se importa siempre automáticamente: por eso nunca has importado String, Math, System ni Integer.
  • Si dos paquetes importados con * tienen una clase con el mismo nombre (por ejemplo, java.util.Date y java.sql.Date), el compilador da un error de ambigüedad al usarla. Se resuelve con un import concreto o con el nombre completo.
  • Los import no 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 a Integer.valueOf(15).
  • Unboxing: de objeto a primitivo. int doble = cantidad * 2; se traduce a cantidad.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

a y b apuntan al mismo objeto Integer 127 de la caché; c y d apuntan a dos objetos Integer 128 distintos

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 compara Integer con == 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. Un Long suma acumulando en un bucle de un millón de vueltas crea un millón de objetos; un long 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

  1. Declara la clase final (para que nadie cree una subclase que añada estado mutable; módulo 12).
  2. Todos los atributos private final.
  3. Sin setters ni métodos que cambien atributos.
  4. El constructor valida todos los datos.
  5. Los métodos que “cambian” algo devuelven un objeto nuevo.
  6. 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 switch con flechas (case 2 -> ...) de diasDelMes es la switch expression de Java 14 que viste en el módulo 05. Combinada con métodos static pequeñ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 String se 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, String puede calcular su hash una vez y reutilizarlo, lo que hace muy rápidos los mapas con claves String (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 atributos private final, el constructor, los métodos de acceso, equals, hashCode y toString.


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:

  1. El constructor guardó la referencia que le pasaron: quien la tenía (misNotas) puede seguir cambiando el array.
  2. El getter devolvió la referencia a su array interno: quien lo llama puede modificarlo.

Sin copia, misNotas y el atributo notas apuntan al mismo array modificado; con copias defensivas, el objeto tiene su propio array con 7, 8, 9

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
  1. a == b → true: 100 está en la caché, a y b son el mismo objeto.
  2. c == d → false: 1000 está fuera de la caché; son dos objetos distintos.
  3. c == e → true: al comparar un Integer con un int se hace unboxing y se comparan valores.
  4. c.equals(d) → true: equals compara valores.
  5. a.equals(f) → false: a es un Integer y f un Long. equals de los envoltorios exige que el tipo también coincida.
  6. f.equals(100) → false: el literal 100 es un int y se convierte en Integer, no en Long.
  7. f.equals(100L) → true: ahora el argumento es un Long con 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