본문 바로가기
books/이펙티브 자바 3판

[이펙티브 자바 : 2장] 모든 객체의 공통 메소드

by Moonsc 2020. 6. 27.
728x90

 

Object는 객체를 만들 수 있는 구체 클래스지만 기본적으로는 상속해서 사용하도록 설계되었다. Object에서 final이 아닌 메소드 (equals, hashCode, toString, clone, finalize)는 모두 재정의(오버라이딩)를 염두에 두고 설계된 것이라 재정의 시 지켜야 하는 일반 규약이 명확히 정의되어 있다.

그래서 Object를 상속하는 클래스, 즉 모든 클래스는 이 메소드들을 일반 규약에 맞게 재정의 해야 한다. 메소드를 잘못 구현하면 대상 클래스가 이 규약을 준수한다고 가정하는 클래스(HashMap과 HashSet 등)를 오동작 하게 만들 수 있다.

 

[아이템 10] equals는 일반 규약을 지켜 재정의하라

 

equals 재정의가 필요치 않는 경우 하나.

각 인스턴스가 본질적으로 고유한 경우. 값을 표현하는게 아니라 동작하는 개체를 표현하는 클래스가 여기에 해당한다. 쓰레드가 좋은 예로 Object의 equals 메소드는 이러한 클래스에 딱 맞게 구현되었다.

equals 재정의가 필요치 않는 경우 둘. 

인스턴스의  '논리적 동치성(logical equality)'을 검사할 일이 경우. 예컨대 자바 유틸 중 Pattern은 equals를 재정의해서 두 Pattern의 인스턴스가 같은 정규표현식을 나타내는지를 검사하는, 즉 논리적 동치성을 검사하는 방법도 있지만 설계자가 원하지 않거나 필요하지 않다고 판단하였다면 Object의 기본 equals만으로도 해결된다.

equals 재정의가 필요치 않는 경우 셋. 

상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는 경우

equals 재정의가 필요치 않는 경우 넷. 

클래스가 private이거나 package-private이고 equals 메소드를 호출할 일이 없는 경우

@Override
public boolean equals(Object o) {
    throw new AssertionError(); // 호출 금지
}

 

equals 메서드를 재정의할 때는 반드시 일반 규약을 따라야 한다. 다음은 Object 명세에 적힌 규약이다.

반사성 : null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 참이다.
대칭성 : null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)가 참이면 y.equals(x)도 참이다.
추이성 : null이 아닌 모든 참조 값 x, y, z에 대해 x와 y의 비교가 참이면 나머지도 모두 참이다.
일관성 : 반복 비교하더라도 결과는 항상 같아야 한다.
not null : null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false이다.

 

반서성

단순히 말하면 객체는 자기 자신과 같아야 한다. 이 요건은 일부러 어기는 경우 가아니라면 만족시키지 못하기가 더 어렵다.

대칭성

두 객체는 서로에 대한 동치 여부에 똑같이 대답해야 한다. 대소문자를 구별하지 않는 다음의 클래스를 살펴보자.

package com.company;

import java.util.Objects;

public class EqualsRule {

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

}


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

 

 

위 코드의 문제점은 CaseInsensitiveString의 equals는 일반 String의 equals를 알고 있지만, String의 equals는 CaseInsensitiveString의 equals를 모른다는 데 있다. 따라서 s.equals(cis)는 false를 반환하여, 대칭성을 명백히 위반한다.

equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없다.

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

 

추이성

첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 나머지도 같아야 한다는 뜻이다. 이 요건은 간단하면서도 자칫하면 어기기 쉽다.

간단히 2차원에서의 점을 표현하는 클래스를 예로 들어보자.

public class EqualsRule {

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

}

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 p.x == x && p.y == y;
    }
}

final class ColorPoint extends Point {
    private final Color color;

    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }

    //잘못된 코드 - 대칭성 위배
    @Override
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint)) {
            return false;
        }
        return super.equals(o) && ((ColorPoint) o).color == color;
    }
}

 

