Clases y objetos en Java — ejercicios con productos, fracciones y una tienda (y el static que vació todo el almacén)

En mi primera práctica de tienda puse static delante de stock sin pensarlo mucho. Me sonaba de main y quedaba «más Java». Todo funcionaba hasta que añadí un segundo producto: de repente, el café tenía el mismo stock que el té. Vendía un té y bajaba el café. Había convertido un dato de cada producto en un dato compartido por todos.

En estos ejercicios vas a escribir clases desde cero: decidir qué atributos son de cada objeto y cuáles de la clase, proteger los datos con private, escribir equals y toString, y hacer que varias clases colaboren. Y antes de las soluciones, tres fragmentos con trampa (el primero es justo mi static). Si necesitas repasar antes de empezar, tienes la teoría y la práctica del tema.

Cómo usar este artículo

  1. Lee el enunciado y la salida esperada.
  2. Inténtalo al menos 15 minutos antes de abrir la pista.
  3. Prueba también casos raros: valores negativos, objetos repetidos, arrays llenos.
  4. Compara con la solución y lee el porqué de cada decisión.

Cada clase va en su propio archivo (Producto.java, Fraccion.java, Tienda.java) y un Main.java con las pruebas. Los ejercicios se apoyan unos en otros: la Tienda usa el Producto del nivel 1.

Nivel 1 — Básico: la clase Producto

Enunciado. Crea la clase Producto con nombre, precio y stock, todos private:

  • El constructor recibe los tres datos. Si el precio o el stock son negativos, se guardan como 0.
  • getNombre(), getPrecio(), getStock().
  • boolean setPrecio(double precio): rechaza precios negativos.
  • boolean vender(int unidades): solo vende si hay stock suficiente y las unidades son positivas.
  • void reponer(int unidades) y boolean estaAgotado().
  • toString() con el formato Café: 3,50 € (10 uds.).

Pruebas y salida esperada:

Producto cafe = new Producto("Café", 3.5, 10);
Producto raro = new Producto("Error", -2, -5);
System.out.println(cafe);
System.out.println(raro);
System.out.println(cafe.vender(4) + " " + cafe.vender(20) + " " + cafe.setPrecio(-1));
cafe.reponer(5);
System.out.println(cafe);
Café: 3,50 € (10 uds.)
Error: 0,00 € (0 uds.)
true false false
Café: 3,50 € (11 uds.)

El nombre puede ser final (un producto no cambia de nombre). Para toString, String.format("%s: %.2f € (%d uds.)", …).

Nivel 2 — Intermedio: una Fraccion inmutable

Enunciado. Crea Fraccion con numerador y denominador private final. Una vez creada, una fracción no cambia nunca (es inmutable):

  • El constructor la deja simplificada (2/4 → 1/2) y con el signo arriba (1/−3 → −1/3). Si el denominador es 0, lanza IllegalArgumentException (basta con throw new IllegalArgumentException("…"); las excepciones las verás a fondo más adelante).
  • Fraccion sumar(Fraccion o) y Fraccion multiplicar(Fraccion o) devuelven una fracción nueva.
  • equals, hashCode y toString (1/2; si el denominador es 1, solo el número).

Pruebas y salida esperada:

Fraccion a = new Fraccion(1, 2), b = new Fraccion(2, 4), c = new Fraccion(1, -3);
System.out.println(a + " " + b + " " + c);
System.out.println(a.equals(b) + " " + (a == b));
System.out.println(a.sumar(c) + " " + a.multiplicar(new Fraccion(4, 1)) + " " + new Fraccion(6, 3));
System.out.println(a.sumar(new Fraccion(1, 2)));
1/2 1/2 -1/3
true false
1/6 2 2
1

El máximo común divisor con el algoritmo de Euclides: mientras b != 0, r = a % b; a = b; b = r;. Si simplificas en el constructor, equals solo tiene que comparar numerador y denominador.

Nivel 3 — Desafío: una Tienda con varios productos

Enunciado. Crea Tienda, que guarda productos en un array con capacidad fija:

  • Constructor con nombre y capacidad.
  • Producto buscar(String nombre): sin distinguir mayúsculas; null si no está.
  • boolean agregar(Producto p): falla si la tienda está llena o ya hay un producto con ese nombre.
  • boolean vender(String nombre, int unidades).
  • double valorInventario() (precio × stock de todos) e int agotados().
  • Un contador de todas las tiendas con las unidades vendidas: static int getVentasTotales().

Pruebas y salida esperada:

Tienda centro = new Tienda("Centro", 3);
Tienda playa = new Tienda("Playa", 2);
centro.agregar(new Producto("Café", 3.5, 10));
centro.agregar(new Producto("Té", 2.0, 5));
System.out.println(centro.agregar(new Producto("café", 9, 1)));   // repetido
centro.agregar(new Producto("Galletas", 1.8, 2));
System.out.println(centro.agregar(new Producto("Leche", 1.1, 6))); // llena
playa.agregar(new Producto("Helado", 2.5, 8));

