Métodos de objetos de Java: equals(Object)

Este artículo es la continuación de una serie de artículos que describen los métodos a menudo olvidados de la clase de objeto base del lenguaje Java. Los siguientes son...

Introducción

Este artículo es una continuación de una serie de artículos que describen los métodos a menudo olvidados de la clase de objeto base del lenguaje Java. Los siguientes son los métodos del Objeto base de Java que están presentes en todos los objetos de Java debido a la herencia implícita de Objeto.

El enfoque de este artículo es el método equals(Object) que se usa para probar la igualdad entre objetos y le da al desarrollador la capacidad de definir una prueba significativa de equivalencia lógica.

== vs igual(Objeto)

Como habrás adivinado, el método equals(Object) se usa para probar la igualdad entre los tipos de referencia (objetos) en Java. Ok, tiene sentido, pero también podrías estar pensando "¿Por qué no puedo simplemente usar ==?" La respuesta a esta pregunta es que cuando se trata de tipos de referencia, el operador == solo es verdadero al comparar dos referencias al mismo objeto instanciado en la memoria. Por otro lado, equals(Object) puede anularse para implementar la noción de equivalencia lógica en lugar de la mera equivalencia de instancia.

Creo que un ejemplo describiría mejor esta diferencia entre usar == versus el método equals(Object) en Strings.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class Main {
    public static void main(String[] args) {
        String myName = "Adam";
        String myName2 = myName; // references myName
        String myName3 = new String("Adam"); // new instance but same content

        if (myName == myName2)
            System.out.println("Instance equivalence: " + myName + " & " + myName2);

        if (myName.equals(myName2))
            System.out.println("Logical equivalence: " + myName + " & " + myName2);

        if (myName == myName3)
            System.out.println("Instance equivalence: " + myName + " & " + myName3);

        if (myName.equals(myName3))
            System.out.println("Logical equivalence: " + myName + " & " + myName3);
    }
}

Producción:

1
2
3
Instance equivalence: Adam & Adam
Logical equivalence: Adam & Adam
Logical equivalence: Adam & Adam

En el ejemplo anterior, creé y comparé tres variables de cadena: myName, myName2, que es una copia de la referencia a myName, y myName3, que es una instancia totalmente nueva pero con el mismo contenido. Primero, muestro que el operador == identifica a myName y myName2 como instancias equivalentes, lo que esperaría porque myName2 es solo una copia de la referencia. Debido al hecho de que myName y myName2 son referencias de instancia idénticas, se deduce que tienen que ser lógicamente equivalentes.

Las últimas dos comparaciones realmente demuestran la diferencia entre usar == y equals(Object). La comparación de instancias usando == demuestra que son instancias diferentes con sus propias ubicaciones de memoria únicas, mientras que la comparación lógica usando equals(Object) muestra que contienen exactamente el mismo contenido.

Inmersión en iguales (Objeto) {#inmersión en iguales objeto}

Ok, ahora conocemos la diferencia entre == y equals(Object), pero ¿qué pasaría si le dijera que la implementación base de la clase Object produce el mismo resultado que el operador ==?

Qué...!? Lo sé... eso parece extraño, pero bueno, los desarrolladores de Java tuvieron que empezar en alguna parte. Permítanme decirlo nuevamente, por defecto, el método equals(Object) que hereda en sus clases personalizadas simplemente prueba la igualdad de instancias. Depende de nosotros como desarrolladores determinar si esto es apropiado o no, es decir, determinar si existe una noción de equivalencia lógica que se requiere para nuestra clase.

Nuevamente, permítanme usar la clase Person que presenté anteriormente en esta serie para una demostración más.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public class Person {
    private String firstName;
    private String lastName;
    private LocalDate dob;

    public Person(String firstName, String lastName, LocalDate dob) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.dob = dob;
    }

    // omitting getters and setters for brevity

    @Override
    public String toString() {
        return "<Person: firstName=" + firstName + ", lastName=" + lastName + ", dob=" + dob + ">";
    }
}

Permítanme usar de nuevo un programa simple envuelto en una clase Principal que demuestra tanto la igualdad de instancias idénticas como la igualdad lógica anulando equals(Object).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import java.time.LocalDate;

public class Main {
    public static void main(String[] args) {
        Person me = new Person("Adam", "McQuistan", LocalDate.parse("1987-09-23"));
        Person me2 = new Person("Adam", "McQuistan", LocalDate.parse("1987-09-23"));

        if (me != me2)
            System.out.println("Not instance equivalent");

        if (!me.equals(me2))
            System.out.println("Not logically equivalent");
    }
}

Producción:

1
2
Not instance equivalent
Not logically equivalent

Como puede ver, las dos instancias de personas yo y yo2 no son ni lógicamente ni equivalentes de instancia listas para usar, aunque uno podría concebir razonablemente que yo y yo2 representan lo mismo en función del contenido.