p.equals(cp)는 true를 cp.equals(p)는 false를 반환한다. ColorPoint.equals가 Point와 비교할 때는 색상을 무시하도록 해보겠다.

    //잘못된 코드 - 추이성 위배
    @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;
    }

이제는 추이성이 위배된다. 이 방식은 재귀에 빠질 위험도 있다. 사실 이 현상은 객체 지향 언어의 동치관계에서 나타나는 근본적인 문제다. 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 지킬 방법은 존재하지 않는다.

얼핏 instanceof 검사를 getClass 검사로 바꾸면 규약도 지키고 값도 추가하면서 구체 클래스를 상속할 수 있다는 뜻으로 들릴 수 있겠지만 사용할 수 없다. Point의 하위 클래스는 정의상 여전히 Point 이므로 Point는 어디서든 활용될 수 있어야 한다.

구체 클래스의 하위 클래스에서 값을 추가할 방법은 없지만 괜찮은 우회 방법이 있다.

"상속 대신 컴포지션을 사용하라" (아이템 18)의 조언을 따르면 된다.

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

//equals 규약을 지키면서 값 추가하기
class ColorPoint{
    private final Color color;
    private final Point point;

    public ColorPoint(int x, int y, Color color) {
        point = new Point(x, y);
        this.color = Objects.requireNonNull(color);
    }
    
    /*
        컬러 포인트의 포인트 뷰를 반환한다.
     */

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

 

자바 라이브러리에도 구체 클래스를 확장해 값을 추가한 클래스가 종종 있다. 한 가지 예로 java.sql.Timestamp는 java.util.Date를 확장한 후 nanoseconds 필드를 추가하였다. 그 결과로 Timestamp의 equals는 대칭성을 위배하며, Date 객체와 한 컬렉션에 넣거나 서로 섞어 사용하면 엉뚱하게 동작할 수 있다.

일관성 요건 하나.

두 객체가 같다면 앞으로도 영원히 같아야 한다. 가변 객체는 비교 시점에 따라 서로 다를 수 도 혹은 같을 수도 있는 반면, 불변 객체는 한번 다르면 끝까지 달라야 한다.

일관성 요건 둘.

또한 클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안된다. 항시 메모리에 존재하는 객체만을 사용한 결정적 계산만 수행해야 한다.

 

Not null

마지막 요건은 이름이 없으니 임의로 널이아님이라고 부르겠다.  모든 객체가 null과 같지 않아야 한다는 뜻이다. 수많은 클래스가 다음 코드처럼 입력이 null인지 확인하여 자신을 보호한다.

@Override
public boolean equals(Object o){
  if(o == null)
    return false;
}

 

이러한 검사는 필요치 않다. 동치성을 검사하려면 equals는 건네받은 객체를 적절히 형 변환한 후 필수 필드들의 값을 알아내야 한다. 그러려면 형 변환에 앞서 instanceof 연산자로 입력 매개변수가 올바른 타입인지 검사해야 한다.

//묵시적 not null 검사
@Override
public boolean equals(Object o){
  if(!(o instanceof MyType))
    return false;
  MyType mt =  (MyType) o;
  ....
}

 

여기까지가 equals 재정의에 필요한 요건들이다. 이런 까다로운 규칙은 사람으로 하여금 실수를 유발할 수 있으니 재정의는 IDE에 맡기는 편이 좋다. 또한 꼭 필요한 경우가 아니면 equals를 재정의하지 말자. 많은 경우에 Object의 equals가 여러분이 원하는 비교를 정확히 수행해준다. 

 

[아이템 11] equals를 재정의 하려거든 hashCode도 재정의하라

equals를 재정의한 클래스 모두에서 hashCode도 재정의해야 한다. 그렇지 않으면 hashCode의 일반 규약을 어기게 되어 HashMap이나 HashSet 같은 컬렉션을 사용할 때 문제가 발생할 것이다.

다음은 오브젝트 명세에서 발췌한 규약이다.