System.out.println(centro.vender("té", 5) + " " + centro.vender("Galletas", 3) + " " + playa.vender("Helado", 2));
System.out.printf("Inventario %s: %.2f € · agotados: %d%n", centro.getNombre(), centro.valorInventario(), centro.agotados());
System.out.println("Ventas totales: " + Tienda.getVentasTotales());
false
false
true false true
Inventario Centro: 38,60 € · agotados: 1
Ventas totales: 7

Array de productos + contador numProductos, como la Biblioteca de la práctica. vender de la tienda debería apoyarse en vender del producto, no repetir su lógica. Y piensa bien qué atributo es static: solo uno.

¿Qué falla aquí?

Tres fragmentos que compilan. Antes de leer la explicación, intenta predecir qué imprimen.

Fragmento 1 — El stock compartido

class Producto {
    private String nombre;
    private static int stock;      // ¿de quién es este stock?

    Producto(String nombre, int stock) {
        this.nombre = nombre;
        Producto.stock = stock;
    }
    int getStock() { return stock; }
}

Producto cafe = new Producto("Café", 10);
Producto te = new Producto("Té", 3);
System.out.println(cafe.getStock() + " " + te.getStock());

Imprime 3 3. Con static, solo existe un stock para toda la clase. Al crear el té, ese único stock pasa de 10 a 3, y el café «ve» el mismo valor. Es mi historia del principio. La regla: si cada objeto debe tener su propio valor, no es static. static es para lo que pertenece a la clase entera: contadores, constantes, totales.

Usar static «porque si no, no compila». Suele pasar al llamar a un método de instancia desde main. La solución no es marcarlo todo como static, sino crear un objeto y llamar al método sobre él.

Fragmento 2 — Dos fracciones iguales que no lo son

class Fraccion {
    private final int num, den;
    Fraccion(int num, int den) { this.num = num; this.den = den; }
}

Fraccion a = new Fraccion(1, 2);
Fraccion b = new Fraccion(1, 2);
System.out.println(a.equals(b));

Imprime false. Si no escribes equals, la clase hereda el de Object, que hace lo mismo que ==: compara referencias. a y b son dos objetos distintos, así que no son «iguales». Para que dos fracciones con los mismos números sean iguales, tienes que sobrescribir equals (y, con él, hashCode).

Fragmento 3 — El getter que abre la puerta

class Tienda {
    private final Producto[] productos = new Producto[3];
    private int numProductos = 0;
    // ...
    Producto[] getProductos() { return productos; }
}

Producto[] lista = tienda.getProductos();
lista[0] = null;
System.out.println(tienda.valorInventario());

Lanza NullPointerException. El array es private, pero el getter devuelve la referencia al mismo array. Quien lo recibe puede cambiar su contenido y romper la tienda desde fuera: la encapsulación se ha escapado por el getter. La solución es devolver una copia: return Arrays.copyOf(productos, numProductos);. Así, lo que hagan con la copia no afecta a la tienda.

Cuando un atributo private sea un array (o cualquier objeto que se pueda modificar), pregúntate si el getter debería devolver una copia. Con String no hace falta, porque los String son inmutables.

Soluciones

Nivel 1 — Producto

public class Producto {
    private final String nombre;
    private double precio;
    private int stock;

    public Producto(String nombre, double precio, int stock) {
        this.nombre = nombre;
        this.precio = precio >= 0 ? precio : 0;   // nunca negativo
        this.stock = Math.max(0, stock);
    }

    public String getNombre() { return nombre; }
    public double getPrecio() { return precio; }
    public int getStock()     { return stock; }

    public boolean setPrecio(double precio) {
        if (precio < 0) return false;
        this.precio = precio;
        return true;
    }

    public boolean vender(int unidades) {
        if (unidades <= 0 || unidades > stock) return false;
        stock -= unidades;
        return true;
    }

    public void reponer(int unidades) {
        if (unidades > 0) stock += unidades;
    }

    public boolean estaAgotado() { return stock == 0; }

    @Override
    public String toString() {
        return String.format("%s: %.2f € (%d uds.)", nombre, precio, stock);
    }
}

**Por qué no hay setStock. El stock solo debería cambiar al vender o reponer**. Un setStock(int) permitiría saltarse esas reglas (poner −5, por ejemplo). Los setters se crean cuando tienen sentido, no por costumbre.

Por qué el constructor corrige en vez de rechazar. Un constructor no puede devolver false. Las opciones son corregir el valor (lo que hacemos, Math.max(0, stock)) o lanzar una excepción, como en la Fraccion. Corregir es más simple para empezar; lanzar es más honesto cuando el dato no tiene arreglo.

