본문 바로가기

reviews/Effective JAVA

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

♦︎ equals()를 옳게 재정의해야 하는 이유

  • 옳게 재정의 되지 않은 경우 프로그램이 이상하게 동작하거나 종료될 수 있고 원인을 찾기 힘들 수 있다.
  • 컬렉션 클래스들을 포함해 수 많은 클래스는 전달받은 객체가 equals 규약을 지킨다고 가정하고 동작한다.
    /** CaseInsensitiveString.java 
     *  Object 명세에 적힌 규약 다섯가지.대칭성 예시 중.. */
    
    (CaseInsensitiveString의 equals()를 규약에 맞게 수정하지 않은 경우)
    
    List<CaseInsensitiveString> list = new ArrayList<>();
    list.add(caseInsensitiveString);
    
    System.out.println(list.contains(string));
    
    ⇒ 위 경우 런타임 에러가 발생하거나 true를 반환할 수 있다. (현재의 OpenJDK에서는 false를 반환하기는 한다.)
  • 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없다.
  • 인스턴스는 값을 비교하길 원하는 프로그래머의 기대에 부응할 수 있다.
  • equals()가 논리적 동치성을 확인하도록 재정의해두면 Map의 키와 Set의 원소로 사용할 수 있게된다.

♦︎ equals()를 재정의해야 할 상황

객체 식별성(object identity)*이 아니라 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때 equals()를 재정의해야 한다.

♦︎ equals()를 재정의 하지 말아야 할 상황

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

값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스인 경우 이에 해당한다.

ex) Thread : Object의 equals()가 이미 적합하다.

/** java.lang.Object.java */
public boolean equals(Object obj) {
    return (this == obj);
}

값 클래스라 해도, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스라면 equals()를 재정의하지 않아도 된다. (Enum 포함)

이런 클래스에서는 어차피 논리적으로 같은 인스턴스가 2개 이상 만들어지지 않으니 논리적 동치성과 객체 식별성이 사실상 똑같은 의미가 되며, 따라서 Object의 equals()가 논리적 동치성까지 확인해준다고 볼 수 있다.

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

클라이언트가 논리적 동치성을 검사하는 방식을 원하지 않거나 애초에 필요하지 않다고 판단하는 경우이다.

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

대부분의 Set 구현체는 AbstractSet이 구현한 equals를 상속받아 쓰고, List 구현체들은 AbstractList로부터, Map 구현체들은 AbstractMap으로부터 상속받아 그대로 쓴다.

- 클래스가 private이거나 package-private이고, equals()를 호출할 일이 없다.

클래스의 접근제한자가 private/package-private이고 equals()를 호출할 일이 없는 경우 해당된다.

호출되는 걸 완전히 막고 싶은 경우 아래와 같이 할 수 있다.

@Override
public boolean equals(Object o) {
    throw new AssertionError();
}

👉 equals() 메서드를 재정의 하는 법

equals() 메서드를 재정의 할 때는 반드시 일반규약을 따라야 한다.

♦︎ Object 명세에 적힌 규약 다섯가지

equals()는 동치관계(equivalence relation)을 구현하며, 다음을 만족한다.

- 반사성(reflexivity)

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

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

- 대칭성(symmetry)

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

두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다.

▼ 대칭성에 위배되는 예시

/** CaseInsensitiveString.java */
@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;
}

/** EqualsTest.java */
CaseInsensitiveString caseInsensitiveString = new CaseInsensitiveString("I aM sTrInG.");
String string = "I am string.";

System.out.println(caseInsensitiveString.equals(string)); // true
System.out.println(string.equals(caseInsensitiveString)); // false

▼ 대칭성에 위배되지 않도록 수정

/** CaseInsensitiveString.java */
@Override
public boolean equals(Object o) {
    return o instanceof CaseInsensitiveString &&
            ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}

/** EqualsTest.java */
System.out.println(caseInsensitiveString.equals(string)); // false
System.out.println(string.equals(caseInsensitiveString)); // false

- 추이성(transitivity)

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

첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체와 세 번째 객체도 같아야 한다.

구체 클래스를 확장해 새로운 값을 추가하면서 equals ****규약을 만족시킬 방법은 존재하지 않는다.

구체 클래스의 하위 클래스에서 값을 추가할 방법은 없지만 괜찮은 우회 방 법이 하나 있다. “상속 대신 컴포지션을 사용하라”는 아이템 18의 조언을 따르면 된다.

▼ Point를 상속하는 대신 Point를 ColorPoint의 private 필드로 두고, ColorPoint와 같은 위치의 일반 Point를 반환하는 뷰(view) 메서드(아이템 6) 를 public으로 추가하는 식이다.

