본문 바로가기

Reading/Effective Java

[Effective-Java] Item 10. equals는 일반 규약을 지켜 재정의하라

equals는 재정의하지 않으면 클래스의 인스턴스는 자기 자신과만 같게 된다. 따라서 필요한 경우에만 재정의해서 사용해야한다.

 

equals를 재정의하지 않아도 될 때


1. 각 인스턴스가 본질적으로 고유할 때.

즉, 값이 아니라 동작하는 개체를 표현할 때. ex) Thread 

 

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

java.util.regex.Pattern은 equal를 재정의하여 두 Pattern의 인스턴스가 같은 정규표현식인지 검사한다.

하지만 설계자의 판단에 따라 기본 Object의 equals를 사용해도 된다.

 

3. 상위 클래스에서 재정의한 equals가 하위 클래스에도 들어맞을 때

ex) Set은 AbstractSet, List구현체들은 AbstractList, Map 구현체들은 AbstractMap으로부터 equals를 상속받아서 사용한다.

상세한 예시는 아래 추이성 부분에 있으니 참고하자.

4. 클래스가 private이거나 package-private고, equals 메소드를 호출할 일이 없을 때

실수로라도 equals가 호출되는 걸 피하고 싶다면, equals를 재정의하면서 예외를 던지자

 

 

equals를 재정의 해야할 때


두 객체가 물리적 동일 여부를 판단하는 객체 식별성(object identity)이 아니라 값 클래스 등의 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때다.(주로 값 클래스가 여기 해당)

객체가 같은지가 아니라 값이 같은지 알고싶을 때 기대에 부응함은 물론, Map의 키와 Set의 원소로 사용할 수 있게된다.

 

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

Enum도 여기에 해당하고, 이런 클래스에서는 어차피 논리적으로 같은 인스턴스가 둘 이상 만들어지지 않으니 논리적 동치성과 객체 식별성이 사실상 똑같은 의미가 되고, Object의 equals가 논리적 동치성까지 확인해준다고 볼 수 있다.

 

 

Equals 메소드 재정의 일반 규약


컬렉션 클래스들을 포함해 수 많은 클래스는 전달받은 객체가 다음으로 설명할 equals() 규약을 지킨다고 가정하고 동작한다.

이 규약을 어기면 프로그램의 이상동작, 종료, 원인 코드 어려움을 겪게된다.

 

equals 메소드는 동치관계(equivalence relation)를 구현한다.

동치관계: 집할을 서로 같은 원소들로 이뤄진 부분집합으로 나누는 연산. 이 때 부분집합을 동치류(equivalence calss:동치클래스)라 한다.

 

1. 반사성(reflexivity)

null이 아닌 모든 참조 값 x에 대해 x.equals(x)는 true다. 객체는 자기 자신과 같아야 한다는 뜻이다.

이 요건을 어긴 클래스의 인스턴스를 컬렉션에 넣은 다음 contains 메소드를 호출하면 없다고 나올거다.

2. 대칭성(symmetry)

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

두 객체는 서로에 대한 동치 여부에 똑같이 답해야한다는 뜻이다. 아래는 대칭성을 위반한 equals 구현이다.

 

public final class CaseInsensitiveString {
    private final String s;

    public CaseInsensitiveString(String s) {
        this.s = Objects.requireNonNull(s);
    }
    
    // equalsIgnoreCase는 대소문자를 구분하지 않고 같은지 비교
    // 대칭성 위배!
    @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("Polish");
        String s = "polish";

        List<CaseInsensitiveString> list = new ArrayList<>();
        list.add(cis);
        System.out.println(cis.equals(s)); // true
        System.out.println(s.equals(cis)); // false
        System.out.println(list.contains(s)); // false
    }
}

 

main문에서 cis.equals(s)를 호출하면 CaseInsensitiveString의 재정의된 equals를 사용하고, 파라미터로 넘어온 Object가