**Por qué unidades <= 0. Vender 0 o −3 unidades no tiene sentido y, con −3, ¡el stock subiría**! Validar también los valores «raros» es lo que distingue una clase robusta.

Nivel 2 — Fraccion

public final class Fraccion {
    private final int num;
    private final int den;

    public Fraccion(int num, int den) {
        if (den == 0) throw new IllegalArgumentException("El denominador no puede ser 0");
        if (den < 0) { num = -num; den = -den; }    // el signo, siempre arriba
        int m = mcd(Math.abs(num), den);
        this.num = num / m;
        this.den = den / m;
    }

    private static int mcd(int a, int b) {
        while (b != 0) { int r = a % b; a = b; b = r; }
        return a == 0 ? 1 : a;
    }

    public Fraccion sumar(Fraccion o) {
        return new Fraccion(num * o.den + o.num * den, den * o.den);
    }

    public Fraccion multiplicar(Fraccion o) {
        return new Fraccion(num * o.num, den * o.den);
    }

    public double valor() { return (double) num / den; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Fraccion)) return false;
        Fraccion f = (Fraccion) o;
        return num == f.num && den == f.den;    // ya están simplificadas
    }

    @Override
    public int hashCode() { return 31 * num + den; }

    @Override
    public String toString() { return den == 1 ? String.valueOf(num) : num + "/" + den; }
}

Por qué inmutable. Si sumar modificara this, después de a.sumar(c) la fracción a ya no valdría 1/2, y cualquiera que la estuviera usando vería el cambio por sorpresa (las referencias compartidas de la teoría). Devolviendo una fracción nueva, a sigue valiendo 1/2 para siempre. Es lo mismo que hacen los String.

**Por qué mcd es private static.** Es una ayuda interna (nadie de fuera la necesita) y no usa ningún atributo del objeto: solo trabaja con sus parámetros. Por eso puede ser static.

**Por qué return a == 0 ? 1 : a.** Con la fracción 0/5, el mcd de 0 y 5 es 5 y queda 0/1 (se imprime como 0), que es lo correcto. mcd solo devolvería 0 si los dos números fueran 0, algo que el constructor ya impide; la comprobación es una protección barata para no dividir nunca entre 0 si alguien reutiliza mcd en otro sitio.

**Por qué 31 * num + den.** hashCode debe dar el mismo número para fracciones equals. Como ya están simplificadas, combinar numerador y denominador es suficiente. Multiplicar por un primo (31) es la costumbre habitual para repartir mejor los valores.

Nivel 3 — Tienda

public class Tienda {
    private static int ventasTotales = 0;      // de TODAS las tiendas

    private final String nombre;
    private final Producto[] productos;
    private int numProductos = 0;

    public Tienda(String nombre, int capacidad) {
        this.nombre = nombre;
        this.productos = new Producto[capacidad];
    }

    public Producto buscar(String nombre) {
        for (int i = 0; i < numProductos; i++) {
            if (productos[i].getNombre().equalsIgnoreCase(nombre)) return productos[i];
        }
        return null;
    }

    public boolean agregar(Producto p) {
        if (numProductos == productos.length || buscar(p.getNombre()) != null) return false;
        productos[numProductos++] = p;
        return true;
    }

    public boolean vender(String nombre, int unidades) {
        Producto p = buscar(nombre);
        if (p == null || !p.vender(unidades)) return false;
        ventasTotales += unidades;
        return true;
    }

    public double valorInventario() {
        double total = 0;
        for (int i = 0; i < numProductos; i++) total += productos[i].getPrecio() * productos[i].getStock();
        return total;
    }

    public int agotados() {
        int n = 0;
        for (int i = 0; i < numProductos; i++) if (productos[i].estaAgotado()) n++;
        return n;
    }

    public static int getVentasTotales() { return ventasTotales; }

    public String getNombre() { return nombre; }
}

**Por qué ventasTotales es static y productos no. Las ventas totales son de todas** las tiendas juntas: por eso las 2 unidades de la tienda de la playa suman con las 5 del centro (7 en total). Los productos, en cambio, son de cada tienda. Es el fragmento 1 al revés: aquí static es justo lo que queremos.

**Por qué !p.vender(unidades).** La tienda no comprueba el stock: le pide al producto que venda y mira si ha podido. La regla de «no vender más de lo que hay» vive en Producto y no se repite. Además, ventasTotales solo suma si la venta se hizo de verdad.

**Por qué equalsIgnoreCase.** Así «café» y «Café» son el mismo producto, tanto al buscar como al evitar duplicados. Por eso falla el tercer agregar.

**Por qué los bucles van hasta numProductos.** Las posiciones sin usar son null: recorrer hasta productos.length daría NullPointerException en cuanto la tienda no estuviera llena.

