Переважна більшість програмістів на Java, звичайно ж, знають, що методи equals і hashCode тісно пов'язані між собою, і що обидва ці методи бажано перевизначати у своїх класах узгоджено. Трохи менша кількість знає, чому це так і які сумні наслідки можуть бути, якщо порушити це правило. Пропоную розглянути концепцію цих методів, повторити їх призначення і розібратися, чому вони так пов'язані. Цю статтю, як і попередню про завантаження класів, я писав для себе, щоб остаточно розкрити всі деталі питання і більше не повертатися до сторонніх джерел. Тому буду радий конструктивній критиці, бо якщо десь є прогалини, їх слід усунути. Стаття, на жаль, вийшла досить об'ємною.

Правила перевизначення equals

Метод equals() необхідний у Java для підтвердження чи заперечення того факту, що два об'єкти одного походження є логічно рівними. Тобто, порівнюючи два об'єкти, програмісту необхідно зрозуміти, еквівалентні чи їхні значущі поля. Не обов'язково всі поля повинні бути ідентичними, оскільки метод equals() передбачає саме логічну рівність. Але іноді немає особливої потреби в використанні цього методу. Як кажуть, найпростіший спосіб уникнути проблем, використовуючи той чи інший механізм — не використовувати його. Також слід зазначити, що одного разу порушивши контракт equals, ви втрачаєте контроль над розумінням того, як інші об'єкти і структури будуть взаємодіяти з вашим об'єктом. І надалі знайти причину помилки буде досить складно.

Коли не варто перевизначати цей метод

  • Коли кожен екземпляр класу є унікальним.
  • У більшій мірі це стосується тих класів, які надають певну поведінку, а не призначені для роботи з даними. Наприклад, клас Thread. Для них реалізація методу equals, надана класом Object, більш ніж достатня. Інший приклад — класи перерахувань (Enum).
  • Коли насправді від класу не потрібно визначати еквівалентність його екземплярів.
  • Наприклад, для класу java.util.Random взагалі немає необхідності порівнювати між собою екземпляри класу, визначаючи, чи можуть вони повернути однакову послідовність випадкових чисел. Просто тому, що природа цього класу навіть не передбачає таку поведінку.
  • Коли клас, який ви розширюєте, вже має свою реалізацію методу equals і поведінка цієї реалізації вас влаштовує.
  • Наприклад, для класів Set, List, Map реалізація equals знаходиться в AbstractSet, AbstractList і AbstractMap відповідно.
  • І, нарешті, немає необхідності перекривати equals, коли область видимості вашого класу є private або package-private і ви впевнені, що цей метод ніколи не буде викликаний.

Контракт equals

При перевизначенні методу equals розробник повинен дотримуватися основних правил, визначених у специфікації мови Java.
  • Рефлексивність
  • для будь-якого заданого значення x, вираз x.equals(x) повинен повертати true.
    Заданого — мається на увазі такого, що x != null
  • Симетричність
  • для будь-яких заданих значень 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) буде повертати значення попереднього виклику цього методу за умови, що поля, використовувані для порівняння цих двох об'єктів, не змінювалися між викликами.
  • Порівняння null
  • для будь-якого заданого значення x виклик x.equals(null) повинен повертати false.

Порушення контракту equals

Багато класів, наприклад класи з Java Collections Framework, залежать від реалізації методу equals(), тому не варто нехтувати ним, бо порушення контракту цього методу може призвести до нераціональної роботи застосунку, і в такому разі знайти причину буде досить важко. Згідно з принципом рефлексивності, кожен об'єкт має бути еквівалентним самому собі. Якщо цей принцип буде порушений, при додаванні об'єкта в колекцію і при подальшому пошуку його за допомогою методу contains() ми не зможемо знайти той об'єкт, який щойно поклали в колекцію. Умова симетричності каже, що два будь-яких об'єкти мають бути рівними незалежно від того, в якому порядку вони будуть порівнюватися. Наприклад, маючи клас, що містить лише одне поле рядкового типу, буде неправильно порівнювати у методі equals це поле зі строкою. Бо у випадку зворотного порівняння метод завжди поверне значення false.

