equals() и hashCode() в Java или попугаи-неразлучники в ваших программах. Часть 1

В мире есть много одинаковых вещей, много просто похожих друг на друга. Есть полностью идентичные вещи. В языке Java более чем часто возникает задача сравнить те или иные объекты, проверить их на равенство. Казалось бы, есть оператор “==” – равно, который возвращает true или false в зависимости от результата проверки на равенство(технически он чутка сложнее чем кажется). И он отлично работает в случае сравнения примитивных типов данных. Но как быть, если нам нужно проверить на равенство два объекта, со своими полями и значениями в них? Для таких сравнений мы используем метод equals(). Давайте разберемся в его особенностях, его контракте, гарантиях, правилах переопределения. И так, начнем!

Контракт equals() и лучший способ его не сломать

Метод equals() является методом класса Object и его реализация выглядит следующим образом:

Несмотря на простоту реализации этого метода, он выполняет свой контракт, который гарантирует выполнения следующих условий:

  • Рефлексивность. x.equals(x) всегда возвращает true
  • Симметричность. x.equals(y) обязан вернуть true, если y.equals(x) возвращает true
  • Транзитивность. Если x.equals(y) возвращает true, y.equals(z) возвращает true, тогда x.equals(z) должен вернуть true
  • Консистентность. x.equals(y) при множественном вызове метода всегда возвращает неизменно true или false, если объекты между вызовами не изменялись.
  • Условие NULL. Вызов x.equals(null) всегда должен возвращать false.

Дополнительным условием для объектов x,y и z является их неравенство null, потому что если осуществить вызов null.equals(x) мы получим исключение NullPointerException.

Реализация equals() класса Object гарантирует выполнения контракта. Лучшим способом его не сломать является отказ от переопределения метода equals() в вашем классе. Но все таки, время от времени нам необходимо это делать.

Как можно сломать контракт equals?

Давайте рассмотрим варианты поломки каждого пункта контракта и на основе негативного опыта, приведем качественное и правильное переопределения метода equals в нашем классе.

  • Рефлексивность. Проще говоря объект должен быть равен самому себе. Звучит логично. Как сломать этот поинт представить сложно. Но если это все таки произойдет, то, например, если вы создадите List от класса, в котором вы неправильно переопределили equals(), и положите в него объект, то метод contains() вернет вам false если вы запросите этот объект.

Так произойдет, потому что реализация метода contains() в ArrayList вызывает метод indexOf() – получение индекса, который внутри себя использует метод equals() нашего класса. В нашем случае происходит следующее: метод indexOf() в цикле от 0 до размера списка вызывает метод equals, и если он возвращает true, тогда в метод contains() передается индекс этого элемента, если equals для каждого объекта возвращает false – как в нашем случае – возвращает -1. Сам метод contains() выглядит следующим образом:

Как мы можем видеть, получив -1 мы вернем false.

  • Симметричность. Сломать проще, чем условие рефлексивности.

Как мы видим симметричность нарушена, потому что если в первом случае мы вызываем метод equals() класса BrokenSymmetry, то во втором – класса String, который не сравнивает внутри себя строку методом equalsIgnoreCase(). Восстановить симметричность в данном случае просто: достаточно убрать условие else if в методе equals()

  • Транзитивность. Это значит, что если первый объект равен второму, а второй равен третьему, то первый должен быть равен третьему. Один из вариантов накосячить с переопределением метода equals() это наследование. Например, класс А расширяет класс Б, добавляет новое значимое поле. Стоит отметить, что пока я писал код для этой части, то умудрился сломать еще и симметричность 😀. С ней я вас уже познакомил, теперь все таки сосредоточимся на поломки транзитивности:

Вот она сломанная транзитивность! Как все таки легко сломать метод equals()! С одной стороны avto равно niva, niva2 равна avto. Мы ожидаем, что niva равна niva2, но оказывается нет. Давайте разбираться что не так. При сравнении родительского класса с потомком мы учитываем только два поля: объем двигателя и мощность, но имя – нет. Вот и получается что по этим параметрам две нивы должны быть равны через равенство с объектом авто. Вообще решения говоря разрешить эту проблему не так просто. Вариант решения: в equlas() для класса avto заменить instanceof на проверку является ли объект в точности экземпляром классом Avto, а не потомком. Это можно сделать с помощью метода getClass(). Т.е. выглядеть это будет примерно так:

