Преобладающее большинство программирующих на 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. Вместо того, чтобы исходники смотреть.