///
Search
💡

item 10. equals는 일반 규약을 지켜 재정의하라 Obey the general contract when overriding equals

equals 메서드에 관하여

equals는 모든 객체의 부모 클래스 Object의 메서드이다. 자식 클래스에서 재정의하지 않으면, 인스턴스 자기 자신과 동일한 객체를 비교하여 동일하다면 true, 그렇지 않다면 false를 반환한다.

언제 재정의 해야할까?

아래와 같은 조건들은 만족한다면 재정의하지 않는 것이 최선이다.
1.
각 인스턴스가 본질적으로 고유할 때
2.
인스턴스의 논리적 동치성(동등성 이라고도 한다)을 검사할 일이 없을 때
3.
상위 클래스에서 정의한 equals가 하위 클래스에도 들어맞을 때
4.
클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없을 때
그렇다면 재정의해야 하는 상황은 언제일까? 보통 VO라고 부르는 값 객체를 정의했을 때, 이 땐 값을 비교해(논리적 동치성) 값이 같으면 동등한 객체라고 판단하는 것이 좋다. 다른 개발자는 값이 동일한 VO를 같은 객체라고 생각할 것이기 때문이고, Set이나 Map 자료구조에서 key로 활용할 수 도 있기 때문이다.
VO라고 해도 Item 1에 따라 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스거나 enum이라면 equals를 재정의하지 않아도 된다.

equals의 명세

Object 명세엔 equals 메서드에 대한 규약이 작성되어 있다. 내용은 다음과 같다.
equals 메서드는 반드시 동치관계(equivalence relation)을 구현하며, 다음을 만족한다. 1. 반사성 (reflexivity) : null이 아닌 모든 참조 값 x에 대해 x.equals(x) 는 true이다. 2. 대칭성 (symmetry) : null이 아닌 모든 참조 값 x, y에 대해 x.equals(y)가 true이면 y.equals(x)도 true이다. 3. 추이성 (transitivity) : null이 아닌 모든 참조 값 x, y, z에 대해 x.equals(y)가 true이고 y.equals(z)도 true이면, x.equals(z)도 true이다. 4. 일관성 (consistency) : null이 아닌 모든 참조 값 x, y에 대해 x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환해야 한다. 5. null 아님 : null이 아닌 모든 참조 값 x에 대하여 x.equals(null)은 false이다.
equals를 이용하는 많은 라이브러리는 객체의 equals가 위의 규약을 따르고 있다고 가정하고 코딩되었다. 컬렉션 클래스들이 좋은 예이다. 위의 규약에 맞지 않다면 프로그램이 이상하게 동작하고 원인을 찾기도 어렵다

반사성

자기 자신과 같아야 한다는 것이므로, 지키지 못하기가 더 어렵다.

대칭성

두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻이다. 대소문자 여부를 가리지 않는 다음 클래스를 작성했다고 가정하자.
// Broken - violates symmetry! public final class CaseInsensitiveString { private final String s; public CaseInsensitiveString(String s) { this.s = Objects.requireNonNull(s); } // Broken - violates symmetry! @Override public boolean equals(Object o) { if (o instanceof CaseInsensitiveString) return s.equalsIgnoreCase(((CaseInsensitiveString) o).s); if (o instanceof String) // One-way interoperability! return s.equalsIgnoreCase((String) o); return false; } ... // Remainder omitted }
Java
복사
위 클래스는 일방향으로 흐르고, 대칭성을 가지고 있지 않다.
CaseInsensitiveString cis = new CaseInsensitiveString("Polish"); String s = "polish"; cis.equals(s) // true s.equals(cis) // false
Java
복사
이 문제를 해결하려면 String과의 대칭성도 보장하겠다는 야무진 꿈을 버려야 한다.
@Override public boolean equals(Object o) { return o instanceof CaseInsensitiveString && ((CaseInsensitiveString) o).s.equalsIgnoreCase(s); }
Java
복사

추이성

