03. 모든 객체의 공통 메서드

zwundzwzig·2023년 5월 7일
2

이펙티브 자바

목록 보기
2/4
post-thumbnail

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

다음은 저자가 equals를 재정의하지 않는 것을 추천하는 상황이다.

  • 각 인스턴스가 본질적으로 고유하다.
    값을 표현하는 게 아니라 동작하는 객체를 표현하는 경우. Thread가 대표적 예시이다.
  • 인스턴스의 '논리적 동치성'logical equality 을 검사할 일이 없다.
    정규표현식을 나타내는지를 검사하는 로직에서 애초에 새롭게 정의할 필요가 없는 것이다.
  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.
    예를 들어 Set 구현체는 AbstractSet의 equals를 상속받아 쓴다.
  • 클래스가 private 혹은 package-private이고 equals 메서드를 호출할 일이 없다.

그렇다면, 언제 equals를 재정의해야 할까?
객체 식별성 object identity이 아니라 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않은 경우이다.

이 말은, String, Integer와 같은 값을 표현하는 객체들의 경우 객체 주소값이 아니라 값 그 자체가 같은 지 여부를 알기 위해 재정의한다는 것이다.

그리고 Enum이나 인스턴스 통제 클래스의 경우 값이 같은 인스턴스가 중복으로 생성되지 않는 클래스의 경우 굳이 재정의할 필요는 없다.

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

  • 반사성(reflexivity) : null이 아닌 모든 참조값 x에 대해 x.equals(x)는 참이다.
  • 대칭성(symmetry) : null이 아닌 모든 참조값 x, y에 대해 x.equals(y)가 참이면 y.equals(x)도 참이다.
  • 추이성(transitivity) : null이 아닌 모든 참조값 x, y,z에 대해 x.equals(y), y.equals(z)가 참이면 나머지도 참이다.
  • 일관성(consistency) : null이 아닌 모든 참조값 x, y에 대해 x.equals(y)를 반복해 호출하면 항상 true/false이다.
  • null-아님 : null이 아닌 모든 참조값 x에 대해 x.equals(null)은 거짓이다.

추이성

추이성은 좀 자세히 살펴보자.

추이성을 위반하는 경우는 상속과 연결된다.

public class Point {
    private final long x;
    private final long y;

    public Point(long x, long y) {
        this.x = x;
        this.y = y;
    }

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

}

public class ColorPoint extends Point{
    private Color color;

    public ColorPoint(long x, long y, Color color) {
        super(x, y);
        this.color = color;
    }
}

이렇게 되면 색상이 다를 경우 추이성을 위반한다. 그렇다고 ColorPoint에서 필드를 추가한다고 추이성이 만족될 순 없다. 이게 바로 상속의 단점이다.

그래서 컴포지션을 사용해 상속 대신 Point 객체를 final 필드로 만들고 Point 뷰 메서드를 만드는 방식이 있다.

public class ColorPointFromComposition {
    private final Point point;
    private final Color color;

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

    public Point asPoint() {
        return point;
    }

    @Override
    public boolean equals(Object o) {
        if(!(o instanceof ColorPointFromComposition))return false;
        ColorPointFromComposition cp = (ColorPointFromComposition) o;
        return cp.point.equals(point) && cp.color.equals(color);
    }
}

추상 클래스의 하위 클래스에서라면 equals 규약을 지키면서도 값을 추가할 수 있다.

아무런 값을 가지지 않는 추상 클래스를 두고 이를 확장한 클래스를 만들기 때문이다.

즉, 상위 클래스를 직접 인스턴스로 만드는 게 불가하다면 위와 같은 문제들이 발생하지 않는다.

단계별로 정리하자면,

  1. 동등 연산자를 사용해 입력이 자기 자신의 참조(반사성)인지 확인한다.
  2. instanceof 연산자로 입력이 올바른 타입인지 확인한다. 컬렉션 인터페이스들을 위해 그렇다.
  3. 입력을 올바른 타입으로 형변환한다.
  4. 입력 객체와 자신의 대응되는 '핵심' 필드들이 모두 일치하는 지 하나씩 검사한다.

마지막으로 세 가지만 자문해보자. 대칭적인가? 추이성이 있나? 일관적인가? hashCode도 재정의했나?

equals를 재정의하려거든 hashCode도 재정의하라

