공부함

모든 객체의 공통 메서드 본문

Java/이펙티브 자바

모든 객체의 공통 메서드

찌땀 2024. 1. 4. 18:10

Object 클래스의 final이 아닌 메서드들은 재정의를 염두에 두고 설계된 것이라 재정의 시 지켜야 하는 일반 규약을 따라야 한다. 잘못 구현하면 클래스가 이 규약을 준수한다고 가정하는 HashMap 같은 클래스가 오동작할 수 있다. 

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

먼저 equals를 재정의하지 않아야 할 상황이 있다.

  • 각 인스턴스가 본질적으로 고유하다
    • 값이 아닌 동작하는 개체 표현 , Thread
  • 논리적 동치성을 검사할 일이 없다 
  • 상위 클래스 equals가 하위 클래스에 들어맞는다
  • 클래스가 private이거나 package-priavte이고 equals를 호출할 일이 없다.
    • equals가 실수로라도 호출되는걸 막고 싶다면 오버라이딩해서 예외를 던지도록 하자

equals는 논리적 동치성을 확인해야 하는데 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의하지 않았을 때 ,76재정의한다. 다만 같은 인스턴스가 2개 이상 만들어지지 안호록 보장되는 인스턴스 통제 클래스나 Enum에는 오버라이딩 하지 않아도 된다. 

 

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

  • 반사성 : null이 아닌  모든 참조 값 x에 대해 x.equals(x)는 true다.
    • 객체는 자기 자신과 같아야 한다
  • 대칭성 : null이 아닌  모든 참조 값 x, y에 대해 x.equals(y)가 true면 y,equlas(x)도 true다.
    • 서로에 대한 동치 여부에 대해 똑같이 답해야 한다.
  • 추이성 : null이 아닌  모든 참조 값 x, y, z에 대해 x.equals(y)가 true고 y,equlas(z)가 true면 x.equals(z)도 true다.
    • 1,2가 같고 2,3이 같으면 1,3이 같아야 한다. 
    • 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 수 없다
    • 리스코프 치환 원칙 : 어떤 타입에 있어 중요한 속성이라면 하위 타입에서도 중요하다. 그 타입의 모든 메서드가 하위 타입에서도 똑같이 잘 작동해야 한다. 
    • equals에서 getInstance를 활용해 같은 구현 클래스끼리 비교할 때만 true를 반환하게 하면 리스코프 치환 원칙에 위배된다. 예를 들어 Set<추상클래스>의 contains(구체클래스)가 무조건 false가 된다.
    • 구체 클래스에서 필드를 추가하는 상속 대신 컴포지션을 활용하라 
      • Point를 ColorPoint의 private 필드로 두고, Point를 반환하는 뷰 메서드 asPoint를 public으로 추가해라
    • 추상 클래스의 하위 클래스에서는 equals 규약을 지키면서 필드를 추가할 수 있다. 상위 클래스를 객체화할 수 없기 때문이다.
  • 일관성 : null이 아닌  모든 참조 값 x, y에 대해 x.equals(y)를 반복해서 호출하면 항상 true거나 항상 false다.
    • 두 객체가 같다면 영원히 같아야 한다.(수정되지 않는 한)
    • 불변 객체인 경우 한번 같으면 영원히 같고 한번 다르면 영원히 다르다.
    • equals 계산에 신뢰할 수 없는 자원이 끼어들어서는 안된다. 항시 메모리에 존재하는 결정적 계산만 수행해야 한다.
  • null-아님 : null이 아닌  모든 참조 값 x에 대해 x.equals(null)은 false다.
    • 모든 객체는 null과 같지 않아야 한다.
    • NPE를 던지는 것이 아니라 false를 반환해야 한다.
    • instanceof 연산자는 첫번째 피연산자가 null이면 false를 반환하므로 명시적 null 체크가 따로 필요없다.

