Item 10. equals는 일반 규약을 지켜 재정의하라

Posted by yunki kim on February 24, 2022

  equals 메서드는 재정의하기 쉬워보이지만 잘못 정의하면 큰 문제가 된다. 따라서 문제를 회피하는 가장 쉬운 방법은 재정의를 하지 않는 거다. 그러면 euqals의 메서드 원형은 다음과 같기 때문에 자기 자신과만 같게 된다.

1
2
3
public boolean equals(Object obj) {
    return (this == obj);
}
cs

  다음 상황 중 하나라도 해당한다면 euqlas 메서드를 재정의 하지 말라.

각 인스턴스가 본질적으로 고유하다.

  각 인스턴스가 고유할 때, 즉, 각 인스턴스의 상태가 중복이 되지 않을 때는 euqals를 재정의 하지 말라. euqals는 깊은 비교를 해야 할 때 사용하라. 

인스턴스의  논리적 동치성(logical equality)을 검사할 일이 없다.

  논리적 동치는 두 피연산자가 같은지를 비교하는 연산이다. 만약 두 값이 같다면 true, 아니면 false다. 클라이언트가 원하지 않거나 논리적 동치가 설계 시 필요 없다고 판단된다면 재정의 하지 말라. 예를 들어 Random이 두 개의 난수를 만드는 지는 비교할 필요가 없다.

상위 클래스에서 재정의한 euqals가 하위 클래스에도 딱 들어 맞는다.

클래스가 private이거나 package-private이고 euqlas 메서드를 호출할 일이 없다.

  만약 실수로 호출되는 것을 막고 싶다면 다음과 같이 재정의하면 된다.

1
2
3
4
@Override
public boolean equals(Object obj) {
    return new AssertionError();
}
cs

equals 메서드는 언제 재정의 해야 하는가

  객체 식별성(object identity: 두 객체가 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야 하지만 상위 클래스가 equals를 논리적 동치성을 비교하게 재정의 하지 않았을 때 재정의 해야 한다. 주로 Integer 같은 값을 표현하는 값 클래스가 여기에 해당된다.

  비록 값 클래스라 해도 Enum 같이 인스턴스가 둘 이상임을 보장한다면 euqlas 메서드를 재정의할 필요가 없다.

equals의 일반 규약

  equals 메서드를 재정의 할 때는 반드시 다음과 같은 규칙을 준수해야 한다.

  equals 메서드는 동치관계를 구현하며 다음을 만족한다.

    1. 반사성(reflexivity): 참조값 x에 대해 x.equals(x) == true (x != null)

    2. 대칭성(symmetry): 참조값 x, y에 대해 if x.equals(y) == true then y.equals(x) (y != null, x != null)

    3. 추이성(transitivity): 참조값 x, y, z에 대해 if x.euqlas(y) == true && y.equals(z) == true

                                    then x.equals(z) == true (x != null, y != null, z != null)

    4. 일관성(consistency): 참조값 x, y에 대해 x.equals(y) is always true or false(x != null, y != null)

    5. null 아님: 참조값 x에 대해 x.equals(null) == false (x != null)

  컬렉션 클래스들을 포함한 많은 클래스는 전달받은 객체가 equals 규약을 지킨다고 가정하고 동작한다. 따라서 위 규약을 반드시 지켜야 한다.

  Object 명세에서 말하는 동치관계는 집합을 서로 같은 원소들로 나누는 것을 의미한다. 이 부분집합을 동치류(equivalence class: 동치 클래스)라 한다. equals 메서드가 쓸모있으려면 동치류에 속한 원소끼리는 문제 없이 교환이 가능해야 한다.

반사성

  객체는 자기 자신과 같아야 한다.

대칭성

  아래 예시는 대칭성을 위반한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 대소문자 구분 없이 문자열
public final class CaseInsensitiveString {
    private final String str;
 
    public CaseInsensitiveString(String str) {
        this.str = Objects.requireNonNull(str);
    }
 
    @Override
    public boolean equals(Object o) {
        if (o instanceof CaseInsensitiveString) {
            return str.equlasIgnoreCase(((CaseInsensitiveString) o).s);
        }
        if (o instanceof String) {
            return s.equlasIgnoreCase((String) o);
        }
        return false;
    }
 
    ...
}
 
CaseInsensitiveString caseInsensitiveString = new CaseInsensitiveString("Polish");
String s = "polish";
caseInsensitiveString.equals(s); // true;
// s의 equals는 caseInsensitiveString의 존재에 대해 모른다.
// 따라서 대칭성 위반
s.equals(caseInsensitiveString); // false;
 
cs

  이를 해결하기 위해서는 String과 equals를 연동하려는 시도를 포기하고 다음과 같이 코드를 바꾸면 된다.

1
2
3
4
5
@Override
public boolean equals(Object o) {
    return (o instanceof CaseInsensitiveString)
        && ((CaseInsensitiveString) o).str.equlasIgnoreCase(s);
}
cs

추이성

  상위 클래스에 없는 필드를 하위 클래스에서 정의할 때 자주 발생하는 문제다. 아래의 예시는 이런 추이성 위반 문제를 다룬다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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(!instanceof Point)
            return false;
        Point p = (Point) o;
        return p.x == x && p.y == y;
    }
}
 
