Equality: best practices. Или как правильно переопределить метод equals.

Полный список в конце поста.



Базовая реализация в классе Object проверяет только равенство ссылок - по умолчанию каждый экземпляр класса равен только самому себе. Часто рекомендуют для новых классов переопределять метод equals и проверять равенство по значимым полям.



Несмотря на кажущуюся простоту, в реализации equals есть несколько тонких моментов. Кода в этом методе мало, выглядит он просто, а тесты на равенство редко кто пишет. Тем не менее часто встречается ситуация:

Странное поведение системы ➡️ неделя дебага по всей бизнес-логике ➡️ ошибка в equals ➡️ гнев и разочарование.



Простой способ не ошибиться — не переопределять equals вообще. Иногда это уместно:

1️⃣ Для перечислений (enum)

2️⃣ Когда важны сами экземпляры, а не данные внутри них. Пример - классы Thread, RecursiveTask.

3️⃣ Сравнение объектов вообще не предполагается. Пример - класс Pattern.

4️⃣ В базовом классе уже есть equals и нам подходит эта реализация.

5️⃣ Класс имеет модификатор доступа private или по умолчанию и мы уверены, что метод equals не будет вызван.



В остальных случаях его лучше переопределить. Во многих проектах при этом используется аннотация @EqualsAndHashCode библиотеки lombok, ограничения при её использовании мы тоже рассмотрим.



Итак, при реализации equals нужно учитывать следующие свойства:

1️⃣ Рефлексивность — экземпляр должен быть равен сам себе.

2️⃣ Консистентность — если экземпляры равны, то без внешних воздействий они должны оставаться равны.

Сравнивать только неизменяемые поля.

Чтобы обезопасить себя от проблем с equals по возможности используйте для ключей в Map не классы целиком, а их неизменяемые поля, например, id.

Если используете аннотацию @EqualsAndHashCode явно указывайте поля, по которым идёт сравнение. Это поможет избежать ошибок при добавлении в класс изменяемого поля.

@EqualsAndHashCode(of={“x”,“y”})

3️⃣ Симметрия.

Если в системе находятся объекты базового класса и его наследника нужно чётко понимать, как они будут между собой сравниваться.

Возьмём класс Точка, где равенство проверяется по координатам.

И его наследника — класс ЦветнаяТочка, где сравниваются координаты и цвет.

Точка обычная=new Точка(1,1);

Точка красная=new ЦветнаяТочка(1,1,RED);

Тогда обычная.equals(красная) будет true, а результат красная.equals(обычная) зависит от требований к системе — нужно ли игнорировать цвет в этом случае или нет.

4️⃣ Транзитивность.

Это свойство тоже легко нарушить, если мы имеем дело с иерархией:

Точка красная=new ЦветнаяТочка(1,1,RED);

Точка обычная=new Точка(1,1);

Точка синяя=new ЦветнаяТочка(1,1,BLUE);

Если мы решили игнорировать цвет при сравнении разных типов, то допустима такая ситуация:

красная.equals(обычная) - true

обычная.equals(синяя) — true

красная.equals(синяя) - true 😥

Для иерархий классов писать юнит-тесты на симметричность и транзитивность.

Не использовать @EqualsAndHashCode для иерархий классов.

🤔 Если в системе используются объекты базового и произвольного типов рассмотрите вариант замены наследования на композицию.

5️⃣ Сравнение существующего объекта с null должно возвращать false.

Добавить отдельную проверку if(o == null)



Итого:

1️⃣ Не переопределяйте equals, если сравнение не предполагается.

2️⃣ Используйте в equals только неизменяемые поля.

3️⃣ Для иерархии классов пишите тесты на симметричность и транзитивность.

4️⃣ Для ключей в Map желательно использовать неизменяемые поля, например, ID.

5️⃣ Не использовать @EqualsAndHashCode для иерархии классов.

6️⃣ При использовании @EqualsAndHashCode явно прописывайте поля.

7️⃣ Кратко проверить два поля на null и равенство можно через Objects.equals(this.a, а)

8️⃣ Проверяйте первыми поля, которые вероятнее всего отличаются.

9️⃣ Не забывайте про hashcode — используйте те же поля, что в методе equals.