모든 객체의 공통 메서드

haaaalin·2023년 10월 20일
0

Effective Java

목록 보기
2/2
post-thumbnail

Java의 모든 클래스의 상위 클래스인 Object

Object에서 final이 아닌 메서드는 모두 재정의할 수 있다. 이때 Object 메서드를 언제, 어떻게 재정의해야 하는지를 한 번 알아보자.

1. equals는 일반 규약을 지켜 재정의하자

1) equals를 재정의하지 않아야 할 때

  • 각 인스턴스가 본질적으로 고유할 때 값을 표현하는 게 아니라, 동작하는 개체를 표현하는 클래스일 경우가 해당된다.
  • 인스턴스의 논리적 동치성(logical equality)를 검사할 일이 없을 때
  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 적절할 때
  • 클래스가 private이거나 equals 메서드를 호출할 일이 없을 때

2) equals 재정의가 필요할 때

논리적 동치성을 확인해야 하는데, 상위 클래스에서 논리적 동치성을 확인하도록 재정의되지 않았을 때, equals 재정의가 필요하다.

주로 String, Integer와 같은 값을 표현하는 클래스, 즉 값 클래스가 equals 재정의가 필요하다.

equals 재정의 일반 규약

공통: null이 아닌 모든 참조 값에 대하여

  • 반사성: x에 대해, x.equals(x)는 true
  • 대칭성: x, y에 대해, x.equals(y)가 true면 y.equals(x)도 true
  • 추이성: x, y, z에 대해, x.equals(y)가 true이고, y.equals(z)도 true이면 x.equals(x)도 true
  • 일관성: x, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false 반환
  • null-아님: x에 대해, x.equals(null)은 false

양질의 equals 메서드 구현 방법

  1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다
  2. instanceof 연산자로 입력이 올바른 타입인지 확인한다
  3. 입력을 올바른 타입으로 형변환한다.
  4. 입력 객체와 자기 자신의 대응되는 ‘핵심’ 필드들이 모두 일치하는지 하나씩 검사한다

3) 비교하는 방법

기본 타입 필드: float과 double을 제외한 나머지는 == 연산자로 비교

참조 타입 필드: 각각의 equals 메서드로, float과 double 필드는 각각 정적 메서드인 compare() 를 사용해 비교

🤔 Float과 Double의 equals 메서드를 사용하면 안될까..?
⇒ Float.equals와 Double.equals 메서드를 대신 사용할 수 있지만, 오토박싱을 수반할 수 있으니 성능상 좋지 않다

어떤 필드를 먼저 비교할까?

필드를 비교하는 순서가 성능을 좌우하기도 한다.

다를 가능성이 더 크거나, 비교 비용이 싼 필드를 먼저 비교한다. 동기화용 lock 필드 같이 객체의 논리적 상태와 관련 없는 필드는 비교하면 안된다.

또, 핵심 필드로부터 계산해낼 수 있는 파생 필드도 비교하지 않아도 되지만, 때로는 파생 필드를 비교하는 게 더 빠른 경우도 있다.

아래는 이상적인 equals 메서드이다.

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, 9999, "가입자 번호");
    }

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

    // 나머지 코드는 생략 - hashCode 메서드는 꼭 필요하다(아이템 11)!
}

4) 주의할 점

  • equals를 재정의할 땐 hashCode도 반드시 재정의
  • 너무 복잡하게 해결하려 들지 말자
  • Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자

아래 메서드는 Object 외의 타입을 받는 equals 메서드라, 재정의가 아닌 다중정의가 된다.

public boolean equals(MyClass o) {
...
}

꼭 필요한 경우가 아니면 equals는 재정의하지 말자.

2. equals를 재정의하면, hashCode도 재정의하자

왜 그러냐면, 해당 클래스의 인스턴스를 HashMap이나 HashSet 같은 컬렉션의 원소로 사용할 때 문제가 발생할 것이다.

아래는 관련 Object 명세의 규약이다.

  • equals 비교에 사용되는 정보가 변경되지 않았다면 그 객체의 hashCode 메서드는 몇 번을 호출해도 일관되게 항상 같은 값을 반환해야 한다.
  • equals(Object)가 두 객체를 같다고 판단 → 두 객체의 hashCode도 똑같은 값을 반환해야 한다.
  • equals(Object)가 두 객체를 다르다고 판단 → 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없다. (다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다)

Object 규약에 맞는 hashCode 재정의

두 객체가 같다면, hashCode도 같게

아래 코드에서는 PhoneNumber의 인스턴스가 2개 사용되고 있다. map에 insert 할 때 1번, map에 get 할 때 한 번 사용되고 있는데, 둘 다 논리적 동치인 객체이지만, 서로 다른 해시코드를 반환하고 있어, 두 번째 규약을 지키지 못하고 있다.

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

m.get(new PhoneNumber(707, 867, 5309));

두 객체가 다르다면, hashCode도 다르게

서로 다른 인스턴스에 다른 해시코드를 반환해야 한다.

이상적인 해시 함수는 주어진 인스턴스들을 32비트 정수 범위에 균일하게 분배해야 한다.

주의할 점

  • equals 비교에 사용되지 않은 필드는 ‘반드시’ 해시코드 계산에서 제외해야 한다.

3. toString을 항상 재정의하자

Object의 toString의 규약은 “모든 하위 클래스에서 이 메서드를 재정의하라”고 나와 있는 만큼 재정의하면 좋다.

Object의 기본 toString은 PhoneNumber@abbd 처럼 단순히 클래스이름@16진수로 표현한 해시코드 를 반환해줄 뿐이다.

우리는 이를 재정의하여 필요한 정보만 나타내는 오류 메시지 로그를 출력하도록 하여 디버깅하기 쉬운 시스템을 구성해보자.

