[Effective Java 3rd] Item10. equals는 일반규약을 지켜 재정의하라

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

  • equals 메서드는 Object의 정보들에 대해 동등성을 비교하는 목적으로 사용한다.
  • equals 메서드를 잘못 작성하면 잘못된 결과를 만들 수 있다.

equals를 재정의하지 않는 경우

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

    값이 아닌 동작을 개체로 표현하는 경우 (ex. Thread)

  • 논리적 동치성(p->q, q->p)을 확인 할 필요가 없다

    클라이언트가 원치 않거나 애초에 필요치 않다고 판단 할 수 있다(기본 equals로 해결).

  • 상위클래스에서 재정의한 equals가 하위 클래스에도 적용된다.

    set, Map, List의 경우 Abstract(Type)의 equals를 쓴다.

  • 클래스가 private, package-private여서 equals를 호출할 일이 없는 경우

  • 싱글턴을 보장하는 클래스(인스턴스 통제 클래스, Enum(열거타입))인 경우- 객체 간 동등성, 동일성이 보장된다.

equals를 재정의하는 경우 5가지 일반 규약

1. 반사성

null이 아닌 모든 참조 값 x에 대해 x.equals(x)를 만족해야한다.
자기 자식과 비교했을 때 같아야 한다.

2. 대칭성

null이 아닌 모든 참조 값 x,y에 대해 x.equals(y)가 true 이면 y.equals(x) true를 만족해야한다.

public final class CaseInsensitiveString {
  private final String s;

  public CaseInsensitiveString(String s) {
    this.s = Objects.requireNonNull(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;
  }
}
  • 위 코드 실행시 x.equals(y)는 true이지만 y.equals(x)는 false로 대칭성을 위반한다.
  • String 클래스에서는 CaseInsentiveString 클래스를 모른다.
@Override
public boolean equals(Object o) {
  return o instanceof CaseInsensitiveString && ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
//형변환을 통해 간단히 처리 가능

3. 추이성

null이 아닌 모든 참조 값 x,y,z에 대해 x.equals(y)가 true이고, y.equals(z)가 true이면 x.equals(z)도 true를 만족해야 한다.

예시) Point클래스와 ColorPoint클래스(ColorPoint는 Point클래스를 상속한 클래스이다.)

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 this.x == p.x && this.y == p.y;
  }
}
//equals 메서드를 그대로 두면 색상 정보는 무시한 채 비교를 한다.
//color 정보를 놓치니 받아 들일 수 없다.

class ColorPoint extends Point {

  private final Color color;

  @Override
  public ColorPoint(int x, int y, Color color) {
    super(x,y);
    this.color = color;
  }
}
  • 대칭성 위배 코드

    class ColorPoint extends Point {
    
    private final Color color;
    
    @Override
    public boolean equals(Object o) {
      if(!(o instanceof ColorPoint)) return false;
    
      return super.equals(o) && this.color == ((ColorPoint) o).color;
    }
    }
    
    

Point p = new Point(1,2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);

// Point를 ColorPoint에 비교한 결과와 ColorPoint를 Point와 비교한 결과가 다를 수 있다.
System.out.println(p.equals(cp)); //true --> 색상을 무시함
System.out.println(cp.equals(p)); //false -->클래스가 다르다며 false



