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

[Effective Java] 이펙티브자바 정리: 3장 모든 객체의 공통 메서드

by 책 읽는 개발자_테드 2022. 1. 25.
반응형

목차

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

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

· 아이템 12. toString을 항상 재정의하라

· 아이템 13. clone 재정의는 주의해서 진행하라

· 아이템 14. Comparable을 구현할지 고려하라


· Object는 객체를 만들 수 있는 구체 클래스지만 기본적으로 상속해서 사용하도록 설계되었다.

· Object에서 final이 아닌 메서드(equals, hashCode, toString, clone, finalize)는 모두 재정의(overriding)를 염두에 두고 설계된 것이라 재정의 시 지켜야 하는 일반 규약이 명확히 정의되어 있다.

- 메서드를 잘못 구현하면 대상 클래스가 이 규약을 준수한다고 가정하는 클래스(HashMap과 HashSet 등)이 오작동하게 만들 수 있다.

 

· 이 장에서는 Object 메서드들이 언제 어떻게 재정의해야 하는지를 다룬다. Comparable.compareTo 또한 성격이 비슷하여 이번 장에서 함께 다룬다. 

 

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


·  equals 메서드는 잘못 재정의하면 끔찍한 결과를 초래한다. 문제를 회피하는 가능 쉬운 길은 아예 재정의 하지 않는 것이다. 그냥 두면 그 클래스의 인스턴스는 오직 자기 자신과만 같게 된다.

 

equals를 재정의하지 않아야 하는 상황

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

- 값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스가 여기에 해당한다. ex) Thread

2. 인스턴스의 논리적 동치(logical equality)를 검사할 일이 없다.

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

ex) 대부분의 Set 구현체는 AbstractSet이 구현한 equals를 상속 받아 쓴다. List와 AbstractList, Map과 AbstracMap도 그렇다.

4. 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다.

 

· equals를 실수로라도 호출되는 걸 막고 싶다면 다음처럼 구현해두자.

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

 

equals를 재정의해야 하는 상황

·  객체 식별성 아니라 논리적 동치성을 확인해야하는 상황이고, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않는다면 equals를 재정의해야 한다.

- 주로 값 클래스(Integer, String)들이 여기 해당한다.

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

- 같은 인스턴스가 2개 이상 만들어지지 않으면, 논리적 동치성과 객체 식별성이 사실상 똑같은 의미가 된다.

- 객체 식별성: Object identity, 두 객체가 물리적으로 같은가

 

· equals가 논리적 동치성을 확인하도록 재정의하면, Map의 키와 Set의 원소로 사용할 수 있게 된다.

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

- 이 규약을 어기면 프로그램이 이상하게 동작하거나 종료될 것이고, 원인이 되는 코드는 찾기 굉장히 어려울 것이다.

 

Object 명세에 적힌 규약

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

1. 반사성(reflexivity): null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true다.

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

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

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

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

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

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

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

 

· 동치관계란 집합을 서로 같은 원소들로 이뤄진 부분집합으로 나누는 연산이다.

- 이 부분집합을 동치류(equivalence class; 동치 클래스)라 한다.

- equals 메서드가 쓸모 있으려면 모든 원소가 같은 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야 한다.

 

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

(객체 지향적 추상화의 이점을 포기하지 않는 한)

- 이는 추이성을 깨는 행위다.

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

- 우회 방법: 상속이 아닌 컴포지션을 사용한다.

 

양질의 equals 메서드 구현 방법

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

-  이는 단순한 성능 최적화용이다.

2. instanceof 연산자로 입력이 올바른 타입인지 확인한다. 그렇지 않으면 false를 반환한다.

- 올바른 타입은 equals가 정의된 클래스인 것이 보통이지만, 가끔은 그 클래스가 구현한 특정 인터페이스가 될 수도 있다.

- 어떤 인터페이스는 자신을 구현한 서로 다른 클래스끼리도 비교할 수 있도록 equals 규약을 수정하기도 한다. 이런 인터페이스를 구현한 클래스라면 equals에 클래스가 아닌 해당 인터페이스를 사용해야 한다. ex) Set, List, Map, Map.Entry

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

