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

노을·2023년 1월 24일
0

이펙티브 자바

목록 보기
7/14
post-thumbnail

⭐ equals가 필요 없는 상황


equals를 재정의하지 않아도 되는 상황에서는 재정의를 안하는 게 최선이다.

  1. 각 인스턴스가 싱글톤처럼 고유할 경우
  2. 인스턴스의 논리적 동치성을 검사할 일이 없는 경우
  3. 상위 타입에서 재정의한 equals로 충분한 경우
  4. 클래스가 private or package-private이고, equals 메서드 호출할 일이 없는 경우


⭐ equals를 재정의 할 때 따라야 하는 규약

equals 메서드는 반사성, 대칭성, 추이성, 일관성, null 아님을 따라랴 한다.

☑️ 반사성

x.equals(x) == true

당연하게 객체는 자기 자신과 비교했을 때 같아야 한다는 뜻이다.


☑️ 대칭성

x.equals(y) == y.equals(x)

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

이렇게equals를 재정의 하는 것은 자연스러운 것처럼 보인다. CaseInsensitiveString 클래스는 String 객체가 와도 CaseInsensitiveString 타입으로 바꿔서 비교하기 때문이다.
근데 StringCaseInsensitiveString을 전혀 알지 못한다!

CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String polish = "polish";

System.out.println(cis.equals(polish)); //true
System.out.println(polish.equals(cis)); //false

그래서 polish.equals(cis)이 결과가 false가 나오기 때문에 대칭성에 위배된다.


☑️ 추이성


x.equals(y) == true, y.equals(z) == true ➡️ x.equals(z) == true


추이성 위배 예시

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;
  }
}
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 클래스와, Point 클래스를 상속 받는 ColorPoint 클래스가 있다고 가정하자.
ColorPointequals는 어떻게 재정의할까?

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

    // o가 일반 Point면 색상을 무시하고 비교한다.
    if (!(o instanceof ColorPoint))
        return o.equals(this);

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

다음과 같이 정의하면 추이성을 위배한다.

ColorPoint cp1 = new ColorPoint(1, 2, Color.RED);
Point p = new Point(1, 2);
ColorPoint cp2 = new ColorPoint(1, 2, Color.BLUE);

cp1.equals(p) //true : ColorPoint의 equals(색상을 무시)
p.equals(cp2) //true : Point의 equals(색상을 무시)
cp1.equals(cp2)); //false : ColorPoint의 equals(색상 무시 X)



무한 재귀 위험

그리고 이것은 무한 재귀에 빠질 위험이 있다.
만약 Point를 상속 받는 SmellPoint를 만들었다고 가정하자.
그럼 SmellPointequals는 다음과 같이 구현할 것이다.

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

    // o가 일반 Point면 색상을 무시하고 비교한다.
    if (!(o instanceof SmellPoint))
        return o.equals(this);

    // o가 ColorPoint면 색상까지 비교한다.
    return super.equals(o) && ((SmellPoint) o).smell.equals(smell);
}
SmellPoint sp = new SmellPoint(1, 0, "sweet");
ColorPoint cp = new ColorPoint(1, 0, Color.RED);
sp.equals(cp);  //StackOverflowError

sp.equals(cp)를 하면,
1. (SmellPoint의) equals가 호출 -> ColorPoint 객체이므로 두번째 if문에서 걸림
2. (ColorPoint의) equals가 호출 -> SmellPoint 객체이므로 두번째 if문에서 걸림
3. 1번 2번 무한 반복 -> StackOverflowError


지금까지 Pointcolor를 추가한 ColorPoint에서의 equals의 잘못된 예시를 살펴보았다.
그럼 ColorPointPoint처럼 사용할 수 있게 하면서 equals를 구현하는 방법은 무엇일까? 안타깝게도 구체클래스를 확장해 새로운 값을 추가하면서 equals 규역을 만족시키는 방법은 존재하지 않는다.

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

getClass()로 두 객체를 비교하는 방법이 있지만 이것은 객체지향적이지 않다.
ColorPoint 객체를 Point 객체처럼 사용할 수 없기 때문이다.

➡️ 상속 대신 컴포지션을 사용하자.


컴포지션

컴포지션이란? 다른객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 메서드를 호출하는 기법

지금까지 Point를 상속 받는 ColorPoint 클래스에 color라는 필드를 추가했지만, 컴포지션을 이용해보자.

public class ColorPoint {
    private final Point point; //Point 객체
    private final Color color; //Color 객체

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

