이펙티브 자바 골라보기
Object 클래스에서 final이 아닌 메서드(equals, hashCode, toString, clone, finalize)는 모두 재정의(overriding)를 염두에 두고 설계된 것이라 재정의 시 지켜야 하는 일반 규약이 명확히 정의되어 있다. 그래서 Object를 상속하는 클래스, 즉 모든 클래스는 이 메서드들을 일반 규약에 맞게 재정의해야 한다. 하지만 잘못 구현하면 이 규약을 준수한다고 가정하여 설계된 클래스들을 오동작하게 만들 수 있으므로, 주의를 기울여야 한다.
equals()를 꼭 재정의해야 할까
가장 쉬운 방법은 아예 재정의하지 않는 것이다. 그냥 두면 해당 클래스의 인스턴스는 오직 자기 자신과만 같게 된다. 다음 상황들 중 하나에 해당한다면 재정의하지 않는 것이 최선이다.
- 각 인스턴스가 본질적으로 고유하다. 값의 표현이 아닌 동작하는 개체를 표현하는 클래스가 해당된다. Thread가 좋은 예라고 할 수 있다.
- '논리적 동치성(logical equality)'을 검사할 일이 없다.
- 상위 클래스에서 재정의한 equals가 하위 클래스에도 알맞다.
- 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다.
- equals의 호출을 막고 싶다면 AsserionError()를 throw하자.
그럼 언제 equals()를 재정의해야 할까? 객체 식별성이 아닌 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때이다. 주로 값 클래스가 이에 해당할 것이다.
값 클래스라고 해도, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스이거나 Enum인 경우에는 equals를 재정의하지 않아도 된다.
equals 재정의 규약
equals 재정의 규약은 반드시 일반 규약(동치관계를 위한)을 따라야 한다.
- 반사성(reflexivity) : null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true다.
- 대칭성(symmetry) : null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)가 true면 y.equals(x)도 true다.
- 추이성(transitivity) : null이 아닌 모든 참조 값 x, y, z에 대해, x.equals(y)가 true고 y.equals(z)도 true면 x.equals(z)도 true다.
- 일관성(consistency) : null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.
- null-아님 : null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false다.
대칭성(symmetry)
대칭성을 어기는 예시를 살펴보자.
public class StringBox {
private final String str;
public StringBox(String str) {
this.str = Objects.requireNonNull(str);
}
@Override public boolean equals(Object o) {
if(o instanceof StringBox)
return str.equalsIgnoreCase(((StringBox) o).str);
if(o instanceof String)
return str.equalsIgnoreCase(((String) o);
return false;
}
}
위 코드는 아래와 같은 경우에 대칭성을 위반한다.
StringBox strBox = new StringBox("test");
String str = "test";
//
strBox.equals(str) == true
str.equals(strBox) == false
위와 같은 결과가 나오는 이유는 String 클래스가 StringBox 클래스의 존재를 모르기 때문이다. 그렇다고 StringBox의 equals를 String과 연동하겠다는 거창한 계획까지 세울 필요는 없다. 생각보다 쉽고 간단한 equals로도 대칭성을 해결할 수 있다.
@Override public boolean equals(Object o) {
return (o instanceof StringBox) && (((StringBox) o).str.equalsIgnoreCase(this.str));
}
추이성(transitivity)
추이성은 첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체와 세 번째 객체도 같아야 한다는 뜻이다. 자칫하면 어기기 쉬운 규칙이다. 위에서 예시로 들었던 StringBox 클래스를 상속하는 하위 클래스를 작성하고 새로운 필드를 추가해보자. 즉, equals 비교에 영향을 주는 정보를 추가하는 것이다.
public class MultiBox extends StringBox {
private final Integer num;
public MultiBox(String str, Integer num) {
super(str);
this.num = num;
}
...
}
equals는 어떻게 재정의해야 할까? 그대로 둔다면 num 정보를 고려하지 않으므로 적절하지 않다. 따라서 비교 대상이 MultiBox이고 str과 num이 같을 때만 true를 반환하는 equals를 작성해보자.
@Override public boolean equals(Object o) {
if (!(o instanceof MultiBox))
return false;
return super.equals(o) && Objects.equals(((MultiBox) o).num, this.num);
}
이 MultiBox.equals()는 상위 객체인 StringBox 객체가 매개변수로 들어온 경우에 클래스가 다르기 때문에 무조건 false를 반환할 것이다. 그럼 상위 객체가 들어온 경우는 따로 고려해주면 되는 것일까?
@Override public boolean equals(Object o) {
if (!(o instanceof StringBox))
return false;
if (!(o instanceof MultiBox))
return o.equals(this);
return super.equals(o) && Objects.equals(((MultiBox) o).num, this.num);
}
이렇게 하면 대칭성은 지킬 수 있지만, 추이성은 지킬 수 없다.
String testStr = "test";
MultiBox multiBox1 = new MultiBox(testStr, 5);
StringBox stringBox = new StringBox(testStr);
MultiBox multiBox2 = new MultiBox(testStr, 10);
System.out.println(multiBox1.equals(stringBox));
System.out.println(stringBox.equals(multiBox1));
System.out.println(stringBox.equals(multiBox2));
System.out.println(multiBox1.equals(multiBox2));
======================================sout======================================
true // multiBox1 == stringBox
true // stringBox == multiBox1 // 대칭성 충족
true // stringBox == multiBox2
false // multiBox1 != multiBox2 // 추이성 미충족
또한 이 방식은 무한 재귀에 빠뜨릴 위험이 있다. 만약 StringBox의 다른 하위 클래스 TripleBox가 equals를 같은 방식으로 구현했다면 tripleBox.equals(multiBox)는 StackOverflowError를 일으킨다. 바로 이 부분 때문이다.
@Override public boolean equals(Object o) {
if (!(o instanceof StringBox))
return false;
// 두 자식 클래스는 서로 클래스가 다르므로 서로의 equals를 계속해서 호출한다.
if (!(o instanceof MultiBox))
return o.equals(this);
return super.equals(o) && Objects.equals(((MultiBox) o).num, this.num);
}
그럼 해법은 무엇일까. 사실 이 현상은 모든 객체 지향 언어의 동치관계에서 나타나는 근복적인 문제라고 한다. 안타깝게도 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다. 객체 지향적 추상화의 이점을 포기하지 않는 한은 말이다. (이후 책에서 예시를 들며 설명한 내용은 결국 리스코프치환 원칙에 위배되는 내용이므로 따로 서술하지 않겠다)
상속 대신 컴포지션을 사용하라
equals 규약을 만족하기 위하여 상속 대신 사용하는 방법이다. MultiBox가 StringBox를 상속받는 대신 private 필드로 두고, StringBox를 반환하는 뷰 메서드를 public으로 추가하는 식이다.
class MultiBox {
private final StringBox stringBox;
private final Integer num;
public MultiBox(String str, Integer num) {
this.stringBox = new StringBox(str);
this.num = num;
}
public StringBox asStringBox() {
return stringBox;
}
@Override public boolean equals(Object o) {
if (!(o instanceof MultiBox))
return false;
MultiBox mb = (MultiBox) o;
return mb.stringBox.equals(this.stringBox) && mb.num.equals(this.num);
}
}
자바 라이브러리에도 구체 클래스를 확장해 값을 추가한 클래스가 종종 있다. 예를들어 java.sql.Timestamp는 java.util.Date를 확장하여 nanoseconds 필드를 추가했다. 그 결과 Timestamp의 equals는 대칭성을 위배하여 Date 객체와 함께 사용할 경우 자칫 이상한 오류를 경험할 수 있다. 이는 명백한 실수이니 절대 따라 해서는 안된다.
일관성(consistency)
일관성은 두 객체가 같다면 앞으로도 영원히 같아야 한다는 뜻이다. 따라서 클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다. 즉 equals는 항시 메모리에 존재하는 객체만을 사용한 결정적(deterministic) 계산만 수행해야 한다.
null-아님
딱히 부를 말이 없어서 이렇게 이름을 지었다고 한다. null-아님은 모든 객체가 null과 같지 않아야 한다는 뜻이다. 그럼 equals에서 매번 null 검사를 수행해야 할까? 그런 검사는 필요하지 않다. instanceof를 사용하면 첫 번째 매개변수가 null인 경우 false를 반환하므로 묵시적 null 검사를 수행할 수 있다.
equals 메서드 구현 방법 단계별 정리
- == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
- instanceof 연산자로 입력이 올바른 타입인지 확인한다.
- 입력을 올바른 타입으로 형변환한다. 2번 과정에 의해서 무조건 성공한다.
- 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다.
- float와 double을 제외한 기본 타입 필드는 == 연산자로 비교한다.
- 참조 타입 필드는 각각의 equals 메서드로 비교한다.
- float, double 필드는 각각 정적 메서드인 Float.compare, Double.compare로 비교한다.
- 특수한 부동소수 값 등을 다뤄야 하기 때문이다.
- Float.equals 또는 Double.equals 메서드를 대신 사용할 수 있지만, 오토박싱을 수반할 수 있으니 성능상 좋지 않다.
- null도 정상 값으로 취급하는 참조 타입 필드는 Objects.equals를 사용하여 비교한다. null-safe하기 때문에 NullPointerException 발생을 예방할 수 있다.
- 복잡한 필드를 가진 경우 해당 필드의 표준형을 저장해둔 후 표준형끼리 비교(?)하면 훨씬 경제적이다.
- 필드를 비교하는 순서에 따라서 equals의 성능을 좌우하기도 한다.
- 다를 가능성이 크거나 비교하는 비용이 싼 필드를 먼저 비교하자.
equals를 다 구현했다면...
세 가지만 자문해보자. 대칭적인가? 추이성이 있는가? 일관적인가?
자문에서 끝내지 말고 단위 테스트를 작성해 돌려보자.
equals를 재정의할 땐 hashCode도 반드시 재정의하자.
너무 복잡하게 해결하지 말자. 필드들의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있다.
'책 > 이펙티브 자바' 카테고리의 다른 글
Effective Java 아이템 5 : DI를 사용해라 (0) | 2022.03.21 |
---|---|
Effective Java 아이템 4 : 인스턴스화 막기 (0) | 2022.03.20 |
Effective Java 아이템 3 : private 생성자나 열거 타입으로 싱글턴임을 보증하라 (0) | 2022.03.20 |
Effective Java 아이템 2 : 빌더 패턴 (0) | 2022.03.02 |