String타입이기 때문에 if문에 걸려서, String에서의 equals를 통해 CaseInsensitiveString의 String 필드와 비교를해서 true가된다.

 

하지만 s.equals(cis)는 다르다. String 입장에서는 CaseInsensitiveString을 모르기때문에, String 내의 equals를 보면 String 타입이 아니면 false를 넘기기 때문에 false가 반환된다.

 

 

즉, 동치성이 위배되었다.

그렇다면 list.contains는 어떨까? ArrayList에서 정의한 contains는 indexOfRange 메소드를 호출해서 비교한다. 

 

 

indexOfRange는 파라미터로 넘긴 객체(o).equals()로 반복문을 돌며 탐색한다. 그러면 파라미터로 넘긴 것은 String 이기때문에,

String의 equals로 비교할거고, CaseInsensitiveString의 존재를 모르기때문에 당연히 false가 반환되는 것이다.

 

물론 jdk 버전이 바뀌거나 다른 jdk에서는 true를 반환하거나(CaseInsensitiveString의 equals 메소드를 호출할 경우) 런타임 에러를 발생시킬지도 모른다. equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없다.

 

수정된 CaseInsensitiveString의 equals는 아래와 같다.

String과 CaseInsensitiveString을 연동하겠다는 허황된 꿈만 버리면 된다.

 

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

 

3. 추이성(transitivity)

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

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

상위 클래스에는 없는 새로운 필드를 하위 클래스에 추가하는 상황이다.

 

필드로 int x, y를 가지는 Point 클래스와, Point 클래스를 상속받고 Color 필드를 가지는 ColorPointIn 클래스가 있다고 해보자.

// Point 클래스의 equals 재정의 ColorPointIn은 재정의 안함
@Override
public boolean equals(Object o) {
    if (!(o instanceof Point))
        return false;
    Point p = (Point) o;
    return p.x == x && p.y == y;
}

public static void main(String[] args) {
    Point p = new Point(1, 2);
    ColorPointIn cp = new ColorPointIn(1, 2, Color.RED);
    System.out.println(p.equals(cp) + " " + cp.equals(p)); // true true
}

 

이 경우 결과는 cp와 p모두 Point 클래스의 equals를 사용하기 때문에 둘 다 true가 나오지만 색상 정보는 무시된 체 비교된다.

그러면 메인문은 고정하고 ColorPointIn에서 위치와 색상이 같을 때만 true를 반환하는 equals는 어떨까?

 

// ColorPointIn 재정의 추가 - 대칭성 위배
@Override public boolean equals(Object o) {
    if (!(o instanceof ColorPointIn))
        return false;
    return super.equals(o) && ((ColorPointIn) o).color == color;
}

 

이 경우는 대칭성에 위배된다. p.equals(cp)는 위치가 같으니 true를 줄 거고,

cp.equals(p)는, P가 ColorPointIn 타입이 아니라 false를 반환할 것이다.

그러면 ColorPointIn.equals가 Point와 비교할 때는 색상을 무시하게 한다면?

 

