Привет! Сегодня мы поговорим о двух важных методах в 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() правильно будет использовать одни и те же поля. Лекция получилось немаленькой, но сегодня ты узнал много нового! :) Самое время вернуться к решению задач!