equals 메서드는 인텔리제이 덕에 재정의 하기 쉽지만 곳곳에 함정이 도사리고 있어서 자칫하면 끔찍한 결과를 초래한다.
- 값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스(Thread)
@Override
public boolean equals(Object obj) {
if (obj == this)
return true;
if (obj instanceof WeakClassKey) {
Object referent = get();
return (referent != null) && (referent == ((WeakClassKey) obj).get());
} else {
return false;
}
}
- equals를 재정의하여 각 인스턴스가 같은 정규표현식을 나타내는지 검사할 수 있지만, 그럴 일이 없다면 굳이 재정의할 필요가 없다.(Pattern)
void patternTest() {
final Pattern P1 = Pattern.compile("//.*");
final Pattern P2 = Pattern.compile("//.*");
System.out.println(P1.equals(P1)); // true
System.out.println(P1.equals(P2)); // false
System.out.println(P1.pattern()); // //.*
System.out.println(P1.pattern().equals(P1.pattern())); // true
System.out.println(P1.pattern().equals(P2.pattern())); // true
}
- Set의 구현체들은 모두 AbstractSet이 구현한 equals를 상속 받아서 쓴다. 그 이유는 서로 같은 Set인지 구분하기 위해서 사이즈, 내부 값들을 비교하면 되기 때문이다.
public boolean equals(Object o) {
if (o == this) {
return true;
}
if (!(o instanceof Set)) {
return false;
}
Collection<?> c = (Collection<?>) o;
if (c.size() != size()) { // 사이즈 비교
return false;
}
try {
return containsAll(c); // 내부 인스턴스 비교
} catch (ClassCastException | NullPointerException unused) {
return false;
}
}
그런데 그로 인해서 이런 일도 생긴다.
void setTest() {
Set<String> hash = new HashSet<>();
Set<String> tree = new TreeSet<>();
hash.add("Set");
tree.add("Set");
System.out.println(hash.equals(tree)) // true;
}
@Override
public boolean equals(Object o){
throw new AssertionError(); // 호출 금지
}
equals는 논리적인 동치성을 확인하고 싶을 때 재정의 한다.
예외
Enum: 값 클래스라고 해도 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장할때
null이 아닌 모든 참조 값 x, y, z에 대해,
- 반사성:
- x.equals(x) == true
- 대칭성:
- x.equals(y) == true
- -> y.equals(x) == true
- 추이성:
- x.equals(y) == true
- -> y.equals(z) == true
- -> x.equals(z) == true
- 일관성:
- x.equals(y)를 반복해도 값이 변하지 않는다.
- null-아님:
- x.equals(null) == false
존 던(John Donne)의 말처럼 세상에 홀로 존재하는 클래스는 없기에,
협력 관계에서 수 많은 클래스는 자신에게 전달된 객체가 equals 규약을 지킨다고 가정하고 동작한다.
- null이 아닌 모든 참조 값 x에 대해 x.equals(x)를 만족해야 한다.
- 자기 자신과 같아야 한다.
- null이 아닌 모든 참조 값 x, y에 대해 x.equals(y)가 true이면, y.equals(x)가 true를 만족해야 한다.
- 서로에 대한 동치 여부가 같아야 한다.
public final class CaseInsensitiveString {
private final String str;
public CaseInsensitiveString(String str) {
this.str = Objects.requireNonNull(str);
}
@Override
public boolean equals(Object o) {
if (o instanceof CaseInsensitiveString) {
return str.equalsIgnoreCase(((CaseInsensitiveString) o).str);
}
if (o instanceof String) { // 한 방향으로만 작동한다.
return str.equalsIgnoreCase((String) o);
}
return false;
}
}
void symmetryTest() {
CaseInsensitiveString caseInsensitiveString = new CaseInsensitiveString("Test");
String test = "test";
System.out.println(caseInsensitiveString.equals(test)); // true
System.out.println(test.equals(caseInsensitiveString)); // false
}
- null이 아닌 모든 참조 값 x, y, z에 대해 x.equals(y)가 true이고, y.equals(z)가 true이면 x.equals(z)도 true이다.
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Point)) {
return false;
}
Point p = (Point) o;
return this.x == p.x && this.y == p.y;
}
}
public class ColorPoint extends Point {
private final Color color;
@Override
public boolean equals(Object o) {
if (!(o instanceof Point)) {
return false;
}
// o가 일반 Point이면 색상을 무시햐고 x,y 정보만 비교한다.
if (!(o instanceof ColorPoint)) {
return o.equals(this);
}
// o가 ColorPoint이면 색상까지 비교한다.
return super.equals(o) && this.color == ((ColorPoint) o).color;
}
}
void transitivityTest() {
ColorPoint a = new ColorPoint(2, 3, Color.RED);
Point b = new Point(2, 3);
ColorPoint c = new ColorPoint(2, 3, Color.BLUE);
System.out.println(a.equals(b)); // true
System.out.println(b.equals(c)); // true
System.out.println(a.equals(c)); // false
}
public class SmellPoint extends Point {
private final Smell smell;
@Override
public boolean equals(Object o) {
if (!(o instanceof Point)) {
return false;
}
// o가 일반 Point이면 색상을 무시햐고 x,y 정보만 비교한다.
if (!(o instanceof SmellPoint)) {
return o.equals(this);
}
// o가 ColorPoint이면 색상까지 비교한다.
return super.equals(o) && this.smell == ((SmellPoint) o).smell;
}
}
void infinityTest() {
Point cp = new ColorPoint(2, 3, Color.RED);
Point sp = new SmellPoint(2, 3, Smell.SWEET);
System.out.println(cp.equals(sp));
}
만약 추이성을 지키기 위해서 Point의 equals를 각 클래스들을 getClass를 통해서 같은 구체 클래스일 경우에만 비교하도록 하면 어떨까?
@Override
public boolean equals(Object o) {
// getClass
if (o == null || o.getClass() != this.getClass()) {
return false;
}
Point p = (Point) o;
return this.x == p.x && this.y = p.y;
}
이렇게 되면 동작은 하지만, 리스코프 치환원칙을 지키지 못하게 된다.
이는 모든 객체 지향 언어의 동치관계에서 나타나는 근본적인 문제이며,
객체 지향적 추상화의 이점을 포기하지 않는 한 불가능하다.
상속 대신 컴포지션을 사용해라(아이템 18) By 루키
public class ColorPoint2 {
private Point point;
private Color color;
public ColorPoint2(int x, int y, Color color) {
this.point = new Point(x, y);
this.color = Objects.requireNonNull(color);
}
public Point asPoint() {
return this.point;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint)) {
return false;
}
ColorPoint cp = (ColorPoint) o;
return this.point.equals(cp) && this.color.equals(cp.color);
}
}
추상 클레스의 하위 클래스에서라면 equals의 규약을 지키면서도 값을 추가할 수 있다.
태그 달린 클래스보다는 클래스 계층구조를 활용하라(아이템 23) By 티키 이와 같은 구조에서는 상위 클래스를 직접 인스턴스로 만드는 것이 불가능 하기 때문에 지금까지 이야기한 문제들은 모두 일어나지 않는다.
- null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.
@Test
void consistencyTest() throws MalformedURLException {
URL url1 = new URL("www.xxx.com");
URL url2 = new URL("www.xxx.com");
System.out.println(url1.equals(url2)); // 항상 같지 않다.
}
java.net.URL 클래스는 URL과 매핑된 host의 IP주소를 이용해 비교하기 때문에 같은 도메인 주소라도 나오는 IP정보가 달라질 수 있기 때문에 반복적으로 호출할 경우 결과가 달라질 수 있다.
- null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false
o.equals(null) == true
인 경우는 상상하기 어렵지만, 실수로
NullPointerException
을 던지는 코드 또한 허용하지 않는다.
이러한 Exception
을 막기 위해서 여러가지 방법이 존재하는데, 그중 하나는 null인지 확인을 하고
false
를 반환하는 것이다. 하지만 책에서는 다른 방법을 추천하고 있다.
@Override
public boolean equals(Object o) {
// 우리가 흔하게 인텔리제이를 통해서 생성하는 equals는 다음과 같다.
if (o == null || getClass() != o.getClass()) {
return false;
}
// 책에서 추천하는 내용은 null 검사를 할 필요 없이 instanceof를 이용하라는 것이다.
// instanceof는 두번째 피연산자(Point)와 무관하게 첫번째 피연산자(o)거 null이면 false를 반환하기 때문이다.
if (!(o instanceof Point)) {
return false;
}
}
@Override
public boolean equals(final Object o) {
// 1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
if (this == o) {
return true;
}
// 2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.
if (!(o instanceof Point)) {
return false;
}
// 3. 입력을 올바른 타입으로 형변환 한다.
final Piece piece = (Piece) o;
// 4. 입력 개체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사한다.
// float와 double을 제외한 기본 타입 필드는 ==를 사용한다.
return this.x == p.x && this.y == p.y;
// 필드가 참조 차입이라면 equals를 사용한다.
return str1.equals(str2);
// null 값을 정상 값으로 취급한다면 Objects.equals로 NullPointException을 예방하자.
return Objects.equals(Object, Object);
}
- 꼭 필요한 경우가 아니라면 재정의하지 말자.
- 그래도 필요하다면 핵심필드를 빠짐 없이 비교하며 다섯 가지 규약을 지키자.
- 어떤 필드를 먼저 비교하느냐가 equals의 성능을 좌우하기도 한다. 최상의 성능을 바란다면 다를 가능성이 더 크거나 비교하는 비용이 더 싼 필드를 먼저 비교하자.
- 객체의 논리적 상태와 관련 없는 필드는 비교하면 안 된다.
- 핵심 필드로부터 파생되는 필드가 있다면 굳이 둘다 비교할 필요는 없다. 편한 쪽을 선택하자.
- equals를 재정의할 땐 hashCode도 반드시 재정의하자(아이템 11) By 에덴
- equals의 매개변수 입력을 Object가 아닌 타입으로는 선언하지 말자.