[이펙티브자바] 3장. 모든 객체의 공통 메서드

이펙티브 자바 3장. 모든 객체의 공통 메서드

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

equals 메서드를 재정의 하기 위해서는 고려해야 할 점들이 많다. 아래의 경우에 해당한다면 재정의 하지 않는 것이 좋다

  • 각 인스턴스가 본질적으로 고유하다
  • 논리적 동시성을 검사할 일이 없다.
  • 상위클래스에서 정의한 equals가 하위클래스에서도 잘 동작한다.
  • 클래스가 private, package-private이고 equals메서드를 호출할 일이 없다.

하지만 위의 경우에 해당하지 않고 재정의가 필요할 경우(논리적 동치성을 확인해야 하는 경우) null이 아닌 모든 참조값 x,y,z에 대해 반드시 아래의 일반규약을 따라야한다.

  • 반사성 (x.equals(x) 는 true)
  • 대칭성 (x.equals(y) 는 true -> y.equals(x) 는 true)
  • 추이성 (x.equals(y) 는 true && y.equals(z) 는 true -> x.equals(z) 는 true)
  • 일관성 (x.equals(x) 는 true 일때 반복해서 호출해도 같은 값이 나온다.)
  • null-아님 (x.equals(null) 은 false)

양질의 equals 메서드를 구현하는 방법은 아래와 같다.

  • == 연산자를 사용해 자기 자신의 참조인지 확인한다.
    • 단순한 성능최적화용으로 작업이 복잡할때 주로 사용된다.
  • instanceof 연산자로 입력이 올바른 타입인지 확인한다.
  • 입력을 올바른 타입으로 형변환한다.
  • 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사한다.
  • equals를 재정의 할땐 반드시 hashcode도 재정의하자
  • Object 외의 타입을 매개변수로 받는 equals 는 선언하지 말자
  • AutoValue 프레임 워크를 사용하면 간편하게 테스트 할 수 있다.

Item 11. equals를 재정의 하려거든 hashCode도 재정의하라

equals를 재정의한 클래스 모두에서 hashCode도 재정의해야 한다. 그렇지 않으면 hashCode 일반 규약을 어기게 되어 해당 클래스의 인스턴스를 컬렉션 원소로 사용할때 문제가 발생한다.

  1. equals 비교에 사용되는 정보가 변경되지 않았다면, 애플리케이션이 실행되는 동안 그 객체의 hashCode 는 몇번을 호출해도 일관되게 항상 같은 값을 반환해야 한다.
  2. equals가 두 객체를 같다고 판단했다면 hashCode는 똑같은 값을 반환해야 한다.
  3. equals가 두 객체를 다르다고 판단했더라도 두 객체의 hashCode는 서로 다를필요는 없다.

일반적으로 hashCode의 재정의를 잘못했을때 문제되는 조항은 2번이다. 논리적으로 같은 객체는 같은 해시코드를 반환해야한다.

Map<PhoneNumber, String> m = new HashMap<>();
m.put(new PhoneNumber(707,867,5309), "제니");

위와 같이 정의된 HashMap에서

m.get(new PhoneNumber(707,867,5309));

을 하더라도 “제니” 라는 값은 나오지 않는다.(null이 나옴)

논리적 동치성을 갖는 두 new PhoneNumber(707,867,5309) 를 사용하였지만, 적절한 hashCode 재정의를 해주지 않았기때문에 각각의 hashCode가 달랐기 때문이다.

좋은 해시 함수라면 서로 다른 인스턴스에 다른 해시코드를 반환한다. 이것을 완벽하게 실행하기는 어렵지만 비슷하게는 구현할 수 있다.

  1. int 변수 reuslt 를 선언한 후 값 c로 초기화한다. 이때 c는 해당 객체의 첫 번째 핵심필드를 2.a 방식으로 계산한 해시코드이다.(핵심 필드는 equals 비교에 사용되는 필드)
  2. 나머지 핵심 필드에도 다음의 작업을 진행한다. a. 해당 필드의 해시코드 c를 계산한다. a.1. 기본 타입 필드라면, Type.hashCode(f)를 수행한다. a.2. 참조 타입 필드면서 이 클래스의 equals 가 이 필드의 equals를 재귀적으로 호출한다면, 이 필드의 hashCode를 재귀적으로 호출한다. a.3. 필드가 배열이라면, 핵심 원소 각각을 별도 필드처럼 다룬다. 이상의 규칙을 재귀적으로 적용해 각 핵심 원소의 해시코드를 계산한 다음 2.b 방식으로 갱신한다. b. 2.a에서 계산한 해시코드 c로 result를 갱신한다. (result = 31 * result + c;)
  3. result 반환

hashCode 재정의를 완료했다면 앞선 3가지 규칙에 부합한지 테스트해본다. 파생필드(다른필드로 부터 계산해 낼 수 있는)는 제외해도 된다. 또한, equals 비교에 사용되지 않는 필드는 반드시 제외해야 한다. 반대로 핵심필드를 생략해서는 안된다.

