Статьи

Как правильно реализовать Java-метод equals

В SitePoint мы всегда стремимся расширить круг обсуждаемых тем. В последнее время мы нацелены на изучение мира Java. Если вы сильный Java-разработчик, который хочет внести свой вклад в наше освещение, свяжитесь с несколькими идеями для статей, которые вы хотели бы написать.

Основополагающим аспектом любого Java-класса является его определение равенства. Он определяется методом equals класса, и для правильной реализации необходимо учитывать несколько моментов. Давайте проверим их, чтобы мы поняли это правильно!

Обратите внимание, что реализация equals всегда означает, что hashCode также должен быть реализован! Мы расскажем об этом в отдельной статье, поэтому обязательно прочитайте ее после этой.

Идентичность против равенства

Посмотрите на этот кусок кода:

 String some = "some string"; String other = "other string"; 

У нас есть две строки, и они, очевидно, разные.

Как насчет этих двух?

 String some = "some string"; String other = some; boolean identical = some == other; 

Здесь у нас есть только один экземпляр String, и some и other ссылаются на него. В Java мы говорим, что some и other идентичны и, соответственно, identical это true .

Как насчет этого?

 String some = "some string"; String other = "some string"; boolean identical = some == other; 

Теперь some и other указывают на разные экземпляры и уже не идентичны, поэтому identical ложно. (Мы будем игнорировать интернирование String в этой статье; если это вас не устраивает, предположим, что каждый строковый литерал был заключен в new String(...) .)

Но у них есть некоторые отношения, так как они «имеют одинаковую ценность». В терминах Java они равны , что проверяется equals :

 String some = "some string"; String other = "some string"; boolean equal = some.equals(other); 

Здесь equals это true .

Идентичность переменной (также называемая ссылочным равенством ) определяется ссылкой, которую она содержит. Если две переменные содержат одну и ту же ссылку, они идентичны . Это проверяется с помощью == .

Равенство переменной определяется значением, на которое она ссылается. Если две переменные ссылаются на одно и то же значение, они равны . Это проверяется equals .

Но что означает «та же ценность»? Фактически, реализация equals определяет «сходство». Метод equals определен в Object и поскольку все классы наследуются от него, у всех есть этот метод.

Реализация в Object проверяет идентичность (обратите внимание, что идентичные переменные также равны), но многие классы заменяют ее чем-то более подходящим. Например, для строк он сравнивает последовательность символов, а для дат он проверяет, чтобы оба указывали на один и тот же день.

Многие структуры данных, в частности собственная коллекция Java, используют equals чтобы проверить, содержат ли они элемент.

Например:

 List<String> list = Arrays.asList("a", "b", "c"); boolean contains = list.contains("b"); 

Переменная contains true потому что, хотя экземпляры "b" не идентичны, они равны.

(Это также момент, когда hashCode вступает в игру.)

Мысли о равенстве

Любая реализация equals должна придерживаться определенного контракта, иначе равенство классов нечетко определено, и происходят всевозможные неожиданные вещи. Мы рассмотрим формальное определение через минуту, но давайте сначала обсудим некоторые свойства равенства.

Это может помочь думать об этом, когда мы сталкиваемся с этим в нашей повседневной жизни. Допустим, мы сравниваем ноутбуки и считаем их равными, если они имеют одинаковые аппаратные характеристики.

  1. Одно свойство настолько тривиально, что вряд ли стоит упоминать: каждая вещь равна себе. Duh.
  2. Есть еще один, который не намного вдохновляет: если одна вещь равна другой, другая также равна первой. Ясно, что если мой ноутбук равен вашему, то ваш будет равен моему.
  3. Это более интересно: если у нас три вещи, и первая и вторая равны, а вторая и третья равны, то первая и третья тоже равны. Опять же, это очевидно в нашем примере с ноутбуком.

Это было бесполезное упражнение, верно? Не так! Мы только что проработали некоторые основные алгебраические свойства отношений эквивалентности. Не жди, не уходи! Это уже все, что нам нужно. Потому что любое отношение, которое имеет три свойства выше, можно назвать равенством.

Да, любой способ составления, который сравнивает вещи и обладает тремя вышеупомянутыми свойствами, может заключаться в том, как мы определяем, равны ли эти вещи. И наоборот, если мы что-то упустим, у нас больше не будет значимого равенства.

равенство

Контракт equals