Aquí es donde se vuelve importante anular la implementación predeterminada y proporcionar una que tenga sentido para la clase que se está definiendo. Sin embargo, de acuerdo con los documentos oficiales de Java, hay algunas reglas que deben seguirse al hacerlo para evitar problemas con algunas dependencias de implementación importantes del lenguaje.

Las reglas descritas en es igual a documentos de Java para las instancias de objeto dadas x, y y z son las siguientes:

  • reflexivo: x.equals(x) debe ser verdadero para todas las instancias de referencia no nulas de x
  • simétrico: x.equals(y) y y.equals(x) deben ser verdaderos para todas las instancias de referencia no nulas de x e y
  • transitiva: si x.equals(y) y y.equals(z) entonces x.equals(z) también debe ser cierto para instancias de referencia no nulas de x, y y z
  • consistencia: x.equals(y) siempre debe ser verdadero cuando ningún valor de miembro utilizado en la implementación de equals ha cambiado en las instancias de referencia no nulas x e y
  • sin igualdad nula: x.equals(null) nunca debe ser verdadero
  • siempre anula hashCode() al anular equals()

Desempaquetando las reglas de anulación de los iguales (Objeto) {#desempaquetando las reglas de anulación de los iguales al objeto}

A. Reflexivo: x.equals(x)

Para mí este es el más fácil de entender. Además, la implementación predeterminada del método equals(Object) lo garantiza, pero en aras de la exhaustividad, proporcionaré un ejemplo de implementación a continuación que sigue esta regla:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Person {
    // omitting for brevity

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        return false;
    }
}

B. Simétrico: x.equals(y) y y.equals(x)

Este puede parecer intuitivo a primera vista, pero en realidad es bastante fácil cometer un error y violar esta regla. De hecho, la razón principal por la que esto se viola a menudo es en los casos de herencia, que resulta ser algo muy popular en Java.

Antes de dar un ejemplo, permítanme actualizar el método equals(Object) para tener en cuenta el nuevo requisito más obvio, que es el hecho de que la prueba de equivalencia debe implementar una prueba lógica además de la prueba de igualdad de instancia.

Para implementar una prueba lógica, querré comparar los campos que contienen estado entre dos instancias de la clase de personas, descritas como x e y. Además, también debo verificar para asegurarme de que las dos instancias sean del mismo tipo de instancia, así:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class Person {
    // omitting for brevity

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof Person)) {
            return false;
        }
        Person p = (Person)o;
        return firstName.equals(p.firstName)
                && lastName.equals(p.lastName)
                && dob.equals(p.dob);
    }
}

Ok, debería ser evidente que Person ahora tiene una implementación equals(Object) mucho más robusta. Ahora permítanme dar un ejemplo de cómo la herencia puede causar una violación de la simetría. A continuación se muestra una clase aparentemente inofensiva, llamada Empleado, que hereda de Persona.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import java.time.LocalDate;

public class Employee extends Person {

    private String department;

    public Employee(String firstName, String lastName, LocalDate dob, String department) {
        super(firstName, lastName, dob);
        this.department = department;
    }

    @Override
    public boolean equals(Object o) {
        if (o == this) {
            return true;
        }

        if (!(o instanceof Employee)) {
            return false;
        }
        Employee p = (Employee)o;
        return super.equals(o) && department.equals(p.department);

    }
}

Es de esperar que pueda notar que estos no deben tratarse como instancias iguales, pero puede sorprenderse con lo que estoy a punto de mostrarle.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import java.time.LocalDate;

public class Main {
    public static void main(String[] args) {
        Person billy = new Person("Billy", "Bob", LocalDate.parse("2016-09-09"));
        MinorPerson billyMinor = new MinorPerson(
                billy.getFirstName(),
                billy.getLastName(),
                billy.getDob());

        System.out.println("billy.equals(billyMinor): " + billy.equals(billyMinor));
        System.out.println("billyMinor.equals(billy): " + billyMinor.equals(billy));
    }
}

Producción:

1
2
billy.equals(billyEmployee): true
billyEmployee.equals(billy): false

¡Ups! Claramente una violación de la simetría, billy es igual a billyEmployee pero lo contrario no es cierto. ¿Entonces qué hago? Bueno, podría hacer algo como lo siguiente, dado que escribí el código y sé qué hereda qué, luego modifique el método Employee equals(Object) así:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java.time.LocalDate;

public class Employee extends Person {

    private String department;

    public Employee(String firstName, String lastName, LocalDate dob, String department) {
        super(firstName, lastName, dob);
        this.department = department;
    }

    @Override
    public boolean equals(Object o) {
        if (o == this) {
            return true;
        }

        if (instanceof Person && !(o instanceof Employee)) {
            return super.equals(o);
        }

        if (o instanceof Employee) {
            Employee p = (Employee)o;
            return super.equals(o) && department.equals(p.department);
        }

        return false;
    }
}

Producción:

1
2
billy.equals(billyEmployee): true
billyEmployee.equals(billy): true

¡Sí, tengo simetría! ¿Pero estoy realmente bien? Note aquí cómo estoy haciendo todo lo posible para que el Empleado ahora se conforme... esto debería estar enviando una bandera roja que volverá a morderme más tarde como lo demuestro en la siguiente sección.