// Порушення симетричності
public class SomeStringify {
    private String s;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o instanceof SomeStringify) {
            return s.equals(((SomeStringify) o).s);
        }
        // порушення симетричності, класи різного походження
        if (o instanceof String) {
            return s.equals(o);
        }
        return false;
    }
}

//Правильне визначення методу equals
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    return o instanceof SomeStringify &&
            ((SomeStringify) o).s.equals(s);
}
З умови транзитивності виходить, що якщо будь-які два з трьох об'єктів рівні, то у такому разі мають бути рівними всі три. Цей принцип легко порушити у тому випадку, коли необхідно розширити базовий клас, додавши до нього значущий компонент. Наприклад, до класу Point з координатами x та y необхідно додати колір точки, розширивши його. Для цього потрібно оголосити клас ColorPoint з відповідним полем color. Таким чином, якщо у розширеному класі викликати метод equals батька, а в батьківському будемо вважати, що порівнюються лише координати x та y, тоді дві точки різного кольору, але з однаковими координатами будуть вважатися рівними, що неправильно. У такому разі потрібно навчити похідний клас відрізняти кольори. Для цього можна скористатися двома способами. Але один буде порушувати правило симетричності, а другий — транзитивності.

// Перший спосіб, порушуючи симетричність
// Метод перевизначений у класі ColorPoint
@Override
public boolean equals(Object o) {
    if (!(o instanceof ColorPoint)) return false;
    return super.equals(o) && ((ColorPoint) o).color == color;
}
У цьому випадку виклик point.equals(colorPoint) поверне значення true, а порівняння colorPoint.equals(point)false, бо очікує об'єкт "свого" класу. Таким чином і порушується правило симетричності. Другий спосіб має на увазі робити "сліпу" перевірку, якщо немає даних про колір точки, тобто маємо клас Point. Або ж перевіряти колір, якщо інформація про нього доступна, тобто порівнювати об'єкт класу ColorPoint.

// Метод перевизначений у класі ColorPoint
@Override
public boolean equals(Object o) {
    if (!(o instanceof Point)) return false;

    // Сліпа перевірка
    if (!(o instanceof ColorPoint))
        return super.equals(o);

    // Повна перевірка, включаючи колір точки
    return super.equals(o) && ((ColorPoint) o).color == color;
}
Принцип транзитивності тут порушується наступним чином. Припустимо, є визначення наступних об'єктів:

ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
Таким чином, хоч і виконується рівність p1.equals(p2) і p2.equals(p3), p1.equals(p3) поверне значення false. При цьому другий спосіб, на мій погляд, виглядає менш привабливим, оскільки в деяких випадках алгоритм може осліпнути й не виконати порівняння повністю, а ви про це можете й не дізнатися. Трохи лірики Взагалі-то конкретного рішення цієї проблеми, як я зрозумів, немає. Є думка одного авторитетного автора на ім'я Кей Хорстманн, що можна замінити використання оператора instanceof на виклик методу getClass(), який повертає клас об'єкта, і, перш ніж почати порівнювати самі об'єкти, переконатися, що вони одного типу, а на факт їх загального походження не звертати уваги. Таким чином, правила симетричності і транзитивності будуть виконані. Але при цьому на іншій стороні барикад стоїть ще один не менш шанований у широких колах автор Джошуа Блох, який вважає, що такий підхід порушує принцип підстановки Барбари Лісков. Цей принцип свідчить, що “код, що викликає, повинен працювати з базовим класом точно так само, як і з його підкласами, не знаючи про це”. І в рішенні, запропонованому Хорстманном, цей принцип явно порушується, бо залежить від реалізації. Коротше, справа ясна, що справа темна. Слід також зазначити, що Хорстманн уточнює правило застосування свого підходу й англійською по білому пише, що потрібно визначитися зі стратегією під час проєктування класів, і якщо перевірка на рівність проводитиметься тільки силами суперкласу, можна це робити, виконуючи операцію instanceof. Інакше, коли семантика перевірки змінюється залежно від похідного класу, і реалізацію методу потрібно спустити вниз по ієрархії, необхідно використовувати метод getClass(). Джошуа Блох, своєю чергою, пропонує відмовитися від наслідування і скористатися композицією об'єктів, включивши до складу класу ColorPoint клас Point і надавши метод доступу asPoint() для отримання інформації конкретно про точку. Це дозволить уникнути порушення всіх правил, але, як на мене, ускладнить розуміння коду. Третій варіант — скористатися автоматичною генерацією методу equals за допомогою IDE. Idea, до речі, відтворює генерацію за Хорстманном, причому дозволяючи обрати стратегію реалізації методу в суперкласі або в його спадкоємцях. І, нарешті, наступне правило узгодженості свідчить, що якщо об'єкти x і y не змінюються, повторний виклик x.equals(y) повинен повернути те саме значення, що й раніше. Останнє правило полягає в тому, що жоден об'єкт не повинен бути дорівнювати null. Тут усе зрозуміло, null — це невизначеність, чи рівний об'єкт невизначеності? Н незрозуміло, тобто false.