이러한 일반 규약에 따라 equals 메서드 구현 방법을 알아보자.

  • == 연산자로 입력이 자기 자신의 참조인지 확인한다. 자기 자신이면 true를 반환한다.
    • 이것은 비교 작업이 복잡할 때 성능을 위해서이다.
  • instanceof 연산자로 입력이 올바른 타입인지 확인한다. 그렇지 않다면 false를 반환한다.
    • 올바른 타입은 일반적으로 equals가 구현된 클래스를 의미한다.
    • 올바른 타입은 가끔은 그 클래스가 구현한 특정 인터페이스가 될 수도 있다. 
      • 어떤 인터페이스는 자신을 구현한 서로 다른 클래스들끼리도 비교할 수 있게 equals를 수정하기도 한다.
      • 이러한 인터페이스를 구현한 클래스에서는 equals의 instanceof에서 해당 인터페이스를 사용해야 한다. 
      • Set,List,Map,Map.entry등이 이에 해당한다.
  • 입력을 올바른 타입으로 형변환한다.
    • 앞의 단계에서 instanceof로 검사했기 때문에 무조건 성공하는 단계다.
  • 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사한다. 모두 일치하면 true, 아니면 false를 반환한다. 
    • 2단계에서 인터페이스를 사용했다면 입력의 필드값을 가져올 때도 인터페이스의 메서드를 사용해야 한다.
    • float, double을 제외한 기본 타입 필드는 ==로 검사한다.
    • float, double은 각각 Float.compare(float, float)과 Dobule.compare(double, double)로 비교한다. 
    • 배열의 모든 원소가 핵심 필드라면 Arrays.equals 메서드들 중 하나를 사용하자
    • null도 정상 값으로 취급하는 필드라면 Objects.equals(Object, Object)로 비교해 NPE를 예방하자
    • 비교하기 복잡한 필드를 가진 클래스는 표준형을 저장해놓고 표준형끼리 비교하면 경제적이다. 불변 클래스에 제격이다. 가변 객체라면 값이 바뀔때마다 표준형을 갱신해야 한다. 
    • 필드를 비교하는 순서가 성능에 영향을 미치기도 한다
      • 다를 가능성이 크거나 비교 비용이 저렴한 필드를 먼저 비교하자
      • 객체의 논리적 상태와 관련 없는 필드는 비교하지 않는다.
      • 핵심 필드로 계산할 수 있는 파생 필드는 비교할 필요가 없지만 파생 필드가 객체 전체의 상태를 대변한다면 파생 필드를 비교하는 게 나을 수도 있다.
  • equals를 다 구현했다면 대칭적인가?추이성이 있는가?일관적인가?를 자문하자.
    • 단위 테스트를 작성해 돌려보자.
    • 구글의 AutoValue 프레임워크를 활용하면 클래스에 애너테이션만 붙이면 테스트코드를 알아서 작성해준다. 

equals를 구현할 때 주의사항들이다. 

  • equals를 재정의 할 때는 hashCode도 반드시 재정의해야 한다. 
  • 너무 복잡하게 해결하려 들지 말자. 
  • 입력 타입은 반드시 Object여야 한다. 입력 타입이 Object가 아닐 경우 오버라이딩이 아니라 오버로딩 한 것이다.

[아이템 11] equals를 재정의하려거든 hashcode도 재정의하라

equals를 재정의 한 클래스 모두에서 hashcode도 재정의해야한다. 그렇지 않으면 HashMap,HashSet 같은 컬렉션의 원소로 사용할 때 문제가 발생한다. 아래는 hashcode 재정의 규약이다.

  • equals 비교에 사용되는 정보가 변경되지 않았다면, 애플리케이션이 실행되는 동안 그 객체의 hashcode는 호출할 때 마다 같은 값을 반환해야 한다.
  • equals가 두 객체를 같다고 판단했다면 두 객체의 hashCode는 같은 값을 반환해야 한다.
  • equals가 두 객체를 다르다고 판단했다고 hashCode 반환값이 다를 필요는 없다. 하지만 다른 값을 반환해야 해시테이블의 성능이 좋아진다.

두번째 규약은 논리적으로 같은 객체는 같은 해시코드를 반환해야 한다는 것이다. 

equals를 재정의하면 물리적으로 다른 두 객체를 논리적으로 같다고 할 수 있다. 하지만 Object의 hashcode는 물리적으로 다른 객체가 논리적으로 같더라도 다른 값을 반환한다. 

 

hashcode 재정의 방법은 아래와 같다.

  1. int result를 c로 초기화한다. c는 객체의 첫번째 핵심 필드(equals 비교에 사용되는 필드)를 계산한 hashcode다.
  2. 객체의 나머지 핵심 필드 f 각각에 대해 아래 작업을 수행한다.
    1. 해당 필드의 해시코드 c를 계산한다.
    2. result = 31*result + c;로 result를 갱신한다.
  3. result를 반환한다.

