JavaRush /Java блог /Random UA /Методи equals & hashCode: практика використання

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

Стаття з групи Random UA
Вітання! Сьогодні ми поговоримо про два важливі методи 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;
}
Ми проводимо всі початкові перевірки, про які сказали вище. Якщо в результаті виявилося, що:
  • ми порівнюємо два об'єкти одного класу
  • це не один і той самий об'єкт
  • ми порівнюємо наш об'єкт не з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)контракт методів , прописаний у документації 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()правильно буде використовувати одні й самі поля. Лекція вийшла чималенькою, але сьогодні ти дізнався багато нового! :) Саме час повернутися до вирішення завдань!
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