Java equal() y hashcode()

Todos los objetos en Java tiene dos métodos muy importantes: el método hashCode() y el método equals(). Estos métodos están diseñados para ser sobreescritos de acuerdo a su contrato general.
En este artículo veremos porqué y cómo sobreescribir el método hashCode() que cumpla con el contrato para los HashCode.

El contrato de un HashCode

El contrato del hashCode() dice: 
"Si dos objetos son iguales usando equals(), entonces la invocación a hashCode() de ambos objetos debe retornar el mismo valor"
Entonces, la pregunta que surge es: ¿es necesario que siempre se cumpla esa oración?
Consideremos una clase que tiene una implementación correcta del método equals(), ¿qué pasaría si no obedecemos el contrato anterior?
Para responder a esa pregunta, vamos a tener que considerar dos situaciones: 
  1. Objetos que son iguales, pero retornan diferentes hashCodes
  2. Objetos que no son iguales, pero retornan el mismo hashCode

Objetos que son iguales, pero retornan diferentes hashCodes

¿Qué pasaría si dos objetos son iguales (invocando equals()) pero retornan diferentes hashCodes? El código se ejecutará a la perfección. Nunca vamos a encontrar problemas... hasta que se nos ocurra almacenar a nuestro objeto dentro de una colección como un HashSet o un HashMap. Cuando hagamos esto, nos vamos a encontrar con problemas raros durante la ejecución.
Primero tenemos que comprender cómo funcionan las clases del tipo HashSet HashMap. Estas clases de colecciones dependen de que los objetos que son agregados cumplan con el contrato del hashCode. Vamos a obtener resultados impredecibles en tiempo de ejecución si no obedecemos el contrato y queremos almacenar estos objetos en la colección.
Veamos por ejemplo el HashMap. Cuando guardamos valores en un HashMap, estos valores en realidad se almacenan dentro de "baldes". Cada uno de estos baldes tiene asignado un número que lo identifica. Cuando agregamos un valor al HashMap, almacena el dato en uno de esos baldes. El balde que se usa depende del hashCode que devuelva el objeto a ser almacenado. Por ejemplo, si el método hashCode() del objeto retorna 49, entonces se almacena en el balde 49 dentro del HashMap.
Más tarde, cuando verifiquemos si la colección contiene al elemento invocando el método contains(elemento), el HashMap primero obtiene el hashCode de ese "elemento". Luego buscará el balde que corresponde a ese hashCode. Si el balde está vacio, significa que el HashMap no contiene al elemento y devuelve false.
Si hay un objeto o más dentro del balde, entonces se compara al "elemento" con todos los elementos en ese balde usando el métodoequals().

Objetos que no son iguales, pero retornan el mismo hashCode

El contrato del hashCode no dice nada sobre este caso. Por lo tanto, objetos distintos pueden devolver el mismo hashCode, pero las colecciones como los HashMap van a ser más ineficientes si se almacenan objetos diferentes con el mismo valor de hashCode.

¿Por qué almacenar en baldes?

Se utiliza este mecanismo de "baldes" por un tema de eficiencia. Pueden imaginarse que si todos los objetos que se agregan a unHashMap se almacenaran en una única lista grande, entonces tendríamos que comparar la entrada con todos los objetos de la lista para dterminar si un elemento en particular está contenido en el Map. Como se usan baldes, sólo se comparan los elementos del balde específico, y en general cada balde sólo almacena una pequeña cantidad de elementos en el HashMap.

Sobreescribir el método hashCode()

Puede resultar complejo escribir un buen método de hashCode() para una clase nueva.

Retornar un valor fijo (es una mala idea...)

Podemos implementar un método de hashCode() que devuelva un valor fijo, como por ejemplo: 
 
//no hagan esto, genera mal rendimiento
@Override
public int hashCode() {
    return 1;
}
 
Este método satisface todos los requerimientos y es "legal" de acuerdo al contrato del hashCode, pero no va a resultar muy eficiente. Si se usa este método, todos los objetos se almacenarán dentro del mismo balde (el correspondiente al "1"), y cuando querramos comprobar si un objeto específico está dentro de la colección, entonces siempre se tendrá que verificar el contenido completa de dicha colección.
Por otro lado, si sobreescribimos el método hashCode() y rompemos el contrato ("dos objetos iguales con equals deben devolver el mismo hashCode"), entonces cuando se invoque el método contains() podría devolver false para un elemento que se encuentra dentro de la colección, pero en un balde diferente.

Método de Effective Java

Joshua Bloch en su libro Effective Java nos brinda una buena guía para generar un valor de hashCode(): 
  1. Guardar alguna constante con un valor distinto al cero; por ejemplo 17, en una variable int llamada result.
  2. Para cada campo significativo f en el objeto (es decir, cada campo que se tiene en cuenta al ejecutar un equals()), hacer lo siguiente: 
    1. Calcular un int de hashCode para el campo:
      1. Si esl campo es un booleano, calcular  c = (f ? 1 : 0)
      2. Si el campo es un byte, char, short o int, calcular c = (int) f
      3. Si el campo es un long, calcular c = (int) (f ^ (f >>> 32))
      4. Si el campo es un float, calcular c = Float.floatToIntBits(f)
      5. Si el campo es un double, calcular long l = Double.doubleToLongBits(f); c = (int) (l ^ (l >>> 32));
      6. Si el campo es una referencia a un objeto, calcular c = f.hashCode()
      7. Si el campo es un array, tratar a cada elemento por separado. Es decir, calcular el hashCode de cada elemento significativo usando las reglas anteriores.
    2. Combinar el hashCode calculado c en el paso 2.1 en un resultado como sigue: result = 37 * result + c;
  3. Retornar result
  4. Mirar al hashCode() resultante y asegurarse que instancias iguales tengan el mismo hashCode.
