ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [이펙티브 자바 : 2장] 모든 객체의 공통 메서드
    Java & Kotlin 2020. 6. 24. 09:30
    반응형

    final이 아닌 Object 메서드들을 언제 어떻게 재정의해야 하는지를 다룬다. (finalize 제외) Comparable.compareTo의 경우 Object의 메서드는 아니지만 성격이 비슷하여 이번 장에서 함께 다룬다.

     

     

     

     

     

     

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

     

     

    equals 메서드는 재정의하기 쉬워 보이지만 함정이 많아 잘못 재정의하면 끔찍한 결과를 초래한다.

    재정의 하지 않으면 오직 자기 자신과만 같게 된다.

     

     

    equals를 재정의하지 않는 것이 최선인 상황

     

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

     

    값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스가 여기 해당한다. Thread가 좋은 예로, Object의 equals 메서드는 이러한 클래스에 딱 맞게 구현되었다.

     

    상황 2 : 인스턴스의 '논리적 동치성(logical equality)'을 검사할 일이 없다.

     

    예컨대 java.util.regex.Pattern은 equals를 재정의해서 두 Pattern의 인스턴스가 같은 정규표현식을 나타내는지를 검사하는, 즉 논리적 동치성을 검사하는 방법도 있다. 하지만 설계자는 클라이언트가 이 방식을 원하지 않거나 애초에 필요하지 않다고 판달할 수도 있다. 설계자가 후자로 판단했다면 Object의 기본 equals만으로 해결된다.

     

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

     

    예컨대 대부분의 Set 구현체는 AbstractSet이 구현한 equals를 상속받아 쓰고, List 구현체들은 AbstractList로부터, Map 구현체들은 AbstractMap으로부터 상속받아 그대로 쓴다.

     

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

     

    여러분이 위험을 철저히 회피하는 스타일이라 equals가 실수로라도 호출되는 걸 막고 싶다면 다음처럼 구현해두자.

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

    equals를 재정의해야 할 상황

     

    객체 식별성(두 객체가 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의도지 않았을 때다.

     

    주로 Integer나 String 같은 값 클래스들이 여기에 해당된다. 값 클래스를 사용하는 프로그래머는 객체가 같은지가 아니라 값이 같은지를 알고 싶어 할 것이기 때문이다.

     

    값 클래스라 해도, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스라면 equals를 재정의하지 않아도 된다. Enum도 여기에 해당한다. (어차피 모든 인스턴스의 값이 똑같기 때문)


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

     

    조건 1 : 반사성

    null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true다.

    단순히 말하면 객체는 자기 자신과 같아야 한다는 뜻이다. 이 요건을 어긴 클래스를 컬렉션에 넣은 다음 contains 메서드를 호출하면 방금 넣은 인스턴스가 없다고 답할 것이다.

     

    조건 2 : 대칭성

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

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

    private final String s;

    x의 equals(Object o) -> return s.equalsIgnoreCase((String) o);

    y의 equals(Object o) -> return s.equals((String) o); - 대칭성 오류

     

    조건 3 : 추이성

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

    @Override
    public boolean equals(Object o) {
        if(!(o instanceof Point)) return false;
        
        // o가 일반 Point라면 색상을 무시하고 비교한다.
        if(!(o instanceof ColorPoint)) return o.equals(this);
        
        // o가 ColorPoint라면 color까지 비교한다.
        return o.equals(this) && ((ColorPoint) o).color == 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) = false(색상이 RED와 BLUE로 다름)를 반환한다.

     

    * 객체지향적 추상화의 이점(instanceof)을 포기자히 않는 한 구체(Point) 클래스를 확장해 새로운 값(color)을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.

     

    equals 안의 instanceof 검사를 getClass 검사로 바꾸면 규약도 지키고 값도 추가하면서 구체 클래스를 상속할 수 있다는 뜻이다.

    잘못된 코드 - 리스코프 치환 원칙1 위배!

    @Override public boolean equals(Object o) {
        if (o == null || getClass() != o.getClass()) return false;
        
        Point point = (Point) o;
        return x == point.x && y == point.y;
    }

    이 방식은 괜찮아 보이지만 실제로 사용할 수 없다. Point의 하위 클래스 는 정의상 여전히 Point이므로 어디서든 Point로써 활용될 수 있어야하지만 이 방식에선 그렇지 못하다. (리스코프 치환 원칙을 위배했다는 뜻)

     

    private static final Set<Point> unitCircle = Set.of(
            new Point(1, 0), new Point(0, 1), 
            new Point(-1, 0), new Point(0, -1)
        );
        
    public static boolean onUnitCircle(Point p) {
        return unitCircle.contains(p);
    }
    
    // ------------------------------------------------------------ 
    
    public class CounterPoint extends Point {
        ...
    }

    Point 클래스의 equals 메서드를 getClass를 이용해 작성했다면, onUnitCircle은 CounterPoint의 x, y 값과는 무관하게 항상 false를 반환할 것이다. onUnitCircle에서 사용한 Set을 포함한 대부분의 Collection은 이 작업에 equals 메서드를 이용하는데, CounterPoint의 인스턴스는 어떤 Point와도 같을 수 없기 때문이다. * Point.class != CounterPoint.class

     

    상속 대신 컴포지션을 사용하는 우회 방법이 있다. Point를 상속하는 대신 Point를 ColorPoint의 private 필드로 두고, ColorPoint와 같은 위치의 일반 Point를 반환하는 뷰(view) 메서드를 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 = Objects.requireNonNull(color);
        }
    
        // ColorPoint의 Point view를 반환
        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);
        }
    
    }
    

     

    조건 4 : 일관성

    null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true 또는 항상 false를 반환한다.

    두 객체가 같다면 영원히 같아야 한다.

     

    * equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다. 

    예컨대 java.net.URL의 equals는 주어진 URL과 매핑된 호스트의 IP 주소를 이용해 비교한다. 호스트 이름을 IP 주소로 바꾸는 과정(호스트는 항상 같은 IP 주소를 반환하지 않음)에서 네트워크라는 자원이 끼어들기 때문에 일관성을 위배했다. 이는 실무에서도 종종 문제를 일으킨다.

     

    조건 5 : null-아님

    null이 아닌 모든 참조 값 x에 대해, x.equlas(null)은 false다.

    모든 객체가 null과 같지 않아야 한다. 

     

    if(!(o instanceof MyType)) return false; // null은 여기서 false를 반환한다.


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

     

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

    if(this == o) return true;

     

    2. instanceof 연산자로 입력이 올바른 타입인지 확인한다. 

    if(!(o instanceof MyType)) return false;

    MyType 대신 MyType이 구현한 인터페이스가 될 수도 있다. Set, List, Map, Map.Entry 등의 컬렉션 인터페이스들은 자신을 구현한 (서로 다른) 클래스끼리도 비교할 수 있도록 equals 규약을 수정하기도 한다.

     

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

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

     

    4. 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다.

    모든 필드가 일치하면 true, 하나라도 다르면 false를 반환한다.

     

    타입 별 비교하는 방법

     

    * float와 double을 제외한 기본 타입 필드는 == 연산자

     

    * 참조 타입 필드는 각각의 equals 메서드

      (null도 정상적인 값으로 다루는 참조 타입은 Objects.equals(Object, Object)를 이용해서 NPE 발생 예방)

     

    * float 필드는 Float.compare(float, float) 

      (Float.equals보다 성능 우수)

     

    * double 필드는 Double.compare(double, double)

      (Double.equals보다 성능 우수)

     

    지금까지의 규칙을 지켜 작성하여 오버라이드 한 equals 메서드

    @Override public boolean equals(Object o) {
        if(this == 0) return true;
        if(!(o instanceof PhoneNumber)) return false;
        
        PhoneNumber pn = (PhoneNumber) o;
        return pn.lineNum == lineNum && pn.prefix == prefix && pn.areaCode = areaCode;
    }

     

    * 잘못된 예 - 입력 타입은 반드시 Object여야 한다

    public boolean equals(Myclass o) { ... } // 재정의 x 다중정의 o

     

    Google이 만든 AutoValue 프레임워크를 사용해서 override한 equals 메서드를 테스트할 수 있다.

    @AutoValue
    public class MyClass { ... }

     

    * 핵심 정리

    1. 꼭 필요한 경우가 아니라면 equals를 재정의하지 말자. 대부분의 경우는 Object의 equals가 비교를 정확히 수행해준다.

    2. 재정의해야 할 때는 그 클래스의 핵심 필드 모두를 빠짐없이, 다섯 가지 규약을 확실히 지켜가며 비교해야 한다.

     

     


     

     

    [ITEM 11] equlas를 재정의하려거든 hashCode도 재정의하라

     

     

    equals를 재정의 할 때 hashCode를 재정의하지 않으면 해당 클래스의 인스턴스를 HashMap이나 HashSet 같은 컬렉션의 원소로 사용할 때 문제를 일으킬 것이다. 다음은 Object 명세에서 발췌한 규약이다.

     

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

     

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

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

     

    * equals 메서드만 재정의한 PhoneNumber 클래스

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

    이 코드 다음에 m.get(new PhoneNumber(707, 867, 5309))를 실행하면 "제니"가 나와야 할 것 같지만, 실제로는 null을 반환한다. (hashCode를 재정의하지 않았기 때문에 put, get을 할 때 다른 hashCode가 반환된다)

     

    해결 방법

     

    * 최악의 (하지만 적법한) hashCode 구현 - 사용 금지!

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

     

     

    모든 인스턴스에 똑같은 hashCode를 반환한다.

     

     

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

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

     

     

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

     

    * Intellij의 경우 equals 메서드를 재정의 하면 자동 생성해줄 뿐만 아니라 hashCode 메서드도 같이 자동 생성해준다.

     

     


     

     

    [ITEM 12] toString을 항상 재정의하라

     

     

    Object의 기본 toString 메서드는 클래스_이름@16진수로_표시한_해시코드 (eg. Point@a6687c03)를 반환한다.

     

    toString 일반 규약

    - 간결하면서 사람이 읽기 쉬운 형태의 유익한 정보 반환하라

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

     

    toString 메서드는 객체를 println, printf 문자열 연결 연산사(+), assert 구문에 넘길 때, 혹은 디버거가 객체를 출력할 때 자동으로 불린다. 나 자신이 직접 toString 메서드를 호출하지 않더라도 어딘가에서 쓰일거란 이야기다.

     

    toString은 이 인스턴스를 포함하는 객체(특히 Collection)에서 유용하기 쓰인다. 

    - map 객체를 출력했을 때 {Jenny=PhoneNumber@adbbd} 보다는 {Jenny=707-867-5309}가 보기 훨씬 좋다.

     

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

     

     


     

     

    [ITEM13] clone 재정의는 주의해서 진행하라

     

     

    Cloneable은 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스지만, 아쉽게도 의도한 목적을 제대로 이루지 못했다. 가장 큰 문제는 clone 메서드가 선언된 곳이 Cloneable이 아니라 Object이고, 그마저도 protected라는 데 있다. 여러 문제점에도 불구하고 Cloneable 인터페이스는 널리 사용되고 있어 잘 알아두는 것이 좋다.

     

    메서드 하나 없는 Cloneable 인터페이스는 놀랍게도 Object의 protected 메서드인 clone의 동작 방식을 결정한다.

     

    Cloneable 인터페이스를 구현한 클래스의 인스턴스에서 clone을 호출하면 인스턴스를 복사한 객체가 반환되며, 그렇지 않은 인스턴스에서 호출하면 CloneNotSupportedException을 던진다.

     

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

     

     


     

     

    [ITEM 14] Comparable을 구현할지 고려하라

     

     

    Comparable 인터페이스의 유일무이한 메서드인 compareTo는 두 가지만 빼면 Object의 equals와 같다.

     

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

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

    이 객체들은 Arrays.sort를 이용해서 손 쉽게 정렬할 수 있다. 알파벳, 숫자, 연대 같이 순서가 명확한 값 클래스를 작성한다면 반드시 Comparable 인터페이스를 구현하자.

     

    compareTo 메서드의 일반 규약

     

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

     

    sng(표현식)은 표현식의 값이 음수, 0, 양수일 때 -1, 0, 1을 반환하도록 정의했다.

     

    규약 1 : Comparable을 구현한 클래스는 모든 x, y에 대해 sgn(x.compareTo(y)) == -sgn(y.compareTo(x))여야 한다.

    - 두 객체 참조의 순서를 바꿔 비교해도 예상한 결과가 나와야 한다.

    - a < b = true

    - b > a = true

     

    규약 2 : Comparable을 구현한 클래스는 추이성을 보장해야 한다. 즉, (x.compareTo(y) > 0 && y.compareTo(z) > 0)이면 x.compareTo(z) > 0이다.

    - a > b = true

    - b > c = true

    - a > c = true

     

    규약 3 : Comparable을 구현한 클래스는 모든 z에 대해 x.compareTo(y) == 0이면 sgn(x.compareTo(z) == y.compareTo(z))다.

    - a == b = true

    - a == c = true

    - b == c = true

     

    규약 4 : 선택 사항이지만 꼭 지키는게 좋다. (x.comareTo(y) == 0) == (x.equals(y))여야 한다. Comparable을 구현하고 이 권고를 지키지 않는 모든 클래스는 그 사실을 명시해야 한다. "주의: 이 클래스의 순서는 equals 메서드와 일관되지 않다."

     

    * hashCode 규약을 지키지 못하면 해시를 사용하는 클래스와 어울리지 못하듯, compareTo 규약을 지키지 못하면 비교를 활용하는 클래스와 어울리지 못한다. (정렬된 컬렉션인 TreeSet와 TreeMap, 정렬 알고리즘을 사용하는 Collections와 Arrays)

     

    compareTo 메서드는 각 필드가 동치인지 비교하는게 아니라 그 순서를 비교한다. 객체 참조 필드를 비교하려면 compareTo 메서드를 재귀적으로 호출한다.

     

    Comparable을 구현하지 않은 필드나 표준(값, 날짜 등)이 아닌 순서로 비교해야 한다면 비교자(Comparator)를 대신 사용한다. 비교자는 직접 만들거나 자바가 제공하는 것 중 골라 쓰면 된다. 

    public final class CaseInsensitiveString implements Comparable<CaseInsensitiveString> {
        private String s;
    
        @Override
        public int compareTo(CaseInsensitiveString cis) {
            return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s);
        }
    }

    CaseInsensitibeString이 Comparable<CaseInsensitibeString>을 구현한 것에 주목하자. CaseInsensitibeString 참조는 CaseInsensitibeString 참조와만 비교할 수 있다는 뜻으로 Comparable을 구현할 때 일반적으로 따르는 패턴이다.

     

    compareTo 메서드에서 관계 연산자 <와 >를 사용하는 방식은 거추장스럽고 오류를 발생하기 때문에 추천하지 않는다.

    박싱된 기본 타입 클래스들에 새로 추가된 정적 메서드인 compare( Integer.compare(1, 2) )을 이용하면 된다.

     

    클래스에 핵심 필드가 여러 개라면 가장 핵심적인 필드부터 비교해나가자. 비교 결과가 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;
    }

     

    자바 8에서는 Comparator 인터페이스가 일련의 비교자 생성 메서드(comparator construction method)와 팀을 꾸려 메서드 연쇄 방식으로 비교자를 생성할 수 있게 되었다. 이 비교자들은 Comparable 인터페이스가 원하는 compareTo 메서드를 구현하는 데 활용할 수 있다. (간결하지만 성능저하가 뒤따른다)

    import static java.util.Comparator.*;
    
    private static final Comparator<PhoneNumber> COMPARATOR =
      comparingInt((PhoneNumber pn) -> pn.areaCode)
        .thenComparingInt(pn -> pn.prefix)
        .thenComparingInt(pn -> pn.lineNum);
    
    @Override
    public int compareTo(PhoneNumber pn) {
    	return COMPARATOR.compare(this, pn);
    }

    1. 리스코프 치환 원칙에 따르면, 어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요하다. 따라서 그 타입의 모든 메서드가 하위 타입에서도 똑같이 잘 작동해야 한다.

    반응형
Designed by Tistory.