// ColorPointIn 재정의 수정 - 추이성 위배
@Override public boolean equals(Object o) {
    if (!(o instanceof Point))
        return false;

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

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

public static void main(String[] args) {
    ColorPointIn p1 = new ColorPointIn(1, 2, Color.RED);
    Point p2 = new Point(1, 2);
    ColorPointIn p3 = new ColorPointIn(1, 2, Color.BLUE);
    System.out.printf("%s %s %s%n", p1.equals(p2), p2.equals(p3), p1.equals(p3));
}

 

이번에는 추이성을 위배하게 된다. p1.equals(p2)는 위치가 같으니 true, p2.equals(p3)도 위치가 같으니 true이나

p1.equals(p3)를 한다면 색상이 달라서 false가 나온다. 그리고 이 방법은 무한 재귀에 바찔 위험이 있다.

Point를 상속받은 또다른 클래스 SmellPoint를 만들고 equals는 추이성이 위배된 바로 위 코드와 똑같이 작성했다고 해보자.

그러면 이 구간에서 SmellPoint와 ColorPointIn은 무한 재귀에 빠지게된다.

 

if (!(o instanceof SmellPoint))
    return o.equals(this);

 

둘 다 Point를 상속받아 Point 타입과 같은지의 조건은 통과했을 거고, 이 구간에서 계속 파라미터의 equals를 부르게된다.

 

해결법은 뭘까?

 

이 현상은 모든 객체 지향 언어의 동치관계에서 나타나는 근본적 문제다.

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

equals 안의 instance 검사를 getClass 검사로 바꾸면 규약도 지키고 값도 추가하면서 구체 클래스를 상속할 수 있을까?

 

// Point 클래스 equals 재정의 수정, ColorPointIn 재정의 없음 - 리스코프 치환 원칙 위배
@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;
}

 

이번 equals는 Point와 같은 구현 클래스의 객체와 비교할 때만 true를 반환한다. 실제로 활용할 수 없다.

여전히 Point 하위 클래스는 정의상 Point이므로 어디서든 Point로써 활용될 수 있어야한다.

리스코프 치환 원칙을 위배한 것이다.(아래에서 설명)

 

예를 들어 주어진 점이 포함되는지 판별하는 메소드가 필요하다고 하고, Point를 상속받은 CounterPoint가 이 개수를 증가시킨다고 하자.

 

public class CounterPoint extends Point {
    private static final AtomicInteger counter =
            new AtomicInteger();

    public CounterPoint(int x, int y) {
        super(x, y);
        counter.incrementAndGet();
    }
    public static int numberCreated() { return counter.get(); }
}

 

public class CounterPointTest {
    // 단위 원 안의 모든 점을 포함하도록 unitCircle을 초기화한다.
    private static final Set<Point> unitCircle = Set.of(
            new Point( 1,  0), new Point( 0,  1),
            new Point(-1,  0), new Point( 0, -1));

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

    public static void main(String[] args) {
        Point p1 = new Point(1,  0);
        Point p2 = new CounterPoint(1,  0);
        // true를 출력한다.
        System.out.println(onUnitCircle(p1));
        // true를 출력해야 하지만, Point의 equals가 getClass를 사용해 작성되었다면 그렇지 않다.
        System.out.println(onUnitCircle(p2));
    }
}

 

리스코프 치환 원칙(Liskov substitution principle)

상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다

 

CounterPoint의 인스턴스를 onUnitCircle 메소드에 넘기면 false를 반환할 것이다. Set의 contatins에서 probe를 호출하는데

probe에서 비교할 때 파라미터의 equals를 호출하고, CounterPoint의 equals가 없음으로 Point의 재정의된 equals를 사용한다.

getClass()로 비교했기때문에 CounterPoint는 Point와 같을 수 없다. 

그래서 리스코프 치환원칙을 위배한 것이다. 

 

 

우회방법

 

(1) 구체 클래스의 하위 클래스에 값을 추가할 방법은 없다. 따라서 상속 대신 컴포지션을 이용하기

 

public class ColorPointComp {
    private final Point point;
    private final Color color;

    public ColorPointComp(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 ColorPointComp))
            return false;
        ColorPointComp cp = (ColorPointComp) o;
        return cp.point.equals(point) && cp.color.equals(color);
    }

    @Override public int hashCode() {
        return 31 * point.hashCode() + color.hashCode();
    }
}

 

ColorPoint 끼리는 각각 비교하면 되고, Point끼리도 각각 비교하면된다. ColorPoint와 Point는 asPoint를 호출해 비교하면 된다.

 

자바 라이브러리에도 구체 클래스를 확장해 값을 추가한 클래스가 있다.

java.sql.Timestamp는 java.util.Date를 확장한 후 Nanoseconds 필드를 추가했다.