그렇다. hashCode도 재정의해야 한다. 그렇지 않으면 해당 클래스의 인스턴스를 컬렉션의 원소로 사용할 때 문제가 생긴다.

hashCode의 일반 규약

  1. 애플리케이션이 유지되는 동안 equals 비교에 사용되는 리소스(핵심필드)가 유지된다면 일관되게 항상 같은 값을 보장해야 한다.
  2. equals(Object)가 같다고 판단하면 두 객체의 hashCode는 동일한 값을 반환해야 한다.
  3. equals(Object)가 다르다고 판단하더라도 hashCode가 서로 다른 값을 반환할 필요는 없다. 하지만, 다른 값을 반환해야 해시테이블의 성능이 좋아진다.

hashCode 재정의를 잘못했을 때 두 번째 규약이 문제가 될 수 있다. 즉, 논리적으로 같은 객체는 같은 해시코드를 반환해야 한다.

기존 equals 메서드는 물리적으로는 다른 객체이나 논리적 동치성은 성립하는 두 객체를 같다고 재정의할 수 있다.

그런데 이 경우, hashCode가 재정의되지 않는다면 Object의 기본 hashCode가 수행되는데 해당 메서드에서는 논리적으로 같다고 해도 물리적으로 다르다고 판단되면 서로 다른 값을 반환한다.

좋은 hashCode 작성법은 주어진 인스턴스들을 32비트 정수 범위에 균일하게 분배하는 것이다.

  1. 지역변수 선언 후 핵심필드 값 하나의 해시코드로 초기화
    ⓐ 기본 타입 필드라면 Type.hashCode(f)를 수행한다.(Type: Wrapper Class)
    ⓑ 참조 타입 필드라면 이 필드의 hashCode를 재귀적으로 호출한다. 계산이 많이 복잡해질 것 같으면 필드의 표준형(canonical representation)을 만들어 표준형의 hashCode를 호출한다. 필드의 값이 null이면 0을 사용한다.
    ⓒ 배열이라면 핵심 원소 각각을 별도 필드처럼 다룬다. 만약 모든 원소가 핵심 원소라면 Arrays.hashCode를 사용한다.
  2. 다른 핵심필드들도 동일하게 해시코드화하여 지역변수에 합친다.
    지역변수 = 31 * 지역변수 + 핵심필드의 해시코드
  3. 지역변수의 값을 반환한다.
@Override 
public int hashCode() {
    int result = prefix.hashCode();
    result = 31 * result + middle.hashCode();
    result = 31 * result + suffix.hashCode();
    return result;
}

곱할 숫자가 31인 이유는 31이 홀수이면서 소수(prime)이기 때문이다.

  • 만약, 짝수이고 오버플로가 발생한다면 정보를 잃게된다. (2를 곱하는건 시프트 연산과 같기 때문이다.)
    그리고 소수를 곱하는 이유는 전통적으로 그래왔다고 한다.(명확하지는 않다.)
    그리고 31 * i는 (i << 5) -1 과 동일하다.

Objects 클래스는 임의의 갯수만큼 객체를 받아 해시코드를 계산해주는 정적메서드 hash를 제공한다.

장점은 위와 비슷한 수준의 hashCode를 한 줄로 작성가능하다.
단점은 속도가 더 느리다는 것이다.
⇒ 입력 인수를 담기위한 배열을 만들고 기본 타입은 박싱/언박싱도 거쳐야 하기 때문이다.

@Override
public int hashCode() {
    return Objects.hash(prefix, middle, suffix);
}

객체가 해시의 키로 사용될 확률이 높다면 객체 생성시 해시코드를 계산해서 캐싱해두는게 좋지만, 그렇지 않은 경우 hashCode를 미리 계산해놓고 캐싱까지 해놓는것은 비용낭비다. 그럴 경우 지연 초기화(lazy initialization) 전략을 고려하자.

public class PhoneNumber {
    private int hashCode;
    private String prefix;
    private String middle;
    private String suffix;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof PhoneNumber)) return false;
        PhoneNumber that = (PhoneNumber) o;
        return Objects.equals(prefix, that.prefix) 
        	&& Objects.equals(middle, that.middle) 
            && Objects.equals(suffix, that.suffix);
    }

    @Override 
    public int hashCode() {
        if (hashCode != 0) return hashCode;
        int result = prefix.hashCode();
        result = 31 * result + middle.hashCode();
        result = 31 * result + suffix.hashCode();
        hashCode = result;
        
        return result;
    }
}

