Saltar al contenido
clase-object.java · devschool

La clase Object en Java

Lección 20 de 39 · 14 min de lectura · Actualizado el

En esta lección
  1. Object, la raíz de todas las clases
  2. == frente a equals
  3. toString(): el objeto como texto
  4. equals(): cuándo dos objetos son iguales
  5. hashCode(): el número que resume al objeto
  6. getClass(): la clase real del objeto
  7. La clase de utilidad Objects
  8. ¿Y clone()?
  9. Los records ya lo traen hecho
  10. Errores frecuentes
  11. Resumen

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étodoPara qué sirve¿Se suele sobrescribir?
toString()Representar el objeto como textoCasi 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 objetoSiempre que sobrescribas equals
getClass()Saber de qué clase es el objeto en ejecuciónNo se puede (es final)
clone()Copiar el objetoRara vez; mejor un constructor de copia
wait(), notify(), notifyAll()Coordinar hilosNo

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.
  • equals es un método, y cada clase decide qué significa “ser igual”. Por defecto, el de Object hace 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, Double y cualquier otro objeto con equals. Reserva == para tipos primitivos, para comprobar null y 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 toString ni cambies el estado del objeto ahí dentro. Solo construye un texto.
  • No muestres datos sensibles (contraseñas, números de tarjeta): los toString acaban en los ficheros de log.
  • Si la variable puede ser null, variable.toString() lanza NullPointerException. En cambio, "Libro: " + variable y String.valueOf(variable) escriben null sin fallar.
  • Los arrays no sobrescriben toString: imprimir un int[] da algo como [I@6d06d69c. Usa Arrays.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
}
  1. Si es el mismo objeto, no hace falta mirar más.
  2. o instanceof Libro otro comprueba que o es un Libro y, si lo es, lo guarda ya convertido en la variable otro (es pattern matching, que verás con detalle en polimorfismo). Si o es null, instanceof da false, así que este paso también protege de null.
  3. 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:

PropiedadQué significa
Reflexivax.equals(x) siempre es true
SimétricaSi x.equals(y), entonces y.equals(x)
TransitivaSi x.equals(y) e y.equals(z), entonces x.equals(z)
ConsistenteSi los objetos no cambian, el resultado tampoco cambia entre llamadas
Con nullx.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(): un Punto y un PuntoColor nunca 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 usando instanceof. 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) es true, entonces a.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, su hashCode debe 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 equals y hashCode atributos que cambian a menudo (como ejemplares). Si un objeto cambia su hashCode mientras está dentro de un HashSet, se queda en el cajón equivocado y ya no lo encontrarás. Por eso en Libro solo usamos el ISBN, que es final.

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, como java.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 da false con null, o.getClass() lanza NullPointerException si o es null. Por eso la versión de equals con getClass() comprueba antes o == 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étodoQué 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. Con String e Integer puede funcionar “a veces” por el pool y la caché, lo que hace el fallo más difícil de encontrar. Usa equals.
  • Escribir equals(Libro o) en lugar de equals(Object o). No sobrescribes: creas una sobrecarga que las colecciones nunca llaman. Con @Override el compilador te avisa.
  • Sobrescribir equals sin hashCode. Los HashSet y HashMap aceptan duplicados y no encuentran lo que buscas.
  • Usar atributos distintos en equals y en hashCode. Si equals mira el ISBN y hashCode mira el título, dos libros iguales pueden dar números distintos.
  • Llamar a equals sobre un atributo que puede ser null. Usa Objects.equals(a, b).

Resumen

ConceptoIdea clave
ObjectSuperclase 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
Objectsequals, hash, toString y requireNonNull sin sustos con null
RecordsGeneran 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

[Java] ¿Qué imprime este código?
Integer a = 127, b = 127;
Integer c = 1000, d = 1000;
System.out.println(a == b);
System.out.println(c == d);
System.out.println(c.equals(d));

[Java] La clase Libro declara public boolean equals(Libro o) en lugar de equals(Object o). Con l1 y l2 del mismo ISBN y Object o = l2, ¿qué imprimen l1.equals(l2) y l1.equals(o)?

[Java] Has sobrescrito equals en Libro (compara por ISBN) pero no hashCode. ¿Qué puede pasar con un HashSet<Libro>?

[Java] ¿Qué imprime este código?
List<String> lista = new ArrayList<>();
System.out.println(lista.getClass().getSimpleName());

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.