필드별로 해시코드 계산 방법은 이러하다.

  • 기본 타입 : Type.hashcode(f), 여기서 Type은 박싱 클래스
  • 참조 타입 필드면서 이 클래스의 equals가 이 필드의 equals를 호출한다면 이 필드의 hashCode를 재귀적으로 호출한다. 필드값이 null이면 0을 사용한다.(전통적으로 0 사용)
  • 필드가 배열이라면 핵심 원소 각각을 별도 필드 취급한다. 배열에 핵심 원소가 하나도 없다면 0을 사용한다. (다른 상수도 무관) 모든 원소가 핵심 원소면 Arrays.hashCode를 사용한다.
    @Override
    public int hashCode() {
        int result = Integer.hashCode(age);
        result = 31 * result + Integer.hashCode(battery);
        result = 31 * result + Integer.hashCode(power);
        return result;
    }

위와 같이 작성할 수 있다. 

 

hashCode 구현을 마쳤으면 단위 테스트를 작성하자.

AutoValue를 활용해 equals오 hashCode를 작성했으면 건너뛰어도 된다. 

 

파생 필드(다른 필드로부터 계산할 수 있는 필드)는 제외해도 된다.

equals에 사용되지 않은 필드는 반드시 제외해야 한다.

 

    @Override
    public int hashCode() {
        return Objects.hash(age, battery, power);
    }

Objects.hash로 단 한 줄로 hashCode를 작성할 수 있다.

하지만 앞의 방법보다 속도가 느리다. 성능에 민감하지 않은 상황에서만 사용하자.

 

    private int hashCode = 0;

    @Override
    public int hashCode() {
        int result = hashCode;
        if (result==0){
            result = Objects.hash(age, battery, power);
        }
        return result;
    }

불변 클래스이고 hashCode 계산 비용이 크다면 매번 새로 생성하기보다 캐싱할 수 있다. 

인스턴스가 만들어 질 때 해시코드를 계산해둘 수 있다. 또는 지연 초기화 할 수 있다.

해시코드 필드 값을 흔히 생성되는 해시코드 값과 다른 값으로 초기화해놓고 hashCode 메서드에서는 해당 값이라면 계산해서 넘겨주는 것이다.

 

해시코드를 계산할 때는 핵심 필드를 생략해서는 안된다. 해시테이블의 성능 저하로 이어질 수 있다.

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

 

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

Object의 toString은 `Robot@adbbd`처럼 `클래스이름@16진수해시코드`를 반환한다. 

toString의 일반 규약에 따르면 간결하면서 사람이 읽기 쉬운 형태의 유익한 정보를 반환해야 한다.

또 모든 하위 클래스에서 이 메서드를 재정의하라고 한다. toString은 println, printf, assert 구문에 넘길 때 등 사용되는 곳이 많다. toString을 잘 구현한 클래스는 사용하기 즐겁고 디버깅하기 쉽다. 

  • 객체가 가진 주요 정보를 모두 반환하는게 좋다. 
    • 객체가 거대하다면 요약 정보를 반환하자.
  • toString을 정의할 때 반환값 포멧을 문서화할지 정해야 한다.
    • 포멧을 명시하던 아니던 의도를 명확히 밝혀야 한다. 
    • 전화번호, 행렬 같은 값 클래스는 문서화하는 것이 좋다.
    • 포맷을 명시하면 포맷에 맞는 문자열과 객체를 상호 변환할 수 있는 정적 팩터리나 생성자를 제공하는 것이 좋다.
    • 포맷을 명시하면 평생 그 포맷에 얽메이게 되고 변경에 취약하다.
    /**
     * 로봇의 문자열 표현을 반환한다.
     * ~~ 구성된다 
     * ~~ 는 ~~고 ~~는 ~~다 
     * 
     * 뭐라면 ~ 뭐다 
     * 어쩌구 저쩌구 ~~
     */
    @Override
    public String toString() {
        return "Robot{" +
                "age=" + age +
                ", battery=" + battery +
                ", power=" + power +
                '}';
    }