/** ColorPoint2.java */
public ColorPoint2 {
    private final Point point;
    private final Color color;

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

    /**
     * 이 ColorPoint의 Point 뷰를 반환한다.
     */
    public Point asPoint() {
        return point;
    }

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

▼ java.sql.Timestamp에서도 java.util.Date를 확장한 후 nanoseconds 필드를 추가했다.

그 결과로 Timestamp의 equals()는 대칭성을 위배하며, Date 객체와 한 컬렉션에 넣거나 서로 섞어 사용하면 엉뚱하게 동작할 수 있다. Timestamp를 이렇게 설계한 것은 실수니 절대 따라 해서는 안 된다.

/** java.sql.Timestamp */
private int nanos;

public Timestamp(long time) {
    super((time/1000)*1000);
    nanos = (int)((time%1000) * 1000000);
    if (nanos < 0) {
        nanos = 1000000000 + nanos;
        super.setTime(((time/1000)-1)*1000);
    }
}

public boolean equals(Timestamp ts) {
    if (super.equals(ts)) {
        if  (nanos == ts.nanos) {
            return true;
        } else {
            return false;
        }
    } else {
        return false;
    }
}

- 일관성(consistency)

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

두 객체가 같다면 (어느 하나 혹은 두 객체 모두가 수정되지 않는 한) 앞으로도 영원히 같아야 한다.

가변 객체는 비교 시점에 따라 서로 다를 수도 혹은 같을 수도 있는 반면, 불변 객체는 한번 대답한 것과 영원히 같게 답하도록 만들어야 한다.

equals()의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안된다.

equals는 항시 메모 리에 존재하는 객체만을 사용한 결정적(deterministic) 계산만 수행해야 한다.

▼ 주어진 URL과 매핑된 호스트의 IP를 이용해 비교하는데 호스트 이름을 IP 주소로 바꾸려면 네트워크를 통해야 하며 그 결과가 항상 같다고 보장할 수 없다. URL의 equals를 이렇게 구현한 것은 커다란 실수였으니 절대 따라 해서는 안 된다.

/** java.net.URL */
public boolean equals(Object obj) {
    if (!(obj instanceof URL))
        return false;
    URL u2 = (URL)obj;

    return handler.equals(this, u2);
}

- null-아님

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

모든 객체가 null과 같지 않아야 한다.

동치성을 검사하기 위해 equals는 받은 객체를 적절히 형변환한 후 필수 필드들의 값을 알아내야 하는데, 형변환에 앞서 instanceof 연산자를 합성하면 묵시적으로 null 검사를 포함할 수 있다.

@Override
public boolean equals(Object o) {
    if (!(o instance of MyType))
        return fasle;
    MyType mt = (MyType) o;
    // 생략
}

- 그 외 주의사항

equals를 재정의할 땐 hashCode도 반드시 재정의하자.

너무 복잡하게 해결하려 들지 말자. 필드들의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있다.

일반적으로 별칭은 비교하지 않는 게 좋다.

equals()의 매개변수로는 Object 타입만 받도록 한다.

// 잘못된 예
public boolean equals(MyType o) {
}

하위 클래스에서의 @Override 어노테이션이 긍정 오류(false positive: 거짓 양성)를 내게 하고 보안 측면에서도 잘못된 정보를 준다. 방지하기 위해 @Override 어노테이션을 일관되게 사용하여 방지할 수 있다.

♦︎ 양질의 equals() 단계별 구현 방법

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

성능 최적화를 위해 자기 자신이면 true를 반환한다.

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

equals가 정의된 클래스 혹은 그 클래스가 구현한 특정 인터페이스 타입이 맞는지 비교 후 그렇지 않다면 false를 반환한다.

인터페이스를 구현한 클래스끼리도 비교할 수 있도록 equals 규약을 수정한 인터페이스들은 Set, List, Map, Entry 등이 있다.

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

앞서 2번으로 무조건 성공한다.

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

모든 필드가 일치하면 true, 하나라도 다르면 false를 반환한다.

모든 필드가 일치하는지 확인할 때에는 타입에 따라 올바른 비교 방법*을 사용해야 한다.

2번에서 인터페이스를 사용했다면 입력의 필드 값을 가져올 때도 그 인터페이스의 메서드를 사용해야 한다.

앞서 예시였던 CaseInsensitiveString 처럼 비교하기 까다로운 필드를 가진 경우 그 필드의 표준형(canonical form)을 저장해둔 후 표준형끼리 비교하면 경제적이다. 가변 객체의 경우 값이 바뀔 때마다 표준형을 최신 상태로 갱신해줘야 하므로 불변 클래스의 경우 더 적합하다.

어떤 필드를 먼저 비교하냐가 equals의 성능을 좌우하기도 하므로 최상의 성능을 위해 다를 가능성이 더 크거나 비교하는 비요이 싼 필드를 먼저 비교하고, 동기화용 락(lock) 필드 같이 객체의 논리적 상태와 관련 없는 필드는 비교하지 않는다.

핵심 필드로부터 계산될 수 있는 파생필드도 비교할 필요가 없지만 파생 필드가 객체 전체의 상태를 대표하는 상황 등에서는 파생 필드를 비교하는 것이 더 빠를 수 있다.

♦︎ equals() 구현 후 테스트해 볼 세 가지

(equals()를 AutoValue를 이용해 작성했다면 테스트를 생략해도 된다.)

  • 대칭적인가?
  • 추이성이 있는가?
  • 일관적인가?

♦︎ AutoValue 프레임워크

구글이 만든 equals()를 작성하고 테스트하는 오픈소스이다.

클래스에 어노테이션을 추가하면 AutoValue가 이 메서드들을 알아서 작성해준다.


*객체 식별성(object identity) : 두 객체가 물리적으로 같은가

*♦︎ 타입에 따른 올바른 비교 방법

기본 타입 필드 (float, double 제외ㅒ)

== 연산자로 비교한다.

float, double

Float.compare(float, float), Double.compare(double, double)로 비교한다.

Float.NaN, -0.0f, 특수한 부동소수 값 등을 다뤄야 하기 때문에 별도로 제공되는 compare()를 사용해야 한다.

Float.equals(), Double.equals()를 사용할 수 있지만 이 오토박싱을 수반할 수 있어 성능상 좋지 않다.

참조 타입

equals()로 비교한다.

때로 null을 정상 값으로 취급하는 참조 타입 필드가 있는데 이런 경우 정적 메서드인 Objects.equals(Object, Object)로 비교해 NullPointException 발생을 예방해야 한다.

배열

원소 각각을 비교하는데, 전부 비교하려면 Arrays.equals 메서드들 중 하나를 사용할 수 있다.

반응형