⇒ 이 때, 핵심 필드는 모두 해시코드를 계산할 때 포함해야한다. 핵심 필드가 누락되면서 해시의 신뢰도가 떨어지면 해시테이블의 성능역시 떨어질 수 있다.

물론, AutoValue, Lombok과 같은 라이브러리를 사용한다면 어노테이션이 자동으로 equals & hashCode를 제공해주고, 일부 IDE에서도 이런 기능을 제공해준다.

가령 예를 들어 성능이 몹시 중요해 Objects 클래스의 hash 메서드를 사용하는게 권장되지 않는 상황에서 IntelliJ IDE의 자동 생성 기능으로 hashCode를 사용하면 기본적으로 Objects의 hash로 hashCode를 구현한다.

toString을 항상 재정의하라

자바를 처음 배울 때 자바스크립트와 달라 의아했던 부분은 배열을 로그에 찍어도 배열 내 값들이 아닌 해당 클래스의 이름과 해시코드를 반환하는 점이었다.

toString 메서드는 System.out.print* 따위의 출력 구문이나 assert 구문에 넘길 때나 디버거가 객체를 출력할 때 자동으로 불리는데, 이때 잘 오버라이드하면 유용한 로그를 활용해 디버깅할 수 있을 것이다.

실전에서 toString은 그 객체가 갖는 주요 정보를 모두 반환하는 게 좋다고 저자는 말한다. 그리고 이를 구현할 때면 반환값의 포맷을 문서화할지 정해야 한다. 대신, 무작정 문서화를 하면 포맷에 대한 의존성이 강해지기 때문에 유연한 포맷을 선정하는 게 중요하다.

그래서 포맷 명시 여부와 상관없이 toString이 반환한 값에 포함된 정보를 얻을 수 있는 API를 제공하자.

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

복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스인 Cloneable을 구현해 객체를 복사하고 clone 메서드를 재정의하는 방법이 있다.

그러나 clone 메서드가 Object에 정의된데다가 protected 제어자와 함께 있기 때문에 Cloneable 인터페이스의 구현만으론 clone 메서드 호출이 안된다.

그럼에도 Cloneable 인터페이스는 clone 메서드의 동작 방식을 결정한다. 즉 상위 클래스인 Object에 정의된 protected 메서드의 동작 방식을 변경한다는 것이다. 물론 Cloneable을 구현하지 않은 클래스에서 clone을 호출하면 CloneNotSupportedException이 발생한다.

그래서 실무에선 Cloneable의 구현 클래스는 clone 메서드를 public으로 받으며 사용자는 당연히 복제가 제대로 이뤄질 것을 기대한다.

Object 클래스의 clone 메서드 규약

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

강제성이 없다는 점만 빼면 생성자 연쇄와 살짝 비슷한 매커니즘인데 이 말은 clone 내부 로직이 생성자를 호출해 얻은 인스턴스를 반환해도 문제가 없다는 것이다.

그러나 해당 클래스의 하위클래스에서 super.clone()으로 호출할 때 상위 객체에서 잘못된 클래스가 생성될 수 있기에 위험하다. 다만, clone을 재정의한 클래스가 final이라면 하위 클래스가 없기에 괜찮다.

그리고 클래스의 모든 필드가 기본타입이거나 불변 객체를 참조한다면 super.clone()만으로도 문제없이 동작한다. 자바는 공변 반환 타이핑(covariant return typing)을 지원하기 때문이다.

그러나 가변 객체를 참조하는 필드가 있다면 clone 메서드는 원본 객체 주소를 바라볼 것이고 이는 불변식에 영향을 주며 데이터 오염 내지는 NullPointerException을 발생시킬 수도 있다.

clone 메서드는 생성자와 같은 효과를 내는데 원본과 동일한 내용을 원본에 영향을 주지 않으며 복제된 객체의 불변식(호출자 입장에서 해당 조건이 항상 참이라고 클래스가 보장)을 보장해야 한다.