public class ColorPoint extends Point {
    private final Color color;
    
    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }
 
    @Override public boolean equals(Object o) {
        if(!instanceof ColorPoint)
            return false;
        return super.equals(o) && ((ColorPoint) o).color == color;
    }
 
    ...
}
 
public static void main(){
    Point p = new Point(1,2);
    ColorPoint cp = new ColorPoint(1,2, Color.RED);
    p.equals(cp); // true
    // ColorPoint equals의 조건문에서
    // false 반환
    cp.equals(p); // false
}
 
cs

  이 문제는 상속 대신 컴포지션을 사용해 해결할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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);
    }
 
  public Point asPoint(){ // view 메서드 패턴
    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);
  }
}
 
// 1. ColorPoint vs ColorPoint: ColorPoint의 equals이용
// 2. ColorPoint vs Point: ColorPoint의 asPoint를 이용해 Point로 바꾸고
// Point의 equals를 사용.
// 3. Point vs Point: Point의 equals 사용
 
cs

  또는 추상 클래스를 사용하면 된다. 추상 클래스는 직접 인스턴스를 생성할 수 없기 때문에 하위 클래스끼리의 비교가 가능한다.

  자바 라이브러리에도 구체 클래스를 확장해 값을 추가한 클래스가 존재한다. java.sql.Timestamp는 java.util.Date를 확장한 후 nanoseconds필드를 추가했다. 그때문에 Timestamp의 equals는 대칭성을 위배하며 Date 객체와 한 컬렉션에 넣어 섞어 사용하면 오작동할 수 있다.

일관성

  가변 객체는 비교 시점에 따라 서로 다를 수 있지만 불변 객체끼리의 equals는 항상 결과가 같아야 한다. 이를 어기면 일관성을 지키기 어렵다. 예컨대 java.net.URL의 equals는 URL과 매핑된 호스트의 IP 주소를 이용해 비교한다. 호스트 이름을 IP 주소로 바꾸기 위해선 네트워크를 통해야 하는데, 이 결과가 항상 같을 거라는 보장이 없다. 이 문제를 피하기 위해서 equals는 항시 메모리에 존재하는 객체만을 사용한 결정적(deterministic) 계산만 수행해야 한다.

  결정적 계산은 true 또는 false로 대답할 수 있는 문제를 의미한다.

null 아님

  인스턴스가 null이 아님을 검사하는 과정을 명시적으로 서술할 필요는 없다. equals에서는 동치성 검사를 위해 건네 받은 객체를 형변환 후 필수 필드들의 값을 알아내기 위해 형변환 전에 instanceof 연산자로 매개변수의 타입을 검사하기 때문이다.

  instanceof는 두 번째 피연산자와 무관하게 첫 번째 피연산자가 null이면 false를 반환한다. 따라서 묵시적 null 검사가 이뤄진다.

equals 메서드 구현 방법

1. == 연산자를 이용해 입력이 자기 자신의 참조인지 확인한다.

2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.

  이때의 보통의 올바른 타입은 equals가 정의된 클래스이다. 하지만 간혹 그 클래스가 구현한 특정 인터페이스가 될 수 있다. 이런 인터페이스를 구현한 클래스라면 equals에서 해당 인터페이스를 사용해야 한다. Set, List, Map 등의 컬렉션 인터페이스들이 여기에 해당한다.

3. 입력을 올바른 타입으로 형변환한다.

4. 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사한다.

 

  float와 double을 제외한 기본 타입 필드는 ==로 비교하고 참조 타입 필든느 각각의 equals 메서드로 비교하라. float와 double은 Float.compare(float, float)와 Double.comapre(double, double)로 비교해야 한다. 이는 float와 double이 Float.NaN, -0.0f 같은 특수한 부동소수 값을 다뤄야 하기 때문이다. 배열 필드는 원소 각각을 위에서 말한 내용을 토대로 비교하고 모든 원소가 핵심이라면 Arrays.equals를 사용하라.

  null도 정상 값으로 취급해야 한다면 해당 필드는 Object.equals(Object, Object)로 비교해 NullPointerException을 예방하라.

  어떤 필드를 먼저 비교하는지가 성능을 판가름 할 때도 있다. 최상의 성능을 위해서는 다를 가능성이 크거나 비교하는 비용이 싼 필드를 먼저 비교하라.

  위에서 설명한 구현 방법을 통해 구현한 equals 메서드의 예시는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
@Override public boolean equals(Object o) {
    if (o == this) {
        return true;
    }
    if (!(o instanceo PhoneNumber)) {
        return false;
    }
    PhoneNumber pn = (PhoneNumber) 0;
    return pn.lineNum == lineNum && pn.prefix == prefix
        && pb.areaCode == areaCode;
}
 
cs

 

출처 - 이펙티브 자바