C. Transitividad: si x.es igual a(y) y y.es igual a(z), entonces x.igual a(z)

Hasta ahora me he asegurado de que mis clases Person y Employee tengan métodos equals(Object) que son tanto reflexivos como simétricos, por lo que necesito verificar que también se siga la transitividad. Lo haré a continuación.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import java.time.LocalDate;

public class Main {
    public static void main(String[] args) {
        Person billy = new Person("Billy", "Bob", LocalDate.parse("2016-09-09"));
        Employee billyEngineer = new Employee(
                billy.getFirstName(),
                billy.getLastName(),
                billy.getDob(),
                "Engineering");
        Employee billyAccountant = new Employee("Billy", "Bob", LocalDate.parse("2016-09-09"), "Accounting");

        System.out.println("billyEngineer.equals(billy): " + billyEngineer.equals(billy));
        System.out.println("billy.equals(billyAccountant): " + billy.equals(billyAccountant));
        System.out.println("billyAccountant.equals(billyEngineer): " + billyAccountant.equals(billyEngineer));
    }
}

Producción:

1
2
3
billyEngineer.equals(billy): true
billy.equals(billyAccountant): true
billyAccountant.equals(billyEngineer): false

¡Maldito! Estuve en un buen camino allí por un tiempo. ¿Qué sucedió? Bueno, resulta que en la herencia clásica dentro del lenguaje Java no se puede agregar un miembro de clase identificador a una subclase y esperar poder anular equals(Object) sin violar la simetría o la transitividad. La mejor alternativa que he encontrado es usar patrones de composición en lugar de herencia. Esto rompe efectivamente la rígida jerarquía de herencia entre las clases, así:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import java.time.LocalDate;

public class GoodEmployee {

    private Person person;
    private String department;

    public GoodEmployee(String firstName, String lastName, LocalDate dob, String department) {
        person = new Person(firstName, lastName, dob);
        this.department = department;
    }

    @Override
    public boolean equals(Object o) {
        if (o == this) {
            return true;
        }

        if (!(o instanceof Employee)) {
            return false;
        }

        GoodEmployee p = (GoodEmployee)o;
        return person.equals(o) && department.equals(p.department);
    }
}

D. Consistencia: x.equals(y) mientras nada cambie

Este es realmente muy fácil de comprender. Básicamente, si dos objetos son iguales, solo permanecerán iguales mientras ninguno de ellos cambie. Aunque esto es fácil de entender, se debe tener cuidado para garantizar que los valores no cambien si pudiera haber consecuencias negativas como resultado de dicho cambio.

La mejor manera de garantizar que las cosas no cambien en una clase es hacerla inmutable proporcionando solo una forma de asignar valores. En general, esta única forma de asignación debe ser a través de un constructor durante la creación de instancias. También declarar los campos de clase final puede ayudar con esto.

A continuación se muestra un ejemplo de la clase Person definida como una clase inmutable. En este caso, dos objetos que son inicialmente iguales siempre serán iguales porque no puedes cambiar su estado una vez creados.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import java.time.LocalDate;

public class Person {
    private final String firstName;
    private final String lastName;
    private final LocalDate dob;

    public Person(String firstName, String lastName, LocalDate dob) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.dob = dob;
    }

    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public LocalDate getDob() {
        return dob;
    }

    @Override
    public String toString() {
        Class c = getClass();
        return "<" + c.getSimpleName() + ": firstName=" + firstName + ", lastName=" + lastName + ", dob=" + dob + ">";
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof Person)) {
            return false;
        }
        Person p = (Person)o;
        return firstName.equals(p.firstName)
                && lastName.equals(p.lastName)
                && dob.equals(p.dob);
    }
}

MI. Sin igualdad nula: x.equals(null)

A veces, verá que esto se aplica a través de una comprobación directa de que la instancia de Objeto o es igual a nulo, pero en el ejemplo anterior esto se comprueba implícitamente utilizando !(o instancia de Persona) debido al hecho de que el comando instanceof siempre devolverá falso si el operando izquierdo es nulo.

F. Siempre anula hashCode() al anular equals(Object)

Debido a la naturaleza de varios detalles de implementación en otras áreas del lenguaje Java, como el marco de las colecciones, es imperativo que si equals(Object) se anula, entonces también se debe anular hashCode(). Dado que el próximo artículo de esta serie cubrirá específicamente los detalles de la implementación de su propio método hasCode(), no cubriré este requisito con más detalle aquí, aparte de decir que dos instancias que exhiben igualdad a través de equals (Objeto) debe producir los códigos hash idénticos a través de hashCode().

Conclusión

Este artículo describe el significado y el uso del método equals(Object) junto con por qué puede ser importante que sus programas tengan una noción de igualdad lógica que difiere de la igualdad de identidad (instancia).

Como siempre, gracias por leer y no se avergüence de comentar o criticar a continuación.

Licensed under CC BY-NC-SA 4.0