Контракт equals — это чуть больше, чем формализация того, что мы видели выше.
Цитировать источник :

Метод equals реализует отношение эквивалентности для ненулевых ссылок на объекты:

  • Это рефлексивно : для любого ненулевого ссылочного значения x , x.equals(x) должно возвращать true .
  • Это симметрично : для любых ненулевых ссылочных значений x и y x.equals(y) должен возвращать true если и только если y.equals(x) возвращает true.
  • Это транзитивно : для любых ненулевых ссылочных значений x , y и z , если x.equals(y) возвращает true а y.equals(z) возвращает true , тогда x.equals(z) должен возвращать true .
  • Это согласуется : для любых ненулевых ссылочных значений x и y множественные вызовы x.equals(y) последовательно возвращают true или последовательно возвращают false при условии, что никакая информация, используемая в сравнениях сравнения для объектов, не изменяется.
  • Для любого ненулевого ссылочного значения x , x.equals(null) должен возвращать false .

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

Реализация equals

Для класса Person со строковыми полями firstName и lastName это будет общий вариант для реализации equals :

 @Override public boolean equals(Object o) { // self check if (this == o) return true; // null check if (o == null) return false; // type check and cast if (getClass() != o.getClass()) return false; Person person = (Person) o; // field comparison return Objects.equals(firstName, person.firstName) && Objects.equals(lastName, person.lastName); } 

Давайте пройдемся по одному.

Подпись

Очень важно, что equals забирает Object ! В противном случае происходит неожиданное поведение.

Например, предположим, что мы реализовали бы функцию equals(Person) следующим образом:

 public boolean equals(Person person) { return Objects.equals(firstName, person.firstName) && Objects.equals(lastName, person.lastName); } 

Что происходит в простом примере?

 Person elliot = new Person("Elliot", "Alderson"); Person mrRobot = new Person("Elliot", "Alderson"); boolean equal = elliot.equals(mrRobot); 

Тогда equal true . А сейчас?

 Person elliot = new Person("Elliot", "Alderson"); Object mrRobot = new Person("Elliot", "Alderson"); boolean equal = elliot.equals(mrRobot); 

Теперь это false . Ват ?! Может быть, не совсем то, что мы ожидали.

Причина в том, что Java называется Person.equals(Object) (как унаследовано от Object , который проверяет идентичность). Почему?

Стратегия Java по выбору перегруженного метода для вызова основана не на типе времени выполнения параметра, а на его объявленном типе. (Это хорошо, потому что в противном случае статический анализ кода, например иерархии вызовов, не сработал бы.) Поэтому, если mrRobot объявлен как Object , Java вызывает Person.equals(Object) вместо нашего Person.equals(Person) .

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

Самопроверка

Равенство является фундаментальным свойством любого класса и может в конечном итоге вызываться очень часто, например, в узких циклах, запрашивающих коллекцию. Таким образом, его производительность имеет значение! И самопроверка в начале нашей реализации — это просто оптимизация производительности.

java if (this == o) return true;

Может показаться, что он должен реализовывать рефлексивность, но проверки ниже будут очень странными, если они не сделают этого.

Null Check

Ни один экземпляр не должен быть равен нулю, поэтому здесь мы должны убедиться в этом. В то же время он защищает код от NullPointerException s.

 if (o == null) return false; 

На самом деле его можно включить в следующую проверку, например так:

 if (o == null || getClass() != o.getClass()) return false; 

Проверка типа и приведение

Следующее, мы должны убедиться, что рассматриваемый нами экземпляр — это на самом деле человек. Это еще одна хитрая деталь.

 if (getClass() != o.getClass()) return false; Person person = (Person) o; 

Наша реализация использует getClass , который возвращает классы, к которым принадлежат this и o . Это требует, чтобы они были идентичны! Это означает, что если бы у нас был класс Employee extends Person , то Person.equals(Employee) никогда не вернул бы true даже если оба имели одинаковые имена.

Это может быть неожиданным.

То, что расширяющийся класс с новыми полями плохо сравнивается, может быть разумным, но если это расширение только добавляет поведение (возможно, ведение журнала или другие нефункциональные детали), оно должно быть в состоянии сопоставить экземпляры своего супертипа. Это становится особенно актуальным, если фреймворк запускает новые подтипы во время выполнения (например, Hibernate или Spring), которые затем никогда не будут равны созданным нами экземплярам.

Альтернативой является оператор instanceof :

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

Экземпляры подтипов Person проходят эту проверку. Следовательно, они продолжают сравнение полей (см. Ниже) и могут оказаться равными. Это решает проблемы, о которых мы упоминали выше, но открывает новую банку с червями.

Скажем, Employee extends Person и добавляет дополнительное поле. Если он переопределяет реализацию equals он наследует от Person и включает в себя дополнительное поле, то person.equals(employee) может быть true (из-за instanceof ), а employee.equals(person) не может (потому что person пропускает это поле). Это явно нарушает требование симметрии.

Кажется, есть выход из этого: Employee.equals может проверить, сравнивается ли он с экземпляром с этим полем, и использовать его только тогда (это иногда называется сравнением срезов ).

Но это тоже не работает, потому что нарушает транзитивность:

 Person foo = new Person("Mr", "Foo"); Employee fu = new Employee("Mr", "Foo", "Marketing"); Employee fuu = new Employee("Mr", "Foo", "Engineering"); 

Очевидно, что все три экземпляра имеют одинаковое имя, поэтому foo.equals(fu) и foo.equals(fuu) имеют значение true . По транзитивности fu.equals(fuu) также должно быть true но это не так, если третье поле, по-видимому, отдел, включен в сравнение.

На самом деле нет способа заставить сравнение срезов работать без нарушения рефлексивности или, что сложнее, анализа транзитивности. (Если вы думаете, что нашли его, проверьте еще раз. Затем пусть ваши коллеги проверят. Если вы все еще уверены, пингуйте меня .;))