Проблема транзитивности решена, но мы нарушили принцип подстановки Лисков (Liskov Substitution Principle). Мы изменили поведение публичного метода базового класса для его потомков. Другими словами, мы теперь не можем заменить объекты Avto на объекты типа Niva, потому что метод equals() не будет работать так же корректно для типа Niva, как он работает для класса Avto. Лучшим решением в нашем примере для починки транзитивности будет являться замена наследования на композицию. В классе Niva мы добавим поле Avto и организуем к нему доступ путем добавления геттера.

Стоит отметить, что в java-библиотеках есть классы, которые нарушают контракт метода equals(), например, класс java.sql.Timestamp расширяет класс java.unit.Date и добавляет поле с наносекундами. Об это, кстати, указано в документации к классу.

  • Консистентность. Понятно, что для двух неизменяемых (immutable) объектов множественный вызов equals() должен возвращать одинаковый результат. Нарушить этот пункт можно, если объекты вашего класса – изменяемые (mutable). Этот пункт как бы намекает на то, что нужно стараться реализовывать неизменяемые классы. В этом пункте еще стоит отметить, неважно какой у вас класс – изменяемый или нет – важно, что предопределенный вами метод equals() не должен зависеть от ненадежных полей класса. Например, если при заполнении поля, конструктор обращается к базе данных, либо получает значение поле из сети, десериализует из некоего объекта. Ко всем этим источникам необходимо подключение, которое может время от времени пропадать. Именно это делает поле ненадежным. Старайтесь избегать таких ситуаций.
  • Условие NULL. Сломать достаточно проблематично, но есть риск схватить NPE. Достаточно легко избежать ситуации с неверным результатом вызова метода equals(null), если использовать метод instanceof, который делает эту проверку за нас. Поэтому не обязательно проверять пришедший объект на равенство null. Хотя это и можно сделать, но излишняя проверка, нагрузка на процессор и все такое.

Чего нужно избегать при переопределении метода equals()?

В дополнении к разбору примеров с нарушением контракта метода equals() напишу пару вещей, которых так же нужно избегать при переопределении метода equals(). Во-первых, всегда переопределяйте методом hashCode() вместе с методом equals() этого требует их совместный контракт. Эти методы как попугаи-неразлучники, всегда должны быть вместе. Его и реализацию метода hashCode() рассмотрим во второй статье. Вообще интересно получается: разделили неразлучимые методы по разным статьям. Во-вторых, лучший – враг хорошему. Не пытайтесь сделать реализацию метода equals() лучше, чем она уже есть у вас. Вы породите только множество проблем. В-третьих, не изменяйте тип у переменной в методе equals(), потому что в этом случае вы не предопределите его, а перегрузите.

Как правильно переопределять метода equals()?

Придерживаясь следующих правил, ваша реализация метода equals() будет стабильна и в рамках выполнения каждого пункта контракта.

  1. Используйте оператор “==” для проверки ссылается ли объект на сравниваемый.
  2. Используйте оператор instanceof для проверки корректности типа
  3. Приводите аргумент к корректному типу
  4. Для каждого значимого поля в классе проверяйте соответствие пришедшего значение из объекта и его соответствующего поля.
  5. Для проверки примитивов (кроме float и double) используйте “==”, для проверки объектов – equals() или форму Objects.equals(Object, Object) чтобы избежать NullPointerException. Для массивов проверяйте рекурсивно каждый его объект. Для примитивов float и double используйте вызов статических методов Float.compare(float, float) и Double.compare(double, double). В данном случае простое “==” не подойдет, потому что есть значения Float.NaN, -0.0f и аналоги в double. Метод equals() для их сравнения лучше не использовать из-за автобоксинга, это безусловно скажется на производительности.

Напоследок, реализация метода equals() с выполнением всех контрактов

Изначально планировалось уместить все в одну статью, но метод equals() оказался не так просто. Поэтому hashCode() рассмотрим во второй части.

Оставить комментарий:

Ваш email не будет опубликован.