위의 방법을 적용하여 PhoneNumber 클래스에 적절한 hashCode를 아래와 같이 재정의 해줄 수 있다.

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

이외에도 com.google.common.hash.Hashing 를 쓰거나 Object.hash를 사용하여 쉽게 구현할 수 있지만 성능이 아쉬운 부분이 있다.

그리고 클래스가 불변이고 해시코드를 계산하는 비용이 크다면, 매번 계산하기보다는 캐싱을 사용하는 방법도 있다.

Item 12. toString을 항상 재정의하라

Object의 기본 toString 메서드는 우리가 작성한 클래스에서 적절한 정보를 반환해주지 못한다.(클래스이름@16진수해시코드 정도의 형태를 반환한다.)

따라서 toString의 규약에도 ‘모든 하위 클래스에서 이 메서드를 재정의하라’ 라고 나와있다. toString의 재정의를 통해 사용하기에 편하고 디버깅이 쉬워지며, 의미있는 로그를 남길 수 있다.

실제로 우리가 직접 toString을 사용하지 않더라도 다양한 경우에서 toString이 사용된다. 특히, 우리가 작성한 객체를 참조하는 컴포넌트가 toString을 통해 로그를 남길때 toString이 적절하게 재정의 되어있다면, 의미있는 로그를 남길 수 있을 것이다.

