JavaRush /Java блог /Java Developer /Методы equals & hashCode: практика использования
Автор
Milan Vucic
Репетитор по программированию в Codementor.io

Методы equals & hashCode: практика использования

Статья из группы Java Developer
Привет! Сегодня мы поговорим о двух важных методах в Java — equals() и hashCode(). Мы встречаемся с ними не впервые: в начале курса JavaRush была небольшая лекция об equals() — прочитай ее, если подзабыл или не встречал ранее. Методы equals & hashCode: практика использования - 1На сегодняшнем же занятии поговорим об этих понятиях подробно — поверь, поговорить есть о чем! И перед тем, как переходить к новому, давай освежим в памяти то, что уже проходили :) Как ты помнишь, обычное сравнение двух объектов через оператор “==” — плохая идея, потому что “==” сравнивает ссылки. Вот наш пример с машинами из недавней лекции:

public class Car {

   String model;
   int maxSpeed;

   public static void main(String[] args) {

       Car car1 = new Car();
       car1.model = "Ferrari";
       car1.maxSpeed = 300;

       Car car2 = new Car();
       car2.model = "Ferrari";
       car2.maxSpeed = 300;

       System.out.println(car1 == car2);
   }
}
Вывод в консоль:

false
Казалось бы, мы создали два идентичных объекта класса Car: все поля у двух машин одинаковые, но результат сравнения все равно false. Причина нам уже известна: ссылки car1 и car2 указывают на разные адреса в памяти, поэтому они не равны. Мы же все-таки хотим сравнить два объекта, а не две ссылки. Лучшее решение для сравнения объектов — метод equals().

Метод equals()

Возможно, ты помнишь, что этот метод мы не создаем с нуля, а переопределяем — ведь метод equals() определен в классе Object. Однако в обычном виде толку от него мало:

public boolean equals(Object obj) {
   return (this == obj);
}
Вот так метод equals() определен в классе Object. То же самое сравнение ссылок. Зачем его сделали таким? Ну а откуда создателям языка знать, какие объекты в твоей программе считать равными, а какие — нет? :) В этом заключается основная идея метода equals() — создатель класса сам определяет характеристики, по которым проверяется равенство объектов этого класса. Сделав это, ты переопределяешь метод equals() в своем классе. Если тебе не совсем понятен смысл «сам определяешь характеристики», давай рассмотрим пример. Вот простой класс человека — Man.

public class Man {

   private String noseSize;
   private String eyesColor;
   private String haircut;
   private boolean scars;
   private int dnaCode;

public Man(String noseSize, String eyesColor, String haircut, boolean scars, int dnaCode) {
   this.noseSize = noseSize;
   this.eyesColor = eyesColor;
   this.haircut = haircut;
   this.scars = scars;
   this.dnaCode = dnaCode;
}

   //геттеры, сеттеры и т.д.
}
Допустим, мы пишем программу, которая должна определять, являются ли два человека родственниками-близнецами, или это просто двойники. У нас есть пять характеристик: размер носа, цвет глаз, прическа, наличие шрамов и результаты биологического теста ДНК (для простоты — в виде кодового числа). Как ты думаешь, какие из этих характеристик позволят нашей программе определить родственников-близнецов? Методы equals & hashCode: практика использования - 2Разумеется, гарантию может дать только биологический тест. У двух людей могут быть одинаковый цвет глаз, прическа, нос, и даже шрамы — людей в мире много, и избежать совпадений невозможно. Нам же нужен надежный механизм: только результат ДНК-теста позволяет сделать точный вывод. Что же это означает для нашего метода equals()? Нам нужно его переопределить в классе Man с учетом требований нашей программы. Метод должен сравнивать поле int dnaCode двух объектов, и если они равны, значит, и объекты равны.

@Override
public boolean equals(Object o) {
   Man man = (Man) o;
   return dnaCode == man.dnaCode;
}
Неужели так просто? Не совсем. Мы кое-что упустили. В данном случае для наших объектов мы определили всего одно «значимое» поле, по которому устанавливается их равенство — dnaCode. А теперь представь, что таких «значимых» полей у нас было бы не 1, а 50. И если все 50 полей у двух объектов равны, то и объекты равны. Такое тоже может быть. Главная проблема в том, что вычисление равенства 50 полей — затратный по времени и ресурсам процесс. Теперь представь, что помимо класса Man у нас есть класс Woman с точно такими же полями, как и в Man. И если твоими классами будет пользоваться другой программист, он запросто может написать в своей программе что-то типа:

