[이펙티브 자바]10. equals는 일반 규약을 지켜서 재정의하라

Wintering·2022년 6월 5일
0

이펙티브 자바

목록 보기
16/18

Object.equals()

Object 클래스의 equals함수는 객체의 주소값을 비교햔다.
즉, 같은 값을 가졌더라도 따로 생성되었다면 False를 출력한다.

  • equals 메소드는 동치관계를 구현한다.

반사성(reflexive)

null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true다.

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

대칭성(symmetric)

null이 아닌 모든 참조 값 x,y에 대해 x.equals(y)가 true라면 y.equals(x)도 true다.

  • 두 객체는 서로의 동치여부에 똑같이 답해야한다.
package effectivejava;

import java.util.Objects;

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;
    }

    public static void main(String[] args) {
        CaseInsensitiveString cis = new CaseInsensitiveString("Test");
        String test = "test";
        System.out.println(cis.equals(test)); //true
        System.out.println(test.equals(cis)); //false
    }
}

CaseInsensitiveString클래스의 equals는 재정의하여 대소문자를 무시하고 값을 비교하도록 정의했다.

따라서 cis.equals(test)는 재정의 된 메소드안에서의 비교이기 때문에 Test와 test가 대소문자를 무시하고 비교하게 되어 true를 리턴하게 되는데

일반적인 test.equals(cis)는 Test와 test를 자체로 비교하기 때문에 대소문자를 가려 false를 리턴하게 된다.

이 경우, 두 객체가 서로의 동치여부에 다른 결과를 내고 있으므로 일관성이 위배됐다고 할 수 있다.


추이성(transitive)

null이 아닌 모든 참조 값 x,y,z에 대하여 x.equals(y)가 true고 y.equals(z)가 true라면 x.equals(z) 또한 true이다.

ColorPoint a = new ColorPoint(1, 2, Color.RED);
Point b = new Point(1, 2);
ColorPoint c = new ColorPoint(1, 2, Color.BLUE);

위와 같은 인스턴스 a,b,c가 존재한다고 할 때,
a.equals(b)와 b.equals(c)에서 a.equals(c)가 되는 과정을 살펴보자

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;
  }
}

CASE1. 대칭성 위반

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;
  }
}
//위처럼 equals()를 재정의했다면
ColorPoint a = new ColorPoint(1, 2, Color.RED);
Point b = new Point(1, 2);

System.out.println(a.equals(b)); //false
System.out.println(b.equals(a)); //true
  • a.equals(b)를 보면 a는 ColorPoint 클래스에서 재정의 된 equals 메서드를 타게 된다. 이렇게 되면 첫번째 if 조건에서 걸리게 된다. b는 Point이지만 ColorPoint는 아니기 떄문이다. 따라서 a.equals(b)는 false가 된다.

  • b.equals(a)를 보면 b는 Point클래스의 equals메서드를 타게 된다.
    이렇게 되면 ColorPoint는 Point클래스를 상속하고 있기 때문에 첫번째 if조건을 통과하게 되고, int x, int y값을 기준으로만 비교하기 떄문에 값이 참이 된다.

CASE2. 추이성 위반

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 a = new ColorPoint(1, 2, Color.RED);
Point b = new Point(1, 2);
ColorPoint c = new ColorPoint(1, 2, Color.BLUE);

System.out.println(a.equals(b)); //true
System.out.println(b.equals(c)); //true
System.out.println(a.equals(c)); //false

CASE3. 무한재귀 발생

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

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

Point cp = new ColorPoint(1, 2, Color.RED);
Point sp = new SmellPoint(1, 2, Smell.SWEET);
System.out.println(cp.equals(sp));  // ?
  • cp.equals(sp) 코드는 ColorPoint 클래스의 재정의 된 equals 메서드를 타게 된다. 이렇게 되면 두번째 if에서 걸리게 된다. 왜냐하면 o는 SmellPoint 타입이기 때문에 !(o instanceof ColorPoint) 이 조건식이 true가 된다.
    그렇기 때문에 o.equals(this)가 실행되게 되면 결과적으로 o는 SmellPoint 클래스의 인스턴스이기 때문에 SmellPoint 클래스의 재정의된 equals메서드를 타게 된다.

  • 다시 SmellPoint클래스의 입장에서 보게되면 두번째 if에서 걸리게 된다.
    여기서 o는 ColorPoint타입이기 때문에 !(o instanceof ColorPoint) 이 조건식이 true가 된다.
    그렇기 때문에 o.equals(this)가 실행되게 되면 결과적으로 다시 ColorPoint 클래스의 재정의된 equals메서드를 타게 된다.

CASE4. LISCOV 치환

LISCOV치환 : 자식 클래스는 최소한 자신의 부모 클래스에서 가능한 행위는 수행할 수 있어야 한다.
//주어진 점이 반지름이 1인 원 안에 포함이 되는지를 확인하는 로직
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;
  }
}

//ColorPoint는 Point를 상속받은 클래스
ColorPoint cp = new ColorPoint(1, 0, Color.RED);
System.out.println(Point.onUnitCircle(cp)); //false

위의 Point클래스의 contains() 메소드 안에서 equals() 메소드를 사용하는 비교가 일어나게 되는데, cp의 경우 재정의 된 메소드를 통과하지 못한다.
조건문의 o.getClass()에서는 ColorPoint.getClass가 되고, this.getClass()에서는 Point.getClass가 되기 때문에 둘은 같지 않다.

//cf)재정의 한 equals의 if문이 instanceof로 타입을 비교하는 형태였다면 통과할 수 있었다!
@Override
public boolean equals(Object o) {
  if(o == null || !(o instanceof Point)) {
    return false;
  }

  Point p = (Point) o;
  return this.x == p.x && this.y = p.y;
}

일관성(consistency)

null이 아닌 모든 참조값 x,y에 대해서 x.equals(y)를 반복해서 호출하면 항상 true 또는 false를 출력한다.

null이 아닌 모든 참조값에 대하여 x.equals(null)은 항상 false를 반환한다.


결론

equals 클래스의 구현 절차

  1. == 연산자를 사용하여 입력되는 파라미터와 자기자신이 같은 객체인지 아닌지를 검사한다.

  2. instanceof를 사용하여 파라미터의 타입이 올바른지 체크(null 체크의 의미도 있음)

  3. 입력을 올바른 타입으로 형변환해준다. (2번을 거쳤기 때문에 무조건 성공)

  4. 파라미터의 Object 객체와 자기자신의 대응되는 핵심필드들이 모두 같은지를 비교한다.
    (하나라도 다르면 false 리턴)

  5. 기본타입은 ==로 비교, 참조타입은 equals()로 비교
    단 float 과 double은 float.compare double.compare를 사용하여 비교

주의사항

  1. equals를 재정의했다면 규약을 반드시 지켰는지 확인하자
  2. equals를 재정의할 때는 반드시 hashcode도 재정의하자
  3. equals의 파라미터는 반드시 Object타입으로 선언하자 (이외의 경우 컴파일에러 발생)
  4. 구글의 @AutoValue를 이용하여 equals 와 hashcode를 자동으로 재정의하자

0개의 댓글