- 추이성 위반 Case
```java
class ColorPoint extends Point {

  private final Color color;

  @Override
  public boolean equals(Object o) {
    if(!(o instanceof Point)) return false;

    //o가 일반 Point이면 색상을 무시햐고 x,y정보만 비교한다.
    if(!(o instanceof ColorPoint)) return o.equals(this);

    //o가 ColorPoint이면 색상까지 비교한다.
    return super.equals(o) && this.color == ((ColorPoint) o).color;
  }
}
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);

System.out.println(p1.equals(p2)); //true  --> 색상 무시
System.out.println(p2.equals(p3)); //true  --> 색상 무시
System.out.println(p1.equals(cp3)); //false  --> 추이성이 깨졌다. 색상까지 고려(Red != BLUE)



  • 무한재귀 발생 case
class SmellPoint extends Point {

  private final Smell smell;

  @Override
  public boolean equals(Object o) {
    if(!(o instanceof Point)) return false;

    //o가 일반 Point이면 색상을 무시햐고 x,y정보만 비교한다.
    if(!(o instanceof SmellPoint)) return o.equals(this);

    //o가 ColorPoint이면 색상까지 비교한다.
    return super.equals(o) && this.smell == ((SmellPoint) o).smell;
  }
}

Point p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new SmellPoint(1, 2, Smell.SWEET);

System.out.println(p1.equals(p2)); // StackOverflow

//StackOverflow 이유
/*p1는 ColorPoint 클래스의 인스턴스이다.
p1.equals(p2) 코드는 ColorPoint의 equals 메서드를 탄다.
ColorPoint의 2번째 if에 걸린다(o는 SmellPoint 타입이기 때문에, SmellPoint는 Point이지만 ColorPoint는 아님)
o가 SmellPoint이므로 SmellPoint의 equals를 탄다.
다시 두 번째 if에서 걸린다.
또 다시 ColorPoint의 equals를 탄다.
무한 재귀가 발생한다.*/

객체생성가능(instantiable)를 상속하여 새로운 속성을 추가하면 equals 규약을 어기지 않을 방법이 없다.
상속을 이용할 경우 동치관계가 깨진다. (getClass를 이용하는 경우도 문제가 생긴다.)

  • 리스코프 치환 원칙

    어떤 타입에 있어 중요하다면, 그 하위 타입에서도 중요하다.
    자식 클래스는 부모 클래스에서 가능한 행위를 수행할 수 있어야 한다.
    부모.equals(자식) = false

class Point {

  private final int x;
  private final int y;

  private static final Set<Point> unitCircle = Set.of(new Point(0, -1),
   new Point(0, 1),
   new Point(-1, 0),
   new Point(1, 0)
  );

  public static boolean onUnitCircle(Point p) {
    return unitCircle.contains(p);
  }

  @Override
  public boolean equals(Object o) {
    if(o == null || o.getClass() != this.getClass()) {
      return false;
    }

    Point p = (Point) o;
    return this.x == p.x && this.y = p.y;
  }
}
//같은 구현 클래스의 객체와 비교할 때만 true를 반환한다.
  • 상속대신 컴포지션을 사용하라
    상속 대신에 Point 변수를 갖도록 구성한다.
public ColorPoint {
  private Point point;
  private Color color;

  public ColorPoint(int x, int y, Color color) {
   **** this.point = new Point(x, y);
    this.color = Objects.requireNonNull(color);
  }

  ****public Point asPoint() {
    return this.point;
  }

  @Override
  public boolean equals(Object o) {
    if(!(o instanceof ColorPoint)) {
      return false;
    }
    ColorPoint cp = (ColorPoint) o;
    return this.point.equals(cp) && this.color.equals(cp.color);
  }
}

4. 일관성

null이 아닌 모든 참조값 x,y에 대해 x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.(불변객체 여부)

5. null아님

null이 아닌 모든 참조값 x에 대해 x.equals(null)은 false이다.

요약 equals 메서드 구현방법

  1. == 연산자를 사용해 자기 자신의 참조인지 확인하라

    자기자신이면 true

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

    null 검사

  3. 입력을 올바른 타입으로 형변환하라.
  4. 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사하라.

    기본 타입 필드는 ==연산자로 비교
    참조타입 필드는 equals()로 비교
    float은 Float.compare(float,float), double은 Double.compare(double,double)로 비교, Float.NaN, -0.0f 때문

  5. 성능을 위해 다를 가능성이 더 크거나, 비용이 싼 필드를 먼저 비교하라.

** 위 구현방법으로 다 구현했다면, 대칭성/추이성/일관성을 확인한다.
** 단위테스트를 작성해 돌려보거나 AutoValue 애노테이션을 사용하자.
** 사람 < IDE < AutoValue

주의사항

  • equals를 재정의할 땐 hashCode도 반드시 재정의하자(Item 11)
  • 너무 복잡하게 해결하려 하지 말자.
  • 필드의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있다. 또한 일반적으로 별칭(alias)는 비교하지 않는게 좋다.
  • object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자.
//잘못된 예 - 입력 타입은 반드시 Object 여야 한다!
//컴파일 되지 않음, Object.equals가 아님으로 다중정의 한 것이다.
@Override public boolean equals(MyClass o){
    ....
}

결론

꼭 필요한 경우가 아니면 equals를 재정의하지 말자.
많은 경우 Object의 equals가 비교를 정확히 수행한다.
재정의 할 때는 그 클래스의 핵심필드 모두를 빠짐없이, 다섯가지 규약을 확실히 지켜가며 비교한다.

댓글

Designed by JB FACTORY