이에 대한 예방으로 객체의 참조 변수나 배열의 clone을 재귀적으로 호출하는 방식이 있다. 물론, '가변 객체를 참조하는 필드는 final로 선언하라'는 일반 용법과 충돌한다. 그래도 복제가능한 클래스를 만들려면 final 한정자를 제거해야 할 수도 있다.

재귀적 호출에도 객체 배열이 연결리스트라면 원본과 복사본이 같은 연결 리스트(배열)을 참조해 의도치 않은 결과를 초래할 수도 있다.

그래서 깊은 복사(deep copy)로 문제를 해결할 수 있다.

우선 HashTable.Entry.deepCopy()이 연결리스트 전체를 복사할 순 있지만 과도한 스택 프레임 소비로 문제를 일으킬 수 있다. 그래서 deepCopy 재귀호출 대신 반복문을 사용해 순회하는 방향으로 접근할 수 있다.

이밖에 super.clone으로 객체 필드를 초기화한 뒤 put이나 setter를 직접 호출해서 내용을 동일하게 해주는 고수준 API 활용 방법도 있지만, 속도가 느리고 필드 단위 객체 복사를 우회하는 방법이기에 Cloneable 아키텍처와는 어울리지 않는다.

주의점으로

  • 생성자와 마찬가지로 clone에선 재정의될 수 있는 메서드를 호출하지 않아야 한다. 하위 클래스는 복제 과정에서 자신의 상태를 바꿀 기회가 사라지며 복제본과 원본의 상태가 달라질 수 있기 때문이다.
  • 재정의한 clone 메서드는 throws 절을 없애야 한다
  • 상속용 클래스는 Cloneable을 구현해서는 안된다.
  • Cloneable을 구현하는 모든 클래스는 clone을 재정의 해야 한다.

그래서? 변환 생성자와 변환 팩터리를 사용하자.

  • 언어 모순적이고 생성자를 쓰지않는 객체 생성 메커니즘을 사용하지 않는다.
  • 정상적인 final 필드 용법과도 충돌하지 않는다.
  • 불필요한 예외가 발생하지 않는다.
  • 형변환도 필요하지 않다.
  • 해당 클래스가 구현한 '인터페이스'타입의 인스턴스를 인수로 받을 수 있다.
  • 웬만하면 복제 기능은 생성자와 팩터리를 사용해라
  • 배열만은 부분적으로 clone이 가장 깔끔할 수 있다.

Comparable을 구현할지 고려하라

Comparable.compareTo는 위 다른 메서드들과 달리 Object 메서드는 아니지만 성격이 비슷하다.

compareTo의 일반 규약 (equals과 비슷하다)

  • 객체와 매개변수를 비교해 크거나 작으면 각각 양수나 음수, 같으면 0을 반환한다.
  • 대칭성과 추이성을 지켜야 한다.
  • equals와 다른 점은 타입이 다른 객체(예외 상황)이 생기면 그저 ClassCastException을 던진다.
  • 각 필드의 동치관계를 보는게 아니라 그 순서를 비교한다
  • 객체 참조 필드를 비교하려면 compareTo 메서드를 재귀적으로 호출한다.
  • Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교해야 할 경우 Comparator를 쓰면 된다.

Comparable은 타입을 인수로 받는 제네릭 인터페이스라 compareTo 메서드의 인수 타입은 컴파일할 때 정해진다. 입력 인수의 타입을 확인하거나 형변환 할 필요가 없다는 의미이다.

정적 메서드나 비교자 생성 메서드를 활용하자.

static Comparator<Object hashCodeOrder = new Comparator<>() {
		public int compare(Object o1, Object 02) {
				return Integer.compare(01.hashCode(), o2.hashCode());
		}
}

static Comparator<Object> hashCodeOrder = 
				Comparator.comparingInt(Object::hashCode);

정리

  • 순서를 고려해야하는 값 클래스는 Comparable인터페이스를 꼭 구현하면 좋다.
  • compareTo 메서드에서는 < , >같은 연산자는 쓰지 않아야 한다.
  • 박싱된 기본 타입 클래스가 제공하는 정적 compare 메서드나 Comparator 인터페이스가 제공하는 비교자 생성 메서드를 활용하자.

🧷 참조 교재

  • [프로그래밍 인사이트] 이펙티브 자바 - 조슈아 블로크
  • Catsbi's DLog
profile
개발이란?

0개의 댓글