그 결과로 Timestamp의 equals는 대칭성을 위배하고, Date 객체와 한 컬렉션에 넣거나 섞어 사용하면 엉뚱하게 동작한다.

Timestamp가 이렇게 설계한 것은 실수니 절대 따라해선 안된다.

 

// timestamp의 equals
public boolean equals(java.lang.Object ts) {
	if (ts instanceof Timestamp) {
		return this.equals((Timestamp)ts);
	} else {
		return false;
	}
}

// date의 equals
public boolean equals(Object obj) {
	return obj instanceof Date && getTime() == ((Date) obj).getTime();
}

 

dateX.equals(timeStampX)는 true겠지만, timeStampX.equals(dateX)는 false가 나오게된다.

 

 

(2) 추상 클래스의 하위 클래스 사용

추상 클래스의 하위 클래스는 equals 규약을 지키며 값을 추가할 수 있다.

추상 클래스는 인스턴스를 만들 수 없으니, 하위 클래스들끼리 비교할 수 있다.

 

 

4. 일관성(consistency)

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

두 객체가 같다면 앞으로도 영원히 같아야 한다는 뜻이다. 가변 객체는 비교 시점에 따라 다를 수 있지만 불변 클래스는 끝까지 같아야한다.

클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안된다.

 

java.net.URL의 equals는 주어진 URL과 매핑된 호스트의 IP 주소를 이용해 비교한다. 호스트 이름을 IP 주소로 바꾸려면 네트워크를 통해야 하는데, 그 겨로가가 항상 같다고 보장할 수 없고, 이는 실무에서도 종종 문제를 일으킨다. 이는 실수였으니 따라해선 안된다.

 

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

 

5. null-아님

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

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

 

// 명시적 null 검사: 필요엇다!
@Override public boolean equals(Object o){
  if( o == null){
    return false;
  }
}

// 묵시적 null 검사: instanceof는 타입 확인 단계에서 null이 오면 false를 반환한다.
@Override public boolean equals(Obejct o){
  if(!(o instanceof MyType))
    return false;
  MyType mt = (MyType) o;
}

 

 

양질의 equals 메소드 구현 방법의 단계


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

자기 자신이면 true를 반환하자

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

올바른 타입의 경우 equals가 정의된 클래스지만, 가끔 그 클래스가 구현한 특정 인터페이스가 될 수 있다.

어떤 인터페이스는 자신을 구현한 클래스끼리도 비교할 수 있도록 equals 규악을 수정하기도 한다.

이런 인터페이스를 구현한 클래스라면 equals에서 클래스가 아닌 해당 인터페이스를 사용해야 한다.

Set, List, Map, Entry 등의 컬렉션 인터페이스들이 여기 해당한다.

 

ex) ArrayList의 equals를 보면 List 인터페이스의 타입인지 확인을 하는 것을 알 수 있다.

 

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

2단계에서 수행했기에 이 단계는 통과한다.

 

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

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

타입이 클래스라면(접근 권한에 따라) 해당 필드에 직접 접근할 수도 있다.

ex) ArrayList의 equals를 보면 getClass로 ArrayList인지 확인하고 각 인스턴스에 따라 다른 액션을 취한다.

 

 

 

추가 체크리스트


1. 각 타입별 비교법 

기본 타입: == 연산자로 비교

참조 타입: 각각의 equals 메소드

배열 타입: 원소 각각을 앞서 지침대로 비교, 모든 원소가 핵심이면 Arrays.equals 메소드들 중 하나를 사용

 

* float과 double은 Float.compare(float, float)과  Double.compare(double, double) 정적 메소드로 비교하자.

이들을 특별취급하는 이유는 Float.Nan, -0.0f, 특수한 부동 소스값등을 다뤄야하기 떄문이다. 

Float.equals(), Double.equals()도 있지만 오토박싱을 수반해 성능이 떨어진다.

 

 

2. null 정상값 취급 예방