  • equals 비교에 사용되는 정보가 변경되지 않았다면, 애플리케이션이 실행되는 동안 그 객체의 hashCode 메서드는  몇 번을 호출해도 일관되게 항상 같은 값을 반환해야 한다. 단, 애플리케이션을 다시 실행한다면 이 값이 달라져도 상관없다.
  • equals(Object)가 두 객체를 같다고 판단했다면, 두 객체의 hashCode도 같은 값을 반환해야 한다.
  • equals(Object)가 두 객체를 다르다 판단했더라도, 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없다. 단, 다른 객체에 대해서는 다른 값을 반환해야 해시 테이블의 성능이 좋아진다.

HashCode 재정의를 잘못했을 때 크게 문제가 되는 조항은 두 번째다. 즉, 논리적으로 같은 객체는 같은 해시 코드를 반환해야 한다.

equals는 물리적으로 다른 두 객체를 논리적으로 가다고 할 수 있지만, 오브젝트의 기본 hashCode 메소드는 이 둘이 전혀 다르고 판단하여 규약과 달리 서로 다른 값을 반환한다.

Map<Foo, String> map = new HashMap<>();
map.put(new Foo(100), "제니");

 

이 코드 다음에 map.get(new Foo(100)) 을 실행하면 제니가 나와야 할 것 같지만, 실제로는 null을 반환한다. 여기에는 2개의 Foo 인스턴스가 사용되었다. 하나는 hashMap에 제니를 넣을 때 사용되었고, (논리적 동치인) 두 번째는 이를 꺼내려할 때 사용되었다. Foo 클래스는 hashCode를 재정의하지 않았기에 논리적 동치인 두 객체가 서로 다른 해시 코드를 반환하여 두 번째 규약을 지키지 못한다.

그 결과 map.get 메소드는 엉뚱한 해시 버킷에 가서 객체를 찾으려 한 것이다. 설사 두 인스턴스를 같은 버킷에 담았더라도 map.get 메소드는 여전히 null을 반환하는데, HashMap은 해시 코드가 다른 엔트리끼리는 동치성 비교를 시도조차 하지 않도록 최적화되어 있기 때문이다.

이 문제는 Foo에 적절한 hashCode 메소드를 작성해주면 해결된다.

최악의 HashCode 구현

@Override
public int hashCode() {
    return 42;
}

 

모든 객체에 똑같은 값만 내어주므로 모든 객체가 하나의 해시 테이블 버킷에 담겨 마치 연결 리스트처럼 동작하게 된다. 그 결과 평균 수행 시간이 O(1)인 해시 테이블이 O(n)으로 느려져서 후에는 도저히 쓸 수 없게 된다.

전형적인 hashCode

@Override
public int hashCode(){
    int result = Short.hashCode(areaCode);
    result = 31 * result + Short.hashCode(prefix);
    result = 31 * result + Short.hashCode(lineNum);
    return result;
}

 

이 코드는 핵심 필드 3개 만을 사용해 간단한 계산만 수행한다. 그 과정에 비결정적인 요소는 전혀 없으므로 동치인 Foo 인스턴스들은 같은 해시 코드를 가질 것이다.  사실 코드는 Foo에 딱 맞게 구현한 hashCode다. 자바 플랫폼 라이브러리의 클래스들이 제공하는 hashCode와 비교해도 손색이 없다. 단순하고, 층분히 바르며, 서로 다른 전화번호들은 다른 해시 버킷들로 제법 훌륭히 분배된다.

성능이 아쉽지만 더 단순한 HashCode

@Override
public int hashCode() {
    return Objects.hash(lineNum, prefix, areaCode);
}

 

이 방법은 클래스가 불변이고 해시 코드를 계산하는 비용이 크다면, 매번 새로 계산하기 보다는 캐싱하는 방식을 고려해야 한다. 이 타입의 객체가 주료 해시의 키로 사용될 것 같다면 인스턴스가 만들어질 때 해시코드를 계산해둬야 한다.

해시의 키로 사용되지 않는 경우라면 hashCode가 처음 불릴 때 계산하는 지연 초기화 전략은 어떤가? 필드를 지연 초기화하려면 그 클래스를 스레드 안전하게 만들도록 신경 써야 한다.

해시 코드를 지연 초기화하는 hashCode (Thread-safe)

private int hashCode; // 자동으로 0으로 초기화된다.

@Override
public int hashCode() {
    int result = hashCode;
    if (result == 0){
        result = Short.hashCode(areaCode);
        result = 31 * result + Short.hashCode(prefix);
        result = 31 * result + Short.hashCode(lineNum);
        hashCode = result;
    }
    return result;
}

 

성능을 높인답시고 해시코드를 계산할 때 핵심 필드를 생략해서는 안된다.

hashCode가 반환하는 값의 생성 규칙을 API 사용자에게 자세히 공표하지 말자. 그래야 클라이언트가 이 값에 의지하지 않게 되고, 추후에 계산 방식을 바꿀 수도 있다. 

 

[아이템 12] toString을 항상 재정의하라

Object의 기본 toString 메서드가 우리가 작성한 클래스에 적합한 문자열을 반환하는 경우는 거의 없다. 이 메소드는 대부분 클래스_이름@16진수로_표현한_해시코드를 반환할 뿐이다.

toString을 잘 구현한 클래스는 사용하기에 훨씬 즐겁고, 그 클래스를 사용한 시스템은 디버깅하기 쉽다. 

멋진 toString 재정의 방법 하나. 

간결하면서 사람이 읽기 쉬운 형태의 유익한 정보`를 반환해야 한다.

멋진 toString 재정의 방법 둘. 

모든 하위 클래스에서 이 메소드를 재정의하라.

멋진 toString 재정의 방법 셋. 

그 객체가 가진 주요 정보 모두를 반환하는 게 좋다.

하지만 객체가 거대하거나 상태가 문자열로 표현하기에 적합하지 않다면 `맨해튼 거주자 전화번호부(총 14888823개)`나 Thread [main,5, main] 같은 요약 정보를 담아야 한다. 이상적으로 toString은 스스로를 완벽히 설명하는 문자열이어야 한다.

멋진 toString 재정의 방법 넷. 

포맷을 명시하든 아니든 여러분의 의도는 명확히 밝혀야 한다. 단, 포맷을 명시하려면 아주 정확하게 해야 한다.

멋진 toString 재정의 방법 다섯. 

toString이 반환한 값에 포함된 정보를 얻어올 수 있는 API를 제공하자. 그렇지 않으면 정보가 필요한 프로그래머는 toString의 반환 값을 파싱 할 수밖에 없다. 성능이 나빠지고, 필요하지도 않은 작업이다. 게다가 추후 포맷을 바꾸면 시스템이 망가지는 결과를 초래할 수 있다.

접근자를 제공하지 않으면 (변경될 수 있다고 문서화했더라도) 그 포맷이 사실상 준-표준 API와 같다.

핵심정리

모든 구체 클래스에서 Object의 toString을 재정의하자. 상위 클래스에서 이미 알맞게 재정의한 경우는 예외다. 

 

[아이템 14] Comparable을 구현할지 구려하라

Comparable 인터페이스의 유일무이한 메소드인 compareTo는 Object의 메소드가 아니다. 성격은 두 가지만 빼면 Object의 equals와 같다. compareTo는 단순 동치성 비교에 더해 순서까지 비교할 수 있으며, 제네릭하다. Comparable을 구현했다는 것은 그 클랫의 인스턴스들에는 자연적인 순서가 있음을 뜻한다.

Comparable을 구현한 객체들의 배열은 다음처럼 손쉽게 정렬할 수 있다.

Arrays.sort(a);

 

검색, 극단값 계산, 자동 정렬되는 컬렉션 관리도 역시 쉽게 할 수 있다. 예컨대 다음 프로그램은 명령줄 인수들을 (중복을 제거하고) 알파벳순으로 출력한다. String이 Comparable을 구현한 덕분이다.

public class WordList {
    public static void main(String[] args){
        Set<String> set = new TreeSet<>();
        Collections.addAll(set, args);
        System.out.println(set);
    }
}

 

좁쌀만 한 노력으로 코끼리만 한 큰 효과를 얻을 수 있다. 사실상 자바 플랫폼 라이버리의 모든 값 클래스와 열거타입이 Comparable을 구현했다. 알파벳, 숫자, 연대 같이 순서가 명확하다면 Comparable을 반드시 구현하자.

public interface Comparable<T> {
    int compareTo(T t);
}

 

compareTo 메소드의 규약

이 객체와 주어진 객체의 순서를 비교한다. 이 객체가 주어진 객체보다 작으면 음의 정수를, 같으면 0을, 크면 양의 정수를 반환한다. 이 객체와 비교할 수 없는 타입의 객체가 주어지면 ClassCastException을 던진다.

다음 설명에서 sgn(표현식) 표기는 수학에서 말하는 부호 함수를 뜻하며, 표현식의 값이 음수, 0 , 양수일 때 -1, 0, 1을 반환하도록 정의했다.

 

  • Comparable을 구현한 클래스는 모든 x, y에 대해 sgn(x.compareTo(y)) == -sgn(y.comparaTo(x))여야 한다. 
    • (따라서 x.compareTo(y)는 y.compareTo(x)가 예외를 던질때에 한해 예외를 던져야 한다).
  • Comparable을 구현한 클래스는 추이성을 보장해야 한다.
    • 즉, (x.compareTo(y) > 0 && y.compareTo(z) > 0) 이면 x.compareTo(z) > 0 이다.
  • Comparable을 구현한 클래스는 모든 z에 대해 x.compareTo(y) == 0 이면 sgn (x.compareTo(z)) == sgn (y.compareTo(z))다.
  • 이번 권고가 필수는 아니지만 꼭 지키는게 좋다.Comparable을 구현하고 이 권고를 않지킨 모든 클래스는 그 사실을 명시해야 한다.
    • (x.compareTo(y) == 0) == (x.equlas(y))여야 한다. 

 

수학적인 이야기라 해서 겁먹을 것 없다. equals(아이템 10)이 그랬던 것처럼, 생각보다 복잡하진 않다. 모든 객체에 대해 전역 동치관계를 부여하는 equals 메소드와 달리, compareTo는 타입이 다른 객체를 신경쓰지 않아도 된다. 

hashCode 규약을 지키지 못하면 해시를 사용하는 클래스와 어울리지 못하듯, compareTo 규약을 지키지 않는다면 비교를 활용하는 클래스와 어울리지 못한다. 비교를 활용하는 클래스의 예로는 정렬된 컬렉션인 TreeSet과 TreeMap, 검색과 정렬 알고리즘을 활용하는 유틸리티 클래스인 Collections와 Arrays가 있다.

compareTo 메서드에서 관계연산자 (<  >)를 사용하지 말아야 한다. 대신 Type.compare(T t1, T t2)를 사용하여 비교하는 것이 좋다.

Comparetor 안티패턴.  해시코드 값의 차를 기준으로 하는 비교자는 추이성을 위배한다.

static Comparator<Object> hashCodeOrder = new Comparator<>() {
    public int compare(Object o1, Object o2){
        return o1.hashCode() - o2.hashCode();
    }
}

 

Comparator 개선 하나. 정적 compare 메소드를 활용한 비교자

static Comparator<Object> hashCodeOrder = new Comparator<>() {
    public int compare(Object o1, Object o2){
        return Integer.compare(o1.hashCode(), o2.hashCode());
    }
}

 

Comparator 개선 둘. 비교자 생성 메소드를 활용한 비교자

static Comparator<Object> hashCodeOrder = 
Comparator.comparingInt(o -> o.hashCode());

 

핵심

순서를 고려해야 하는 값 클래스를 작성한다면 꼭 Comparable 인터페이스를 구현하여, 그 인스턴스들을 쉽게 정렬하고, 검색하고, 비교 기능을 제공하나는 컬렉션과 어우러지도록 해야 한다. compareTo 메소드에서 필드의 값을 비교할 때 < 와 > 연산자는 쓰지 말아야 한다. 그 대신 박싱된 기본 타입 클래스가 제공하는 정적 compare 메소드나 Comparator 인터페이스가 제공하는 비교자 생성 메소드를 사용하자.

 

 

 

 

댓글