Загальний алгоритм визначення equals

  1. Перевірити на рівність посилання об'єктів this і параметра методу o.
    if (this == o) return true;
  2. Перевірити, чи визначено посилання o, тобто чи є воно null.
    Якщо надалі під час порівняння типів об'єктів буде використовуватися оператор instanceof, цей пункт можна пропустити, оскільки цей параметр повертає false в цьому випадку null instanceof Object.
  3. Порівняти типи об'єктів this і o за допомогою оператора instanceof або методу getClass(), керуючись описом вище і власним чуттям.
  4. Якщо метод equals перевизначається в підкласі, не забудьте зробити виклик super.equals(o)
  5. Виконати перетворення типу параметра o до потрібного класу.
  6. Виконати порівняння всіх значущих полів об'єктів:
    • для примітивних типів (крім float і double), використовуючи оператор ==
    • для референсних полів необхідно викликати їхній метод equals
    • для масивів можна скористатися перебором у циклі або методом Arrays.equals()
    • для типів float і double необхідно використовувати методи порівняння відповідних обгорткових класів Float.compare() і Double.compare()
  7. І, нарешті, відповісти на три питання: чи є реалізований метод симетричним? Транзитивним? Узгодженим? Два інших принципи (рефлексивність і визначеність), як правило, виконуються автоматично.

Правила перевизначення hashCode

Хеш — це деяке число, згенероване на основі об'єкта й описує його стан у якийсь момент часу. Це число використовується в Java переважно в хеш-таблицях, таких як HashMap. При цьому хеш-функція отримання числа на основі об'єкта має бути реалізована таким чином, щоб забезпечити відносно рівномірний розподіл елементів по хеш-таблиці. А також мінімізувати ймовірність появи колізій, коли за різними ключами функція поверне однакове значення.

Контракт hashCode