public static void main(String[] args) {
  
   Man man = new Man(........); //куча параметров в конструкторе

   Woman woman = new Woman(.........);//такая же куча параметров.

   System.out.println(man.equals(woman));
}
Проверять значения полей в данном случае бессмысленно: мы же видим, что перед нами объекты двух разных классов, и они не могут быть равны в принципе! Значит в метод equals() нам нужно поместить проверку — сравнение объектов двух одинаковых классов. Хорошо, что мы об этом подумали!

@Override
public boolean equals(Object o) {
   if (getClass() != o.getClass()) return false;
   Man man = (Man) o;
   return dnaCode == man.dnaCode;
}
Но, может мы забыли что-то еще? Хм… Как минимум, надо бы проверить, что мы не сравниваем объект сам с собой! Если ссылки А и Б указывают на один адрес в памяти, значит, это один и тот же объект, и нам тоже не надо тратить время и сравнивать 50 полей.

@Override
public boolean equals(Object o) {
   if (this == o) return true;
   if (getClass() != o.getClass()) return false;
   Man man = (Man) o;
   return dnaCode == man.dnaCode;
}
Кроме того, не помешает добавить проверку на null: никакой объект не может быть равен null, и в таком случае нет смысла в дополнительных проверках. С учетом всего этого, наш метод equals() для класса Man будет выглядеть так:

@Override
public boolean equals(Object o) {
   if (this == o) return true;
   if (o == null || getClass() != o.getClass()) return false;
   Man man = (Man) o;
   return dnaCode == man.dnaCode;
}
Мы проводим все первоначальные проверки, о которых сказали выше. Если в итоге оказалось, что:
  • мы сравниваем два объекта одного класса
  • это не один и тот же объект
  • мы сравниваем наш объект не c null
...тогда мы переходим к сравнению значимых характеристик. В нашем случае — поля dnaCode двух объектов. Переопределяя метод equals(), обязательно соблюдай эти требования:
  1. Рефлексивность.

    Любой объект должен быть equals() самому себе.
    Мы уже учли это требование. В нашем методе указано:

    
    if (this == o) return true;
    

  2. Симметричность.

    Если a.equals(b) == true, то и b.equals(a) должно возвращать true.
    Этому требованию наш метод тоже соответствует.

  3. Транзитивность.

    Если два объекта равны какому-то третьему объекту, значит, они должны быть равны друг и другу.
    Если a.equals(b) == true и a.equals(c) == true, значит проверка b.equals(c) тоже должна возвращать true.

  4. Постоянность.

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

  5. Неравенство с null.

    Для любого объекта проверка a.equals(null) должна возвращать false
    Это не просто набор каких-то «полезных рекомендаций», а именно жесткий контракт методов, прописанный в документации Oracle

Метод hashCode()

Теперь поговорим о методе hashCode(). Зачем он нужен? Ровно для той же цели — сравнения объектов. Но ведь у нас уже есть equals()! Зачем же еще один метод? Ответ прост: для повышения производительности. Хэш-функция, которая представлена в Java методом hashCode(), возвращает числовое значение фиксированной длины для любого объекта. В случае с Java метод hashCode() возвращает для любого объекта 32-битное число типа int. Сравнить два числа между собой — гораздо быстрее, чем сравнить два объекта методом equals(), особенно если в нем используется много полей. Если в нашей программе будут сравниваться объекты, гораздо проще сделать это по хэш-коду, и только если они равны по hashCode() — переходить к сравнению по equals(). Таким образом, кстати, работают основанные на хеше структуры данных — например, известная тебе HashMap! Метод hashCode(), так же как и equals(), переопределяется самим разработчиком. И так же, как для equals(), для метода hashCode() есть официальные требования, прописанные в документации Oracle:
  1. Если два объекта равны (т.е. метод equals() возвращает true), у них должен быть одинаковый хэш-код.

    Иначе наши методы будут лишены смысла. Проверка по hashCode(), как мы и сказали, должна идти первой для повышения быстродействия. Если хэш-коды будут разными, проверка вернет false, хотя объекты на самом деле равны (согласно нашему определению в методе equals()).

  2. Если метод hashCode() вызывается несколько раз на одном и том же объекте, каждый раз он должен возвращать одно и то же число.

  3. Правило 1 не работает в обратную сторону. Одинаковый хэш-код может быть у двух разных объектов.