Veamos un ejemplo de este algoritmo: 
 
public class HashTest {
     private String campo1;
     private short  campo2;
 
     //resto de la clase...
 
     @Override
     public int hashCode() {
          int result = 17;
          result = 37*result + campo1.hashCode();
          result = 37*result + (int)campo2;
          return result;
     }
}
Como vemos elegimos la constante 37. La idea es ejegir un número que sea un número primo. Podemos elegir cualquier número primo. Al usar un número primo los objetos se distribuirán mejor en los baldes. Pueden aprender más sobre este algoritmo y la distribución que genera buscando en Internet.

Apache HashCodeBuilder

Como estamos aprendiendo, no es siempre facil retornar un buen valor de hashCode. Por suerte existen clases que nos pueden ayudar.
El paquete org.apache.commons.lang.builder de Jakarta-Commons contiene la clase HashCodeBuilder que está diseñada para ayudarnos a implementar el método hashCode(). Muchos desarrolladores luchan por escribir sus hashCode cuando existe esta clase que nos simplifica el proceso.
Así es como quedaría la clase de prueba anterior usando la clase HashCodeBuilder:
 
public class HashTest {
     private String campo1;
     private short  campo2;
 
     //resto de la clase...
 
     @Override
     public int hashCode() {
          return new HashCodeBuilder(83, 7)
               .append(campo1)
               .append(campo2)
               .toHashCode();
     }
}
Noten que los dos números del constructor del HashCodeBuilder son dos números impares distintos a cero - estos números ayuda a evitar la colisión de valores de hashCode en otros objetos.
Si se necesita, se puede invocar al hashCode() de la superclase usando appendSuper(int).
Resulta muy facil escribir el método hashCode() usando la clase Apache HashCodeBuilder.

Objetos mutables como clave

Como consejo general, deberíamos usar objetos inmutables como clave en una colección. El hashCode funciona mejor cuando se calcula con datos inmutables. Si usamos objetos mutables como clave y estos objetos cambian su estado de manera que el hashcode también cambia, entonces el objeto almacenado quedará ubicado en un balde incorrecto dentro de la colección.
La cosa más importante a consdierar cuando se implementa el hashCode() es que, sin importar cuándo se invoca a este método, tiene que producir el mismo valor para un objeto en particular cada vez que se invoca. Si tenemos un escenario en donde el objeto produce un valor de hashCode() cuando se invoca al put() del HashMap y luego produce otro valor durante un get(), en ese caso no podremos recuperar este objeto. Por lo tanto, si nuestro hashCode() depende de datos mutables en el objeto, cambiar estos datos con seguridad producirán una nueva clave al generar un hashCode() diferente.
Veamos el siguiente ejemplo: 
 
public class Empleado {
 
     private String nombre;
     private int edad;
 
     public Empleado() {     
     }
 
     public Empleado(String nombre, int edad) {
          this.nombre = nombre;
          this.edad = edad;
     }
 
     public String getNombre() {
          return nombre;
     }
 
     public void setNombre(String nombre) {
          this.nombre= nombre;
     }
 
     public int getEdad() {
          return edad;
     }
 
     public void setEdad(int edad) {
          this.edad = edad;
     }
 
     @Override
     public boolean equals(Object obj) {
          if (obj instanceof Empleado) {
               Empleado emp = (Empleado)obj;
               return (emp.nombre.equals(nombre) && emp.edad == edad);
          }
          return false;
     }
 
     @Override
     public int hashCode() {
          return nombre.length() + edad;
     }
 
     public static void main(String[] args) {       
          Empleado e = new Empleado("muhammad", 24);
          Map map = new HashMap();
          map.put(e, "Muhammad Ali Khojaye");                    
 
          // encuentra el resultado
          System.out.println(map.get(e));
 
          e.nombre = "abid";
 
          // el map devolverá null porque no lo encuentra
          System.out.println(map.get(e));
 
          // otra vez devolverá null
          System.out.println(map.get(new Empleado("muhammad", 24)));
     }
}
 
Vemos en el ejemplo anterior que obtenemos algunos resultados extraños. Después de cambiar el campo nombre, el cálculo delhashCode() devuelve un nuevo número y apuntará a un nuevo balde, por lo que el contains() devolverá false.
Podemos arreglar esta situación usando alguna de estas alternativas:
  • El hashcode es mejor calcularlo con datos inmutables; por lo tanto, asegurarnos que sólo usaremos objetos inmutables como claves de una colección.
  • Implementar el hashCode() usando la primer técnica: devolver un valor constante. En este caso tenemos que ser conscientes que estamos quitando todas las ventajas del mecanismo de baldes de las colecciones.
  • Si necesitamos incluiir campos mutables en el método de hashCode(), entonces podemos calcular y almacenar el valor del hash cuando se crea el objeto, y cada vez que se actualiza alguno de los campos mutables, primero debemos quitarlo de la colección y luego agregarlo nuevamente una vez hecho el cambio.

No hay comentarios:

Publicar un comentario en la entrada