- 앞서 instanceof 검사를 했기 때문에 이 단계는 100% 성공 한다.

4. 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사한다. 모든 필드가 일치하면 true를 하나라도 다르면 false를 반환한다.

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

 

TODO: p63 이후

 

 

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


· equals를 재정의한 클래스 모두에서 hashCode도 재정의 재정의해야 한다.

- 그렇지 않으면 hashCode 일반 규약을 어기게 되어 해당 클래스의 인스턴스를 HashMap, HashSet 등 컬렉션의 원소로 사용할 때 문제가 발생한다.

 

·  아래는 Object 명세의 hashCode 관련 규약을 발췌한 것이다. hashCode 재정의를 잘못했을 때, 두 번째 조항이 크게 문제가 된다.

- 즉, 논리적으로 같은 객체는 같은 해시코드를 반환해야 한다.

- equals는 물리적으로 다른 두 객체를 논리적으로 같다고 할 수 있다.

하지만 Object의 기본 haCode 메서드는 이 둘이 전혀 다르다고 판단하여 서로 다른 값을 반환한다.

· equals 비교에 사용되는 정보가 변경되지 않았다면, 애플리케이션이 실행되는 동안 그 객체의 hashCode 메서드는 
몇 번을 호출해도 일관되게 항상 같은 값을 반환해야 한다. 단, 애플리케이션을 다시 실행한다면 이 값이 달라져도 상관없다.

· equals(Object)가 두 객체를 같다고 판단했다면, 두 객체의 hashCode는 똑같은 값을 반환해야 한다.

· equals(Object)가 두 객체를 다르다고 판단했더라도, 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없다. 
단, 다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다.

 

·  좋은 해시 함수라면 서로 다른 인스턴스에 다른 해시코드를 반환해야한다.

- 위 규약의 세 번째 규약이 요구하는 속성이다.

- 다음은 동치인 모든 객체에서 똑같은 해시코드를 반환하는 최악이지만 적법한 코드다. 모든 객체가 해시테이블 버킷 하나에 담겨 마치 연결 리스트처럼 동작한다. 그 결과 평균 수행 시간이 O(1)인 해시테이블이 O(n)으로 느려진다.

// 최악이지만 적법한 hashCode 구현.
// 동치인 모든 객체에서 똑같은 해시코드를 반환한다.
@Override
public int hashCode() {
     return 42;
}

 

좋은 hashCode를 작성하는 요령

  • 1. int 변수 result 를 선언한 후 값 c로 초기화한다. 이때 c는 해당 객체의 첫 번째 핵심 필드를 단계 2.a 방식으로 계산한 해시코드다.
    - 핵심 필드: equals 비교에 사용되는 필드
  • 2. 해당 객체의 나머지 핵심 필드 f 각각에 대해 다음 작업을 수행한다.
    • a. 해당 필드의 해시코드 c를 계산한다.
      • i. 기본 타입 필드라면, Type.hashCode(f)를 수행한다. 여기서 Type은 해당 기본 타입의 박싱 클래스다.
      • ii. 참조 타입 필드면서 이 클래스의 equals 메서드가 이 필드의 equals를 재귀적으로 호출해 비교한다면, 이 필드의 표준형(canonical representation)을 만들어 그 표준형의 hashCode를 호출한다. 필드의 값이 null 이면 0을 사용한다(다른 상수도 괜찮지만 전통적으로 0을 사용한다).
      • iii. 필드가 배열이라면, 핵심 원소 각각을 별도 필드처럼 다룬다. 이상의 규칙을 재귀적으로 적용해 각 핵심 원소의 해시코드를 계산한 다음, 단계 2.b 방식으로 갱신한다. 배열에 핵심 원소가 하나도 없다면 단순히 상수(0을 추천한다)를 사용한다. 모든 원소가 핵심 원소라면 Arrays.hashCode를 사용한다.
    • b. 단계 2.a에서 계산한 해시코드 c로 result를 갱신한다. 코드로는 다음과 같다.
      • result = 31 * result + c
  • 3. result 를 반환한다.