Третье правило немного сбивает с толку. Как такое может быть? Объяснение достаточно простое. Метод hashCode() возвращает int. int — это 32-битное число. У него есть ограниченное число значений — от -2,147,483,648 до +2,147,483,647. Иными словами, всего существует чуть больше 4 миллиардов вариантов числа int. Теперь представь, что ты создаешь программу для хранения данных обо всех живущих людях на Земле. Каждому человеку будет соответствовать свой объект класса Man. На земле живет ~7.5 миллиарда человек. Иными словами, какой бы хороший алгоритм преобразования объектов Man в число мы ни написали, нам просто не хватит чисел. У нас всего 4,5 миллиарда вариантов, а людей намного больше. Значит, как бы мы ни старались, для каких-то разных людей хэш-коды будут одинаковыми. Такая ситуация (совпадение хэш-кодов у двух разных объектов) называется коллизией. Одна из задач программиста при переопределении метода hashCode() — сократить потенциальное число коллизий насколько это возможно. Как же будет выглядеть наш метод hashCode() для класса Man с учетом всех этих правил? Вот так:

@Override
public int hashCode() {
   return dnaCode;
}
Удивлен? :) Неожиданно, но если ты посмотришь на требования, увидишь, что мы соблюдаем все. Объекты, для которых наш equals() возвращает true, будут равны и по hashCode(). Если два наших объекта Man будут равны по equals (то есть у них одинаковый dnaCode), наш метод вернет одинаковое число. Рассмотрим пример посложнее. Допустим, наша программа должна отбирать элитные автомобили для клиентов-коллекционеров. Коллекционирование — штука сложная, и в ней много особенностей. Автомобиль 1963 года выпуска может стоить в 100 раз дороже, чем такой же автомобиль 1964 года. Красный автомобиль 1970 года может стоить в 100 раз дороже, чем синий автомобиль той же марки того же года. Методы equals & hashCode: практика использования - 4В первом случае, с классом Man, мы отбросили большинство полей (т.е. характеристик человека) как незначительные и для сравнения использовали только поле dnaCode. Здесь же мы работаем с очень своеобразной сферой, и незначительных деталей быть не может! Вот наш класс LuxuryAuto:

public class LuxuryAuto {

   private String model;
   private int manufactureYear;
   private int dollarPrice;

   public LuxuryAuto(String model, int manufactureYear, int dollarPrice) {
       this.model = model;
       this.manufactureYear = manufactureYear;
       this.dollarPrice = dollarPrice;
   }

   //...геттеры, сеттеры и т.д.
}
Здесь при сравнении мы должны учитывать все поля. Любая ошибка может стоить сотен тысяч долларов для клиента, поэтому лучше перестраховаться:

@Override
public boolean equals(Object o) {
   if (this == o) return true;
   if (o == null || getClass() != o.getClass()) return false;

   LuxuryAuto that = (LuxuryAuto) o;

   if (manufactureYear != that.manufactureYear) return false;
   if (dollarPrice != that.dollarPrice) return false;
   return model.equals(that.model);
}
В нашем методе equals() мы не забыли о всех проверках, о которых говорили ранее. Но теперь мы сравниваем каждое из трех полей наших объектов. В этой программе равенство должно быть абсолютным, по каждому полю. А что же с hashCode?

@Override
public int hashCode() {
   int result = model == null ? 0 : model.hashCode();
   result = result + manufactureYear;
   result = result + dollarPrice;
   return result;
}
Поле model в нашем классе — строка. Это удобно: в классе String метод hashCode() уже переопределен. Мы вычисляем хэш-код поля model, а к нему прибавляем сумму двух остальных числовых полей. В Java есть одна небольшая хитрость, которая используется для сокращения числа коллизий: при вычислении хэш-кода умножать промежуточный результат на нечетное простое число. Чаще всего используется число 29 или 31. Мы не будем сейчас углубляться в математические тонкости, но на будущее запомни, что умножение промежуточных результатов на достаточно большое нечетное число помогает «размазать» результаты хэш-функции и получить в итоге меньшее число объектов с одинаковым хэшкодом. Для нашего метода hashCode() в LuxuryAuto это будет выглядеть вот так:

@Override
public int hashCode() {
   int result = model == null ? 0 : model.hashCode();
   result = 31 * result + manufactureYear;
   result = 31 * result + dollarPrice;
   return result;
}
Подробнее обо всех тонкостях этого механизма можно прочитать в этом посте на StackOverflow, а также у Джошуа Блоха в книге «Effective Java». Напоследок еще один важный момент, о котором стоит сказать. Каждый раз при переопределении equals() и hashCode() мы выбирали определенные поля объекта, которые в этих методах учитывались. Но можем ли мы учитывать разные поля в equals() и hashCode()? Технически, можем. Но это плохая идея, и вот почему:

@Override
public boolean equals(Object o) {
   if (this == o) return true;
   if (o == null || getClass() != o.getClass()) return false;

   LuxuryAuto that = (LuxuryAuto) o;

   if (manufactureYear != that.manufactureYear) return false;
   return dollarPrice == that.dollarPrice;
}