이때 toString의 효과적인 재정의를 위해서는 다음과 같은 조건이 필요하다.

  1. toString은 그 객체가 가진 주요정보 모두를 반환하는게 좋다.(단, 객체가 거대하거나 문자열로 표현하기 어렵다면 요약정보를 담아야한다.
  2. toString의 반한 포맷을 문서화 하면, 표준적이고, 명확하게 재정의 할 수 있다.
  3. 포맷을 명시하든 아니든 의도는 명확히 밝혀야 한다.
  4. toString이 반환한 값에 포함된 정보를 얻어올 수 있는 api를 제공하자.(toString의 값을 매번 파싱해서 쓸 수는 없으니 각각 요소를 따로 가져올 수 있도록 api 제공)
  5. AutoValue 프레임워크를 사용하면 자동으로 toString을 재정의해주지만, 완벽하게 의미와 개발자의 의도를 파악하지는 못하므로 필요시 수정해야한다.

Item 13. clone 재정의는 주의해서 진행해라

Cloneable 은 복제해도 되는 클래스 임을 명시하는 용도의 인터페이스이지만, clone 메서드가 선언된 곳이 Object이고, protected로 선언되어 있기때문에 항상 성공적으로 clone이 되는것은 아니다. Cloneable 은 protected로 선언된 clone의 동작방식을 결정하는 역할을 한다. Cloneable 구현체 인스턴스에서 clone() 호출 시 그 객체의 필드들을 하나하나 복사한 객체를 반환하고, Cloneable 구현체가 아닌 경우 CloneNotSupportedException을 던진다.

Cloneable 을 통해 clone이 이루어지기위해서는 복잡하고, 허술하게 기술된 아래와 같은 프로토콜을 지켜야 한다.

  1. x.clone() != x
  2. x.clone().getClass() == x.getClass()
  3. x.clone().equals(x)
  4. 관례상, 이 메서드가 반환하는 객체는 super.clone을 호출해 얻어야 한다
  5. 관례상, 반환된 객체와 원본 객체는 독립적이어야 한다.

위와 같은 요구조건들은 반드시 만족되어야 하는것은 아니기 때문에 그 과정이 깨지기 쉽고, 위험하고, 모순적인 매커니즘이 생성되게 된다.

예를들어, 클래스 B가 클래스 A를 상속할때 하위 클래스인 B의 clone은 B타입 객체를 반환해야 한다. 하지만 A의 clone이 자신의 생성자(new A())로 생성된 객체를 반환한다면, B의 clone 도 A타입 객체를 반환할 수 밖에 없다. 즉, super.clone을 연쇄적으로 호출하게 두면 상위에서 clone이 처음 호출된 상위클래스의 객체가 만들어지게 된다.

Clonable을 구현한 모든 클래스는 clone을 재정의 해야 한다. 이때 접근 제한자는 public으로 반환타입은 클래스 자신으로 변경한다.

그외의 방법으로는 복사 생성자와 복사 팩터리 라는 방식이 존재한다.

public Yum(Yum yum) {...}; // 복사 생성자

public static Yum newInstance(Yum yum) {...}; 복사 팩터리

두 가지 방법은 Clonable/clone 보다 나은면이 많다. 앞서 설명한 위험천만한 객체 생성 메커니즘을 사용하지 않고 엉성한 규양에 기대지 않으며, 불필요한 검사 예외(CloneNotSupportedException)를 던지지도 않고 형변환도 필요없다. 또한, 원본의 구현타입에 얽매이지 않고 복제본의 타입을 직접 선택할 수 있다.

  • 요약 Clonable/clone 응 다양한 위험요소가 많기때문에 드물게 사용하는 것이 좋다. 그보다는 생성자와 팩터리를 이용하는것이 좋으며, clone 메서드는 배열에서만 사용하는 것이 좋다.

Item 14. Comparable을 구현할지 고려하라

compareTo는 Object의 메서드가 아닌 Comparable 인터페이스의 유일무이한 메서드이다. compareTo는 equals 처럼 두 객체를 비교하는데 쓰이는데 차이점은 다음과 같다.

  1. 단순 동치성 비교에 더해 순서까지 비교할 수 있다.
  2. 제네릭하다.
  • Comparable 을 구현했다는 것은
    • 그 클래스의 인스턴스에는 자연적인 순서가 있다는 것을 의미하며, 그렇기 때문에 손쉽게 정렬할 수 있다.(Arrays.sort(a))
    • 그뿐 아니라 검색, 극단값 계산, 자동정렬되는 컬렉션 관리도 쉽게 할 수 있다. (즉, 제너릭 알고리즘과 컬렉션을 다루기 편리해진다.)

    이러한 이점들이 있기때문에 사실상 자바 플랫폼 라이브러리의 모든 값 클래스와 열거타입이 Comparable을 구현했다. 따라서, 알파벳, 숫자, 연대 같이 순서가 명확한 값 클래스를 작성한다면 반드시 Comparable 인터페이스를 구현하자

compareTo 메서드의 일반 규약은 equals의 규약과 비슷하다. 차이점이 있다면 모든 객체에 대해 전역 동치관계를 부여하는 equals 메서드와 달리, compareTo는 타입이 다른 객체를 신경쓰지 않아도 된다.(타입이 다른 객체가 주어지면 간단히 ClassCastException을 던져도 된다.) 물론 비교가 불가능 한 것은 아니고, 객체들이 구현한 공통 인터페이스를 통해 비교가 이루어지기도 한다. 또한, compareTo의 일반 규약을 지키지 못하면 비교를 활용하는 클래스와 어울리지 못한다.(TreeSet, TreeMap, Collections, Arrays 와 같이 검색과 정렬 알고리즘을 활용하는 클래스)

  • compareTo의 일반 규약을 살펴보면

    1. 두 객체 참조의 순서를 바꿔비교해도 예상한 결과가 나와야한다.
    2. 첫번째가 두번째 보다 크고, 두번째가 세번째 보다 크면 첫번째는 세번째보다 커야한다.
    3. 크기가 같은 객체들끼리는 어떤 객체와 비교하더라도 항상 같아야한다.

    즉, 앞서 살펴본 equals의 규약과 같이 반사성, 대칭성, 추이성을 충족해야한다.

    이때, Comparable을 구현한 클래스를 확장하여 새로운 값 컴포넌트를 추가하고 싶다면,(compartTo 규약을 지키면서) 독립된 클래스를 만들어 원래 인스턴스를 가르키는 필드를 두고, 내부 인스턴스를 반환하는 ‘뷰’메서드를 제공하면 된다.

    3번 규약은 필수는 아니지만 지켜지기를 권장한다. 3번 규약은 compareTo 메서드로 수행한 동치성 테스트의 결과가 equals와 같아야 한다는 것이다. 이를 지키게 되면 compareTo로 줄지은 순서와 equals의 결과가 일관되게 된다.

    만약 두 메서드의 결과가 다르다면 해당 클래스의 객체를 정렬된 컬렉션에 넣었을때, 해당 컬렉션이 구현한 인터페이스의 동작에 좋지 않은 영향을 줄 수 있다. 이 인터페이스들은 equals 메서드의 규약을 따르지만, 정렬된 컬렉션들은 동치성을 비교할때 compareTo를 사용하기 때문이다.

    예를들어, HashSet에 new BigDecimal(“1.0”) 과 new BigDecimal(“1.00”)을 추가할 경우 equals를 통해 비교되기 때문에 2개의 원소가 생성되고. TreeSet(정렬된 컬렉션)을 사용하면 compareTo 메서드로 비교하기 때문에 1개의 원소를 갖게 된다.

  • compareTo 메서드 작성 요령

    타입을 인수로 받는 제네릭 인터페이스이므로 입력 인수의 타입을 확인하거나 형변환할 필요가 없다.( 잘못됐다면 컴파일이 되지 않는다.) 관계 연산자 <, > 사용시 거추장스럽고 오류를 유발할 수 있으니 박싱된 기본 타입 클래스들의 compare 메서드 사용한다. 핵심필드가 여러개라면 가장 중요한 필드부터 비교해 나간다. Comparable 하게 구현되지 않았다면 비교자를 대신 사용한다. - 자바 8에서부터는 연쇄방식으로 비교자를 생성할 수 있지만 성능저하가 뒤따른다… - 수많은 보조 생성 메서드들이 있다(comparingInt, thenComparingInt…)


© 2020. All rights reserved.