포맷을 정했으면 위와 같이 포맷에 대한 설명을, 정하지 않았다면 향후 변경될 수 있다는 설명을 작성하면 된다.

 

  • 포맷 명시 여부와 상관없이 toString이 반환한 값에 포함된 정보를 얻어올 수 있는 api를 제공하자.
    • 예를 들어 Robot의 age, battery, power에 대한 접근자를 제공해야 한다. 
    • 제공하지 않는다면 사용자는 toString의 반환값을 파싱해야 한다. 성능이 저하되고 불필요한 작업이다. 
  • 정적 유틸리티 클래스, 열거 타입은 toString을 재정의하지 않아도 된다.
  • 하위 클래스들이 공유해야 할 문자열 표현이 있는 추상 클래스라면 toString을 재정의해야 한다. 
  • AutoValue나 IDE에서 toString을 자동생성해주기도 한다. 하지만 의미를 나타내지는 못한다. 

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

Cloneable은 복제해도 되는 클래스임을 나타내는 믹스인 인터페이스이다. 

하지만 clone 메서드가 Cloneable이 아닌 Object에 선언되었고, protected이다. 따라서 Clonealbe을 구현하는 것만으로는 clone 메서드를 호출할 수 없다. 그럼에도 불구하고 Cloneable 방식은 널리 쓰이므로 알아보자.

 

Cloneable 인터페이스에는 아무 메서드가 없다. 이 인터페이스는 Object의 protected 메서드인 clone의 동작 방식을 결정한다. Clonealbe을 구현한 클래스의 인스턴스에서 clone을 호출하면 객체의 필드를 하나씩 복사한 객체를 반환한다. Cloneable을 구현하지 않은 인스턴스에서 clone을 호출하면 CloneNotSupportedException을 던진다. 

인터페이스 선언은 보통 해당 클래스가 인터페이스에서 정의한 기능을 사용한다는 뜻인데, Cloneable은 상위 클래스에 정의된 clone의 동작방식을 변경하는 이례적인 경우다.

 

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

객체의 복사본을 생성해 반환한다. 복사의 의미는 클래스에 따라 다를 수 있다.
일반적인 의미는 이렇다
x.clone () != x 가 참이다.
x.clone().getClass() == x.getClass() 가 참이다.
x.clone.equals(x) 가 참이다.

관례상 clone이 반환하는 객체는 super.clone()을 통해 얻어야 한다. Object를 제외한 이 클래스의 모든 상위 클래스가 이 관례를 따른다면 
x.clone().getClass() == x.getClass() 가 참이다.

관례상 반환된 객체와 원본 객체는 독립적이어야 한다.  

 

clone이 super.clone을 반환하지 않게 정의할 수도 있다. 하지만 이 클래스를 상속한 클래스의 clone이 정상작동하지 않을 것이다. 이 클래스가 final 클래스라 상속할 수 없다면 상관없다. 하지만 clone에서 super.clone을 호출하지 않는다면 굳이 Cloneable을 구현하는 의미가 없다. 

 

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

    @Override
    protected Robot clone() {
        try {
            return (Robot) super.clone();

        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }

모든 필드가 기본 타입이거나 불변 객체를 참조하는 경우 clone이다. 

이 메서드가 동작하게 하려면 Robot이 Cloneable을 구현하게 해야 한다.

Object의 clone은 Object를 반환하고 이것을 재정의 한 Robot의 clone은 Robot을 반환한다.

자바는 공변 반환 타이핑(재정의한 메서드의 반환 타입은 상위 클래스의 메서드가 반환하는 타입의 하위 타입일 수 있다)를 지원하기 때문이다. 이 방식을 사용하면 클라이언트가 형변환하지 않아도 된다.

try-catch를 사용하는 이유는 Object의 clone이 CloneNotSupportedException을 던지기 때문이다. 

 

가변 상태를 참조하는 않는 클래스용 clone 메서드

객체를 참조하는 필드가 있는 클래스에서 super.clone을 그대로 반환하면 원본과 복제된 객체가 같은 객체를 참조할 것이다. 따라서 원본이나 복제본 중 하나를 수정하면 다른 하나도 수정된다. 불변식을 해치게 된다. 

불변식 : 어떤 객체가 정상적으로 동작하기 위해 허물어지지 않아야 하는 값, 식, 상태의 일관성을 보장하기 위해 항상 참이 되는 조건 

 

clone 메서드는 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야 한다.

사실상 생성자와 같은 효과를 낸다.

 

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

가변 필드의 clone 메서드를 재귀적으로 호출해 이 문제를 해결할 수 있다.

가변 필드가 배열이라면 굳이 형변환하지 않아도 된다. 배열의 clone은 원본 배열과 똑같은 배열을 반환하기 때문이다.

 

이 방식은 arm 필드가 final이면 사용하지 못하므로 가변 객체를 참조하는 필드는 final로 선언하라는 일반 용법과 충돌한다. 따라서 필드에 final이 붙어 있다면 제거해야 할 수 있다.

 

clone을 재귀적으로 호출하는 것 만으로는 불충분할때가 있다. HashTable 클래스의 필드 buckets가 배열을 참조하고, 배열의 각 원소는 이너 클래스 Entry로 구성된 LinkedList의 첫번째 노드이다. clone을 재귀적으로 호출하면 복제된 HashTable은 배열은 새로 갖지만, 원본과 복제된 객체의 배열이 같은LinkedList들을 가리킨다. 이를 해결하기 위해서는 각 Entry 연결리스트도 복사해야 한다.

 

    Entry deepCopy() {
        return new Entry(key, value, next == null ? null : next.deepCopy());
    }

Entry에 깊은복사 메서드를 정의해준다. 

    @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();
        }
    }

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

