equals
и hashCode
тесно связаны друг с другом, и что оба этих метода желательно переопределять в своих классах согласованно. Чуть меньшее количество знают, почему это так и какие печальные последствия могут быть, если нарушить данное правило. Предлагаю рассмотреть концепцию этих методов, повторить их назначение и разобраться, почему они так связаны.
Эту статью, как и предыдущую про загрузку классов, я писал для себя, чтобы окончательно раскрыть все детали вопроса и больше не возвращаться к сторонним источникам. Поэтому буду рад конструктивной критике, т. к. если где-то есть пробелы, их следует устранить. Статья, увы, получилась достаточно объемная.
Правила переопределения equals
Методequals()
необходим в Java для подтверждения или отрицания того факта, что два объекта одного происхождения являются логически равными. То есть, сравнивая два объекта, программисту необходимо понять, эквивалентны ли их значимые поля. Не обязательно все поля должны быть идентичны, так как метод equals()
подразумевает именно логическое равенство.
Но иногда нет особой необходимости в использовании этого метода. Как говорится, самый легкий путь избежать проблем, используя тот или иной механизм — не использовать его. Также следует заметить, что однажды нарушив контракт equals
вы теряете контроль над пониманием того, как другие объекты и структуры будут взаимодействовать с вашим объектом. И впоследствии найти причину ошибки будет весьма затруднительно.
Когда не стоит переопределять этот метод
- Когда каждый экземпляр класса является уникальным. В большей степени это касается тех классов, которые предоставляют определенное поведение, нежели предназначены для работы с данными. Таких, например, как класс
- Когда на самом деле от класса не требуется определять эквивалентность его экземпляров. Например для класса
- Когда класс, который вы расширяете, уже имеет свою реализацию метода
equals
и поведение этой реализации вас устраивает.
Например, для классов - И, наконец, нет необходимости перекрывать
equals
, когда область видимости вашего класса являетсяprivate
илиpackage-private
и вы уверены, что этот метод никогда не будет вызван.
Thread
. Для них реализации метода equals
, предоставляемого классом Object
, более чем достаточно. Другой пример — классы перечислений (Enum
).
java.util.Random
вообще нет необходимости сравнивать между собой экземпляры класса, определяя, могут ли они вернуть одинаковую последовательность случайных чисел. Просто потому, что природа этого класса даже не подразумевает такое поведение.
Set
, List
, Map
реализация equals
находится в AbstractSet
, AbstractList
и AbstractMap
соответственно.
Контракт equals
При переопределении методаequals
разработчик должен придерживаться основных правил, определенных в спецификации языка Java.
- Рефлексивность для любого заданного значения
- Симметричность для любых заданных значений
- Транзитивность для любых заданных значений
- Согласованность для любых заданных значений
- Сравнение null для любого заданного значения
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)
будет возвращать значение предыдущего вызова этого метода при условии, что поля, используемые для сравнения этих двух объектов, не изменялись между вызовами.
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
- Проверить на равенство ссылки объектов
this
и параметра методаo
.
if (this == o) return true;
- Проверить, определена ли ссылка
o
, т. е. является ли онаnull
.
Если в дальнейшем при сравнении типов объектов будет использоваться операторinstanceof
, этот пункт можно пропустить, т. к. этот параметр возвращаетfalse
в данном случаеnull instanceof Object
. - Сравнить типы объектов
this
иo
с помощью оператораinstanceof
или методаgetClass()
, руководствуясь описанием выше и собственным чутьем. - Если метод
equals
переопределяется в подклассе, не забудьте сделать вызовsuper.equals(o)
- Выполнить преобразование типа параметра
o
к требуемому классу. - Выполнить сравнение всех значимых полей объектов:
- для примитивных типов (кроме
float
иdouble
), используя оператор==
- для ссылочных полей необходимо вызвать их метод
equals
- для массивов можно воспользоваться перебором по циклу, либо методом
Arrays.equals()
- для типов
float
иdouble
необходимо использовать методы сравнения соответствующих оберточных классовFloat.compare()
иDouble.compare()
- для примитивных типов (кроме
- И, наконец, ответить на три вопроса: является ли реализованный метод симметричным? Транзитивным? Согласованным? Два других принципа (рефлексивность и определенность), как правило, выполняются автоматически.
Правила переопределения hashCode
Хэш — это некоторое число, генерируемое на основе объекта и описывающее его состояние в какой-то момент времени. Это число используется в Java преимущественно в хэш-таблицах, таких какHashMap
. При этом хэш-функция получения числа на основе объекта должна быть реализована таким образом, чтобы обеспечить относительно равномерное распределение элементов по хэш-таблице. А также минимизировать вероятность появления коллизий, когда по разным ключам функция вернет одинаковое значение.
Контракт hashCode
Для реализации хэш-функции в спецификации языка определены следующие правила:- вызов метода
hashCode
один и более раз над одним и тем же объектом должен возвращать одно и то же хэш-значение, при условии что поля объекта, участвующие в вычислении значения, не изменялись. - вызов метода
hashCode
над двумя объектами должен всегда возвращать одно и то же число, если эти объекты равны (вызов методаequals
для этих объектов возвращаетtrue
). - вызов метода
hashCode
над двумя неравными между собой объектами должен возвращать разные хэш-значения. Хотя это требование и не является обязательным, следует учитывать, что его выполнение положительно повлияет на производительность работы хэш-таблиц.
Методы equals и hashCode необходимо переопределять вместе
Исходя из описанных выше контрактов следует, что переопределяя в своем коде методequals
, необходимо всегда переопределять и метод hashCode
. Так как фактически два экземпляра класса отличаются, потому что находятся в разных областях памяти, сравнивать их приходится по некоторым логическим признакам. Соответственно, два логически эквивалентных объекта, должны возвращать одинаковое значение хэш-функции.
Что произойдет, если будет переопределен только один из этих методов?
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));
Очевидно, что помещаемый и искомый объект — это два разных объекта, хотя они и являются логически равными. Но, т.к. они имеют разное хэш-значение, потому что мы нарушили контракт, можно сказать, что мы потеряли свой объект где-то в недрах хэш-таблицы.
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
. Т. е. мы видим, что логическое сравнение двух объектов не должно противоречить нигде в приложении и всегда быть согласованным.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