본문 바로가기

나(다)/책

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

반응형

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

Object는 객체를 상속해서 사용하도록 설계되었고 final이 아닌 메서드(equals, hashCode, toString, clone, finalize)는 모두 재정의를 염두하고 설계 된 메서드이며 재정의 시 지켜야 하는 일반 규약이 명확히 정의되어 있다.

이번 장에서는 final이 아닌 object 메서드들을 언제 어떻게 재정이해야 하는지를 다루며 finalize 메서드는 아이템 8에서 다뤘으니 더 이상 언급하지 않고 Compareable.compareTo의 경우 Object의 메서드는 아니지만 성격이 비슷하여 이번 장에서 다룬다.

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

eqals 메서드 재정의하기 쉬워 보이지만 자칫하면 함정에 빠져 문제를 일으킬 수 있고 문제를 회피하는 가장 쉬운 길은 재정의를 하지 않는 방법이다.

아래 같은 상황에서는 재정의 하지 않는 것이 최선이다.

재정의 하지 말아야 하는 경우

  1. 각 인스턴스가 본질적으로 고유하다.
    • 값이 아닌 개체를 표현하는 클래스에 해당 되며 Thread가 좋은 예시이고 Object의 equals 메서드는 이에 딱 맞게 구현되었다. Th[Thread-2,5,main] 형태로 출력 된다.
        // -- 각 인스턴스가 본질적으로 고유하다.
            Thread t = new Thread();
            System.out.println(t.toString());
    • 값으로 표현 하지 않는 경우를 의미하는걸로 보이며 bean에 등록하는 객체 repository, controller, service 등이 해당되며 VO, DDomain 객체는 값 검증이 필요하여 해당하지 않는다.
  2. 인스턴스의 '논리적 동치성(logical equality)'을 검사할 일이 없다.
    • java.util.regex.Pattern의 equals를 재정의해서 두 Patten의 인스턴스가 같은 정규표현식을 가지는지 검사하는 논리적 동치성을 검사할 수 있지만 필요하다고 생각하지 않는다.
    • 두 가지의 랜덤 난수 객체가 같은 난수열을 만드는지 확인해야 한다고 생각하지 않는다.
  3. 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.
    • Set 구현체는 AbstractSet이 구현한 equals를 상속받아 그대로 사용해도 된다.
  4. 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다.
    • 실수라도 호출 할 경우를 막기 위해 아래처럼 구현해두자
        @Override
            public boolean equals(Object o) {
                throw new AssertionError(); // 호출 금지
            }

재정의 해야 하는 경우

  • 객체 식별성이 아니라 논리적 동치성을 확인하는 경우, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의 되지 않았을 경우이다.
    • Integer와 String처럼 값을 표현하는 클래스를 사용할 경우이다.
    • 값 클래스라고 해도 통제 클래스(싱글턴, 단일클래스)인 경우에는 재정의 하지 않아도 되며 Enum도 해당된다.