· 2.b에서 곱할 숫자를 31로 정한 이유는 31이 홀수이면서 소수(prime)이기 때문이다.

- 이 숫자가 짝수이고 오버플로가 발생한다면 정보를 잃게 된다. 2를 곱하는 것은 시프트 연산과 같은 결과를 내기 때문이다.

- 소수를 곱하는 이유는 명확하지 않고, 전통적으로 그리 해왔다.

- 31을 이용하면 이 곱셈을 시프트 연산과 뺄셈으로 대체해 최적화할 수 있다. (31 * 1 == (1 << 5) - i) 요즘 VM들은 이런 최적화를 자동으로 해준다.

 

추가적인 팁

· hashCode를 구현했다면, 동치인 인스턴스에 대해 똑같은 해시코드를 반환할지 자문하고, 검증할 단위 테스트를 작성하자. 

- equals와 hashCode 메서드를 AutoValue로 생성했다면 건너뛸 수 있다.

 

· 파생 필드는 해시 코드 계산에서 제외해도 된다.

- 파생 필드: 다른 필드로부터 계산해 낼 수 있는 필드

 

· equals 비교에 사용되지 않은 필드는 반드시 제외한다. 

- 그렇지 않으면 hashCode 규약 두 번째를 어기게 될 수 있다.

 

· 해시 충돌이 더 적은 방법을 쓰고 싶다면 구아바의 com.google.common.hash.Hashing을 참고하자.

 

· Objects 클래스는 임의의 개수만큼 객체를 받아 해시코드를 계산해주는 정적 메서드 hash를 제공하여 hashCode 함수를 단 한 줄로 작성할 수 있다.

@Override public int hashCode() {
	return Objects.hash(a, b, c);
}

- 속도가 느리므로 성능에 민감하지 않은 경우만 사용하자.

(입력 인수를 담기 위한 배열을 만들고, 기본 타입의 박싱 언박싱을 거쳐야 해서 느리다)

 

·  클래스가 불변이고 해시코드 계산 비용이 크다면, 매번 새로 계산하지 말고 캐싱을 고려하자.

- 해당 타입의 객체가 주로 해시 키로 사용될 것 같다면, 인스턴스가 만들어질 때 미리 해시코드로 계산해둔다.

- 해시의 키로 사용되지 않는 경우라면 hashCode가 처음 불릴 때 지연 초기화 전략을 사용할 수 있다.

   - 이 경우 클래스를 스레드 안전하게 만들어야한다.

 

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

- 해시 품질이 나빠져 해시테이블의 성능이 심각하게 떨어질 수 있다.

- 특정 영역에 몰린 인스턴스들의 해시코드를 넓은 범위로 고르게 퍼뜨리는 효과를 가지는 필드일 수도 있다.

 

· hashCode가 발환하는 값의 생성 규칙을 API 사용자에게 자세히 공표하지 말자.

- 클라이언트가 이 값에 의지하지 않게 되고, (해시 기능의 결함 발견 등의 이유로 )추후 계산 방식을 바꿀 수 있다.

- String, Integer 등 자바 라이브러리는 hashCode 메서드가 반환하는 값을 정확하게 알려주는 부작용이 있다.

 

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


· Object의 기본 toString 메서드는 우리가 작성한 클래스에 적합한 문자열을 반환하는 경우가 거의 없다.

- '클래스이름@16진수로표시된해시코드'를 반환할 뿐이다.

 

· toString 일반 규약에 따르면 '간결하면서 사람이 읽기 쉬운 형태의 유익한 정보'를 반환해야 하고, '모든 하위 클래스에서 이 메서드를 재정'해야 한다.

 

· toString을 잘 구현하면, 그 클래스를 사용한 시스템을 디버깅하기 쉽다.

- toString 메서드는 객체를 println, printf, 문자열 연결 연산자(+), assert 구문에 넘길 때, 디버거가 객체를 출력할 때 자동으로 불린다.

- toString을 제대로 재정의하지 않는다면 쓸모없는 메시지만 로그에 남는다.

 

· 좋은 toString은 컬렉션처럼 이 인스턴스를 포함하는 객체에서 유용하게 쓰인다.

ex) {Jenny=PhoneNumber@abbd}보다는 {Jenney=707-867-5309}라는 메시지가 훨씬 반가울 것이다.

 

·  실전에서 toString은 그 객체가 가진 주요 정보 모두를 반환하는 게 좋다.

- 단, 객체가 거대하거나 객체의 상태가 문자열로 표현하기에 적합하지 않다면 예외다. 이런 상황에서는 요약 정보를 담도록 하자.

- 이상적으로는 스스로를 완벽하게 설명하는 문자열이어야 한다. 

- toString에 주요한 정보가 담기지 않았을 때 문제가 되는 대표적인 예시:

Assertion failure: expected {abc, 123}, but was {abc, 123}.

 

·  toString을 구현할 때면 반환값의 포맷을 문서화할 지 정해야 한다.

- 장점: 포맷을 명시하면 그 객체는 표준적이고 명확하고, 사람이 읽을 수 있게 된다.

- 그 값 그대로 입출력에 사용하거나 CSV 파일처럼 사람이 읽을 수 있는 데이터 객체로 저장할 수도 있다.

- 명시한 포맷에 맞는 문자열과 객체를 상호 전환할 수 있는 정적 팩터리나 새성자를 함께 제공해주면 좋다. 

   자바 플랫폼에 많은 값 클래스가 따르는 방식이다. ex) BigInteger, BigDecimal

/**
 * PhoneNumber 객체(전화번호)의 문자열 표현을 반환한다.
 * 문자열은 14자이며 형식은 "(XXX) YYY-ZZZZ"이다.
 * 여기서 XXX는 지역번호이고, YYY는 국번, ZZZZ는 선번호이다.
 * 각, 영문 대문자는 한 자리의 십진수를 나타낸다.
 * 지정된 자리수가 다 채워지지 않은 경우는 세 부분 모두 0을 앞에 붙힌다.
 * 예를 들어, 선번호의 값이 123이면, 문자열 표현 시 "0123"이 된다.
 * 지역번호의 우 괄호 다음에는 스페이스를 하나 추가한다.
 */
@Override
public String toString() {
  return String.format("(%03d) %03d-%04d", areaCode, prefix, lineNumber);
}

 

- 단점: 포맷을 한 번 명시하면 평생 그 포맷에 얽매이게 된다.

  포맷을 명시하지 않으면, 향후 릴리스에서 정보를 더 넣거나 포맷을 개선할 수 있는 유연성을 얻게 된다.

/**
 * 이 부분의 간략한 표현을 반환한다.
 * 반환 문자열의 형식은 정해지지 않았으므로 변경될 수 있다.
 * 일반적인 형태는 다음과 같다 :
 *
 * "[Potion #9: type=love, smell=turpentine, look=india ink]" */
@Override
public String toString() { ... }

 

· 정적 유틸리티 클래스(아이템 4)는 toString을 제공할 이유가 없다. 대부분의 열거 타입도 그렇다.

- 대부분의 열거 타입은 자바에서 이미 완벽한 toString을 제공한다.

 

· 하위 클래스들이 공유해야 할 문자열 표현이 있는 추상 클래스라면 toString을 재정의해줘야 한다.

ex) 다수의 컬렉션 구현체는 추상 컬렉션 클래스들의 toString 메서드를 상속해 쓴다.

 

 

TODO: 아이템 13. clone 재정의는 주의해서 진행하라


· Cloneable은 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스(아이템 20)지만, 의도한 목적을 제대로 이루지 못했다.

- clone 메서드가 선언된 곳이 Cloneable이 아닌 Object이고, 그마저도 protected다. 그래서 Cloneable을 구현하는 것만으로는 외부 객체에서 clone 메서드를 호출할 수 없다.

- 리플렉션(아이템 65)을 사용하면 가능하지만, 해당 객체가 접근이 허용된 clone 메서드를 제공한다는 보장이 없다.

 

· 여러 문제점에도 불구하고 Cloneable 방식은 널리 쓰이고 있다. 따라서 1. clone 메서드를 잘 동작하게끔 해주는 구현 방법과 2. 언제 그렇게 해야하는지 잘알아야한다.

 

· Cloneable 인터페이스는 Object의 protected 메서드인 clone의 동작 방식을 결정한다. 

- Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환하며, 그렇지 않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException을 던진다.

 

 

TODO: 아이템 14. Comparable을 구현할지 고려하라


· Comparacle 인터페이스는 compareTo 메서드를 갖는다.

· compareTo는 두 가지만 빼면, Object의 equals와 같다. 

- compareTo는 단순 동치성 비교에 더해 1. 순서까지 비교할 수 있으며, 2. 제네릭하다.

- Comparable을 구현했다는 것은 그 클래스의 인스턴스들에는 자연적인 순서(natural order)가 있음을 뜻한다.

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

 

· Comparable을 구현한 객체들의 배열을 손쉽게 정렬할 수 있다. Ex) Arrays.sort(a);

- 검색, 극단값 계산, 자동 정렬되는 컬렉션 관리도 쉽게 할 수 있다.

// 명령줄 인수들을 (중복 제거 후) 알파벳순으로 출력한다.
// String이 Comparable을 구현한 덕분에 가능하다.
public class WordList
{
	putlic static void main(String[] args)
    {
		Set<String> s = new TreeSet<>();
        Collections.addAll(s, args);
        System.out.println(s);
}

 

· Comparable을 구현하면, 이 인터페이스를 활용하는 수많은 제네릭 알고리즘과 컬렉션의 힘을 누릴 수 있다.

- 자바 플랫폼 라이브러리의 거의 모든 값 클래스와 열겨 타입은 Comparable을 구현한다.

- 알파벳, 숫자, 연대 같이 순서가 명확한 값 클래스를 작성한다면 반드시 Comparable 인터페이스를 구현하자.

 

· compareTo 메서드의 일반 규약은 equals의 규약과 비슷하다.

이 객체와 주어진 객체의 순서를 비교한다. 이 객체가 주어진 객체보다 작으면 음의 정수를, 같으면 0을, 크면 양의 정수로 반환한다. 이 객체와 비교할 수 없는 타입의 객체가 주어지면 ClassCastException을 던진다.
다음 설명에서 sgn(표현식) 표기는 수학에서 말하는 부호 함수(signum function)를 뜻하며, 표현식의 값이 음수, 0, 양수일 때 -1, 0, 1을 반환하도록 정의했다.
  • Comparable을 구현한 클래스는 모든 x, y에 대해 sgn(x.compartTo(y)) == -sgn.(y.compareTo(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))다.
  • 이번 권고가 필수는 아니지만 꼭 지키는 게 좋다. (x.compareTo(y) == 0) == (x.equals(y))여야 한다. Comparable을 구현하고 이 권고를 지키지 않는 모든 클래스는 그 사실을 명시해야 한다. 다음과 같이 명시하면 적당할 것이다.
"주의 : 이 클래스의 순서는 equals 메서드와 일관되지 않다."

 

· compareTo 메서드에서 필드와 값을 비교할 때 <, > 연산자는 쓰지 말아야 한다.

- 관계 연산자 <, >를 사용하는 방식은 오류를 유발한다. (부동 소수점 비교를 생각해보자)

- 대신 기본 타입 클래스가 제공하는 정적 compare 메서드나 Comparator 인터페이스가 제공하는 비교자 생성 메서드를 사용하자.

 

출처

이펙티브자바 3판

반응형

댓글