따라서 deepCopy를 재귀  호출 대신 반복자를 써서 순회하는 방향으로 수정한다. 

 

마지막 방법으로 super.clone으로 얻은 객체의 모든 필드를 초기 상태로 설정하고 고수준 api를 활용해 복제할 수 있다.

HashTable을 예로 들자면 buckets를 새로운 버킷 배열로 초기화하고, 원본 테이블에 담긴 모든 키-값 쌍에 대해 put(key,value)로 복제하는 것이다. 코드가 간단하지만 저수준 코드보다는 느리다. 

생성자에서는 재정의 될 수 있는 메서드를 호출하지 않아야 한다. clone에서도 마찬가지다.

clone에서 하위 클래스에서 재정의 한 메서드를 호출하면 하위 클래스는 재정의 한 메서드가 아닌 상위 클래스의 메서드에 따라 복제되고 자신의 상태를 교정할 기회를 잃는다. 따라서 clone에서 호출하는 고수준 api put은 final이거나 private이어야 한다. 

 

Object의 clone은 CloneNotSupportedException을 던진다.

clone을 재정의 한 메서드에서는 throws를 없애야 한다. 그래야 사용하기 편하다. (try-catch로 잡아주면 된다)

 

상속해서 쓰기 위한 클래스(상속용 클래스)에서는 Cloneable을 구현하면 안된다. Object가 한 것처럼 작동하는 clone을 protected로 선언할 수 있다. 이렇게 하면 Cloneable 구현 여부를 하위 클래스에서 선택할 수 있다. Cloneable을 구현하면 clone에서 super.clone을 선언하는데, 이 때 상위 클래스의 protected로 선언한, 작동하는 clone을 호출하기 때문이다. 

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

다른 방법은 clone을 동작하지 않게 구현하고 하위 클래스에서 재정의하지 못하게 할 수 있다. 

 

Cloneable을 구현한 스레드 안전 클래스를 작성할 때는 clone 메서드 역시 동기화해줘야 한다. Object의 clone은 메서드 동기화를 신경쓰지 않았다.

Java에서 동기화는 공유 리소스에 대한 여러 스레드의 액세스를 제어하는 ​​기능을 의미합니다.

 

요약

  • Cloneable을 구현하면 clone을 재정의해야한다
  • 접근제한자 : public, 반환형 : 자기 자신 으로 바꾼다. 
  • 먼저 super.clone을 호출한 후 필요한 필드를 수정한다
    • 가변 객체를 참조하는 경우 일반적으로 clone을 재귀적으로 호출한다

 

복사 생성자, 복사 팩터리

Cloneable을 이미 구현한 클래스를 확장하는게 아니라면 복자 생성자, 박새 팩터리를 사용할 수 있다.

    public Robot(Robot robot) {

    }

    public static Robot newInstance(Robot robot) {
        
    }
  • 복사 생성자 : 자신과 같은 클래스의 객체를 인수로 받는 생성자
  • 복사 팩터리 : 복사 생성자를 모방한 정적 팩터리

Cloneable보다 낫다. 생성자를 쓰지 않고 객체를 생성하는 위험한 방식을 사용하지 않고, 엉성하지 않고, final 필드 용법과 충돌하지 않고, 불필요한 검사 예외를 던지지 않고, 형변환도 불필요하다.