toString은 해당 객체에 관한 명확하고 유용한 정보를 읽기 좋은 형태로 반환해야 한다.

4. clone 재정의는 주의하자

Cloneable과 Object

Cloneable이란?

복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스

하지만 Cloneable은 목적을 제대로 달성하지 못했다. clone 메서드가 정의된 곳이 Cloneable이 아닌 Object였고 그 마저도 protected로 선언되어 있기 때문이다.

따라서 여러 문제점이 있지만, Cloneable을 이용하는 방식도 널리 쓰이고 있으니 알아두자

Clonable은 무슨 일을 할까?

메서드 하나 없지만, Object의 protected 메서드인 clone의 동작 방식을 결정한다.

Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면, 그 객체의 필드들을 하나하나 복사한 객체를 반환하고, Cloneable을 구현한 클래스가 아니라면 CloneNotSupportedException 을 던진다.

보통 인터페이스는 해당 기능을 제공한다고 정의하기 위해 사용되지만, Cloneable은 이례적으로 상위 클래스에 정의된 protected 메서드의 동작 방식을 변경했다.

clone 메서드의 일반 규약

이 객체의 복사본을 생성해 반환한다. 아래는 필수사항이다.

  • x.clone() ≠ x
  • x.clone().getClass() == x.getClass()

아래는 일반적으로 참이지만, 필수는 아니다.

  • x.clone().equals(x)

관례상, 이 메서드가 반환하는 객체는 super.clone()을 호출해 얻어야 한다. 만약 모든 상위 클래스가 이 관례를 따른다면 다음식은 참이다.

  • x.clone().getClass() == x.getClass()

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

제대로 동작하는 clone 메서드

제대로 동작하는 clone 메서드를 가진 상위 클래스를 상속해 Cloneable을 구현해보자.

먼저, super.clone을 호출한다면 원본의 완벽한 복제본일 것이고, 모든 필드가 원본과 같은 상태이다. 이때, 모든 필드가 기본 타입이거나 불변 객체를 참조한다면 완벽하다.

아래는 PhoneNumber 클래스에 clone() 메서드를 추가한 모습이다.

public final class PhoneNumber implements Cloneable {
    private final short areaCode, prefix, lineNum;

    @Override public PhoneNumber clone() {
        try {
            return (PhoneNumber) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();  // 일어날 수 없는 일이다.
        }
    }
}

원래 Object의 clone() 은 Object를 반환하지만 PhoneNumber의 clone에서는 공변 반환 타이핑을 이용해 클라이언트가 타입을 변환할 필요 없게 PhoneNumber를 반환하도록 구현했다.

가변 객체를 참조하는 클래스는?

아래 Stack 클래스를 복제할 수 있도록 만들어보자.

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

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

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

clone 메서드가 단순히 super.clone의 결과를 그대로 반환한다면 elements 필드는 원본 인스턴스와 똑같은 배열을 참조할 수 밖에 없다.

그렇다면 Stack의 clone 메서드가 제대로 작동하려면? 가장 쉬운 방법은 elements 배열의 clone을 재귀적으로 호출하는 것이다.

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

elements 필드가 final이었다면, 새로운 값을 할당할 수 없기 때문에 위와 같은 방법이 통하지 않는다. Cloneable 아키텍처는 ‘가변 객체를 참조하는 필드는 final로 선언하라’는 일반 용법과 충돌한다. (만약, 원본과 복제된 객체가 해당 가변 객체를 공유해도 안전하다면 괜찮다)

5. Comparable 구현은 고려하자

compareTo() 는 Object의 메서드가 아니지만, equals와 유사하다. compareTo는 단순 동치성 비교에 더해, 순서까지 비교할 수 있으며 제네릭하다.

compareTo를 구현하면 좋은 점

  • Arrays.sort() 를 이용한 정렬이 쉽다.
  • 검색, 극단값 계산, 자동 정렬되는 컬렉션 관리도 쉽게 할 수 있다.

compareTo의 일반 규약

해당 객체와 주어진 객체의 순서를 비교한다. 만약 작으면 음의 정수를, 같으면 0을, 크면 양의 정수를 반환한다.

  • Comparable을 구현한 클래스는 모든 x, y에 대해 sgn(x.compareTo(y)) == -sgn(y.compareTo(x))
  • Comparable을 구현한 클래스는 추이성을 보장해야 한다.
  • Comparable을 구현한 클래스는 모든 z에 대해 x.compareTo(y) == 0이면 sgn(x.compareTo(z)) == sgn(y.compareTo(z))
  • 권고: (x.compareTo(y) == 0) == (x.equals(y))여야 한다.

hashCode 규약을 지키지 못하면, 해시를 사용하는 클래스와 어울리지 못하듯, compareTo 규약을 어긴다면, 비교를 활용하는 클래스와 어울리지 못한다.

마지막 규약은 필수는 아니지만 지키기를 권한다. 마지막 규약은 즉, compareTo 메서드로 수행한 동치성의 테스트 결과가 equals와 같아야 한다는 것이다.

compareTo 순서와 equals의 결과가 일관되지 않으면 큰일날 수 있다.

compareTo와 equals가 일관되지 않는 BigDecimal 클래스를 예로생각해보자.

new BigDecimal("1.0")
new BigDecimal("1.00")

// HashSet은 원소를 2개 갖게된다 -> equals 메서드로 비교한다면 서로 다르다.
// TreeSet은 원소를 1개 갖게 된다 -> compareTo 메서드로 비교한다면, 두 인스턴스는 같기 때문에 
profile
한 걸음 한 걸음 쌓아가자😎

0개의 댓글