Коротко. В статье описан приём, который позволяет переопределять
equalsтак, чтобы соблюсти его договор даже тогда, когда подклассы добавляют к конкретному классу новые поля.
В пункте 8 книги Effective Java[1] Джош Блох называет трудности с соблюдением договора equals при наследовании «фундаментальной проблемой отношений эквивалентности в объектно-ориентированных языках». Блох пишет:
Невозможно расширить инстанцируемый класс, добавив новое поле со значением, и при этом сохранить договор equals — если только вы не готовы отказаться от преимуществ объектно-ориентированной абстракции.
В 28-й главе книги Programming in Scala показан подход, при котором подкласс может расширить инстанцируемый класс, добавить новое поле и всё-таки сохранить договор equals. В книге приём описан в контексте определения классов на Scala, но он точно так же применим и к классам на Java. В этой статье мы перескажем соответствующий раздел книги, переведя код со Scala на Java.
Типичные ошибки в реализации equals
Класс java.lang.Object определяет метод equals, и подклассы могут его переопределять. Но написать корректный метод сравнения в объектно-ориентированном языке оказывается на удивление трудно. В работе 2007 года[2] исследователи изучили большой объём кода на Java и пришли к выводу, что почти все реализации equals написаны неправильно.
Это серьёзно — на сравнении завязано многое другое. Если для типа C метод equals написан с ошибкой, объект этого типа может вести себя в коллекциях странно. Допустим, у нас есть два значения elem1 и elem2 типа C, которые считаются равными, то есть elem1.equals(elem2) возвращает true. С типичной поломанной реализацией equals можно увидеть вот такое:
Set<C> hashSet = new java.util.HashSet<C>();
hashSet.add(elem1);
hashSet.contains(elem2); // returns false!
Вот четыре[3] типичных ошибки, из-за которых equals ведёт себя несогласованно:
- Сигнатура
equalsнаписана неправильно. equalsпереопределили, аhashCode— нет.equalsсравнивает изменяемые поля.equalsне задаёт отношение эквивалентности.
Ошибка №1. Неправильная сигнатура equals
Возьмём такой простой класс точек и попробуем добавить ему сравнение:
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
// ...
}
Напрашивается такое решение:
// An utterly wrong definition of equals
public boolean equals(Point other) {
return (this.getX() == other.getX() && this.getY() == other.getY());
}
Что здесь не так? На первый взгляд всё работает:
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
Point q = new Point(2, 3);
System.out.println(p1.equals(p2)); // prints true
System.out.println(p1.equals(q)); // prints false
Но стоит положить точки в коллекцию — и начинаются проблемы:
import java.util.HashSet;
HashSet<Point> coll = new HashSet<Point>();
coll.add(p1);
System.out.println(coll.contains(p2)); // prints false
Как так получилось, что coll не содержит p2, хотя p1 мы туда добавили, а p1 и p2 равны? Причина становится понятна, если замаскировать точный тип одной из сравниваемых точек. Заведём p2a как тот же p2, но с типом Object:
Object p2a = p2;
Если повторить первое сравнение, но через p2a вместо p2, получим:
System.out.println(p1.equals(p2a)); // prints false
В чём дело? Тот вариант equals, который мы написали выше, на самом деле не переопределяет стандартный метод — у него другой тип параметра. Вот сигнатура equals из корневого класса Object:
public boolean equals(Object other)
В нашем Point метод equals принимает Point, а не Object, поэтому он не переопределяет equals из Object, а становится просто перегруженным методом с тем же именем. А перегрузка в Java разрешается по статическому типу аргумента, а не по типу во время выполнения. Пока статический тип аргумента — Point, вызывается наш equals из Point. Как только статический тип становится Object, вызывается equals из Object. Этот метод мы не переопределяли, и он по-прежнему сравнивает объекты по идентичности. Поэтому p1.equals(p2a) возвращает false, хотя у точек p1 и p2a одинаковые x и y. И поэтому же contains у HashSet возвращает false: коллекция работает с обобщёнными элементами и вызывает общий equals из Object, а не перегруженный вариант из Point.
Вот вариант получше:
// A better definition, but still not perfect
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result = (this.getX() == that.getX() && this.getY() == that.getY());
}
return result;
}
Теперь у equals правильная сигнатура: он принимает Object и возвращает boolean. Внутри метода через instanceof и приведение типа сначала проверяем, что other — это Point. Если да, сравниваем координаты; если нет — возвращаем false.
Ошибка №2. equals переопределили, а hashCode — нет
Если повторить сравнение p1 и p2a с этой новой версией Point, получим true, как и ожидалось. Но если повторить опыт с HashSet.contains, скорее всего, по-прежнему будет false:
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
HashSet<Point> coll = new HashSet<Point>();
coll.add(p1);
System.out.println(coll.contains(p2)); // prints false (probably)
«Скорее всего» — потому что результат не стопроцентный, могло выйти и true. Если получилось true, попробуйте другие пары точек с теми же координатами — рано или поздно наткнётесь на ту, которой в множестве «нет». Проблема в том, что мы переопределили equals, а hashCode оставили нетронутым.
Обратите внимание: коллекция в примере — HashSet. Её элементы раскладываются по «хеш-бакетам» в зависимости от хеш-кода. Проверка contains сначала определяет, в какой бакет смотреть, и там сравнивает наш элемент со всеми, что в этом бакете лежат. В нашем Point мы переопределили equals, а hashCode — нет, поэтому hashCode всё ещё работает так, как в Object: через какое-то преобразование адреса объекта в памяти. Хеш-коды p1 и p2 почти наверняка разные, даже при одинаковых координатах. А разные хеш-коды — это, с большой вероятностью, разные бакеты. contains ищет совпадение в бакете, соответствующем хеш-коду p2, и не находит там p1, который в большинстве случаев лежит в другом бакете. Иногда p1 и p2 случайно попадают в один бакет — тогда проверка возвращает true.
В предыдущей реализации Point мы нарушили договор hashCode из Object:
Если два объекта равны по методу equals(Object), вызов hashCode у каждого из них должен возвращать одинаковое целое число.
В Java хорошо известно, что hashCode и equals нужно переопределять одновременно. И hashCode должен зависеть только от тех полей, от которых зависит equals. Для класса Point подойдёт, например, такая реализация:
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result = (this.getX() == that.getX() && this.getY() == that.getY());
}
return result;
}
@Override public int hashCode() {
return (41 * (41 + getX()) + getY());
}
}
Это одна из многих возможных реализаций hashCode. К одному из целочисленных полей x прибавляем константу 41, умножаем на простое число 41 и прибавляем второе поле y — получается разумное распределение хеш-кодов при небольших затратах по времени и объёму кода.
С hashCode проблемы сравнения для классов вроде Point уходят. Но есть и другие подводные камни.
Ошибка №3. equals по изменяемым полям
Возьмём слегка изменённый вариант Point:
public class Point {
private int x;
private int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
public void setX(int x) { // Problematic
this.x = x;
}
public void setY(int y) {
this.y = y;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result = (this.getX() == that.getX() && this.getY() == that.getY());
}
return result;
}
@Override public int hashCode() {
return (41 * (41 + getX()) + getY());
}
}
Разница только в том, что поля x и y больше не final, и появились два сеттера, которыми клиенты могут менять координаты. equals и hashCode теперь зависят от изменяемых полей, и их результаты меняются вслед за полями. Если положить точку в коллекцию, начинаются странности:
Point p = new Point(1, 2);
HashSet<Point> coll = new HashSet<Point>();
coll.add(p);
System.out.println(coll.contains(p)); // prints true
А теперь поменяем поле точки p — что вернёт коллекция?
p.setX(p.getX() + 1);
System.out.println(coll.contains(p)); // prints false (probably)
Странно: куда делась p? Дальше — ещё странней, проверим, есть ли p в итераторе множества:
Iterator<Point> it = coll.iterator();
boolean containedP = false;
while (it.hasNext()) {
Point nextP = it.next();
if (nextP.equals(p)) {
containedP = true;
break;
}
}
System.out.println(containedP); // prints true
Множество говорит, что p в нём нет, но при этом p оказывается среди его элементов. Дело в том, что после смены x точка p оказалась не в том бакете: её хеш-код изменился, а бакет, в который её положили при добавлении, остался прежним. Если выражаться по-человечески, p «выпала из поля зрения» множества, оставшись внутри него.
Мораль такая: если equals и hashCode зависят от изменяемого состояния, у пользователя такого класса будут проблемы. Положив объект в коллекцию, пользователь должен потом очень аккуратно не трогать поля, от которых зависит сравнение, — а это легко проворонить. Если вам нужно сравнение, учитывающее текущее состояние объекта, его обычно стоит назвать как-то иначе, не equals. В нашем последнем варианте Point лучше было бы не трогать hashCode и назвать метод equalContents или как-нибудь ещё — главное, не equals. Тогда Point унаследовал бы стандартные equals и hashCode из Object, а p нашлась бы в coll даже после изменения x.
Ошибка №4. equals — не отношение эквивалентности
Договор equals из Object требует, чтобы метод задавал отношение эквивалентности на объектах, отличных от null:
- Рефлексивность: для любого не-
nullзначенияxвыражениеx.equals(x)должно возвращатьtrue.- Симметричность: для любых не-
nullзначенийxиyx.equals(y)возвращаетtrueтогда и только тогда, когдаy.equals(x)возвращаетtrue.- Транзитивность: для любых не-
nullзначенийx,yиz: еслиx.equals(y)иy.equals(z)оба возвращаютtrue, то иx.equals(z)должно вернутьtrue.- Согласованность: для любых не-
nullзначенийxиyпоследовательные вызовыx.equals(y)должны возвращать один и тот же результат, пока поля, от которых зависит сравнение, не изменяются.- Для любого не-
nullзначенияxx.equals(null)должно возвращатьfalse.
Тот equals, который мы написали для Point, всем этим требованиям удовлетворяет. Но как только в дело вступают подклассы, всё усложняется. Допустим, у Point есть подкласс ColoredPoint, в котором добавлено поле color типа Color. Пусть Color — это enum:
public enum Color {
RED, ORANGE, YELLOW, GREEN, BLUE, INDIGO, VIOLET;
}
ColoredPoint переопределяет equals, чтобы учесть новое поле:
public class ColoredPoint extends Point { // Problem: equals not symmetric
private final Color color;
public ColoredPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof ColoredPoint) {
ColoredPoint that = (ColoredPoint) other;
result = (this.color.equals(that.color) && super.equals(that));
}
return result;
}
}
Большинство программистов написали бы примерно это. Заметим, что hashCode переопределять не пришлось: новый equals строже того, что был в Point (он считает равными меньше пар), поэтому договор hashCode не нарушен. У двух равных цветных точек обязательно совпадают координаты, а значит, и хеш-коды совпадут.
Сам по себе ColoredPoint выглядит нормально. Но договор equals ломается, как только цветные точки смешиваются с обычными:
Point p = new Point(1, 2);
ColoredPoint cp = new ColoredPoint(1, 2, Color.RED);
System.out.println(p.equals(cp)); // prints true
System.out.println(cp.equals(p)); // prints false
Сравнение p.equals(cp) вызывает equals из Point — там учитываются только координаты, поэтому возвращается true. Сравнение cp.equals(p) вызывает equals из ColoredPoint, который возвращает false, потому что p — не ColoredPoint. Симметричность нарушена.
В коллекциях это даёт неожиданные эффекты:
Set<Point> hashSet1 = new java.util.HashSet<Point>();
hashSet1.add(p);
System.out.println(hashSet1.contains(cp)); // prints false
Set<Point> hashSet2 = new java.util.HashSet<Point>();
hashSet2.add(cp);
System.out.println(hashSet2.contains(p)); // prints true
p и cp равны, но одна проверка contains срабатывает, а другая — нет.
Как починить equals, чтобы он стал симметричным? Есть два пути: либо сделать отношение более мягким, либо более строгим. Мягкий вариант: считать a и b равными, если хотя бы одно из сравнений a.equals(b) или b.equals(a) даёт true:
public class ColoredPoint extends Point { // Problem: equals not transitive
private final Color color;
public ColoredPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof ColoredPoint) {
ColoredPoint that = (ColoredPoint) other;
result = (this.color.equals(that.color) && super.equals(that));
}
else if (other instanceof Point) {
Point that = (Point) other;
result = that.equals(this);
}
return result;
}
}
Новый equals в ColoredPoint обрабатывает на одну ситуацию больше: если other — это Point, но не ColoredPoint, то метод делегирует сравнение в equals из Point. Это и даёт симметричность: теперь cp.equals(p) и p.equals(cp) оба возвращают true. Но договор equals всё равно нарушен — теперь сломалась транзитивность. Покажем это. Заведём точку и две цветные точки разных цветов с одинаковыми координатами:
ColoredPoint redP = new ColoredPoint(1, 2, Color.RED);
ColoredPoint blueP = new ColoredPoint(1, 2, Color.BLUE);
По отдельности redP равна p, а p равна blueP:
System.out.println(redP.equals(p)); // prints true
System.out.println(p.equals(blueP)); // prints true
Но сравнение redP и blueP даёт false:
System.out.println(redP.equals(blueP)); // prints false
Транзитивность нарушена.
Сделать отношение мягче не выходит — попробуем сделать его строже. Способ — всегда считать объекты разных классов неравными. Для этого изменим equals и в Point, и в ColoredPoint. В Point добавим проверку, что у второго объекта тот же класс времени выполнения:
// A technically valid, but unsatisfying, equals method
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result = (this.getX() == that.getX() && this.getY() == that.getY()
&& this.getClass().equals(that.getClass()));
}
return result;
}
@Override public int hashCode() {
return (41 * (41 + getX()) + getY());
}
}
Тогда можно вернуть в ColoredPoint ту самую реализацию, которая раньше ломала симметричность[4]:
public class ColoredPoint extends Point { // No longer violates symmetry requirement
private final Color color;
public ColoredPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof ColoredPoint) {
ColoredPoint that = (ColoredPoint) other;
result = (this.color.equals(that.color) && super.equals(that));
}
return result;
}
}
Теперь объекты класса Point считаются равными, только если у них совпадают координаты и они принадлежат одному и тому же классу времени выполнения, то есть getClass() у обоих возвращает одно и то же значение. Симметричность и транзитивность соблюдаются, потому что любое сравнение объектов разных классов теперь даёт false. Цветная точка никогда не будет равна обычной. Договорённость на первый взгляд разумная, но можно возразить, что она слишком строгая.
Заведём точку с координатами (1, 2) чуть кружным путём:
Point pAnon = new Point(1, 1) {
@Override public int getY() {
return 2;
}
};
Равна ли pAnon точке p? Нет, потому что java.lang.Class у них разные: у p это Point, а у pAnon — анонимный подкласс Point. Но pAnon — это просто ещё одна точка с координатами (1, 2). Считать её неравной p — натяжка.
Метод canEqual
Похоже, мы в тупике. Есть ли разумный способ переопределять сравнение на нескольких уровнях иерархии классов, не нарушая договора? Способ есть, но нужен ещё один метод помимо equals и hashCode. Идея в том, что класс, переопределяющий equals (и hashCode), должен ещё и явно сказать: его объекты не равны объектам какого-либо суперкласса, в котором сравнение устроено иначе. Для этого в каждый класс, переопределяющий equals, добавляется метод canEqual. Его сигнатура такая:
public boolean canEqual(Object other)
Метод возвращает true, если other — экземпляр того класса, где canEqual (пере)определён, и false иначе. Он вызывается из equals, чтобы убедиться, что объекты сравнимы в обе стороны. Вот итоговый вариант Point:
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result = (that.canEqual(this) && this.getX() == that.getX() && this.getY() == that.getY());
}
return result;
}
@Override public int hashCode() {
return (41 * (41 + getX()) + getY());
}
public boolean canEqual(Object other) {
return (other instanceof Point);
}
}
В новом equals появляется дополнительное требование: второй объект должен соглашаться быть равным первому — это и проверяется через canEqual. Реализация canEqual в Point говорит, что все экземпляры Point могут участвовать в сравнении на равенство.
Соответствующий ColoredPoint выглядит так:
public class ColoredPoint extends Point { // No longer violates symmetry requirement
private final Color color;
public ColoredPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof ColoredPoint) {
ColoredPoint that = (ColoredPoint) other;
result = (that.canEqual(this) && this.color.equals(that.color) && super.equals(that));
}
return result;
}
@Override public int hashCode() {
return (41 * super.hashCode() + color.hashCode());
}
@Override public boolean canEqual(Object other) {
return (other instanceof ColoredPoint);
}
}
Можно показать, что новые Point и ColoredPoint соблюдают договор equals. Сравнение симметрично и транзитивно. Point и ColoredPoint всегда сравниваются как неравные. Для любых p и cp вызов p.equals(cp) вернёт false, потому что cp.canEqual(p) вернёт false. Обратный вызов cp.equals(p) тоже вернёт false: p не является ColoredPoint, и первая же проверка instanceof внутри equals в ColoredPoint не сработает.
При этом экземпляры разных подклассов Point всё-таки могут оказаться равными — пока ни один из этих подклассов не переопределяет сравнение. С новыми классами p и pAnon будут равны. Примеры:
Point p = new Point(1, 2);
ColoredPoint cp = new ColoredPoint(1, 2, Color.INDIGO);
Point pAnon = new Point(1, 1) {
@Override public int getY() {
return 2;
}
};
Set<Point> coll = new java.util.HashSet<Point>();
coll.add(p);
System.out.println(coll.contains(p)); // prints true
System.out.println(coll.contains(cp)); // prints false
System.out.println(coll.contains(pAnon)); // prints true
Если equals в суперклассе вызывает canEqual, то автор подкласса сам решает, могут ли его объекты быть равны объектам суперкласса. ColoredPoint переопределяет canEqual, поэтому цветная точка никогда не равна обычной. А анонимный подкласс, на который ссылается pAnon, canEqual не переопределяет — поэтому его экземпляр может быть равен экземпляру Point.
Подход с canEqual иногда критикуют — мол, он нарушает принцип подстановки Лисков (LSP). Например, приём, в котором equals сравнивает классы времени выполнения и из-за этого нельзя сделать подкласс, экземпляры которого были бы равны экземплярам суперкласса, иногда называют нарушением LSP[5]. Аргументируют так: LSP требует, чтобы подкласс можно было подставить вместо суперкласса. А у нас coll.contains(cp) возвращает false, хотя у cp координаты совпадают с координатами точки в коллекции — значит, ColoredPoint нельзя подставить туда, где ждут Point. Мы считаем, что это неверное прочтение LSP: подкласс не обязан вести себя в точности как суперкласс, он обязан соблюдать договор суперкласса.
Проблема с equals, который сравнивает классы времени выполнения, не в нарушении LSP, а в другом: такой подход не даёт способа создать подкласс, экземпляры которого могут быть равны экземплярам суперкласса. Если бы мы в том варианте использовали такой equals, то coll.contains(pAnon) тоже вернул бы false, а это уже не то, чего мы хотим. А вот то, что coll.contains(cp) возвращает false, нам как раз и нужно: переопределяя equals в ColoredPoint, мы тем самым говорим, что точка цвета индиго в координатах (1, 2) — это не то же самое, что бесцветная точка в (1, 2). В примере мы передали в contains два разных подкласса Point и получили два разных, но правильных ответа.
Примечания
Об авторах
Мартин Одерски — создатель языка Scala. Профессор EPFL в Лозанне (Швейцария); занимается языками программирования, в первую очередь языками для объектно-ориентированного и функционального программирования. Его рабочий тезис — что эти две парадигмы суть две стороны одной медали и должны быть сближены настолько, насколько это возможно. Чтобы это показать, Одерски экспериментировал с целой серией языков — от Pizza и GJ до Functional Nets. Кроме того, он повлиял на развитие Java: был соавтором дженериков и автором текущего эталонного компилятора javac. С 2001 года занимается проектированием, реализацией и развитием языка Scala.
Лекс Спун — инженер-программист в Google. До этого два года занимался Scala в EPFL в качестве пост-дока. Защитил PhD по компьютерным наукам в Georgia Tech, где работал над статическим анализом динамических языков. Помимо Scala, успел поработать с самыми разными языками — от динамического Smalltalk до научного X10. Живёт в Атланте с женой, двумя кошками, чихуахуа и черепахой.
Билл Веннерс — президент компании Artima, издающей сайт Artima Developer (www.artima.com). Автор книги Inside the Java Virtual Machine — обзора архитектуры и внутреннего устройства платформы Java, ориентированного на программистов. Его колонки в журнале JavaWorld были посвящены устройству Java, объектно-ориентированному проектированию и Jini. С момента появления Jini Community Веннерс участвует в её работе; он руководил проектом ServiceUI, чей API стал де-факто стандартом для привязки пользовательских интерфейсов к Jini-сервисам. Также — ведущий разработчик и проектировщик ScalaTest, открытого инструмента тестирования для Scala и Java.
Примечания
- Bloch, Joshua. Effective Java Second Edition. Addison-Wesley, 2008. ↩
- Vaziri, Mandana, Frank Tip, Stephen Fink, and Julian Dolby. «Declarative Object Identity Using Relation Types». In Proc. ECOOP 2007, pages 54–78. 2007. ↩
- Все, кроме третьего, описаны у Джоша Блоха в Effective Java Second Edition (Addison-Wesley, 2008). ↩
- С новой реализацией
equalsвPointэтот вариантColoredPointуже не нарушает симметричность. ↩ - Bloch, Joshua. Effective Java Second Edition. Addison-Wesley, 2008, стр. 39. ↩