Object.equals(Object, Object)로 비교해 NPE를 예방하자.

 

 

3. 필드의 표준형(canonical form)을 저장해둔 후 표준형끼리 비교하기

앞선 CaseInsensitiveString처럼 비교하기 아주 복잡한 필드의 클래스들에 사용하자. 특히 불변 클래스에 제격이다.

가변 객체라면 값이 바뀔 때마다 표준형을 최신 상태로 갱신해줘야 한다.

참고한 링크: (1),  (2)

 

 

4. 필드 비교 순서의 equals 성능 좌우

다룰 가능성이 더 큰, 비교 비용이 비싼 필드를 먼저 비교하자. 동기화용 락(lock) 필드 같이 객체의 논리적 상태와 관련 없는 필드는 비교하면 안된다. 말 그대로 더 중요한 필드들이 있을텐데 굳이 동기화용으로 사용하는 boolean? 일지 모르는 lock 필드를 비교할 필요가 없다.

 

핵심 필드로부터 계산해낼 수 있는 파생 필드 역시 굳이 비교할 필요 없지만 파생 필드를 비교하는 쪽이 더 빠를 때도 있다.

파생 필드가 객체 전체의 상태를 대표하는 상황이 그렇다.

ex) 자신의 영역을 캐시해두는 Polygon 클래스가 있다면 모든 변과 정점을 일일이 비교할 필요 없이 캐시해둔 영역만 비교하면 된다.

 

 

5. 대칭적인가? 추이성이 있는가? 일관적인가? 를 자문하고 단위 테스트로 확인하기

 

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

 

7. 너무 복잡하게 해결하려 들지 말자.

필드 동치성만 검사해도 equals 규약을 어렵지않게 지킨다. 일반적으로 별칭(alias)은 비교하지 않는게 좋다.

 

 

8. Ojbect 외의 타입을 매개변수로 받는 equals 메소드 선언 금지

@Override
public boolean equals(MyClass o){
	...
}

이는 Object.equals를 재정의한 게 아니다. 입력타입이 Object가 아니니 다중정의한 것이다. 따라서 Override되지 않는다.

기본 equals를 그대로 둔 채로 추가한 것일지라도, 타입을 구체적으로 명시한 equals는 하위 클래스에서

@Override 애노테이션이 긍정 오류(false positive: 거짓 양성)를 내게 하고 보안 측면에서도 잘못된 정보를 준다.

 

 

 

전형적인 equals 메소드의 예


 

public final class PhoneNumber {
    private final short areaCode, prefix, lineNum;
    public PhoneNumber(int areaCode, int prefix, int lineNum) {
        this.areaCode = rangeCheck(areaCode, 999, "지역코드");
        this.prefix   = rangeCheck(prefix,   999, "프리픽스");
        this.lineNum  = rangeCheck(lineNum, 9999, "가입자 번호");
    }
    private static short rangeCheck(int val, int max, String arg) {
        if (val < 0 || val > max)
            throw new IllegalArgumentException(arg + ": " + val);
        return (short) val;
    }
    @Override public boolean equals(Object o) {
        if (o == this)
            return true;
        if (!(o instanceof PhoneNumber))
            return false;
        PhoneNumber pn = (PhoneNumber)o;
        return pn.lineNum == lineNum && pn.prefix == prefix
                && pn.areaCode == areaCode;
    }
}

 

 

AutoValue 프레임워크 


equals, hashcode를 작성하고 테스트 하는 것은 지루하다. 이 작업을 대신해 줄 오픈소스는

구글이 만든 프레임워크로, 클래스에 애노테이션 하나만 추가하면 AutoValue가 이 메소드들을 알아서 작성해준다

Lombok 느낌인데 나중에 알아보자. 

 

 

 

정리


꼭 필요한 경우가 아니면 equals를 재정의 하지말고, 해야하면 핵심 필드를 모두 다섯가지 규약을 지켜 비교해야한다.

 

 

 

 

* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.