@Override
public int hashCode() {
   int result = model == null ? 0 : model.hashCode();
   result = 31 * result + manufactureYear;
   result = 31 * result + dollarPrice;
   return result;
}
Вот наши методы equals() и hashCode() для класса LuxuryAuto. Метод hashCode() остался без изменений, а из метода equals() мы убрали поле model. Теперь модель — не характеристика для сравнения двух объектов по equals(). Но при расчете хэш-кода она по-прежнему учитывается. Что же мы получим в результате? Давай создадим два автомобиля и проверим!

public class Main {

   public static void main(String[] args) {

       LuxuryAuto ferrariGTO = new LuxuryAuto("Ferrari 250 GTO", 1963, 70000000);
       LuxuryAuto ferrariSpider = new LuxuryAuto("Ferrari 335 S Spider Scaglietti", 1963, 70000000);

       System.out.println("Эти два объекта равны друг другу?");
       System.out.println(ferrariGTO.equals(ferrariSpider));

       System.out.println("Какие у них хэш-коды?");
       System.out.println(ferrariGTO.hashCode());
       System.out.println(ferrariSpider.hashCode());
   }
}

Эти два объекта равны друг другу?
true
Какие у них хэш-коды?
-1372326051
1668702472
Ошибка! Использовав разные поля для equals() и hashCode() мы нарушили установленный для них контракт! У двух равных по equals() объектов должен быть одинаковый хэш-код. Мы же получили для них разные значения. Подобные ошибки могут привести к самым невероятным последствиям, особенно при работе с коллекциями, использующими хэш. Поэтому при переопределении equals() и hashCode() правильно будет использовать одни и те же поля. Лекция получилось немаленькой, но сегодня ты узнал много нового! :) Самое время вернуться к решению задач!
Комментарии (207)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Silver2024 Уровень 14
29 февраля 2024
Супер! Спасибо за статью
Ислам Уровень 33
23 февраля 2024
Прекрасная статья
5 февраля 2024
Спасибо за статью, довольно понятно.
Stanislav Уровень 15
24 декабря 2023
Добрый день! Подскажите пожалуйста, почему при такой реализации java не ругается на this.year (когда я hashCode() вызываю у this.model (внутри hashCode). Я думал, что так как у this.model нет св-ва this.year, то будет ошибка ) public class Car { private String model; private int year; public Car(String model, int year) { this.model = model; this.year = year; } @Override public int hashCode() { int result = this.model == null ? 0 : this.model.hashCode(); result = 31 * result + this.year; return result; } public static void main(String[] args) { Car lamborghini = new Car("Lamborghini", 2020); Car lamborghini1 = new Car("Lamborghini", 2020); System.out.println(lamborghini.hashCode() == lamborghini1.hashCode()); } }
Рин Уровень 39
14 декабря 2023
Хочу заметить, что во всех приведенных здесь примерах не соблюден принцип неизменяемости Проще говоря, возьмем мы человека, присвоим ему днк код, положим в мапку. Изменим ему днк код. И все) Методом get() мы его из мапки уже не достанем, потому что хэшкод изменился, и по новому "адресу" этот человек не живет) Поэтому hashCode() и equals() нужно завязывать на неизменяемые переменные
Anonymous #2664456 Уровень 13
28 ноября 2023
Это шикарная статья, почему то именно это мне тяжело было понять. Благодарю. Но я по прежнему не понимаю зачем даункастить (Man ) o, попозже перечитаю еще может я что то пропустила.
Денис Черемных Уровень 26
17 октября 2023
Хотелось бы дополнить, что при переопределении метода equals, при сравнении параметров обязательна проверка на null, если параметры строковые (или иной другой объект). Без проверки может быть выброшена ошибка, если мы пытаемся сравнить null с другой строкой.
Anatoly Уровень 30
25 августа 2023
полезно
Peregrinus Umbra Уровень 16
28 апреля 2023
День добрый, уважаемые дамы и господа знатоки. Есть вопрос прикладного характера. В начале объясняется, что прежде сравнения нужно убедиться, что сравниваются объекты одного класса. Это логично и понятно. Соответствующая строка в коде дополняет сказанное. НО! Зачем тогда следующей строкой приводить проверяемый объект к классу объекта-эталона? Если мы и так знаем, что объекты одного класса, иначе до выполнения этой строки не дошло бы - метод прекращается после return, зачем ещё раз приводить их к одному классу? Выглядит дико и просто как пятое колесо. У велосипеда. @Override public boolean equals(Object o) { if (getClass() != o.getClass()) return false; Man man = (Man) o; // Вот это что?? Зачем??? return dnaCode == man.dnaCode; }