Итак, мы заканчиваем двумя альтернативами:

  • Используйте getClass и помните, что экземпляры типа и его подтипов никогда не могут быть равны.
  • Используйте instanceof но сделайте equals final, потому что нет способа переопределить его правильно.

Какой из них имеет больше смысла, действительно зависит от ситуации. Лично я предпочитаю instanceof потому что его проблемы (не могут включать новые поля в унаследованные классы) возникают на сайте объявления, а не на сайте использования.

Сравнение полей

Вау, это было много работы! И все, что мы делали, это решали некоторые угловые случаи! Итак, давайте наконец доберемся до сути теста: сравнение полей.

Это довольно просто, хотя. В подавляющем большинстве случаев все, что нужно сделать, это выбрать поля, которые должны определить равенство класса, а затем сравнить их. Используйте == для примитивов и equals для объектов.

Если какое-либо из полей может быть пустым, дополнительные проверки значительно снижают читабельность кода:

 return (firstName == person.firstName || firstName != null && firstName.equals(person.firstName)) && (lastName == person.lastName || lastName != null && lastName.equals(person.lastName)) 

И это уже использует неочевидный факт, что null == null — это true .

Гораздо лучше использовать служебный метод Java Objects.equals (или, если вы еще не в Java 7, Objects.equal ‘s Objects.equal ):

 return Objects.equals(firstName, person.firstName) && Objects.equals(lastName, person.lastName); 

Он делает точно такие же проверки, но гораздо более читабелен.

Резюме

Мы обсудили разницу между тождеством (должна быть == и та же ссылка; проверено с помощью == ) и равенством (могут быть разные ссылки на «одно и то же значение»; проверено с помощью equals ), а затем подробно рассмотрели, как реализовать equals ,

Давайте соберем эти части вместе:

  • Обязательно переопределите equals(Object) чтобы наш метод вызывался всегда.
  • Включите собственную и нулевую проверку для раннего возврата в простых крайних случаях.
  • Используйте getClass чтобы разрешить подтипам свою собственную реализацию (но без сравнения между подтипами) или используйте instanceof и сделайте equals final (а подтипы могут быть равными).
  • Сравните нужные поля, используя Objects.equals .

Или позвольте вашей IDE сгенерировать все это для вас и отредактируйте, где необходимо.

Заключительные слова

Мы увидели, как правильно реализовать equals (и скоро рассмотрим hashCode ). Но что, если мы используем классы, которые мы не можем контролировать? Что, если их реализации этих методов не удовлетворяют нашим потребностям или просто ошибочны?

LibFX на помощь! (Отказ от ответственности: я — автор.) Библиотека содержит трансформируемые коллекции, и одна из их функций заключается в том, чтобы позволить пользователю указывать методы equals и hashCode которые ему нужны.