Для реалізації хеш-функції в специфікації мови визначені наступні правила:
  • виклик методу hashCode один і більше разів над одним і тим самим об'єктом має повертати одне й те саме хеш-значення, за умови що поля об'єкта, які беруть участь у обчисленні значення, не змінювалися.
  • виклик методу hashCode над двома об'єктами має завжди повертати одне й те саме число, якщо ці об'єкти рівні (виклик методу equals для цих об'єктів повертає true).
  • виклик методу hashCode над двома об'єктами, які не рівні між собою, має повертати різні хеш-значення. Хоча ця вимога і не є обов'язковою, слід враховувати, що її виконання позитивно вплине на продуктивність роботи хеш-таблиць.

Методи equals і hashCode необхідно перевизначати разом

Виходячи з описаних вище контрактів, слід, що перевизначаючи у своєму коді метод equals, необхідно завжди перевизначати і метод hashCode. Оскільки фактично два екземпляри класу відрізняються, бо знаходяться в різних областях пам'яті, порівнювати їх доводиться за деякими логічними ознаками. Відповідно, два логічно еквівалентних об'єкти мають повертати однакове значення хеш-функції. Що станеться, якщо буде перевизначено лише один з цих методів?
  1. equals є, hashCode немає

    Припустимо ми правильно визначили метод equals у нашому класі, а метод hashCode вирішили залишити як він є у класі Object. Тоді з точки зору методу equals два об'єкти будуть логічно рівними, у той час як з точки зору методу hashCode вони не матимуть нічого спільного. І, таким чином, розміщуючи якийсь об'єкт в хеш-таблицю, ми ризикуємо не отримати його назад за ключем.
    Наприклад, так:

    
    Map<Point, String> m = new HashMap<>();
    m.put(new Point(1, 1), "Point A");
    // pointName == null
    String pointName = m.get(new Point(1, 1));
    

    Очевидно, що розміщений і шуканий об'єкт — це два різні об'єкти, хоча вони й є логічно рівними. Але, оскільки вони мають різне хеш-значення, тому що ми порушили контракт, можна сказати, що ми втратили свій об'єкт десь у глибинах хеш-таблиці.

  2. hashCode є, equals немає.

    Що буде якщо ми перевизначимо метод hashCode, а реалізацію методу equals успадкуємо з класу Object. Як відомо метод equals за замовчуванням просто порівнює вказівники на об'єкти, визначаючи, чи посилаються вони на один і той самий об'єкт. Припустимо, що метод hashCode ми написали за всіма канонами, а саме — згенерували засобами IDE, і він буде повертати однакові хеш-значення для логічно однакових об'єктів. Очевидно, що тим самим ми вже визначили певний механізм порівняння двох об'єктів.

    Відповідно, приклад з попереднього пункту по ідеї має виконуватися. Але ми, як і раніше, не зможемо знайти наш об'єкт у хеш-таблиці. Хоча будемо вже близько до цього, тому що як мінімум знайдемо корзину хеш-таблиці, у якій об'єкт буде лежати.

    Для успішного пошуку об'єкта у хеш-таблиці, окрім порівняння хеш-значень ключа, використовується також визначення логічної рівності ключа із шуканим об'єктом. Тобто без перевизначення методу equals ніяк не обійтися.

Загальний алгоритм визначення hashCode

Тут, мені здається, взагалі не варто сильно переживати і виконати генерацію методу у своїй улюбленій IDE. Тому що всі ці зміщення бітів вправо, вліво у пошуках золотого перетину, тобто нормального розподілу — це для зовсім упоротих чуваків. Особисто я сумніваюся, що зможу зробити краще і швидше, ніж та ж Idea.

Замість висновку

Таким чином, ми бачимо, що методи equals і hashCode грають чітко визначену роль у мові Java і призначені для отримання характеристики логічної рівності двох об'єктів. У випадку з методом equals це має пряме відношення до порівняння об'єктів, у випадку з hashCode опосередковане, коли необхідно, скажімо так, визначити приблизне розташування об'єкта у хеш-таблицях або подібних структурах даних з метою збільшення швидкості пошуку об'єкта. Окрім контрактів equals і hashCode існує ще одна вимога, що стосується порівняння об'єктів. Це узгодженість методу compareTo інтерфейсу Comparable з методом equals. Ця вимога зобов'язує розробника завжди повертати x.equals(y) == true, коли x.compareTo(y) == 0. Тобто ми бачимо, що логічне порівняння двох об'єктів не повинно суперечити ніде в застосунку і завжди бути узгодженим.

Джерела

Effective Java, Second Edition. Joshua Bloch. Вільний переклад дуже непоганої книги. Java, бібліотека професіонала. Том 1. Основи. Кей Хорстманн. Трохи менше теорії і більше практики. Але не так детально розібрано все, як у Блоха. Хоча є свій погляд на той самий equals(). Структури даних у картинках. HashMap Надзвичайно корисна стаття щодо пристрою HashMap у Java. Замість того, щоб дивитися вихідний код.