Java’s Object.equals(): The Overlooked Function That Can Make or Break Your Code?

Sameh Adel
6 min readMar 31, 2023

--

Photo by Max Duzij on Unsplash

As software engineers, we know that even the smallest details can make a big difference in the performance and quality of our code. No one wants to spend endless hours debugging issues that could have been avoided with proper coding practices.

Unfortunately, the misuse of programming language features is a common culprit behind these nightmares. But fear not, by taking the time to learn and apply best practices, we can ensure our code is efficient, reliable, and easy to maintain. In this article, we’ll explore one such best practice in Java: the proper implementation of the Object.equals() method. We'll dive into the reasons why this seemingly small detail can have a significant impact on the behavior of our applications and how to do it right. So, if you're looking to improve your Java skills and write better code, read on!

Default implementation

If you ignored or decided not to implement the inherited Object.equals(), then you have to know what is the default implementation that Object class provides. But first let’s know the difference between the two ways of comparing objects:
1. Comparing objects using “==” operator.
2. Comparing objects usingObject.equals(Object o)

The first way compares the references of the two objects, the second calls the equals method that exists inside your object (if overridden).

public class Number {
private final int number;

public Number (int n){

number = n;
}
}
@Test
public void testEqualityWays() {
Number n1= new Number(50);
Number n2= new Number(50);

Assertions.assertTrue(n1 == n2); // Fails
Assertions.assertTrue(n1.equals(n2)); // Fails
}

As we can see from the above test n1 == n2result is false because they don’t have the same reference. But why the second statement gives us the same result ? That’s because th Object class implementation of the equals method uses the “==” operator !

class Object {

/* .. remainded of the object code ommitted .. */

public boolean equals(Object obj) {
return this == obj;
}
}

Being aware of this crucial detail motivates us to override the Object.equals() method whenever we need to compare two objects. In the following sections, we will dive into the correct implementation of this method.

Equals method general contract

Before we dive into details, we should know the contract that we must follow when overriding the equals method which is inherited from the Object class.

  1. Reflexive: if we have non-null object instance A, then A.equals(A) must return true.
  2. Symmetric: if we have two non-null object instances A and B, if A.equals(B) is true, then B.equals(A) must be true.
  3. Transitive: if we have three non-null object instances A, B and C, if A.equals(C) is true, and B.equals(C) is true, then A.equals(C) must be true.
  4. Consistent: if we have two non-null object instances A and B, then calling A.equals(B) multiple times must return a consistent value whether true or false.

These concepts might be hard to grasp at first, but it’s important to avoid violating them. In this article, I won’t be discussing the Reflexive and Consistent parts of the contract, since they are difficult to break. However, it’s still crucial to test them in your code to ensure they are functioning properly.

Violation of Symmetry

Violations of the Symmetric part of the contract are common, especially in cases involving inheritance. To illustrate this point, let’s take a closer look at an example:

public class Person {
private final Long id;
private final String name;

public Person(long id, String name) {
this.id = id;
this.name = name;
}

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

if (o == null || !(o instanceof Person)) return false;

Person person = (Person) o;
return Objects.equals(id, person.id)
&& Objects.equals(name, person.name);
}
}
public class Employee extends Person {
private final Integer salary;

public Employee(long id, String name, Integer salary) {
super(id, name);
this.salary = salary;
}

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

if (o == null || !(o instanceof Employee)) return false;

Employee that = (Employee) o;
return super.equals(o) && Objects.equals(salary, that.salary);

}
}

We now possess a Person superclass and an Employee subclass. While using the superclass equals method and passing the subclass as a parameter would produce the right outcome, doing otherwise would be inaccurate. The following example illustrates this behavior:

@Test
public void testSymmetric() {
Person person = new Person(1L, "Name");
Employee employee = new Employee(1L, "Name", 1000);

Assertions.assertTrue(person.equals(employee)); // Succeed
Assertions.assertTrue(employee.equals(person)); // Fails
}

Obviously, this test will not pass since employee.equals(person) would result in false due to Person not being an instance of Employee. Can you identify the violation of Symmetry in this scenario?

Violation of Transitivity

One way to solve this issue is to compare the two objects blindly by ignoring the salary attribute if the parameter passed to the Employee equals method is a Person object employee.equals(person).
However, while this approach may fix the Symmetry problem, it will lead to disruption of the Transitivity part.

// Employee equals method
// Solves the Symmetric but disrupt Transitivity

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

if(!(o instanceof Person))
return false;

if(o.getClass() == Person.class) //If superclass -> neglect salary
return o.equals(this);

Employee that = (Employee) o;

return super.equals(o) && Objects.equals(salary, that.salary);
}

Again, the result of employee.equals(person) and person.equals(employee) is true. But we still have a Transitivity issue.

Let’s see how the Transitive part of the contract has been broken:

@Test
public void testTransitive() {
Employee e1 = new Employee(1L, "Name", 1000);
Person p1 = new Person(1L, "Name");
Employee e2 = new Employee(1L, "Name", 5000);

Assertions.assertTrue(e1.equals(p1)); // true
Assertions.assertTrue(p1.equals(e2)); // true
Assertions.assertTrue(e1.equals(e2)); // false
}

Real effect on these violations on your program

At this point, you may be wondering how the violation of Symmetry or Transitivity could impact your program in practical terms. This is a valid concern; let’s examine the following example to provide the answer.

@Test
public void testRealWorldCase() {
List<Person> persons = new ArrayList<>();
Person parentObject = new Person(1, "Person_1");
Employee inheritedObject = new Employee(1, "Person_1", 1000);

persons.add(parentObject);

Assertions.assertTrue(persons.contains(inheritedObject)); // false
}

Clearly, this outcome is not what was anticipated, as the inheritedObject, despite being of Employee type, is still regarded as a Person object, and calling the persons.contains(parentObject) should return true.

Solution

The issue at hand is a fundamental problem with equivalence relations in object-oriented languages. When trying to extend an instantiable class and add a value component (in our case is salary component), while preserving the equals contract, there is no solution available unless one is willing to sacrifice the advantages of object-oriented abstraction.

If you opt to abandon the OOP abstraction and instead implement objects that can be compared to each other to achieve the desired outcome, you could refer to the following example:

class Employee {
private final Person person;
private final Integer salary;

public Employee(Long id, String name, Integer salary) {
person = new Person(id, name);
this.salary = Objects.requireNonNull(salary);
}

/**
* Returns the person-view of this employee.
*/
public Person asPerson() {
return person;
}

@Override
public boolean equals(Object o) {
if (!(o instanceof Employee))
return false;

Employee employee = (Employee) o;

return employee.person.equals(person)
&& employee.salary.equals(salary);
}
}

Conclusion

The problem discussed in this context is not meant to find a straightforward solution, but rather to alert you about the significance of implementing the equals method carefully when using inheritance, as well as when using codes that depend on the implementation of the equals method, such as List.contains(Object). If you choose to rely on OOP abstractions, a conservative approach is to refrain from using Polymorphism with Collections that use the equals method of the object behind the scene. In our case this means to use List<Employee> to hold Employee objects without the use of Person to achieve the abstraction. Further analysis and comprehension of the internal workings of the libraries utilized in your program are required to insure the reliability and order to write reliable and consistent applications.

--

--

Sameh Adel

Software Engineer specialized in backend development based on Java. Passionate about building scalable systems capable of serving millions of users reliably