    /**
     * 이 ColorPoint의 Point 뷰를 반환한다.
     */
    // ColorPoint지만, Point 만을 밖에 노출시킬 수 있다.
    // 이건 상위타입으로 반환할 수 없기 때문에 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);
    }
}

ColorPointPoint를 상속받는 것이 아니라, Point 객체를 참조하고 있다. 더불어 Color 객체도 참조하고 있다.
이렇게 되면 당연히 Point point = new ColorPoint(); 이렇게 상위 타입으로 변환해서 쓰지 못한다.
그래서 view 메서드가 필요하다. view 메서드ColorPoint 클래스에서 Point 만을 밖에 노출시킬 수 있다.


☑️ 일관성

x.equals(y)은 항상 true 혹은 false를 반환해야 한다.

두 객체가 같다면 영원히 같다야 하고, 다르다면 영원히 달라야 한다.
java/net/URL의 equals는 이 규약을 지키지 않았다.

URL google1 = new URL("https", "about.google", "/products/");
URL google2 = new URL("https", "about.google", "/products/");
System.out.println(google1.equals(google2)); //IP가 다르면 false 가 나올 수 있다.

주어진 URL과 매핑된 호스트의 IP 주소를 비교하는데, ip 주소는 변경될 수 있기 때문이다. 이렇게 일관적이지 않게 구현하지 말자!
그냥 URL 문자열이 같다면 같은 URL로 판단하게 (복잡하지 않게) 구현해야 한다.


☑️ null 아님

x.equals(null) == false

모든 객체가 null과 같지 않아야 한다는 의미이다.



⭐ equals 구현 방법

전형적인 equals 메서드의 예

@Override public boolean equals(Object o) {
    if (o == this) // 1. 입력 객체가 자기 자신의 참조인지 확인
        return true;
    if (!(o instanceof PhoneNumber)) // 2. 올바른 타입이 들어왔느지 검사
        return false;
    PhoneNumber pn = (PhoneNumber)o; // 3. 형 변한
    return pn.lineNum == lineNum && pn.prefix == prefix && pn.areaCode == areaCode; // 4. 핵심 필드들이 같은지 검사
}



⭐ equals 구현을 도와주는 것들


☑️ 인텔리제이의 도움을 받아 직접 만들기


인텔리제이에서는 equals, hashcode를 쉽게 재정의할 수 있게 해준다.
인텔리제이와 같은 툴을 이용해서 직접 equals를 구현해주는 방법이 있지만

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Point point = (Point) o;
    return x == point.x && y == point.y; // 필드 추가 시 이 부분이 바껴야 함
}

코드가 지저분해진다는 단점이 있고, 클래스에 int z 필드 추가 시 equals 메서드를 지우고 다시 만들어야 한다.


☑️ @AutoValue

구글에서 만든 @AutoValue는 값 클래스를 만들 때 재정의 해야 하는 equals, toString, hashCode를 자동으로 컴파일 시점에 만들어 준다. 우리는 단지 추상클래스만 만들면 된다.

근데 @AutoValue를 쓰면 지켜야 할 규약들이 생기고, 컴파일 시점에 .class 파일을 만들어주기 때문에 인텔리제이에서 빨간줄로 표현되어 불편하다는 단점이 있다.

구글에서도 만약 코틀린을 쓴다면 데이터클래스를, record를 지원하는 java 버전을 쓴다면 record를 쓰라고 추천하고 있다.


☑️ lombok의 @EqualsAndHashCode

보통은 롬복을 제일 많이 사용한다.


☑️ record

record는 자바 14와 15에서 preview로 추가된 이후, 16버전에서 정식 스펙으로 올라왔다.

recordVO, DTO에서 사용하기 좋다.
DTO를 구현하기 위해서는 getter, setter, equals, hashCode, toString 같은 메서드를 오버라이드 해야 하는데, 오버라이드할 때 아주 적은 부분만 수정한다. 그리고 코드가 지저분해진다는 단점이 있다. 이때 record를 쓰면 자동으로 오버라이드 할 메서드들을 만들어준다.

Point 클래스를 record로 구현하면 다음과 같다.

public record Point(int x, int y) {
}
System.out.println(p1.equals(p2)); //true

(주의할 점 : 자바빈즈를 대체하기 위한 기술은 아니며, entity를 record로 구현해서는 안된다. 자세한 건 이 글을 참고하자.)

0개의 댓글