Míralo paso a paso en Python Tutor

Vamos a ver en directo el fallo del fragmento 1. Abre Python Tutor, elige Java, pega este código y avanza paso a paso:

public class Tutor {
    static class Producto {
        private String nombre;
        private static int stock;      // ¿de quién es este stock?

        Producto(String nombre, int stock) {
            this.nombre = nombre;
            Producto.stock = stock;
        }
        int getStock() { return stock; }
    }

    public static void main(String[] args) {
        Producto cafe = new Producto("Café", 10);
        Producto te = new Producto("Té", 3);
        System.out.println(cafe.getStock() + " " + te.getStock());
    }
}

Salida:

3 3

Qué observar:

  1. Fíjate en que stock no aparece dentro de los objetos: Python Tutor lo muestra aparte, como algo de la clase. Cada objeto solo tiene su nombre.
  2. Al crear el café, stock pasa a 10. Al crear el té, ese mismo stock pasa a 3.
  3. Las dos llamadas a getStock() leen el mismo valor: por eso sale 3 3.
  4. Quita static (y cambia Producto.stock por this.stock) y vuelve a ejecutar: ahora cada objeto tiene su propio stock dentro y la salida es 10 3.

Chuletario: clases y objetos en Java

Lo esencial en 30 segundos

  • Una clase es el molde; un objeto se crea con new y la variable guarda una referencia a él.
  • Atributos private, métodos public. Getters para leer; setters solo si tiene sentido cambiar el dato, y validando.
  • this.x = x en el constructor: sin this, el atributo se queda vacío.
  • static = uno para toda la clase (contadores, constantes, totales). Si cada objeto necesita su valor, no es static.
  • == compara referencias; equals compara lo que tú decidas. Si sobrescribes equals, sobrescribe también hashCode.

Sintaxis

Quiero…CódigoResultado
Declarar una clasepublic class Producto { … }molde
Atributo protegidoprivate double precio;solo accesible desde la clase
Atributo que no cambiaprivate final String nombre;se asigna una vez
Constructorpublic Producto(String n, double p) { this.nombre = n; … }se ejecuta con new
Llamar a otro constructorthis(nombre, 0); (primera línea)reutiliza código
Crear un objetoProducto p = new Producto("Café", 3.5, 10);referencia a un objeto nuevo
Getter / settergetPrecio() / setPrecio(double)lectura / escritura controlada
Getter de booleanisPrestado()convenio is…
Atributo de claseprivate static int contador;uno para todos
Constantepublic static final double IVA = 0.21;compartida e inmutable
Método de claseTienda.getVentasTotales()sin necesidad de objeto
Texto del objeto@Override public String toString()lo usa println
Igualdad@Override public boolean equals(Object o) + hashCode()a.equals(b)
Array de objetosnew Producto[3]3 huecos con null

Plantillas de los ejercicios

Método que valida y avisa (Producto):

public boolean vender(int unidades) {
    if (unidades <= 0 || unidades > stock) return false;
    stock -= unidades;
    return true;
}

cafe.vender(4) → true · cafe.vender(20) → false

equals de un objeto inmutable (Fraccion):

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Fraccion)) return false;
    Fraccion f = (Fraccion) o;
    return num == f.num && den == f.den;
}

new Fraccion(1, 2).equals(new Fraccion(2, 4)) → true

Array de objetos + contador (Tienda):

private final Producto[] productos = new Producto[capacidad];
private int numProductos = 0;
// ...
for (int i = 0; i < numProductos; i++) { … productos[i] … }

Inventario del ejemplo → 38,60 € · ventas totales → 7

Errores que más se repiten

SíntomaCausa probable
Un atributo sale null aunque lo pasaste al constructorFalta this.: nombre = nombre;
Todos los objetos comparten un mismo valorAtributo marcado static por error
non-static variable cannot be referenced from a static contextUsas un atributo de instancia desde main u otro método static: crea un objeto
a.equals(b) es false con datos igualesNo has sobrescrito equals (usa el de Object, que compara referencias)
NullPointerException al recorrer un array de objetosRecorres hasta length en vez de hasta el contador, o no hiciste new de cada objeto
Desde fuera alguien cambia tu array privadoEl getter devuelve la referencia: devuelve una copia con Arrays.copyOf
new Clase() deja de compilarEscribiste un constructor con parámetros: el vacío ya no existe
Se imprime Producto@1b6d3586Falta toString()

Antes de entregar, pregúntate

  1. ¿Todos los atributos son private, y cada setter valida lo que recibe?
  2. ¿Cada static es de verdad algo de la clase entera y no de cada objeto?
  3. ¿Mis constructores usan this. y reutilizan código con this(...)?
  4. Si comparo objetos, ¿he sobrescrito equals y hashCode?
Sigue aprendiendoTodos los artículos de Java

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *