Effective Java 3rd - Ch03
포스트
취소

Effective Java 3rd - Ch03

Java 기초를 다지기 위해 효과적인 자바란 책을 공부 중입니다.

제3장 모든 객체의 공통 메서드

  • Object 의 final 이 아닌 메서드 : equals, hashCode, toString, clone, finalize
  • 재정의 시 지켜야 하는 일반 규약 설명
  • 언제 어떻게 재정의해야 하는지 설명
  • finalize 는 8번 항목에서 다뤘으므로 제외. Comparable.compareTo 는 포함

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

다음 사항에 해당된다면 equals 메서드는 재정의 하지 않는게 좋다.

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

equals 메서드는 동치관계를 구현하며, 다음을 만족해야 한다 (규약)

  • null-아님 : x.equals(null) == false
  • 반사성(reflexivity) : x.equals(x) == true
  • 대칭성(symmetry) : x.equals(y) == true 이면 y.equals(x) == true
  • 추이성(transitivity) : x.equals(y) == true 이고 y.equals(z) == true 이면 x.equals(z) == true
  • 일관성(consistency) : x.equals(x) == true 를 반복해서 호출해도 항상 true 또는 false
    • equals 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안된다
    • 항시 메모리에 존재하는 객체만을 사용한 결정적(deterministic) 계산만 수행해야 한다

코드 10-1 잘못된 코드 : 대칭성 위배

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public final class CaseInsensitiveString {
  private final String s;

  public CaseInsensitiveString(String s) {
    this.s = Objects.requiredNonNull(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;
  }

  /*
  // String 과도 비교하겠다는 허황된 꿈을 버려야 한다
  @Override public boolean equals(Object o) {
    return o instanceof CaseInsensitiveString &&
        ((CaseInsensitiveString) o).s.equalsIgnoreCase(this.s);
  }
  */
}

/////////////////////

CaseInsensitiveString cis = new CaseInsensitiveString("polish");
String s = "polish";

cis.equals(s) == true   // OK
s.equals(cis) != true   // wrong!
1
- equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없다

코드 10-2 잘못된 코드 : 대칭성 위배

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override public boolean equals(Object o) {
  if( !(o instanceof ColorPoint) )
    return false;
  return super.equals(o) && ((ColorPoint) o).color == color;
}

////////////////////////

Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);

p.equals(cp) == true;
cp.equals(p) != true;  // 클래스 종류가 다르다고 false 반환

코드 10-3 잘못된 코드 : 추이성 위배

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@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 == this.color;
}

////////////////////////

ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);

p1.equals(p2) == true && p2.equals(p3) == true;
p1.equals(p3) != true  // color 가 다르다고 false 반환

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

코드 10-4 잘못된 코드 : 리스코프 치환 원칙 위배

1
2
3
4
5
6
7
8
9
@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;
}

// 괜찮아 보이지만 실제로 활용할 수는 없다
// Point 하위 클래스는 정의상 Point 이므로 어디서든 Point 로서 활용될 수 있어야 한다

:bangbang:   리스코프 치환 원칙

어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요하다. 따라서 그 타입의 모든 메서드가 하위 타입에서도 똑같이 잘 작동해야 한다.

코드 10-5 equals 규약을 지키면서 값 추가하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ColorPoint {
  private final Point point;
  private final Color color;

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

  // '항목 18' 참조 : 상속 대신 컴포지션을 사용하라
  // 이 ColorPoint 의 Point 뷰를 반환한다
  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);
  }
}

:bangbang:   equals 메소드 구현 방법

  1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다
  2. instanceof 연산자로 입력이 올바른 타입인지 확인한다
  3. 입력을 올바른 타입으로 형변환 한다
  4. 입력 객체와 자기 자신의 대응되는 핵심필드들이 모두 일치하는지 하나씩 검사한다
  5. 어떤 필드를 먼저 비교하느냐에 따른 성능(비용) 문제도 고민한다

코드 10-6 전형적인 equals 메서드의 예

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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, 999, "가입자 번호");
  }

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

:bangbang:   주의사항

1
2
3
4
- equals 를 재정의 할 땐 hasCode 도 반드시 재정의 하자
- 너무 복잡하게 해결하려 들지 말자
- Object 외의 타입을 매개변수를 받는 equals 는 만들지 말자
- - ex) equals(MyClass o) : `재정의`가 아니라 `다중정의`이다!

11) equals 를 재정의 하려거든 hasCode 도 재정의 하라

hashCode 일반 규약

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

코드 11-1 최악의 (하지만 적법한) hashCode 구현 : 사용 금지!

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

좋은 hashCode 를 작성하는 요령

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

코드 11-2 전형적인 hashCode 메서드

1
2
3
4
5
6
@Override public int hashCode() {
  int result = Short.hashCode(areaCode);
  result = 31 * result + Short.hashCode(prefix);
  result = 31 * result + Short.hashCode(lineNum);
  return result;
}

코드 11-3 Guava 를 이용한 한 줄짜리 hashCode 메서드 : 성능이 살짝 아쉽다

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

클래스가 불변이고 해시코드를 계산하는 비용이 크다면 캐싱하는 방식을 고려해야 한다

코드 11-4 해시코드를 지연 초기화하는 hashCode 메서드 : 스레드 안정성까지 고려해야 한다

1
2
3
4
5
6
7
8
9
10
11
12
13
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;
}

12) toString 을 항상 재정의 하라

toString 의 일반 규약

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

toString 이 잘 구현된 클래스는 사용하기에 훨씬 즐겁고 디버깅하기 쉽다

1
2
// phoneNumber.toString() 이 쓰인다
System.out.println( phoneNumber + "에 연결할 수 없습니다");

13) clone 재정의는 주의해서 진행하라

코드 13-1 가변 상태를 참조하지 않는 클래스용 clone 메서드

1
2
3
4
5
6
7
8
// 이 메서드가 동작하려면 PhoneNumber 선언부에 Cloneable 을 구현한다고 추가해야 한다
@Override publicc PhoneNumber clone() {
  try {
    return (PhoneNumber) super.clone();
  } catch (CloneNotSupportedException e) {
    throw new AssertionError();   // 일어날 수 없는 일이다
  }
}

clone 메서드는 사실상 생성자와 같은 효과를 나타낸다. 즉, clone 은 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야 한다.

코드 13-2 가변 상태를 참조하는 클래스용 clone 메서드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Stack {
  private Object[] elements;
  private int size = 0;
  ... // 생략

  @Override public Stack clone() {
    try {
      Stack result = (Stack) super.clone(); // size 는 정상 복사됨
      result.elements = elements.clone();   // Object[] 의 복사본 만들기 (재귀적 호출)
                                            // elements 가 final 이면 작동하지 않는다
      return result;
    } catch( CloneNotSupportedException e){
      throw new AssertionError();
    }
  }
}

직렬화와 마찬가지로, Cloneable 아키텍처는 ‘가변객체를 참조하는 필드는 final로 선언하라’는 일반 용법과 충돌한다. 그래서 복제할 수 있는 클래스를 만들기 위해 일부 필드에서 final 한정자를 제거해야 할 수도 있다.

코드 13-3 잘못된 clone 메서드 : 가변상태를 공유한다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class HashTable implements Cloneable {
  private Entry[] buckets = ...;

  private static class Entry {
    final Object key;
    Object value;
    Entry next;

    Entry(Object key, Object value, Entry next) {
      this.key = key;
      this.value = value;
      this.next = next;
    }
  }
  ... // 생략

  @Override public HashTable clone() {
    try {
      HashTable result = (HashTable) super.clone();
      result.buckets = buckets.clone();   // deepCopy 가 필요하다!!
      return result;
    } catch( CloneNotSupportException e){
      throw new AssetionError();
    }
  }
}

코드 13-4 복잡한 가변 상태를 갖는 클래스용 재귀적 clone 메서드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class HashTable implements Cloneable {
  private Entry[] buckets = ...;

  private static class Entry {
    final Object key;
    Object value;
    Entry next;

    Entry(Object key, Object value, Entry next) {
      this.key = key;
      this.value = value;
      this.next = next;
    }

    // 이 엔트리가 가리키는 연결 리스트를 재귀적으로 복사
    Entry deepCopy() {
      // **NOTE: next 리스트가 길면 스택 오버플로를 일으킬 위험이 있다
      return new Entry(key, value, next == null ? null : next.deepCopy());
    }
  }
  ... // 생략

  @Override public HashTable clone() {
    try {
      HashTable result = (HashTable) super.clone();   // New
      result.buckets = new Entry[buckets.length];     // 할당
      for( int i=0; i<buckets.length; i++)
        if( buckets[i] != null )
          result.buckets[i] = buckets[i].deepCopy();  // 보강!
      return result;
    } catch( CloneNotSupportException e){
      throw new AssetionError();
    }
  }
}

코드 13-5 엔트리 자신이 가리키는 연결 리스트를 반복적으로 복사한다

1
2
3
4
5
6
7
Entry deepCopy() {
  Entry result = new Entry(key, value, next);
  // **NOTE: 한꺼번에 할당하는 대신 점진적으로 할당하도록 수정
  for( Entry p = result; p.next != null; p = p.next )
    p.next = new Entry(p.next.key, p.next.value, p.next.next);
  return result;
}

코드 13-6 하위 클래스에서 Cloneable 을 지원하지 못하게 하는 clone 메서드

1
2
3
@Override protected final Object clone() throws CloneNotSupportedException {
  throw new CloneNotSupportedException();
}

코드 13-7 복사 생성자

1
public Yum(Yum yum) { ... }

코드 13-8 복사 팩토리

1
public static Yum newInstance(Yum yum) { ... }

:bangbang:   핵심정리

  • Cloneable 이 몰고 온 모든 문제를 되짚어봤을 때, interface 이든 class 이든 안쓰는게 낫다
  • final 클래스라면 위험이 크지 않지만, 성능 최적화 관점에서 검토한 후 드물게 허용해야 한다
  • 기본 원칙은 ‘복제 기능은 생성자와 팩토리를 이용하는게 최고’라는 것
    • 단, 배열만은 clone 메서드 방식이 가장 깔끔한, 합당한 예외라 할 수 있다

14) Comparable 을 구현할지 고려하라

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

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

  • compareTo 는 단순 동치성 비교에 더해 순서까지 비교할 수 있으며
  • compareTo 는 제네릭 하다
    • ex) Arrays.sort(a)
1
2
3
4
5
6
7
8
public class WordList {
  public static void main(String[] args) {
    Set<String> s = new TreeSet<>();
    // Set 이 comparable 하기 때문에 Collections 사용 가능 (순서 비교)
    Collections.addAll(s, args);    // 알파벳 순으로 정렬되어 추가됨
    System.out.println(s);
  }
}

Comparable을 구현하면 이 인터페이스를 활용하는 수많은 제네릭 알고리즘과 컬렉션의 힘을 누릴 수 있다. 좁쌀만한 노력으로 코끼리만한 큰 효과를 얻는 것이다.

compareTo 메서드의 일반 규약

  • 이 객체와 주어진 객체의 순서를 비교한다.
    • 작으면 음의 정수를, 같으면 0을, 크면 양의 정수를 반환한다
    • 비교할 수 없는 타입의 객체가 주어지면 ClassCastException 을 던진다
  • 부호함수 sgn 을 이용한 정의
    • 대칭성 : sgn(x.compareTo(y)) == -sgn(y.compareTo(x))
    • 추이성 : (x.compareTo(y) > 0 && y.compareTo(z) > 0) 이면 x.compareTo(z) > 0
    • 일관성 : x.compareTo(y) == 0 이면 sgn(x.compareTo(z)) == sgn(y.compareTo(z))
    • 반사성(권고사항) : (x.compareTo(y) == 0) == (x.equals(y))
      • ex) BigDecimal(“1.0”) 과 BigDecimal(“1.00”) 은 equals 로는 다르지만 compareTo 로는 같다

compareTo 메서드는 각 필드가 동치인지를 비교하는게 아니라 그 순서를 비교한다

코드 14-1 객체 참조 필드가 하나뿐인 비교자

1
2
3
4
5
6
7
8
// CaseInsensitiveString 타입하고만 비교할 수 있다는 의미
public final class CaseInsensitiveString
        implements Comparable<CaseInsensitiveString> {
  public int compareTo(CaseInsensitiveString cis) {
    return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s);
  }
  ... // 생략
}

코드 14-2 기본 타입 필드가 여럿일 때의 비교자

1
2
3
4
5
6
7
8
9
public int compareTo(PhoneNumber pn) {
  int result = Short.compare(areaCode, pn.areaCode);    // 핵심필드
  if( result == 0 ){
    result = Short.compare(prefix, pn.prefix);  // 두번째 핵심필드
    if( result == 0 )
      result = Short.compare(lineNum, pn.lineNum);  // 세번째 핵심필드
  }
  return result;
}

코드 14-3 비교자 생성 메서드를 활용한 비교자

1
2
3
4
5
6
7
8
9
// 자바 8에서 지원 시작. 약간의 성능 저하가 있다 (10% 정도)
private static final Comparator<PhoneNumber> COMPARATOR =
    comparingInt((PhoneNumber pn) -> pn.areaCode)
        .thenComparingInt(pn -> pn.prefix)
        .thenComparingInt(pn -> pn.lineNum);

public int compareTo(PhoneNumber pn) {
  return COMPARATOR.compare(this, pn);
}

자바 8에서 Comparator 는 수많은 보조 생성 메서드들로 무장하고 있다. comparingInt 처럼 float, double 용도 제공하고 있고 객체 참조용 비교자 생성 메서드도 준비되어 있다. 이를 이용하면 이름만으로도 정적 비교자 생성 메서드들을 사용할 수 있어 코드가 훨씬 깔끔해진다.

코드 14-4 해시코드 값의 차를 기준으로 하는 비교자 : 추이성을 위배한다!

1
2
3
4
5
6
7
// **NOTE: 이 방식은 사용하면 안된다!!
//    정수 오버플로를 일으키거나 부동소수점 계산 방식에 따른 오류를 낼 수 있다
static Comparator<Object> hashCodeOrder = new Comparator<>() {
  public int compare(Object o1, Object o2) {
    return o1.hashCode() - o2.hashCode();
  }
}

코드 14-5 정적 compare 메서드를 활용한 비교자

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

코드 14-6 비교자 생성 메서드를 활용한 비교자

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

 
 

끝!   읽어주셔서 감사합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.