다음은 저자가 equals를 재정의하지 않는 것을 추천하는 상황이다.
logical equality
을 검사할 일이 없다.그렇다면, 언제 equals를 재정의해야 할까?
객체 식별성 object identity
이 아니라 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않은 경우이다.
이 말은, String, Integer와 같은 값을 표현하는 객체들의 경우 객체 주소값이 아니라 값 그 자체가 같은 지 여부를 알기 위해 재정의한다는 것이다.
그리고 Enum이나 인스턴스 통제 클래스의 경우 값이 같은 인스턴스가 중복으로 생성되지 않는 클래스의 경우 굳이 재정의할 필요는 없다.
equals 메서드를 재정의할 때는 반드시 다음 일반 규약을 따라야 한다.
추이성은 좀 자세히 살펴보자.
추이성을 위반하는 경우는 상속과 연결된다.
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 규약을 지키면서도 값을 추가할 수 있다.
아무런 값을 가지지 않는 추상 클래스를 두고 이를 확장한 클래스를 만들기 때문이다.
즉, 상위 클래스를 직접 인스턴스로 만드는 게 불가하다면 위와 같은 문제들이 발생하지 않는다.
그렇다. hashCode도 재정의해야 한다. 그렇지 않으면 해당 클래스의 인스턴스를 컬렉션의 원소로 사용할 때 문제가 생긴다.
hashCode의 일반 규약
hashCode 재정의를 잘못했을 때 두 번째 규약이 문제가 될 수 있다. 즉, 논리적으로 같은 객체는 같은 해시코드를 반환해야 한다.
기존 equals 메서드는 물리적으로는 다른 객체이나 논리적 동치성은 성립하는 두 객체를 같다고 재정의할 수 있다.
그런데 이 경우, hashCode가 재정의되지 않는다면 Object의 기본 hashCode가 수행되는데 해당 메서드에서는 논리적으로 같다고 해도 물리적으로 다르다고 판단되면 서로 다른 값을 반환한다.
좋은 hashCode 작성법은 주어진 인스턴스들을 32비트 정수 범위에 균일하게 분배하는 것이다.
@Override
public int hashCode() {
int result = prefix.hashCode();
result = 31 * result + middle.hashCode();
result = 31 * result + suffix.hashCode();
return result;
}
장점은 위와 비슷한 수준의 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 메서드는 System.out.print* 따위의 출력 구문이나 assert 구문에 넘길 때나 디버거가 객체를 출력할 때 자동으로 불리는데, 이때 잘 오버라이드하면 유용한 로그를 활용해 디버깅할 수 있을 것이다.
실전에서 toString은 그 객체가 갖는 주요 정보를 모두 반환하는 게 좋다고 저자는 말한다. 그리고 이를 구현할 때면 반환값의 포맷을 문서화할지 정해야 한다. 대신, 무작정 문서화를 하면 포맷에 대한 의존성이 강해지기 때문에 유연한 포맷을 선정하는 게 중요하다.
그래서 포맷 명시 여부와 상관없이 toString이 반환한 값에 포함된 정보를 얻을 수 있는 API를 제공하자.
복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스인 Cloneable을 구현해 객체를 복사하고 clone 메서드를 재정의하는 방법이 있다.
그러나 clone 메서드가 Object에 정의된데다가 protected 제어자와 함께 있기 때문에 Cloneable 인터페이스의 구현만으론 clone 메서드 호출이 안된다.
그럼에도 Cloneable 인터페이스는 clone 메서드의 동작 방식을 결정한다. 즉 상위 클래스인 Object에 정의된 protected 메서드의 동작 방식을 변경한다는 것이다. 물론 Cloneable을 구현하지 않은 클래스에서 clone을 호출하면 CloneNotSupportedException이 발생한다.
그래서 실무에선 Cloneable의 구현 클래스는 clone 메서드를 public으로 받으며 사용자는 당연히 복제가 제대로 이뤄질 것을 기대한다.
Object 클래스의 clone 메서드 규약
강제성이 없다는 점만 빼면 생성자 연쇄와 살짝 비슷한 매커니즘인데 이 말은 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 아키텍처와는 어울리지 않는다.
주의점으로
Comparable.compareTo는 위 다른 메서드들과 달리 Object 메서드는 아니지만 성격이 비슷하다.
compareTo의 일반 규약 (equals과 비슷하다)
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);
정리
[프로그래밍 인사이트] 이펙티브 자바 - 조슈아 블로크