심지어 복사 생성자, 복사 팩터리는 해당 클래스가 구현한 인터페이스를 인수로 받을 수 있다. 원본의 구현 타입에 얽매이지 않고 복사본의 구현 타입을 선택할 수 있다. 즉 HashSet객체 s를 TreeSet으로 복제할 수 있다. (new TreeSet<>(s))

 

결론적으로 Cloneable을 확장하지 않는 것이 좋다. 복사 생성자, 팩터리가 최고다. 다만 배열은 예외적으로 clone이 좋다.

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

https://blog9909.tistory.com/39

 

Java 정렬

https://80000coding.oopy.io/21cb57a3-681b-404d-a4ac-8ab0e7289bc0 [JAVA] Sort 사용 (Arrays, Collections) Java’s Sort 80000coding.oopy.io Arrays.sort int[] arr = new int[] {1,5,3,2,4}; Arrays.sort(arr); Integer[] arr2 = new Integer[] {3,4,6,1}; Arrays.so

blog9909.tistory.com

 

Comparable 인터페이스의 compareTo는 Object의 equals랑 비슷한데, 동치성 비교에 더해 순서까지 비교할 수 있다. 

Comparable을 구현한 클래스는 자연적인 순서가 있음을 뜻하고, Arrays.sort(a)처럼 배열을 정렬할 수 있다. 컬렉션 관리도 쉽다. 조금만 노력해서 큰 이득을 얻을 수 있으니 순서가 명확한 클래스를 작성한다면 Comparable 인터페이스를 구현하자.

 

compareTo 메서드 일반 규약 

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

아래 규약에서 sgn은 부호 함수로 sgn(식)에서 식이 음수,0,양수 일 때 -1,0,1을 반환한다.

  • 1. sgn(x.compareTo(y)) == -sgn(y.compareTo(x))여야 한다. 
  • 2. Comparable을 구현한 클래스는 추이성을 보장해야 한다. 
    • x.compareTo(y)>0 && y.compareTo(z)>0 이면 x.compareTo(z) >0
  • 3. Comparable을 구현한 클래스는 모든 z에 대해 x.compareTo(y) ==0 이면 sgn(x.compareTo(z)) == sgn(y.compareTo(z))이다. 
  • 4. (x.compareTo(y) == 0) == (x.equals(y))
    • 필수는 아니지만 지키는 게 좋다 
    • Comparable을 구현하고 이 권고를 지키지 않으면 클래스의 순서가 equals와 일관되지 않다. 

compareTo는 equals와 달리 타입이 다른 객체를 신경쓸 필요가 없다. 타입이 다른 객체가 주어지면 ClassCastException을 던져도 되며, 대부분 그렇게 한다. compareTo 규약을 지키지 않으면 비교를 활용하는 클래스와 어울리지 못한다. 예를 들자면 TreeSet, TreeMap, Collections, Arrays등이 있다. 

 

규약 1,2,3은 compareTo로 수행하는 동치성 검사도 equals와 같이 반사성, 대칭성, 추이성을 충족해야 한다는 뜻이다. 

equals와 주의사항도 마찬가지로 기존 클래스를 확장한 구체 클래스에서 새로운 필드를 추가했다면 compareTo 규약을 지킬 수 없다. 해결방법도 마찬가지로 상속 대신 컴포지션을 사용하도록 한다. 새로운 클래스를 만들고 필드로 원래 클래스를 참조하도록 하자. 그리고 내부 인스턴스를 반환하는 뷰 메서드를 제공한다. 이렇게 하면 바깥 클래스에 compareTo 메서드를 구현할 수 있다. 클라이언트는 원한다면 바깥 클래스를 원래 클래스의 인스턴스로 다룰 수도 있다.

 

마지막 규약은 꼭 지키는게 좋다. compareTo로 수행한 동치성 테스트의 결과가 equals와 같아야 한다는 것이다. 

지키지 않아도 클래스가 동작은 하지만, 이러한 객체를 정렬된 컬렉션에 넣으면 해당 컬렉션이 구현한 인터페이스(Collection, Set, Map 등)에 정의된 동작과 어긋나게 된다. 이 인터페이스들은 equals의 규약을 따른다고 명시되어 있지만, 정렬된 컬레션들은 동치를 비교할 때 compareTo를 사용한다. 

 

compareTo 작성 요령

  • 입력 인수의 타입을 확인하거나 형변환 할 필요가 없다
    • 컴파일에러로 잡을 수 있다.
    • null을 넣어 호출하면 NPE를 던져야 한다.

