Clases y objetos en Java — práctica con un banco, una biblioteca y el inventario de un laboratorio (y la cuenta que era de nadie)
Mi primera clase CuentaBancaria compilaba a la primera. Eso ya debería haberme hecho sospechar. Creé la cuenta de Sergio, ingresé dinero, la imprimí y salió esto:
Cuenta de null: 150.0 €
El dinero estaba, pero el titular no. En el constructor había escrito titular = titular; en lugar de this.titular = titular;. Java no se queja: asigna el parámetro a sí mismo y deja el atributo en null. Desde ese día escribo this. en todos los constructores, aunque no haga falta, por pura costumbre.
En la teoría viste las piezas: atributos, constructores, this, encapsulación, static, toString y equals. Aquí las juntamos en tres programas completos:
- Una cuenta bancaria que protege su saldo y genera números de cuenta únicos.
- Una biblioteca con un array de libros que se prestan y se devuelven.
- El inventario de un laboratorio, con códigos automáticos y
equals.
Tabla de Contenidos
Programa 1: una cuenta bancaria que se defiende sola
El saldo es el ejemplo perfecto de por qué existen private y los métodos: nadie de fuera debería poder escribir cuenta.saldo = 1000000.
public class CuentaBancaria {
private static int siguienteNumero = 1; // compartido: genera números únicos
private final int numero; // asignado una vez, no cambia
private final String titular;
private double saldo; // solo se toca desde dentro
public CuentaBancaria(String titular, double saldoInicial) {
this.numero = siguienteNumero++; // usa el valor y lo prepara para la siguiente
this.titular = titular;
this.saldo = Math.max(0, saldoInicial);
}
public CuentaBancaria(String titular) {
this(titular, 0);
}
public boolean ingresar(double cantidad) {
if (cantidad <= 0) return false;
saldo += cantidad;
return true;
}
public boolean retirar(double cantidad) {
if (cantidad <= 0 || cantidad > saldo) return false;
saldo -= cantidad;
return true;
}
public boolean transferir(CuentaBancaria destino, double cantidad) {
if (destino == null || destino == this) return false;
if (!retirar(cantidad)) return false; // si no hay saldo, no sigue
destino.ingresar(cantidad); // accede a OTRO objeto de la misma clase
return true;
}
public double getSaldo() { return saldo; }
public String getTitular() { return titular; }
@Override
public String toString() {
return String.format("Cuenta %03d · %s · %.2f €", numero, titular, saldo);
}
public static void main(String[] args) {
CuentaBancaria sergio = new CuentaBancaria("Sergio", 150);
CuentaBancaria laura = new CuentaBancaria("Laura");
System.out.println(sergio.ingresar(50)); // true
System.out.println(sergio.retirar(500)); // false: no hay tanto
System.out.println(sergio.transferir(laura, 80));
System.out.println(laura.ingresar(-20)); // false: cantidad negativa
System.out.println(sergio);
System.out.println(laura);
}
}
Salida:
true false true false Cuenta 001 · Sergio · 120,00 € Cuenta 002 · Laura · 80,00 €
¿Por qué los métodos devuelven boolean?
Porque una operación puede no hacerse (retirar más de lo que hay, ingresar una cantidad negativa) y quien llama tiene que enterarse. Devolver true/false es la forma más sencilla de avisar. Cuando veas excepciones, tendrás otra herramienta para esto; de momento, un boolean es claro y suficiente.
¿Cómo se generan los números 001 y 002?
Con un atributo static: siguienteNumero es uno solo para toda la clase. Cada constructor hace siguienteNumero++: guarda el valor actual en numero y deja el contador listo para la siguiente cuenta. Si siguienteNumero no fuera static, cada cuenta tendría su propio contador empezando en 1, y todas serían la cuenta 001.
numero es final porque un número de cuenta no cambia nunca. Al marcarlo así, si alguien intenta cambiarlo por error, el código ni compila.
¿Cómo puede transferir tocar el saldo de otra cuenta?
Dentro de transferir usamos destino.ingresar(cantidad), un método público. Pero también podríamos haber escrito destino.saldo += cantidad, y compilaría: private significa «privado de la clase«, no «privado del objeto». Un objeto CuentaBancaria puede ver los atributos privados de otro CuentaBancaria. Aun así, usamos ingresar porque así la validación vive en un solo sitio.
Olvidar comprobar destino == this. Transferir de una cuenta a sí misma parece inofensivo, pero en sistemas reales genera movimientos absurdos. Si una operación no tiene sentido, mejor rechazarla.
Un detalle de formato
%03d rellena con ceros hasta 3 cifras (1 → 001) y %.2f muestra 2 decimales. Salen con coma (120,00) porque el sistema está en español.
Programa 2: una biblioteca con array de objetos
Ahora tenemos dos clases que colaboran: Libro, que sabe si está prestado, y Biblioteca, que guarda muchos libros.
public class Libro {
private final String isbn;
private final String titulo;
private final String autor;
private boolean prestado; // false por defecto
public Libro(String isbn, String titulo, String autor) {
this.isbn = isbn;
this.titulo = titulo;
this.autor = autor;
}
public String getIsbn() { return isbn; }
public boolean isPrestado() { return prestado; } // los boolean usan "is"
public boolean prestar() {
if (prestado) return false;
prestado = true;
return true;
}
public boolean devolver() {
if (!prestado) return false;
prestado = false;
return true;
}
@Override
public String toString() {
return titulo + " (" + autor + ")" + (prestado ? " [prestado]" : "");
}
}
public class Biblioteca {
private final Libro[] libros;
private int numLibros = 0;
public Biblioteca(int capacidad) {
libros = new Libro[capacidad];
}
public boolean agregar(Libro libro) {
if (numLibros == libros.length || buscar(libro.getIsbn()) != null) return false;
libros[numLibros++] = libro;
return true;
}
// Devuelve el libro o null si no está
public Libro buscar(String isbn) {
for (int i = 0; i < numLibros; i++) {
if (libros[i].getIsbn().equals(isbn)) return libros[i];
}
return null;
}
public boolean prestar(String isbn) {
Libro l = buscar(isbn);
return l != null && l.prestar(); // si es null, no llega a llamar a prestar()
}
public boolean devolver(String isbn) {
Libro l = buscar(isbn);
return l != null && l.devolver();
}
public void listarDisponibles() {
for (int i = 0; i < numLibros; i++) {
if (!libros[i].isPrestado()) System.out.println(" " + libros[i]);
}
}
public static void main(String[] args) {
Biblioteca bib = new Biblioteca(10);
bib.agregar(new Libro("978-84-01", "Java para todos", "L. Pérez"));
bib.agregar(new Libro("978-84-02", "Estructuras de datos", "M. Ruiz"));
bib.agregar(new Libro("978-84-03", "Python a fondo", "A. Gil"));
System.out.println(bib.agregar(new Libro("978-84-01", "Copia", "X"))); // ISBN repetido
System.out.println(bib.prestar("978-84-02")); // true
System.out.println(bib.prestar("978-84-02")); // false: ya prestado
System.out.println(bib.prestar("999")); // false: no existe
System.out.println("Disponibles:");
bib.listarDisponibles();
System.out.println(bib.devolver("978-84-02"));
System.out.println(bib.buscar("978-84-02"));
}
}
Salida:
false true false false Disponibles: Java para todos (L. Pérez) Python a fondo (A. Gil) true Estructuras de datos (M. Ruiz)
¿Quién decide si un libro se puede prestar?
El propio libro. Biblioteca.prestar solo lo busca y le pide que se preste; la regla («no se puede prestar dos veces») vive en Libro.prestar. Cada clase se encarga de lo suyo. Si mañana la regla cambia (por ejemplo, libros que no se prestan), solo tocas Libro.
¿Por qué l != null && l.prestar() no revienta?
Por el cortocircuito de &&: si l != null es false, Java ya sabe que el resultado es false y no evalúa la segunda parte. Así nunca se llama a prestar() sobre null. Si escribieras l.prestar() && l != null, el orden sería el contrario y con un ISBN inexistente tendrías un NullPointerException.
Devolver null desde un método (como buscar) y usarlo sin comprobar. Cada vez que un método puede devolver null, quien lo llama debe preguntar antes if (x != null).
¿Por qué isPrestado y no getPrestado?
Es un convenio de Java: los getters de boolean empiezan por is. Así if (!libro.isPrestado()) se lee casi como una frase.
Programa 3: inventario de un laboratorio con códigos automáticos
El último programa junta static, constantes y equals. Cada equipo del laboratorio recibe un código único (LAB-001, LAB-002…) en cuanto se crea.
public class Equipo {
public static final String PREFIJO = "LAB";
private static int contador = 0;
private final String codigo;
private final String tipo;
private boolean averiado;
public Equipo(String tipo) {
contador++;
this.codigo = PREFIJO + "-" + String.format("%03d", contador);
this.tipo = tipo;
}
public static int getContador() { return contador; }
public String getCodigo() { return codigo; }
public String getTipo() { return tipo; }
public boolean isAveriado() { return averiado; }
public void marcarAveriado() { averiado = true; }
public void reparar() { averiado = false; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Equipo)) return false;
return codigo.equals(((Equipo) o).codigo);
}
@Override
public int hashCode() { return codigo.hashCode(); }
@Override
public String toString() {
return codigo + " " + tipo + (averiado ? " (averiado)" : "");
}
}
public class InventarioLab {
public static void main(String[] args) {
Equipo[] lab = {
new Equipo("Portátil"), new Equipo("Monitor"),
new Equipo("Portátil"), new Equipo("Router")
};
lab[1].marcarAveriado();
lab[3].marcarAveriado();
int averiados = 0;
for (Equipo e : lab) {
System.out.println(e);
if (e.isAveriado()) averiados++;
}
System.out.println("Equipos creados: " + Equipo.getContador() + " · averiados: " + averiados);
Equipo mismo = lab[0];
System.out.println(lab[0].equals(mismo) + " " + lab[0].equals(lab[2]));
lab[1].reparar();
System.out.println(lab[1]);
}
}
Salida:
LAB-001 Portátil LAB-002 Monitor (averiado) LAB-003 Portátil LAB-004 Router (averiado) Equipos creados: 4 · averiados: 2 true false LAB-002 Monitor
¿Por qué dos portátiles no son equals?
Porque nuestro equals compara el código, no el tipo. lab[0] y lab[2] son portátiles, pero son dos máquinas distintas (LAB-001 y LAB-003). Decidir qué significa «igual» es una decisión de diseño: para el inventario, dos equipos son el mismo solo si tienen el mismo código.
¿Qué pinta el array con llaves?
Equipo[] lab = { new Equipo(...), ... }; crea el array y los objetos a la vez. A diferencia de new Equipo[4], aquí no hay huecos null: cada posición tiene ya su objeto.
PREFIJO es public static final: una constante que cualquier clase puede leer (Equipo.PREFIJO) y nadie puede cambiar. Si mañana el prefijo pasa a ser AULA, lo cambias en una sola línea.
El contador es private aunque sea static. Que algo sea de la clase no significa que deba ser público: solo lo leemos con getContador(), así nadie puede ponerlo a 0 desde fuera.
Pruébalo tú
- En la cuenta bancaria, añade un límite de descubierto: permite que el saldo baje hasta −100 €. ¿Qué método tienes que cambiar? ¿Y si el límite fuera distinto para cada cuenta?
- En la biblioteca, añade un contador de veces que se ha prestado cada libro y un método que diga cuál es el más prestado.
- En el inventario, haz que
reparar()solo funcione si el equipo estaba averiado y devuelva unboolean, como enLibro.devolver().
Míralo paso a paso en Python Tutor
Python Tutor ejecuta el código línea a línea y dibuja los objetos y las llamadas. Elige Java, pega este programa y avanza con Next:
public class Tutor {
static class Cuenta {
double saldo;
Cuenta(double saldo) { this.saldo = saldo; }
boolean transferir(Cuenta destino, double cantidad) {
if (cantidad > saldo) return false;
saldo -= cantidad; // this.saldo
destino.saldo += cantidad; // el saldo del OTRO objeto
return true;
}
}
public static void main(String[] args) {
Cuenta a = new Cuenta(100);
Cuenta b = new Cuenta(20);
boolean ok = a.transferir(b, 30);
System.out.println(ok + " " + a.saldo + " " + b.saldo);
}
}
Salida:
true 70.0 50.0
Qué observar:
- Tras los dos
new, hay dos objetosCuentaen el montón, uno con 100 y otro con 20. - Al entrar en
transferir, aparece un marco nuevo con tres flechas:this(apunta aa),destino(apunta ab) ycantidad(vale 30). - Con
saldo -= cantidad, cambia el objeto al que apuntathis: 100 → 70. Condestino.saldo += cantidad, cambia el otro: 20 → 50. - Al salir del método, el marco desaparece, pero los objetos siguen ahí con sus nuevos saldos. Los cambios no se pierden porque se hicieron en los objetos, no en copias.
Resumen
| Programa | Idea clave | Lo que te llevas |
|---|---|---|
| Cuenta bancaria | private + métodos que validan + static para numerar | El objeto protege sus datos y avisa con boolean si no puede hacer algo |
| Biblioteca | Dos clases que colaboran + array de objetos | Cada clase se encarga de sus reglas; comprueba siempre los null |
| Inventario | static final, contador y equals por código | «Igual» lo defines tú; las constantes van en MAYÚSCULAS |
En los ejercicios lo harás tú solo: una clase Producto, una Fraccion inmutable y una Tienda completa, además de tres fragmentos con trampa.
¿Te ha servido este artículo? Así puedes apoyar SergioLearns →