추이성은 첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체와 세 번째 객체도 같다는 뜻이다. 간단해 보이지만, 위배하기 쉽다. 만약에 자식 클래스에서 값을 추가하는 경우가 그러하다. 본문에 나오는 예제이다.
public class Point { private final int x; private final int y; public Point(int x, int y) { this.x = x; this.y = y; } @Override public boolean equals(Object o) { if (!(o instanceof Point)) return false; Point p = (Point)o; return p.x == x && p.y == y; } ... // Remainder omitted }
Java
복사
public class ColorPoint extends Point { private final Color color; public ColorPoint(int x, int y, Color color) { super(x, y); this.color = color; } ... // Remainder omitted }
Java
복사
Point 객체와, 색깔이 추가된 자식 객체
이제 Color Point에 값 비교를 하는 equals를 오버라이드 해보자.
// Broken - violates symmetry! @Override public boolean equals(Object o) { if (!(o instanceof ColorPoint)) return false; return super.equals(o) && ((ColorPoint) o).color == color; }
Java
복사
대칭성이 위배되는 equals
Point x = new Point(1,2); ColorPoint y = new ColorPoint(1,2,RED); x.equals(y) // true y.equals(x) // false
Java
복사
대칭성이 위배되는 것을 확인할 수 있다. 대칭성을 지키려면 어떻게 해야할까? ColorPoint의 equals메서드에서 Point인 경우는 Color 비교를 하지 않도록 하면 되지 않을까?
// Broken - violates transitivity! @Override public boolean equals(Object o) { if (!(o instanceof Point)) return false; // If o is a normal Point, do a color-blind comparison if (!(o instanceof ColorPoint)) return o.equals(this); // o is a ColorPoint; do a full comparison return super.equals(o) && ((ColorPoint) o).color == color; }
Java
복사
대칭성은 만족했으나 추이성은 만족하지 않는 equals
안타깝게도 이 경우 대칭성은 지켜지지만 추이성은 지켜지지 않는다.
ColorPoint x = new ColorPoint(1,2,RED); Point y = new Point(1,2); ColorPoint z = new ColorPoint(1,2,BLUE); x.equals(y) // true y.equals(z) // true x.equals(z) // false
Java
복사
난감하기 짝이 없다. getClass() 메서드를 이용해 클래스가 일치하는 객체일때만 true를 반환하도록 구현하면 추이성을 지킬 수 있지만, 그땐 리스코프 치환원칙이 깨지게 된다.
// Broken - violates Liskov substitution principle @Override public boolean equals(Object o) { if (o == null || o.getClass() != getClass()) return false; Point p = (Point) o; return p.x == x && p.y == y; }
Java
복사
ColorPoint는 Point로서도 기능할 수 있어야 하는데, getClass를 통한 비교는 ColorPoint가 Point와 한 곳에 섞이는 걸 막을 것이기 때문이다.
이때문에 하위 클래스에서 상위 클래스에 값을 추가하는 것은 어렵다. '상속 대신 컴포지션을 활용하라'(item 18)는 방법을 통해 우회하는 방안이 있다.
// Adds a value component without violating the equals contract public class ColorPoint { private final Point point; private final Color color; public ColorPoint(int x, int y, Color color) { point = new Point(x, y); this.color = Objects.requireNonNull(color); } /** * Returns the point-view of this color point. */ public Point asPoint() { return point; } @Override public boolean equals(Object o) { if (!(o instanceof ColorPoint)) return false; ColorPoint cp = (ColorPoint) o; return cp.point.equals(point) && cp.color.equals(color); } ... // Remainder omitted }
Java
복사

일관성

일관성은 두 객체가 같다면 (둘 중 어느 하나도 수정되지 않았을 때) 앞으로도 영원히 같아야 한다는 뜻이다.
가변 객체는 상태가 바뀌면 equals 결과가 달라질 수 있지만, 불변 객체인 경우 그래선 안 된다.(상태가 안 변하는 것이 불변이기 때문에)
불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해선 안 된다. java.net.URL의 equals는 주어진 URL과 매핑된 호스트의 IP 주소를 이용해 비교한다. 호스트 이름으로 IP 주소로 바꾸려면 네트워크를 통해야 하는데, 해당 결과가 항상 같다고 보장할 수 없기 때문에 종종 오류를 일으키는 원인이 된다. 이후 호환성이 발목을 잡아 영영 수정할 수 없는 내용이 되어버렸다. 이를 교훈삼아 항상 메모리에 존재하는 객체만을 사용한 계산을 수행하여야 한다.

null 아님

모든 객체가 null과 같지 않아야 한다는 의미이다. 보통 null check를 통해 할 수 도 있으나, instancof 연산자가 비교대상이 null이면 false를 반환하기 때문에 instance 연산자를 사용함으로써 묵시적 null check를 할 수 있다.
@Override public boolean equals(Object o) { if (!(o instanceof MyType)) return false; MyType mt = (MyType) o; ... }
Java
복사

equals 제대로 구현하기

위의 규약을 준수하는 equals를 작성하려면 다음 step을 따라보자.
1.
== 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다. (성능 최적화를 위해)
2.
instanceof 연산자로 입력이 올바른 타입인지 확인한다. 그렇지 않다면 false를 반환한다.
3.
입력을 올바른 타입으로 형변환 한다. (instanceof를 통해 타입체크를 했기 때문에 오류가 발생하지 않는다.)
4.
입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다.
이 때 float과 double이 아닌 기본 필드는 ==을 통해 비교하고, 참조 타입은 equals로, float과 double은 Float.compare(), Double.compare() 메서드를 이용한다.
null 값을 정상적이라고 취급하는 객체라면, NPE를 방지하기 위해 Objects.equals(a,b); 메서드를 이용하자.
equals를 구현했다면 세 가지 질문을 던지자. 대칭적인가? 추이성이 있는가? 일관적인가?

구현할 때 주의사항

equals를 재정의 할 때는 hashcode도 반드시 재정의하자.
또 너무 복잡하게 해결하려고 하지 말자. 필드들의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있다.
Object 외의 타입을 매개변수로 받는 equals메서드를 정의하지 말자. 해당 메서드는 Object.equals를 오버라이드 한 게 아니라 오버로딩 한 것에 불과하다.

용어정리

한글명
영어명
논리적 동치성
logical equality