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.
Tabla de Contenidos
Cómo usar este artículo
- Lee el enunciado y la salida esperada.
- Inténtalo al menos 15 minutos antes de abrir la pista.
- Prueba también casos raros: valores negativos, objetos repetidos, arrays llenos.
- 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)yboolean estaAgotado().toString()con el formatoCafé: 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 conthrow new IllegalArgumentException("…"); las excepciones las verás a fondo más adelante). Fraccion sumar(Fraccion o)yFraccion multiplicar(Fraccion o)devuelven una fracción nueva.equals,hashCodeytoString(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;nullsi 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) eint 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:
- Fíjate en que
stockno aparece dentro de los objetos: Python Tutor lo muestra aparte, como algo de la clase. Cada objeto solo tiene sunombre. - Al crear el café,
stockpasa a 10. Al crear el té, ese mismostockpasa a 3. - Las dos llamadas a
getStock()leen el mismo valor: por eso sale3 3. - Quita
static(y cambiaProducto.stockporthis.stock) y vuelve a ejecutar: ahora cada objeto tiene su propiostockdentro y la salida es10 3.
Chuletario: clases y objetos en Java
Lo esencial en 30 segundos
- Una clase es el molde; un objeto se crea con
newy la variable guarda una referencia a él. - Atributos
private, métodospublic. Getters para leer; setters solo si tiene sentido cambiar el dato, y validando. this.x = xen el constructor: sinthis, el atributo se queda vacío.static= uno para toda la clase (contadores, constantes, totales). Si cada objeto necesita su valor, no esstatic.==compara referencias;equalscompara lo que tú decidas. Si sobrescribesequals, sobrescribe tambiénhashCode.
Sintaxis
| Quiero… | Código | Resultado |
|---|---|---|
| Declarar una clase | public class Producto { … } | molde |
| Atributo protegido | private double precio; | solo accesible desde la clase |
| Atributo que no cambia | private final String nombre; | se asigna una vez |
| Constructor | public Producto(String n, double p) { this.nombre = n; … } | se ejecuta con new |
| Llamar a otro constructor | this(nombre, 0); (primera línea) | reutiliza código |
| Crear un objeto | Producto p = new Producto("Café", 3.5, 10); | referencia a un objeto nuevo |
| Getter / setter | getPrecio() / setPrecio(double) | lectura / escritura controlada |
| Getter de boolean | isPrestado() | convenio is… |
| Atributo de clase | private static int contador; | uno para todos |
| Constante | public static final double IVA = 0.21; | compartida e inmutable |
| Método de clase | Tienda.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 objetos | new 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íntoma | Causa probable |
|---|---|
Un atributo sale null aunque lo pasaste al constructor | Falta this.: nombre = nombre; |
| Todos los objetos comparten un mismo valor | Atributo marcado static por error |
non-static variable cannot be referenced from a static context | Usas un atributo de instancia desde main u otro método static: crea un objeto |
a.equals(b) es false con datos iguales | No has sobrescrito equals (usa el de Object, que compara referencias) |
NullPointerException al recorrer un array de objetos | Recorres hasta length en vez de hasta el contador, o no hiciste new de cada objeto |
| Desde fuera alguien cambia tu array privado | El getter devuelve la referencia: devuelve una copia con Arrays.copyOf |
new Clase() deja de compilar | Escribiste un constructor con parámetros: el vacío ya no existe |
Se imprime Producto@1b6d3586 | Falta toString() |
Antes de entregar, pregúntate
- ¿Todos los atributos son
private, y cada setter valida lo que recibe? - ¿Cada
statices de verdad algo de la clase entera y no de cada objeto? - ¿Mis constructores usan
this.y reutilizan código conthis(...)? - Si comparo objetos, ¿he sobrescrito
equalsyhashCode?
¿Te ha servido este artículo? Así puedes apoyar SergioLearns →
