08. equals를 재정의할 때는 일반 규약을 따르라.
equals 메서드는 재정의하기 쉬워 보이지만 실수할 여지도 많고, 그 결과는 끔찍하다. 그런 문제를 피하는 가장 간단한 방법은 equals메서드를 재정의 하지 않는 것인데, 그러면 그 객체는 오직 자신하고만 같다.
아래의 경우의 조건을 만족하면, equals 메서드를 재정의 하지 않아도 된다.
- 각각의 객체가 고유한 경우 : 값(value)대신 활성 객체(active entity)를 나타내는 Thread 같은 클래스(값에 대한 처리를 담당하는 객체)가 이조건에 부합한다.
- 클래스에 “논리적 동일성(logical equality)” 검사 방법이 있건 없건 상관 없는 경우 : 예를 들어, java.util.Random클래스는 두 Random객체가 같은 난수열(sequence of random numbers)을 만드는지 검사하는 equals메서드를 재정의할 수도 있었지만, 이 클래스를 설계한 사람들은 클라이언트가 그런 기능을 원할 거라 생각지 않았다. 이런 경우 Object에서 계승한 equals만으로 충분하다.
- 상위 클래스에서 재정의한 equals가 하위 클래스에서 사용하기에도 적당한 경우 : 예를 들어, 대부분의 Set, List, Map등의 클래스는 AbstractSet, AbstractList, AbstractMap의 equals메서드를 그대로 사용한다.
- 논리적으로 최대 하나의 객체만 존재하도록 제한된 클래스(예를 들어 싱글톤이 적용된 클래스)나 열거 자료형(enum)을 사용한 경우 : 이런 객체의 경우 동일성이 곧 논리적 동일성이다. 따라서, Object에 정의된 equals메서드만 사용해도 논리적 동일성을 검사할 수 있다.
- 클래스가 private또는 package-private로 선언되었고, equals 메서드를 호출할 일이 없는 경우 :
이런 경우에는 Object equals를 사용하기 보단 아래와 같이 방어적으로 equals를 정의하는 것이 좋을 것 같다.
@Override public boolean equals(Object o){ throw new AssertionError(); // equals 메서드가 호출되면 안된다는 방어적인 표현 }
Object equals를 재정의해야 하는 경우는 객체의 동일성(object equality)이 아닌 논리적 동일성(logical equality)의 개념을 지원하는 클래스일 때나 상위 클래스의 equals가 하위 클래스의 필요를 충족하지 못할 때 재정의해야 한다.
equals메서드는 동치 관계(equivalence relation)을 구현한다. equals메서드를 정의할 때 준수해야 하는 일반 규약(general contract)은 다음과 같다.
- 반사성(reflexive) : null이 아닌 참조 x가 있을 때, x.equals(x)는 true
- 대칭성(symmetric) : null이 아닌 참조 x, y가 있을 때, x.equals(y)가 true이면, y.equals(x)가 true이다.
- 추이성(transitive): null이 아닌 참조 x, y, z가 있을 때, x.equals(y)가 true이고, y.equals(z)가 true이면, x.equals(z)도 true이다.
- 일관성(consistent): null이 아닌 참조 x, y가 있을 때, equals를 통해 비교되는 정보에 아무 변하가 없다면, x.equals(y) 호출 횟수에 상관없이 항상 같아야 한다.
추가로 null아닌 참조 x에 대해서, x.equals(null)은 항상 false 이다.
> 대칭성(symmetric)
CaseInsensitiveString와 같이 String 클래스와 호환되도록 구성하기 위해 아래와 같이 equals를 구현하면, 대칭성이 깨지는 문제가 발생한다.
public final class CaseInsensitiveString {
final String s;
public CaseInsensitiveString( String s ){
if( null == s ){
throw new NullPointerException();
}
this.s = s;
}
@Override
public boolean equals( Object o ){
if( o instanceof CaseInsensitiveString ){
return s.equalsIgnoreCase( ((CaseInsensitiveString)o).s );
}
if( o instanceof String ){
return s.equalsIgnoreCase( (String)o );
}
return false;
}
}
이유는 아래와 같이, CaseInsensitiveString는 String 객체를 알고 있기 때문에, equals 호출시 true가 호출되지만 반대로, String 객체에서 CaseInsensitiveString를 equals 하면, String 객체는 CaseInsensitiveString 객체를 알지 못하기 때문에, false를 리턴하여 대칭성이 깨지기 때문이다.
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";
cis.equals(s); // 결과값 : true
s.equals(cis); // 결과값 : false // <-- 한방향으로만 동작하기 때문에 대칭성 깨짐
이와 같이 equals 메서드가 비교하는 범위를 자신을 포함한 하위 객체에 대해서만 비교하는 등 비교 범위를 최소화 하여, 대칭성에 대한 지원을 해야 한다.
@Override
public boolean equals( Object o ){
return o instanceof CaseInsensitiveString
&& s.equalsIgnoreCase( ((CaseInsensitiveString)o).s );
}
> 추이성(transitive)
상위 클래스에 없는 새로운 값 컴포넌트(value compoent)를 하위 클래스에 추가하는 경우
public class Point{
final int x;
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 ){
Point p = (Point)o;
return p.x == x && p.y == y;
}
return false;
}
}
public class ColorPoint extends Point{
final Color color;
public ColorPoint( int x, int y, Color ){
super(x, y);
this.color = color;
}
}
위와 같이 Point 클래스를 선언하여, equals를 구현한 경우 ColorPoint 클래스는 Point equals를 그대로 사용한다. Color에 대한 비교가 없기 때문에 문제의 소지가 있다.
@Overried
public boolean equals(Object o){
if( o instanceof ColorPoint ){
return supper.equals(o) && ((ColorPoint)o).color == color;
}
return false;
}
위 메서드의 문제 점은 Point 객체와 ColorPoint 객체를 비교하는 순서를 바꾸면 다른 결과가 반환 될수 있다는 것이다.
Point p = new Point(1, 2);
ColorPoint cp = new Point(1, 2, Color.RED);
p.equals(cp); // 결과 값 : true
cp.equlas(p); // 결과값 : false // 대칭성 위배!!!
ColorPoint equals를 수정해서 Point 객체와 비교할 때, 색상 정보는 무시하는 경우
@Overried
public boolean equals(Object o){
// o가 Point 객체인 경우, 색상정보는 무시하고 비교
if( o instanceof Point ){
return o.equals( this );
}
if( o instanceof ColorPoint ){
return supper.equals(o) && ((ColorPoint)o).color.equals(color);
}
return false;
}
위와 같은 경우 대칭성은 보존되지만 추이성이 깨진다.
ColorPoint cp1 = new ColorPoint(1, 2, Color.RED);
ColorPoint cp2 = new ColorPoint(1, 2, Color.BLUE);
Point p = new Point(1, 2);
p1.equals(p); // 결과 값 : true
p.equals(p2); // 결과 값 : true
// 앞서 두번의 비교는 색상을 무시지만, 마지막 비교는 색상도 보기 때문에....
p1.equals(p2); // 결과 값 : false // 추이성 위배 !!!
사실 위와 같은 문제는 객체 지향 언어에서 동치 관계(equivalence relation)를 구현할 때, 발생하는 본질적인 문제이다. 객체 지향적 추상화(object-oriented abstraction)의 혜택을 누리지 않을 거라면 모를까, 객체 생성 기능(instantiable) 클래스를 계승하여 새로운 값 컴포넌트를 추가하면서 equals 규약을 어기지 않을 방법은 없다.
이를 해결 하는 방법은, 계승하는 대신 구성(composition)을 사용하는 방법으로 해결 할 수 있다. Point를 계승해서 ColorPoint를 만드는 대신, ColorPoint안에 private Point 필드를 두고 public view메서드를 하나 만드는 것이다.
public class ColorPoint{
final Point point;
final Color color;
public ColorPoint( int x, int y, Color ){
if( null == color ){
throw new NullPointerException();
}
point = new Point(x, y);
this.color = color;
}
// ColorPoint의 Point View 반환(public view 메서드)
public Point asPoint(){
return point;
}
// 끔찍한 상속관계가 없어 졌기 때문에, ColorPoint에 대한 equals에만 집중하면 된다.
@Overried
public boolean equals(Object o){
if( o instanceof ColorPoint ){
ColorPoint cp = (ColorPoint)o;
return cp.point.equals(point) && cp.color.equals(color);
}
return false;
}
}
추가적으로 abstract로 선언된 클래스에 값 필드를 추가는 것은 equals규약을 어기지 않는다. abstract 클래스는 상속은 지원되지만 자신의 객체가 인스턴화 되지 않기 때문이다.
> 일관성(consistent)
변경 가능한 객체들(mutable objects)간의 동치 관계는 시간에 따라 달라 질수 있지만 변경 불가능한 객체들(immutable Objects) 사이의 동치 관계는 달라질 수 없다. 그리고, 변경 유무에 상관 없이 신쇠성을 보장되지 않은 자원(unreliable resource)들을 비교하는 equals를 구현하는 것은 피해야 한다. 예를 들어, java.netURL의 equals 메서드는 URL에 대응되는 호스트의 IP를 비교하여 equals의 반환값을 결정한다. 문제는 호스트명을 IP주소로 변환하려면 네트워크 접속해야 하므로, 언제나 같은 결과가 나온다는 보장이 없다. 따라서, URL의 equals 메서드는 equals 규약을 준수하지 못한다.
[훌륭한 equals메서드를 구현하기 위해 따라야 할 지침들]
-
==연산자를 사용하여 equals의 인자가 자기 자신인지 검사하라.
단순히 성능 최적화(performance optimization)를 위한 것으로, 객체 비교 오버헤드가 클 경우에 위력을 발휘한다. -
instanceof 연산자를 사용하여 인자의 자료형이 정확한지 검사하라.
일반적으로 인자의 자료형은 equals가 정의된 클래스와 같아야 한다.
그리고, 인터페이스의 equals 규약이 해당 인터페이스를 구현하는 클래스의 모든 객체를 비교할 수 있도록 구성되어 있다면 instanceof 연산자를 통해서 그 인터페이스도 검증해야 한다.
Set, List, Map, Map.Entry와 같은 컬렉션 인터페이스들이 equals 규약을 따르는 인터페이스이다. - 중요 필드와 인자로 주어진 객체의 필드와 일치 하는지 검사한다.
- 인자로 인터페이스가 주어진다면, 인터페이스 메서드를 통해 필드 접근하여 비교하고, 같은 클래스였다면, 직접 접근하여 비교 한다.
- 만약, 기본 자료형인 경우, float이나 double이외에는 ==연산자로 비교하면 된다. float이나 double의 경우 Float.compare메서드를 사용하고, double필드의 경우 Double.compare메서드로 비교한다. float, double을 특별하게 취급하는 이유는 Float.NaN, -0.0f와 같은 상수들 때문이다.
- 객체 참조 필드는 equals 메서드를 재귀적으로 호출하여 검사하면 된다.
- 배열 필드의 경우 JDK 1.5 부터 Arrays.equals 메서드 가운데 하나를 사용하면 된다.
* 객체 참조 필드 가운데는 null이 허용되는 것도 있다.
NullPointerException를 피하려면,
(field == null ? o.filed == null : field.equals(o.field))
와 같이 비교 하고 field와 o.field가 같을 때가 많다면,(field == o.filed) || ((field == null) && field.equals(o.field))
와 같이 비교하여, 자기 참조 인지 먼저 확인하면 좀더 빠르다.
-
equals메서드 구현이 끝냈다면, 대칭성, 추이성, 일과성을 만족하는지 검토하라.
- equals메서드를 구현 할때 hashCode도 재정의하라.