La clase Object en Java
En esta lección
Todas las clases de Java, las tuyas y las de la biblioteca estándar, heredan de una misma clase: Object. De ella reciben unos pocos métodos que usas constantemente, a veces sin darte cuenta: cada vez que imprimes un objeto, lo comparas o lo guardas en un HashSet. En esta lección verás qué hace cada uno de esos métodos por defecto, cómo sobrescribirlos bien (toString, equals y hashCode), qué es getClass() y por qué == y equals no son lo mismo.
Trabajaremos con el catálogo de una biblioteca: libros identificados por su ISBN y socios identificados por su DNI.
Object, la raíz de todas las clases
Como viste en herencia, si una clase no escribe extends, Java hace que herede de Object. Y si hereda de otra, esa otra hereda de Object (directa o indirectamente). Por eso una variable de tipo Object puede guardar cualquier objeto: un String, un Integer, un array o un Libro.
Estos son los métodos de Object que más te vas a encontrar:
| Método | Para qué sirve | ¿Se suele sobrescribir? |
|---|---|---|
toString() | Representar el objeto como texto | Casi siempre |
equals(Object o) | Decidir si dos objetos son “iguales” | Cuando los objetos tienen identidad propia (DNI, ISBN…) |
hashCode() | Dar un número que resume el objeto | Siempre que sobrescribas equals |
getClass() | Saber de qué clase es el objeto en ejecución | No se puede (es final) |
clone() | Copiar el objeto | Rara vez; mejor un constructor de copia |
wait(), notify(), notifyAll() | Coordinar hilos | No |
Nota: en código antiguo verás también
finalize(). Está obsoleto porque no hay garantía de cuándo se ejecuta, ni de que se ejecute. No lo uses.
Partimos de esta clase, todavía sin sobrescribir nada:
public class Libro {
private final String isbn;
private String titulo;
private int ejemplares;
public Libro(String isbn, String titulo, int ejemplares) {
this.isbn = isbn;
this.titulo = titulo;
this.ejemplares = ejemplares;
}
public String getIsbn() { return isbn; }
public void setEjemplares(int ejemplares) { this.ejemplares = ejemplares; }
}
== frente a equals
Antes de ver cada método, conviene tener clara esta diferencia, porque es la fuente de muchos errores:
==compara lo que hay dentro de la variable. En los tipos primitivos (int,double…) es el valor. En los objetos es la referencia, es decir, la dirección del objeto en memoria. Dos referencias son iguales solo si apuntan al mismo objeto.equalses un método, y cada clase decide qué significa “ser igual”. Por defecto, el deObjecthace exactamente lo mismo que==.
int a = 5, b = 5;
System.out.println(a == b); // true: mismo valor
Libro l1 = new Libro("978-84-206-7429-2", "La Regenta", 3);
Libro l2 = new Libro("978-84-206-7429-2", "La Regenta", 3);
Libro l3 = l1; // l3 apunta al mismo objeto que l1
System.out.println(l1 == l2); // false: dos objetos distintos
System.out.println(l1 == l3); // true: el mismo objeto
System.out.println(l1.equals(l2)); // false: el equals de Object usa ==
Esa última línea es poco intuitiva: los dos libros tienen el mismo ISBN y, sin embargo, Java dice que no son iguales. Lo arreglaremos enseguida.
Las trampas de String e Integer
Con String e Integer, == a veces “parece funcionar”, y eso es peor que si fallara siempre:
String t1 = "hola";
String t2 = "hola";
String t3 = new String("hola");
System.out.println(t1 == t2); // true
System.out.println(t1 == t3); // false
System.out.println(t1.equals(t3)); // true
Java reutiliza los textos literales que aparecen en el código (los guarda en una zona llamada pool de cadenas), así que t1 y t2 apuntan al mismo objeto. Pero un String creado con new, leído con Scanner o calculado mientras el programa se ejecuta es un objeto nuevo.
Con los envoltorios numéricos pasa algo parecido. Java guarda en una caché los Integer entre -128 y 127:
Integer i1 = 127, i2 = 127;
Integer i3 = 128, i4 = 128;
System.out.println(i1 == i2); // true (vienen de la caché)
System.out.println(i3 == i4); // false (dos objetos distintos)
System.out.println(i3.equals(i4)); // true
Cuidado: compara siempre
String,Integer,Doubley cualquier otro objeto conequals. Reserva==para tipos primitivos, para comprobarnully para las constantes de un enum.
toString(): el objeto como texto
System.out.println(objeto) y la concatenación "texto" + objeto llaman por dentro a toString(). La versión heredada de Object devuelve el nombre de la clase, una arroba y el hashCode en hexadecimal:
System.out.println(l1); // Libro@15db9742 (el número cambia de una ejecución a otra)
No dice nada útil. Lo normal es sobrescribirlo para mostrar los datos que ayudan a reconocer el objeto:
@Override
public String toString() {
return "Libro[isbn=" + isbn + ", titulo=" + titulo + ", ejemplares=" + ejemplares + "]";
}
System.out.println(l1);
// Libro[isbn=978-84-206-7429-2, titulo=La Regenta, ejemplares=3]
String aviso = "Préstamo: " + l1;
System.out.println(aviso);
// Préstamo: Libro[isbn=978-84-206-7429-2, titulo=La Regenta, ejemplares=3]
Un buen toString es la herramienta de depuración más barata que existe: imprimes el objeto y ves su estado de un vistazo. Las colecciones lo aprovechan al imprimir sus elementos.
Algunos detalles prácticos:
- No pongas lógica en
toStringni cambies el estado del objeto ahí dentro. Solo construye un texto. - No muestres datos sensibles (contraseñas, números de tarjeta): los
toStringacaban en los ficheros de log. - Si la variable puede ser
null,variable.toString()lanzaNullPointerException. En cambio,"Libro: " + variableyString.valueOf(variable)escribennullsin fallar. - Los arrays no sobrescriben
toString: imprimir unint[]da algo como[I@6d06d69c. UsaArrays.toString(notas), que da[7, 9, 5].
equals(): cuándo dos objetos son iguales
Para la biblioteca, dos objetos Libro con el mismo ISBN representan el mismo libro, aunque se hayan creado por separado. Eso se expresa sobrescribiendo equals. La firma debe ser exactamente public boolean equals(Object o), con parámetro de tipo Object:
@Override
public boolean equals(Object o) {
if (this == o) return true; // 1. ¿Es el mismo objeto?
if (!(o instanceof Libro otro)) return false; // 2. ¿Es un Libro? (null no lo es)
return isbn.equals(otro.isbn); // 3. Comparar lo que identifica al libro
}
- Si es el mismo objeto, no hace falta mirar más.
o instanceof Libro otrocomprueba queoes unLibroy, si lo es, lo guarda ya convertido en la variableotro(es pattern matching, que verás con detalle en polimorfismo). Sioesnull,instanceofdafalse, así que este paso también protege denull.- Se comparan los atributos que definen la igualdad. Aquí basta el ISBN: el número de ejemplares puede cambiar y sigue siendo el mismo libro.
Libro l4 = new Libro("978-84-206-7429-2", "La Regenta", 1);
System.out.println(l1.equals(l2)); // true
System.out.println(l1.equals(l4)); // true: mismo ISBN
System.out.println(l1.equals(null)); // false
System.out.println(l1.equals("978-84-206-7429-2")); // false: un String no es un Libro
El contrato de equals
Las colecciones de Java dan por hecho que tu equals cumple unas reglas. Si no las cumple, no habrá error de compilación, pero contains, remove o indexOf se comportarán de forma rara:
| Propiedad | Qué significa |
|---|---|
| Reflexiva | x.equals(x) siempre es true |
| Simétrica | Si x.equals(y), entonces y.equals(x) |
| Transitiva | Si x.equals(y) e y.equals(z), entonces x.equals(z) |
| Consistente | Si los objetos no cambian, el resultado tampoco cambia entre llamadas |
Con null | x.equals(null) siempre es false, nunca una excepción |
La receta de arriba cumple todas, salvo en un caso: cuando hay herencia.
equals y la herencia: instanceof o getClass()
Imagina una clase Punto y una subclase PuntoColor que añade un color. Cada una compara con instanceof:
class Punto {
int x, y;
Punto(int x, int y) { this.x = x; this.y = y; }
@Override
public boolean equals(Object o) {
return o instanceof Punto p && x == p.x && y == p.y;
}
}
class PuntoColor extends Punto {
String color;
PuntoColor(int x, int y, String color) { super(x, y); this.color = color; }
@Override
public boolean equals(Object o) {
return o instanceof PuntoColor p && super.equals(p) && color.equals(p.color);
}
}
Punto p = new Punto(1, 2);
PuntoColor pc = new PuntoColor(1, 2, "rojo");
System.out.println(p.equals(pc)); // true: un PuntoColor es un Punto
System.out.println(pc.equals(p)); // false: un Punto no es un PuntoColor
Se rompe la simetría. Hay dos salidas habituales:
- Comparar la clase exacta con
getClass(): unPuntoy unPuntoColornunca serán iguales.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Libro otro = (Libro) o;
return isbn.equals(otro.isbn);
}
- Hacer la clase
final(nadie puede heredar de ella) y seguir usandoinstanceof. Es lo más sencillo cuando la clase no está pensada para tener subclases.
Los IDE como IntelliJ o Eclipse generan equals y hashCode con un clic y te dejan elegir entre las dos versiones.
hashCode(): el número que resume al objeto
hashCode() devuelve un int calculado a partir del objeto. Lo usan HashSet y HashMap para decidir en qué “cajón” interno guardarlo, de modo que buscar no obligue a recorrer todos los elementos. El funcionamiento interno lo tienes en mapas y conjuntos; aquí nos importa el contrato:
- Si
a.equals(b)estrue, entoncesa.hashCode() == b.hashCode()obligatoriamente. - Al revés no: dos objetos distintos pueden coincidir (es una colisión). Es legal, aunque si pasa mucho las búsquedas se vuelven lentas.
- Mientras el objeto no cambie los atributos usados en
equals, suhashCodedebe ser siempre el mismo.
La forma práctica de cumplirlo es usar los mismos atributos que en equals:
@Override
public int hashCode() {
return isbn.hashCode();
}
Con un solo atributo basta su hashCode. Con varios, usa Objects.hash(a, b, c), que los combina y admite null.
Set<Libro> catalogo = new HashSet<>();
catalogo.add(l1);
catalogo.add(l2); // mismo ISBN: no se añade
catalogo.add(l4); // mismo ISBN: no se añade
System.out.println(catalogo.size()); // 1
Si sobrescribes equals y te olvidas de hashCode, cada objeto conserva el número heredado de Object, distinto para cada uno: los duplicados se cuelan en el HashSet y contains no encuentra lo que buscas, porque mira en el cajón equivocado y equals ni siquiera llega a llamarse. Lo tienes con un ejemplo en mapas y conjuntos.
Cuidado: no uses en
equalsyhashCodeatributos que cambian a menudo (comoejemplares). Si un objeto cambia suhashCodemientras está dentro de unHashSet, se queda en el cajón equivocado y ya no lo encontrarás. Por eso enLibrosolo usamos el ISBN, que esfinal.
getClass(): la clase real del objeto
getClass() devuelve un objeto de tipo Class que describe la clase real del objeto en tiempo de ejecución, no el tipo de la variable:
Object o = l1;
System.out.println(o.getClass().getSimpleName()); // Libro
System.out.println("hola".getClass().getName()); // java.lang.String
List<String> lista = new ArrayList<>();
System.out.println(lista.getClass().getSimpleName()); // ArrayList
getSimpleName()da solo el nombre de la clase.getName()da el nombre completo con el paquete, comojava.lang.String.
Aunque la variable lista es de tipo List, el objeto es un ArrayList, y eso es lo que ve getClass(). Se usa sobre todo en equals (como has visto), en mensajes de log y en toString genéricos que sirven para varias subclases, como getClass().getSimpleName() + "[" + isbn + "]".
Cuidado: a diferencia de
instanceof, que dafalseconnull,o.getClass()lanzaNullPointerExceptionsioesnull. Por eso la versión deequalscongetClass()comprueba anteso == null.
La clase de utilidad Objects
En java.util hay una clase llamada Objects (con s) llena de métodos static que te ahorran comprobaciones de null al escribir estos métodos:
| Método | Qué hace |
|---|---|
Objects.equals(a, b) | true si los dos son null o si a.equals(b); nunca lanza excepción |
Objects.hash(a, b, ...) | Calcula un hashCode combinando varios valores |
Objects.toString(o, "por defecto") | o.toString(), o el texto por defecto si o es null |
Objects.requireNonNull(o, "mensaje") | Lanza NullPointerException con tu mensaje si o es null |
Objects.isNull(o) / nonNull(o) | o == null / o != null, útiles en streams |
Son muy prácticos cuando algún atributo puede ser null, como el teléfono de un socio:
public class Socio {
private final String dni;
private final String nombre;
private final String telefono; // opcional: puede ser null
public Socio(String dni, String nombre, String telefono) {
this.dni = Objects.requireNonNull(dni, "El DNI es obligatorio");
this.nombre = nombre;
this.telefono = telefono;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Socio otro)) return false;
return dni.equals(otro.dni) && Objects.equals(telefono, otro.telefono);
}
@Override
public int hashCode() {
return Objects.hash(dni, telefono);
}
@Override
public String toString() {
return nombre + " (tel. " + Objects.toString(telefono, "sin teléfono") + ")";
}
}
Socio s1 = new Socio("12345678Z", "Lucía", null);
Socio s2 = new Socio("12345678Z", "Lucía", null);
System.out.println(s1.equals(s2)); // true
System.out.println(s1); // Lucía (tel. sin teléfono)
new Socio(null, "Pablo", null);
// Exception in thread "main" java.lang.NullPointerException: El DNI es obligatorio
Si hubiéramos escrito telefono.equals(otro.telefono), la comparación habría lanzado NullPointerException. requireNonNull hace lo contrario: falla pronto y con un mensaje claro en el constructor, en lugar de dejar que el null explote mucho más tarde en otra parte del programa.
¿Y clone()?
clone() copia un objeto, pero su diseño es incómodo: exige implementar la interfaz Cloneable, lanza una excepción comprobada y hace una copia superficial (los objetos de dentro se comparten). Hoy se prefiere un constructor de copia, como public Libro(Libro original). La excepción son los arrays: notas.clone() funciona sin problemas.
Los records ya lo traen hecho
Escribir a mano toString, equals y hashCode es repetitivo y fácil de equivocar. Si tu clase solo guarda datos que no cambian, un record los genera automáticamente a partir de todos sus componentes.
Errores frecuentes
- Comparar objetos con
==. Comparas referencias, no contenido. ConStringeIntegerpuede funcionar “a veces” por el pool y la caché, lo que hace el fallo más difícil de encontrar. Usaequals. - Escribir
equals(Libro o)en lugar deequals(Object o). No sobrescribes: creas una sobrecarga que las colecciones nunca llaman. Con@Overrideel compilador te avisa. - Sobrescribir
equalssinhashCode. LosHashSetyHashMapaceptan duplicados y no encuentran lo que buscas. - Usar atributos distintos en
equalsy enhashCode. Siequalsmira el ISBN yhashCodemira el título, dos libros iguales pueden dar números distintos. - Llamar a
equalssobre un atributo que puede sernull. UsaObjects.equals(a, b).
Resumen
| Concepto | Idea clave |
|---|---|
Object | Superclase de todas las clases; una variable Object admite cualquier objeto |
== | Compara valores en primitivos y referencias en objetos |
toString() | Texto del objeto; lo usan println y la concatenación |
equals(Object o) | Igualdad de contenido; reflexiva, simétrica, transitiva y false con null |
hashCode() | Objetos iguales, mismo número; mismos atributos que equals |
getClass() | Clase real en ejecución; getSimpleName() y getName() |
instanceof frente a getClass() | Admite subclases / exige la clase exacta |
Objects | equals, hash, toString y requireNonNull sin sustos con null |
| Records | Generan toString, equals y hashCode automáticamente |
En la siguiente lección verás cómo definir clases que sirven de plantilla y contratos que otras clases cumplen: las clases abstractas e interfaces.
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.