compareTo는 각 필드가 동치인지가 아니라 순서를 비교한다. 객체 참조 필드를 비교하려면 compareTo를 재귀적으로 호출한다. 필드가 Comparable을 구현하지 않았거나, 표준이 아닌 순서로 비교해야 한다면 Comparator(비교자)를 사용한다. Comparator는 직접 만들거나 자바가 제공하는 것을 사용한다.

public final class CaseInsensitiveString implements Comparable<CaseInsensitiveString> {
    // 자바가 제공하는 비교자를 사용해 클래스를 비교한다.
    public int compareTo(CaseInsensitiveString cis) {
        return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s);
    }
    ...
}

위는 자바에서 제공하는 비교자를 사용해 참조 필드를 비교한 것이다. 

참고로 CaseInsensitivString이 Comparable<CaseInsensitiveString>을 구현한다. 이것은 CaseInsensitiveString의 참조는 CaseInsensitiveString 참조와만 비교할 수 있다는 뜻이고, Comparable을 구현할 때 일반적으로 이렇게 한다. 

 

정적 메서드 compare 사용

Integer.compare(age,robot.age);

compareTo 메서드에서 관계 연산자 <, >를 사용하는 방식은 거추장스럽고 오류를 유발한다.

박싱된 기본 타입 클래스의 정적 메서드 compare를 사용하자. 

 

기본 타입 필드가 여럿일 때 비교자

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

 

  • 필드가 여러개라면 핵심적인 필드부터 비교하자
  • 비교 결과가 0이 아니라면 (순서가 결정되면) 거기서 끝이다. 그 결과를 반환하자.

비교자 생성 메서드를 활용한 비교자

Comparator(비교자) 생성 메서드를 통해 메서드 체이닝 방식으로 비교자를 생성할 수 있다. 이러한 비교자들을 Comparable의 compareTo 메서드 구현에 활용할 수 있다. 간결하지만 약간의 성능 저하(약 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);
    }

비교자 생성 메서드 comparingInt와 thenComparingInt로 비교자를 생성한다.

  • comparingInt
    • 객체 참조를 int 타입 키에 매핑하는 키 추출 함수를 인자로 받아 그 키를 기준으로 순서를 정하는 비교자를 반환하는 정적 메서드 
    • 예시에서 람다를 인수로 받음, 람다는 areCode로 순서를 정하는 Comparator<PhoneNumber> 반환 
    • 람다 입력 인수 타입을 명시함
  • thenComparingInt
    • Comparator의 인스턴스 메서드, int 키 추출자 함수를 입력받아 다시 비교자 반환. 이 비교자는 첫 번재 비교자를 적용한 후 다음 키로 추가 비교 수행 
    • 원하는 만큼 연달아 호출할 수 있다.

객체 참조용 비교자 생성 메서드도 있다. 

  • comparing 정적 메서드 
    • 1. 키 추출자 받아서 자연적 순서 사용 
    • 2. 키 추출자, 키를 비교할 비교자 2개 받음 
  • thenComparing 인스턴스 메서드 
    • 1. 비교자 받아서 비교자로 부차 순서를 정함 (comparing으로 정하고 나서 다음 순서이므로 부차 순서)
    • 2. 키 추출자를 받아서 키의 자연적 순서 사용 
    • 3. 키 추출자와 키를 비교할 비교자까지 2개의 인수 받음 

 

    static Comparator<Robot> hashCodeOrder = new Comparator<Robot>() {
        @Override
        public int compare(Robot o1, Robot o2) {
            return o1.hashCode() - o2.hashCode();
        }
    };

위 예시처럼 값의 차로 compareTo나 compare를 구현하는 경우가 있다. 이 방식은 정수 오버플로를 일으키거나 IEEE754 부동소수점 계싼방식에 따른 오류를 낼 수 있다. 

    static Comparator<Robot> hashCodeOrder = new Comparator<Robot>() {
        @Override
        public int compare(Robot o1, Robot o2) {
            return Integer.compare(o1.hashCode(),o2.hashCode());
        }
    };
    
    static Comparator<Robot> hashCodeOrder = 
    	Comparator.comparingInt(o->o.hashCode());

위 두 방식 중 하나를 대신 사용하도록 하자. 

 

 

'Java > 이펙티브 자바' 카테고리의 다른 글

클래스와 인터페이스  (1) 2024.01.05
객체 생성과 파괴  (0) 2024.01.02