Object 명세에 적힌 equals 재정의 규약

  • 동치 관계를 만족한다.
    • 집합을 서로 같은 원소들로 이뤄진 부분집합으로 나누는 연산으로 부분집합을 동치류(동치 클래스)라 한다.
    • 모든 원소가 같은 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야 한다.
    • 동치 관계를 만족하기 위해 아래 5가지를 요건을 가져야 한다.
  1. 반사성(reflexivity) : null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true다.
    • 객체는 자기 자신과 같아야 한다.
      // -- 반사성
        class Fruit {
            private String name;
      
            public Fruit(String name) {
                this.name = name;
            }
        }
      
        public static void main(String[] args) {
            // -- 각 인스턴스가 본질적으로 고유하다.
            // Thread t = new Thread();
            // System.out.println(t.toString());
      
            // -- 반사성
            List<Fruit> list = new ArrayList<>();
            Fruit f = new Fruit("apple");
            list.add(f);
            list.contains(f); // false 일 경우에는 반사성을 만족하지 못함.
        }
  2. 대칭성(symmetry) : null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)가 true면 y.equals(x)도 true다.
    • 두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다.
    • 한 방향으로만 동작하는 상황
        // -- 대칭성
        static 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;
            }
        }
        public static void main(String[] args) {
                // -- 대칭성
                CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
                String s = "polish";
                System.out.println(cis.equals(s));
                System.out.println(s.equals(cis));
      
                List<CaseInsensitiveString> list = new ArrayList<CaseInsensitiveString>();
      
                list.add(cis);
                System.out.println(list.contains(s)); // false로 나오지만 다른 객체에서 어떻게 반응될지 알 수 없다.
            }
    • CaseInsensitiveString의 equals를 String과도 연동하겠다는 허황된 꿈을 버려리면 간단하게 처리할 수 있다.
        @Override
        public boolean equals(Object o) {
            // CaseInsensitiveString과 String과도 연동하도록 하는 생각을 접으면 생기는 소스 String을 CaseInsensitiveString으로 형변환
            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(true다.
    • 첫 번째 객체가 두 번째 객체와 같고, 두 번째 객체가 세 번째 객체와 같으면 첫 번째 객체와 세 번째 객체도 같아야 한다.
    • 추이성으로 equals를 재정의하면 안되는 경우에 superclass에서 equals를 정의한 경우를 언급했다. / 상위 클래스에서 재equals가 하위 클래스에도 딱 들어맞는다.
    • 위배되는 경우
      1. 대칭성 위배되는 경우
         @Override
         // 대칭성 위배 : Point를 ColorPoint에 비교한 결과와 바꿔 비교한 결과가 다르다.
         public boolean equals(Object o) {
             if (!(o instanceof ColorPoint))
                 return false;
         return super.equals(o) && ((ColorPoint) o).color == color;
         }
      2. 추이성 위배되는 경우
         @Override
         public boolean equals(Object o) {
             if (!(o instanceof ColorPoint))
                 return false;
             // o가 일반 point면 색상을 무시하고 비교한다.
             if (!(o instanceof ColorPoint))
                 return o.equals(this);
             // -- 추이성 위배
             return super.equals(o) && ((ColorPoint) o).color == color;
         }
        
         public static void main(String[] args) {
             ColorPoint cp1 = new ColorPoint(1, 2, Color.RED);
             Point p1 = new Point(1, 2);
             ColorPoint cp2 = new ColorPoint(1, 2, Color.BLUE);
             System.out.println(cp1.equals(p1) + " : " + p1.equals(cp1) + " : " + cp1.equals(cp2));
         }
      3. 무한 재귀되는 경우
         @Override
         public boolean equals(Object o) {
             if (!(o instanceof Point))
                 return false;
             // o가 일반 point면 색상을 무시하고 비교한다.
             if (!(o instanceof SmellPoint))
                 return o.equals(this);
             return super.equals(o) && ((SmellPoint) o).color == color;
         }
        
         public static void main(String[] args) {
             // -- 추이성
             ColorPoint cp3 = new ColorPoint(1, 2, Color.RED);
             SemllPoint cp4 = new SemllPoint(1, 2);
             cp3.equals(cp4); // ColorPoint의 equals로 비교 하면서 SemllPoint로 빠지고 SmellPoint의 equals에서 ColorPoint를 비교하여 무한 재귀에 빠짐
         }
      • 이 현상은 모든 객체 지향 언어의 동치관계에서 나타나는 근본적인 문제로 구체 클래스를 확장해 새로운 값을 추가하면서 equals 만족시킬 방법은 존재하지 않는다.
        • 잘못 들으면 equals 안의 instanceof 검사를 getClass 검사로 바꾸면 규약도 지키고 값도 추가하면서 구체 클래스를 상속할 수 뜻으로 들리지만 리스코프 치환 원칙을 위배한다.
            // 리스코프 치환 원칙 위배
              if (o == null || o.getClass() != getClass())
                  return false;
              Point p = (Point) o;
              return p.x == x && p.y == y;
      • 리스코프 치환 법칙 : 어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요하고 잘 작동해야 한다.
        • Point의 하위 클래스는 정의상 여전히 Point이므로 어디서든 point로써 활용될 수 있어야 한다.
    • 해결 방법
      • 해결 방법으로는 우회 하는 방법으로 "상속 대신 컴포지션을 사용하라"라는 방법이다.
        • Point를 상속하는 대신 Point를 ColorPoint의 private 필드로 두고 ColorPoint와 같은 위치의 일반 Point를 반환하는 뷰 메public으로 추가하는 방법이다.
          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 = color;
            }
          
            public Point aspoint() {
                return point;
            }
          
            @Override
            public boolean equals(Object obj) {
                if(!(obj instanceof ColorPoint))
                    return false;
                ColorPoint cp = (ColorPoint) obj;
             return cp.point.equals(point) && cp.color.equals(color);
            }
      • 추상 클래스의 하위 클래스라면 equals 규약을 지키면서 값을 추가 할 수 있다.
        • 아무 값을 가지지 않는 추상 클래스인 Shape을 확장 한 radius 클래스, Circle 클래스를 만들 수 있고 상위 클래스를 직접 인스턴스화가 불가능하면 위 문제들은 일어나지 않는다.
    • 참고 사항
      • 자바 라이브러리에서 구체 클래스를 확장해서 값을 추가 한 클래스가 종종 있으며 대표적으로 java.sql.Timestamp는 java.util.Date를 확장하여 nanoseconds 필드를 추가 했고 Timestamp의 equals는 대칭성에 위배되어 엉뚱하게 동작한다.
  4. 일관성(consistency) : null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나false를 반환한다.
    • 두 객체가 같다면 어느 하나 혹은 다 객체 모두가 수정되지 않는 한 처음부터 끝까지 결과가 영원히 같아야 한다.
    • 클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다.
      • 가변 객체 = 비교 시점에 따라 서로 다를 수 있다.
      • 불변 객체 = 한번 다르면 끝까지 달라야 한다.
      • 예를 들어 java.net.URL의 equals는 주어진 URL과 매핑된 호스트의 IP를 비교하는데 호스트 이름을 IP주소로 바꾸려면 네트워크를 통해야 해서 외부로부터 온 자원을 항상 같다고 보장 할 수 없다.
    • equals는 항시 메모리에 존재하는 객체만을 사용한 결정적 계산만 수행해야 한다.
  5. null-아님 : null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false다.
    • 모든 객체가 null과 같지 않아야 한다.
    • o.equals(null)이 true를 반환하는 상황은 생기기 어려우며 NullPointerException을 던지는 상황이 발생한다.
      • 명시적으로 null 검사(비권장)
          // 명시적으로 null 검사 할 필요다 없다.
            if( obj == null) {
                return false;
            }
        
      • instanceof로 묵시적으로 null 검사를 할 수 있음(권장)
          if(!(obj instanceof Item10))
              return false;
        

양질의 equals 메서드 구현 방법 단계별 정리

  1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
    • 자기 자신을 true로 반환하며 단순한 성능 최적화용으로 복잡한 상황일 때 값어치를 한다.
  2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.
    • 올바른 타입인 경우 : equals가 정의 된 클래스를 리턴하는게 보통인 경우
    • 올바른 타입이 아닌 경우 : 정의 된 클래스의 인터페이스를 리턴하는 경우에는 자신을 구현한 클래스끼리도 비교 할 수 있도록 equals규약을 수정하기도 하고 인터페이스의 equals를 이용하여 비교해야 한다.
  3. 입력을 올바른 타입으로 형변환한다.
    • 2번에서 instanceof 검사를 했기 때문에 100% 성공한다.
  4. 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다.
    • 모든 필드가 일치하면 true를, 하나라도 다르면 false를 반환해야 한다.
    • 2단계에서 인터페이스를 사용했다면 입력의 필드 값을 가져올 때도 해당 인터페이스의 메서드를 사용해야 한다.

equals 구현 시 추가 주의사항

  1. float, double을 제외한 기본 타입 필드는 == 연산자 비교
  2. 참조 타입 필드는 각각 equals 메서드로 비교
  3. float와 double 필드는 각각 정적 메서드인 Float.compare(float, float)와 Double.compare(double, double)로 비교
    • Float.NaN, -0.0, 특수한 부동소수 값 등을 다루어야 하기 때문에 특별하다.
    • Float.equals와 Double.equals를 사용할 수 있지만 오토박싱을할 수 있으니 성능상 좋지 않다.
  4. 배열의 모든 원소가 핵심 필드라면 Arrays.equals 메서드들 중 하나를 사용하자.
  5. 때론 null도 정상 값을 취급하는 참조 타입 필드의 경우 정적 메서드인 Objects.equals(Object, Object) 로 NullPointerException을 예방하자.
  6. 필드의 표준형으로 갱신하자
    • 복잡한 필드를 가진 클래스의 경우 필드의 표준형을 저장해 둔 후 표준형끼리 비교하면 되며 불변 클래스가 제격이다.
    • 필드의 표준형이란? : 확 와닿지 않지만 필드의 원형이라고 생각된다. 불변 클래스처럼 바뀌지 않는 표준
  7. 필드의 비교 순서에 따라 성능이 좌우 된다.
    • 다를 가능성이 크거나 비교하는 비용기 싼 필드를 먼저 비교 하자.
    • 핵심 필드로부터 계산해 낼 수 있는(결과를 낼 수 있는) 파생 필드를 굳이 비교 하지 않아도 되지만 파생 필드가 객체 전체의 상태를 대표하는 상황에서는 파생 필드를 비교하는게 빠를 수 있다.
  8. equals를 다 구현했다면 세 가지만 자문해보자.
    • 대칭적인가, 추이성이 있는가, 일관적인가
    • 자문 후에 단위 테스트를 돌려보도록 하고 구글에서 만든 AutoValue를 이용해서 작성하면 테스트를 생략해도 안심할 수 있다.
    • 반사성과 null-아님도 만족해야 하지만, 둘이 문제되는 경우는 별로 없다.
      private final short areaCode, prefix, lineNum;
      
        public PhoneNumber(short areaCode, short prefix, short lineNum) {
            this.areaCode = areaCode;
            this.prefix = prefix;
            this.lineNum = lineNum;
        }
        private static short rangeCheck(int val, int max, String arg) {
            if (val < 0 || val > max)
                throw new IllegalArgumentException(arg + ": " + val);
            return (chort) val;
        }
      
        @Override
        public boolean equals(Object obj) {
            if(obj == this) {
                return true;
            }
            if (!(o instanceof PgoneNumber))
                return false;
            PhoneNumber pn = (PhoneNumber) o;
            return pn.lineNum == lineNum && pn.prefix == prefix && pn.areaCode == areaCode;
        }
  9. equals를 재정의할 땐 hashCode도 반드시 재정의하자(아이템 11)
  10. 너무 복잡하게 해결하려 들지 말자.
    • 필드들의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있다.
    • 너무 공격적이지 않게 하고 일반적으로 별칭은 비교하지 않는 게 좋다.
      • 예를 들어 File 클래스라면, 심볼릭 링크를 비교해 같은 파일을 가리키는지를 확인하려 들지 않는다.
  11. Object외의 타입을 매개변수로 받는 equals메서드는 선언하지 말자.
    • @Override 어노테이션을 쓰면 미연의 방지가 가능하다.
    // 컴파일 되지 않으며 입력 타입은 반드시 Object여야 한다. 재정의가 아니라 다중정의 상태임
    @Override
        public boolean equals(Item10 obj) {
  12. equals(hashCode도 마찬가지)를 작성하고 테스트하는 일은 지루하고 테스트하는 코드도 뻔하므로 AutoValue 프레임워크를 활용@AutoValue 어노테이션이면 해결 된다.

⇒ `통제 클래스(아이템1), Enum(아이템34), 상속 대신 컴포지션을 사용하라(아이템18), 뷰 메서드(아이템6), 불변클래스로 만드는게 나을지를 심사숙고하자(아이템17), equals를 재정의할 땐 hashCode도 반드시 재정의하자(아이템 11), 재정의가 아니라 다중정의(아이템52), @Override어노테이션을 일관되게 사용하면 실수를 예방할 수 있다.(아이템40)

꼭 필요한 경우가 아니면 equals를 재정의하지 말자. 할꺼면 규약을 준수하고 핵심 필드를 빠짐없이 비교하자.


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

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

  • 재정의 하지 않으면 hashCode 일반 규약을 어기게 되어 해당 클래스의 인스턴스를 HashMap이나 HashMap 같은 컬렉션의 원소를 사용 할 때 문제를 일으킨다.

hashCode 재정의 규약

  1. equals 비교에 사용되는 정보가 변경되지 않는다면, hashCode는 항상 같은 값을 반환해야 한다. 단, 어플리케이션을 재실행하면 이 값은 달라져도 된다.
  2. equals(Object)가 두 객체를 같다고 판다했다면, 두 객체의 hashCode는 똑같은 값을 반환해야 한다.
  3. equals(Object)가 두 객체를 다르다고 판단했더라도, 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없지만 다른 값을 반환해야 성능에 좋다.

2. 문제가 되는 상황

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

  • equals는 물리적으로 다른 두 객체를 논리적으로 같다고 할 수 있지만 Object의 기본 hashCode 메서드는 다르다고 판단하여 서로 다른반환한다.
    • 아래 소스는 put할 때 1번, get할 때 1번으로 2개의 PhoneNumber 인스턴스가 사용 되어 서로 다른 해시코드를 반환하여 두 번째 지키지 못한다.
      • HashMap은 해시코드가 다른 엔트리끼리는 동치성 비교를 시도조차 하지 않도록 최적화 되어 있다.
        public static void main(String[] args) {
              Map<PhoneNumber, String> m = new HashMap<>();
              m.put(new PhoneNumber(707, 867, 5309), "제니");
              System.out.println(m.get(new PhoneNumber(707, 867, 5309)));
          }
    • 최악의 해결법 - 사용 금지
      • 동치인 모든 객체에 똑같은 해시코드를 반환하니 적법하지만 모든 객체가 해시테이블의 커빗 하나에 담겨 마치 연결 리스트처럼 동작하여 으로 느려진다.
        @Override
          public int hashCode() {
              return 42;
          }

좋은 해시코드를 만드는 방법(p.69 참고)

좋은 해시 코드는 서로 다른 인스턴스에 다른 해시코드를 반환하는 세 번째 규약에 요구하는 속성이다.

  • 이상적인 해시 함수는 인스턴스마다 32비트 정수 범위에 균일하게 분배해야 한다.
  • 이상을 완벽하게 실현하기는 어렵지만 비슷하게 만들 수는 있다.
    1. int 변수 result를 선언한 후 값 c로 초기화 하고 c는 해당 객체의 첫 번째 핵심 필드를 단계 2.1 방식으로 계산한 해시코드다.필드란 equals 비교에서 사용되는 필드를 말함
    2. 해당 객체의 나머지 핵심 필드 f 각각에 대해 다음 작업을 수행한다.
      1. 해당 필드의 해시코드 c를 계산한다.
        • 기본 타입 필드라면, Type.hashCode(f)를 수행하고 Type은 기본타입의 박싱 클래스이다.
        • 참조 타입 필드면서 equals 메서드가 이 필드의 equals를 재귀적으로 호출해 비교한다면, 이 필드의 hashCode를 재귀적으로 호출한다.
          • 더 복잡해질 것 같으면, 이 필드의 표준형을 만들어 hashCode를 호출한다.
          • 필드의 값이 null이면 0을 사용한다.(다른 상수도 괜찮지만 전통적으로 0을 사용한다.)
        • 필드가 배열이면, 핵심 원소 각각을 별도 필드처럼 다룬다.
          • 이상의 규칙을 재귀적으로 적용해 각 핵심 원소의 해시코드를 계산한 다음, 단계 2.2 방식으로 갱신한다.
          • 배열에 핵심 원소가 하나도 없다면 단순히 상수(0을 추천한다)를 사용한다.
          • 모든 원소가 핵심 원소라면 Arrays.hashCode를 사용한다.
      2. 단계 2.1에서 계산한 해시코드 c로 result를 갱신한다.
         result = 31 * result + c;
      3. result를 반환하다.
@Override
    public int hashCode() {
//        return 42;
        int result = Integer.hashCode(areaCode);
        result = 31 * result + Integer.hashCode(prefix);
        result = 31 * result + Integer.hashCode(lineNum);
        return result;
    }

주의 할 점

  1. 파생 필드(다른 필드로 계산 가능한)는 제외해도 된다.
  2. 사용하지 않는 필드는 반드시 제외한다.
    • 제외하지 않으면 두 번째 규약을 어기게 될 위험이 있다.
  3. 만약 hash로 바꾸려는 필드가 기본 타입이 아니면 해당 필드의 hashCode를 불럭 구현한다.
  4. 참조 타입 필드가 null이면 0을 사용한다.
  5. 단계 2.1의 곱셈 31 * result는 필드를 곱하는 순서에 따라 result 값이 달라지게 한다.
    • String의 hashCode를 곱셈 없이 구현한다면 모든 아나그램(구성하는 철자가 같고 그 순서만 다른 문자열)의 해시코드가 같아진다.
    • 31로 곱한 이유는 소수이고 홀수이기 때문이고 만약 짝수이고 오버플로가 발생하면 정보를 잃게 되므로 31로 한다. 정확하게 소수로 해야 하는 이유는 모르고 전통적으로 그리해왔다.
    • 31을 이용하면 시프트 연산과 뺄셈으로 대체해 최적화 할 수 있다. (31 * i는 (i << 5) - i와 같다)
  6. 해쉬 충돌이 더 적은 방법은 구아바의 Hasing을 참고하자
  7. Objects 클래스에 있는 hash함수도 있지만 성능이 느리므로 IntelliJ의 자동완성 Object의 해쉬를 사용하자
     @Oberride
         public int hashCode() {
             return Objects.hash(lineNum, prefix, areaCode);
         }
  8. 스레드 안전성을 위해 해시코드를 지연 초기화 하자.
    • 해시의 키가 바로 사용 되지 않을 것같으면 지연 초기화 한다.
  9. 자주 해시의 키로 사용 할거 같으면 캐싱하여 사용하도록 한다.

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

  • 속도는 빨라야하지만 해시 품질이 나빠져 해시테이블의 성능을 심각하게 떨어뜨릴수도 있다.
  • 어떤 필드는 특정 영역에 몰린 인스턴스들의 해시코드를 넓은 범위로 고르게 퍼트려주는 효과가 있을지도 모르며 이런 필드를 생략하면 한 영역의 수많은 인스턴스가 단 몇 개의 해시코드로 집중되어 해시테이블의 속도가 선형으로 느려질 것이다.

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

아이템10에서 보았듯이 equals는 물리적으로 다른 두 객체를 논리적으로는 같다고할 수 있다.(아이템10), 필드를 지연 초기화하려면 그 클래스를 스레드 안전하게 만들도록 신경 써야 한다.(아이템83)

equals를 재정의할 때는 hashCode도 반드시 재정의해야 한다.


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

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

toString()은 단순히 클래스_이름@16진수로_표시한_해시코드를 반환한다.

  1. toString을 잘 구현한 클래스는 사용하기에 훨씬 즐겁고, 그 클래스를 사용한 시스템은 디버깅하기 쉽다.
    • println, printf, 문자열 연결자(+), assert 구문에 넘길 때, 혹은 디버거가 객체를 출력 할 때 자동으로 호출된다.
  2. 실전에서 toString은 그 객체가 가진 주요 정보 모두를 반환하는게 좋다.
    • 예를 들어 "맨해튼 거주자 전화번호부(총 1487536개)" 이거나 "Thread[main, 5, main]" 같은 요약 정보를 담아야 한다.
  3. 포맷을 명시하든 아니든 여러분의 의도는 명확히 밝혀야 한다.
    • toString을 구현할 때면 반환값의 포맷을 문서화할지 정해야 하며 명시하든 하지 않든 포맷은 아주 중요하다.
    • 전화번호나 행렬 같은 값 클래스라면 문서화 하기를 권장한다.
    • 포맷을 명시하기로 했다면, 명시한 포맷에 맞는 문자열과 객체를 상호 전환할 수 있는 정적 팩터리나 생성자를 함께 제공하면 좋다.
      • 단점으로는 포맷이 바뀌면 기존 프로그램들은 엉망이 되어 프로그래머들은 절규할 것이다.
        /**
        * 이 전화번호의 문자열 표현을 반환한다.
        * 이 문자열은 "XXX-YYY-ZZZZ"형태의 12글자로 구성된다.
        * XXX는 지역코드, YYY는 프리픽스, ZZZZ는 가입자 번호다.
        * 각각의 대문자는 10진수 숫자 하나를 나타낸다.
        *
        * 전화번호의 각 부분의 값이 너무 작아서 자릿수를 채울 수 없다면,
        * 앞에서부터 0으로 채워나간다. 예컨대 가입자 번호가 123이라면
        * 전화번호의 마지막 네 문자는 "0123"이 된다.
        * */
        @Override public String toString() {
          return String.format("%03d-%03d-%04d", areaCode, prefix, lineNum);
        }
    • 포맷을 명시하지 않기로 했다면, 아래처럼 작성할 수 있다.
        /**
        * 이 약물에 관한 대략적인 설명을 반환한다.
        * 다음은 이 설명의 일반적인 형태이나, 상세 형식은 정해지지 않았으며 향후 변경될 수 있다.
        * "[약물 #9: 유형=사랑, 냄새=테레빈유, 겉모습=먹물]" */
        @Override
        public String toString() {
            return "";
        }
  4. 포맷 명시 여부와 상관 없이 toString이 반환한 값에 포함된 정보를 얻어올 수 있는 API를 제공하자
    • 정적 유틸리티 클래스는 toString을 제공할 이유가 없다.
    • 대부분의 열거 타입도 자바가 이미 완벽한 toString을 제공하고 있다.
    • 하위 클래스들이 공유해야 할 문자열 표현이 있는 추상 클래스라면 toString을 재정의해줘야 한다.
      • 예를 들어 PhoneNumber 클래스는 지역 코드, 프리픽스, 가입자 번호용 접근자를 제공해야 한다.

equals와 hashCode규약(아이템10,11), 정적 유틸리티 클래스(아이템4), 열거 타입(아이템34)

모든 구체 클래스에서 Object의 toString을 재정의하자.


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

clone은 복제해도 되는 클래스인 믹스인 인터페이스이지만 의도한 목적을 제대로 이루지 못했다.

  • 가장 큰 문제는 clone 메서드가 선언된 곳이 Cloneable이 아닌 Object이면서 protected이기에 외부 객체에서 호출할 수 없다.
  • Cloneable에 메서드가 하나도 없지만 Object의 clone의 동작 방식을 결정한다.
    • Cleanable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환하며, 그렇지 않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException을 던진다.
    • 이는 인터페이스에서 상당히 이례적으로 사용한 예이니 따라하지 말자.

실무에서 Cloneable을 구현한 클래스는 clone 메서드를 public으로 제공하며, 사용자는 당연히 복제가 제대로 이뤄지리라 기대한다.

clone메서드의 일반 규약은 허술하다.

  • 이 객체의 복사본을 생성해 반환한다. '복사'의 정확한 뜻은 객체를 구현한 클래스에 따라 다를 수 있다.
    1. x.clone() ≠ x 해당 식은 참
    2. x.clone().getClass() == x.getClass() 해당 식도 참이지만 반드시 만족해야 하는 것은 아니다.
    3. x.clone().equals(x) 해당 식도 참이지만 반드시 필수는 아니다.
    4. x.clone().getClass() == x.getClass() 관례상, 반환하는 객체는 super.clone을 호출해 얻고 해당 클래스와(Object를 제외한) 모든 상위 클래스가 관례를 따르면 참이다.
      • 반환된 객체와 원본 객체는 독집적이어야하고 super.cone으로 얻은 객체의 필드 중 하나 이상을 반환 전에 수정해야 할 수 있다.
  • clone 메서드가 super.clone이 아닌, 생성자를 호출해 얻은 인스턴스를 반환해도 컴파일러는 불평하지 않지만 하위 클래스에서 super.clone을 호출한다면 잘못된 클래스의 객체가 만들어지고 하위 클래스의 clone 메서드가 제대로 동작하지 않게 된다. 단, 재정의한 클래스가 final이라면 하위 클래스 없으니 관레는 무시해도 된다.
    1. 클래스 B가 클래스 A를 상속할 때, 하위 클래스인 B는 clone은 B타입 객체를 반환해야 한다.
    2. A의 clone이 자신의 생성자인 new A(..)로 생성한 객체를 반환한다면 B의 clone도 A타입 객체를 반환해야 한다.
    3. super.clone을 연쇄적으로 호출하도록 구현해두면 clone이 처음 호출된 하위 클래스의 객체가 만들어진다.

제대로 동작하는 clone 메서드 만드는 방법

  • super.clone을 호출하면 원본의 완벽한 복제본일 것이고 클래스에 정의된 모든 필드는 원본 필드와 똑같은 값을 갖는다.
    • 모든 필드가 기본 타입이거나 불변 객체를 참조하면 수정 할 부분이 없지만 쓸데없는 복사를 지양한다면 불변 클래스는 clone 메제공하지 않는게 좋다.
      @Override
        public PhoneNumber clone() {
            try {
                return (PhoneNumber) super.clone(); // 클라이언트가 형변환하지 않도록 하여 절대 실패하지 않는다.
            } catch (CloneNotSupportedException e) {
                throw new AssertionError();
            }
        }
    • 재정의한 메서드의 반환 타입은 상위 클래스의 메서드가 반환하는 타입의 하위 타입일 수 있다.

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

  • Stack의 clone을 사용하는 경우 단순히 super.clone의 결과를 그대로 반환한다면 size 필드는 올바른 값을 가지지만, elements 필드는 원본 Stack 인스턴스와 똑같은 배열을 참조 하게 되어 불변식을 해친다.
  • 배열의 clone은 런타임과 컴파일타임 타입 모두가 원본 배열과 똑같은 배열로 반환하기 때문에 배열을 복제할 경우 배열의 clone 메서드를 사용하라고 권장한다.
import java.util.Arrays;
import java.util.EmptyStackException;

public class Stack implements Cloneable {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack(Object[] elements) {
        this.elements = elements;
    }

    public void push(Object e) {
        ensureCapacity();
        elements[size++] = e;
    }

    public Object pop() {
        if (size == 0)
            throw new EmptyStackException();
        Object result = elements[--size];
        elements[size] = null;
        return result;
    }

    private void ensureCapacity() {
        if (elements.length == size)
            elements = Arrays.copyOf(elements, 2 * size + 1);
    }

    @Override
    public Stack clone() {
        try {
            Stack result = (Stack) super.clone();
            result.elements = elements.clone();
            return result;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }

    public static void main(String[] args) {
        String[] ss = new String[]{};
        Stack s = new Stack(ss);
        s.push("asd");

        String[] ss2 = new String[]{};
        Stack sss = new Stack(ss2);
            sss = s.clone();
        sss.push("zxc1");
        sss.push("zxc2");
        sss.push("zxc3");
        sss.push("zxc4");
        System.out.println(s.pop());
        System.out.println(sss.pop());
    }
}

Cloneable 아키텍처는 '가변 객체를 참조하는 필드는 final로 선언하라'는 일반 용법과 충돌한다.

  • 만약 elements가 final이었다면 위 방식은 동작하지 않고 가변 객체를 참조하는 필드는 final로 선언하라는 용법과 맞지 않는다.
  • 단, '원본과 복제된 객체가 가변 객체를 공유해도 안전하다면 괜찮다' 라는 조건이 추가로 생기게 되어 final을 제가 해야할 수도 있다.

해시테이블용 clone메서드를 만드는 방법

  • 해시테이블의 내부는 버킷들의 배열이고, 각 버킷은 키-값 쌍으로 담은 연결 리스트의 첫 번째 엔트리를 참조한다.
    1. HashTable의 clone 메서드는 적절한 크기의 새로운 버킷 배열을 할당하고 배열을 순회하며 비지 않은 각 버킷에 대해 깊은 복사를 수행한다. Entry의 deepCopy 메서드는 자신이 가리키는 연결 리스트 전체를 복사하기 위해 자신을 재귀적으로 호출한다.
    2. 버킷이 길지 않으면 잘 작동하지만 연결 리스트를 복제하는 방법이 그리 좋지 않은데 재귀 호출 때문에 리스트의 원소 수만큼 스택 프레임을 소비하여 스택 오버플로를 일으킬 위험이 있다.
Entry deepCopy() {
    return new Entry(key, value, next == null ? null : next.deepCopy());
}

@Override public HashTable clone() {
    try {
                HashTable result = (HashTable) super.clone();
                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 (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}
  • 연결 리스트를 개선하는 방법으로는 deepCopy를 재귀 호출 하는 대신 반복자를 써서 순회하는 방향으로 수정한다.
Entry deepCopy() {
    Entry result = new Entry(key, value, next);
    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;
}
  • 고수준의 메서드로 구현하는 방법
    1. super.clone을 호출하여 얻은 객체의 모든 필드를 초기 상태로 설정하고 원본 객체의 상태를 다시 생성하는 고수준 메서드들을 호출한다.
    2. 예시인 HashTable로 한다면 buckets 필드를 새로운 버킷 배열로 초기화한 다음 원본 테이블에 담긴 모든 키-값 쌍 각각에 대해 복제본 테이블의 put(key, value) 메서드를 호출해 원본과 같도록 하면 된다.
    3. 소스는 우하해지지만 저수준에서 바로 처리하는 경우보다는 느리고 Cloneable 아키텍처의 기초가 되는 필드 단위 객체 복사를 우회하기 때문에 전체 Cloneable 아키텍처와는 어울리지 않는 방식이다.

주의 해야 할 점

  1. 생성자에서는 재정의될 수 있는 메서드를 호출하지 않아야 하는데 clone 메서드에서도 마찬가지이다.
    • clone이 하위 클래스에서 재정의한 메서드를 호출하면, 복제 과정에서 자신의 상태를 교정할 기회를 잃게 되어 원본과 복제본의 상태가 달라질 가능성이 크게 된다.
    • put 메서드는 final이거나 private이어야 하고 private이면 public 메서드가 사용하는 도우미 메서드로일 것이다.
  2. public인 clone 메서드에서는 throws 절을 없애야 한다.
  3. 상속용 클래스는 Cloneable을 구현해서는 안 된다.
    • Object의 방식을 모방하여 처리 해야 하는데 clone 메서드를 구현해 protected로 두고 ClonNotSupportedException도 던질 수 선언하면 마치 Object를 바로 상속할 때처럼 Cloneable 구현 여부를 하위 클래스에서 선택하도록 해준다.
    • clone을 동작하지 않게 구현하고 하위 클래스에서 재정의하지 못하도록 한다.
        @Override
        protected final Object clone() throws CloneNotSupportedException {
            throw new CloneNotSupportedException();
        }
  4. Cloneable을 구현한 스레드 안전 클래스를 작성할 때는 clone 메서드 역시 적절히 동기화해줘야 한다.
    • Object의 clone 메서드는 동기화를 신경 쓰지 않기에 super.clone 호출 외에 다른 할 일이 없더라도 clone을 재정의하고 동기화해줘야 한다.

중요 : Cloneable을 구현하는 모든 클래스는 clone을 재정의해야 한다.

  • 접근 제한자는 public으로 하고 반환 타입은 클래스는 자기 자신으로 변경한다.
  • super.cone을 가장 먼저 호출하고 필요한 필드를 전부 수정해준다. 모든 가변 객체를 복사하고, 복제본이 가진 객체 참조 모두가 복사된 객체들을 가리키게 함을 의미한다.
  • 기본 타입 필드와 불변 객체 참조만 갖는 클래스라면 아무 필드도 수정할 필요가 없지만, 일련번호나 고유 ID는 기본 타입이나 불변일지라도 수정해줘야 한다.

복사 생성자와 복사 팩터리라는 더 나은 객체 복사 방식을 제공할 수 있다.

  • 위처럼 복잡한 경우는 드물어 꼭 필요하지 않으면 복사 생성자와 복사 팩터리를 이용하자.
  • 복사 생성자란 단순히 자신과 같은 클래스의 인스턴스를 인수로 받는 생성자를 의미한다.
      // 복사 생성자
      public Yum(Yum yum) {..}
    
      // 복사 팩터리 : 복사 생성자를 모방한 정적 팩터리이다.
      public static Yum newInstance(Yum yum) { ... }
  • 복사 생성자와 복사 팩터리가 Cloneable/clone 방식보다 나은 면이 많으며 엉성한 문서 규약이나 정상적인 final 필드 용법과도 충않고 불필요한 검사 예외를 던지지 않고 형변환도 하지 않아도 된다.
  • '인터페이스' 타입의 인스턴스를 인수로도 받을 수 있는데 관례상 모든 법용 컬렉션 구현체는 Collection이나 map 타입을생성자를 제공한다.
    • 인터페이스 기반 복사 생성자(변환 생성자)와 복사 팩터리(변환 팩터리)이다.
    • 원본의 구현 타입에 얽매이지 않고 복제본의 타입을 직접 선택할 수 있다.
    • clone으로는 불가능한 기능을 변환 생성자로 사용 할 수 있고 예를 들어 HashSet 객체를 TreeSet 타입으로 복제할 수 있다. new TreeSet<>(s)

⇒ `믹스인 인터페이스(아이템20), 리플렉션(아이템65), 비검사예외였어야 했다는 신호다(아이템71), 생성자에서는 재정의될 수 있는 메서드를 호출하지 않아야 하는데(아이템19), Cloneable을 구현한 스레드 안전 클래스를 작성할 때는 clone 메서드 역시 적절히 동기화해줘야 한다.(아이템78), 성능 최적화 관점에서 검토한 후 별다른 문제가 없을 때만 드물게 허용해야 한다.(아이템67)

새로운 인터페이스를 만들 때는 절대 Cloneable을 확장해서는 안 되며, 새로운 클래스도 구현해서는 안된다. 기본 원칙은 '복제 기능은 생성자와 팩터리를 이용하는게 최고'라는 것이며 배열만은 clone 메서드 방식이 가장 깔끔한, 합당한 예외라 할 수 있다.


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

compareTo는 Object 메서드가 아니지만 단순 동치성 비교에 순서까지 비교할 수 있고 제네릭한 성격만 빼면 Object의 equals와 같다.

  • Comparable을 구현되어 있으면 인스턴스들에는 자연적인 순서가 있음을 의미한다.
      Arrays.sort(a); // 쉽게 정렬할 수 있다.
  • 다음 프로그램에서는 인수들의 중복을 제거하고 알파벳순으로 출력한다.
      public class WordList {
          public static void main(String[] args) {
              Set<String> s = new TreeSet<>();
              Collections.addAll(s, args);
              System.out.println(s);
          }
      }
  • 알파벳, 숫자, 연대 같이 순서가 명확한 값 클래스를 작성할 경우 Comparable 인터페이스를 구현하면 좁쌀만한 노력으로 코끼리만효과를 볼 수 있다.
      public interface Comparable<T> {
          int compareTo(T t);
      }

compareTo 메서드의 일반 규약

  • T > 0 = 음의 정수, T < 0 = 양의 정수를 반환하고 비교할 수 없으면 ClassCastException을 던진다.
  • 설명에서 sgn(표현식) 표기는 수학에서 ㅁ라하는 부호 함수를 의미한다.
  • 반사성, 대칭성, 추이성을 충족해야 함을 의미한다.
  1. Comparable을 구현한 클래스는 모든 x, y에 대해 sgn(x.compareTo(y)) == -sgn(y.compareTo(x)) 여야 한다. (따라서 x.compareTo(y)는 y.compareTo(x)가 예외를 던질 때에 한해 예외를 던져야 한다.
  2. Comparable을 구현한 클래스는 추이성을 보장해야 한다. 즉, (x.compareTo(y) > 0 && y.compareTo(z) > 0)이면 x.compareTo(z) > 0 이다.
  3. 이번 권고가 필수는 아니지만 꼭 지키는게 좋다. (x.compareTo(y) == 0) == (x.equals(y))여야 한다. Comparable을 구현하고 이 권고를 지키지 않는 모든 클래스는 그 사실을 명시해야 한다.
    • "주의 : 이 클래스의 순서는 equals 메서드와 일관되지 않는다."

주의해야 할 점

  • 주의 사항은 equals의 주의사항과 똑같다.
  • 기존 클래스를 확장한 구체 클래스에서 새롱누 값 컴포넌트를 추가했다면 compareTo 규약을 지킬 방법이 없다.
  • compareTo의 마지막 규약은 필수는 아니지만 꼭 지키길 권하는 이유는 compareTo의 동치성 테스트 결과가 equals와 같아야 줄지운 일관되게 동작한다.
  • 정렬 된 컬렉션들은 동치성을 비교할 경우 equals 대신 compareTo를 사용하므로 주의해야 한다.
    • HashSet 인스턴스를 생성하고 new BigDecimal("1.0")과 new BigDecimal("1.00")을 비교하면 다르다고 하지만 TreeSet을 사용하면 같다고 나온다.
  • Comparable은 타입을 인수로 받는 제네릭 인터페이스이므로 compareTo 메서드의 인수 타입은 컴파일타임에 정해지므로 입력 인수의 확인 하지 않아도 된다.
  • compareTo 메서드는 각 필드가 동치인지를 비교하지 않고 순서를 비교한다.
  • Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교해야 한다면 비교자(Comparator)를 대신 사용한다.
      public final class CaseInsensitiveString implements Comparable<CaseInsensiviveString> {
          public int compareTo(CaseInsensitiveString cis) {
              return String.CASE_INSENSITIVE_ORDER.comapre(s, cis.s);
          }
      }

우회 방법

  • 우회 방법도 equals와 똑같다.
  • Comparable을 구현한 클래스를 확장해 값 컴포넌트를 추가하고 싶다면, 확장하는 대신 독립된 클래스를 만들고 원래 클래스의 인스턴스를 가리키는 필드를 두고 내부 인스턴스를 반환하는 '뷰' 메서드를 제공한다.

compareTo 메서드에서 관계 연산자 '<' 와 '>'를 사용하는 이전 방식은 거추장스럽고 오류를 유발하니, 이제는 추천하지 않는다.

  • 기존에는 정수 기본 타입 필드는 '<'와 '>'를 비교했는데 지금은 compare를 추천한다.
  • 비교 결과가 0이 아니라면 순서가 결정되는 순간 끝이니 반환하면 된다.
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;
    }
  • 자바의 정적 임포트 기능을 이용하면 정적 비교자 생성 메서드들을 해당 이름으로 사용할 수 있어서 깔끔해진다.
private static final Comparator<PhoneNumber> COMPARATOR = Comparator.comparingInt(
            (PhoneNumber pn) -> pn.areaCode)
            .thenComparing(pn -> pn.prefix)
          .thenComparing(pn -> pn.lineNum);

public int compareTo(PhoneNumber pn) {
    return COMPARATOR.compare(this, pn);
}
  • '값의 차'를 기준으로 첫 번째 값이 두 번째 값보다 작으면 음수를, 두 값이 같으면 0을, 첫 번째 값이 크면 양수를 반compareTo나 compare 메서도와 마주치게 되는데 아래 방식처럼 사용하면 안된다.
    • 부동소수점 계산 방식에 따른 오류를 낼 수 있다.
      static Comparator<Object> hashCodeOrder = new Comparator<Object>() {
            @Override
            public int compare(Object o1, Object o2) {
                return o1.hashCode() - o2.hashCode();
            }
        };
    • 위 방식 보다는 아래 방식 중 하나를 사용하도록 하자
        static Comparator<Object> hashCodeOrder = new Comparator<Object>() {
                @Override
                public int compare(Object o1, Object o2) {
                    return Integer.compare(o1.hashCode(), o2.hashCode());
                }
            };
        static Comparator<Object> hashCodeOrder = Comparator.comparingInt(o -> o.hashCode());

열거타입(아이템34), eqauls규약(아이템10)

순서를 고려해야 하는 상황에는 꼭 Comparable을 구현하고 비교 할 경우 '<' 와 '>'를 사용하지 말고 compare 메서드나 Comparator 인터페이스가 제공하는 비교자 생성 메서드를 사용하자.


반응형