equals는 일반 규약을 지켜 재정의하라
equals() 메서드의 함정
- 기본적으로 객체의 내용이 동일한지 논리적 동치성을 확인하는 메서드이다.
- 하지만, 직접 구현하다보면 생각치 못한 여러가지 함정이 있으므로 직접 구현하지 않는 편이 안전하다.
구현하지 않아야 할 때
- 각 인스턴스가 본질적으로 고유할 때
- ex) 스레드는 각각의 스레드가 고유하다.
- 인스턴스의 논리적 동치성을 검사할 일이 없을 때
- 상위 클래스에서 재정의한
equals()
가 하위클래스에서도 문제없이 이용 가능할 때- ex)
Map
,Set
은AbstractMap
,AbstractSet
에서 내려받은equals()
를 그대로 사용한다.
- ex)
- 클래스가
private
혹은package-private
일 때 - 논리적 동치성과 객체 식별성이 같은 의미를 가지게 될 때
- ex)
enum
- ex)
@Override
public boolean equals(Object o) {
throw new AssertionError(); // 호출 금지
}
equals()
메서드를 지원하지 않을 때 위와 같이 표현할 수 있다.
가끔 구현해야 할 때
- 객체간의 논리적 동치성을 구현해야 할 때
- 그런데, 상위 클래스에
equals()
를 재사용할 수 없을 때
- 그런데, 상위 클래스에
주로
Integer
,String
과 같은 값 클래스 의 경우equals()
를 구현해야 하는 상황이 있다.
이 경우엔 동치 비교는 물론,Map
의 키,Set
의 원소로도 활용할 수 있다.
equals()
메서드의 일반 규약
null-아님
:null
이 아닌 모든 참조 값x
에 대해x.equals(null)
은false
이다.null
이 아니란 전제 하에 아래 조건들이 지켜져야 한다.
반사성(reflectivity)
:x.equals(x)
는true
여야 한다.- 이는 일부러 어기지 않는 이상 어기기 어렵다.
대칭성(symmetry)
:x.equals(y)
가true
면,y.equals(x)
도true
여야 한다.추이성(transivity)
:x.equals(y)
가true
이고,y.equals(z)
도true
면,x.equals(z)
도true
여야 한다.일관성(consistency)
:x.equals(y)
를 얼마나 반복하든, 결과는 항상 같아야 한다.
자바 API에서
equals()
를 이용하는 내용은 모든 클래스가 위 규약을 지키고 있다고 가정한다.
대칭성
대칭성은 실수로 어기기 굉장히 쉽다.
public class Item10Test {
static class CaseInsensitiveString {
private final String s;
public CaseInsensitiveString(String s) {
this.s = Objects.requireNonNull(s);
}
@Override
public boolean equals(Object o) {
if(o instanceof CaseInsensitiveString) {
return s.equalsIgnoreCase(
((CaseInsensitiveString) o).s
);
}
if(o instanceof String) {
return s.equalsIgnoreCase((String) o);
}
return false;
}
}
@Test
@DisplayName("대칭성을 위배하는 예시")
public void symmetryViolation() {
CaseInsensitiveString caseInsensitiveString = new CaseInsensitiveString("abc");
String string = "AbC";
boolean caseInsensitiveEquals = caseInsensitiveString.equals(string);
System.out.println("caseInsensitiveEquals = " + caseInsensitiveEquals); // true
boolean stringEquals = string.equals(caseInsensitiveString);
System.out.println("stringEquals = " + stringEquals); // false
}
}
CaseInsensitiveString
클래스와String
클래스를 비교할 때는 대소문자 구분없이 비교가 된다.String
클래스는CaseInsensitiveString
타입을 비교대상으로 받는 경우 무조건false
가 나타난다.
한쪽 클래스만 다른쪽 클래스와 비교할 준비가 된 상태이다. 대칭성이 위배된다.
equals()
메서드를 잘못 구현하면, 단순히equals()
메서드만 문제가 생기는 것이 아니고, 클래스를 받아equals()
를 활용하는 모든 곳에서 문제가 생긴다. ex)List
,Map
,Set
추이성
a = b
이고,b = c
일 때,a = c
를 만족해야 한다는 조건이다.
대칭성을 위배하는 코드
static 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 p.x == x && p.y == y;
}
}
static class ColorPoint extends Point {
private final String color;
public ColorPoint(int x, int y, String color) {
super(x, y);
this.color = color;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint)) {
return false;
}
return super.equals(o) && ((ColorPoint) o).color == color;
}
}
@Test
public void symmetryViolation3() {
Point p = new Point(1, 0);
ColorPoint cp = new ColorPoint(1, 0, "red");
boolean pEqualsCp = p.equals(cp);
System.out.println("pEqualsCp = " + pEqualsCp); // true
boolean cpEqualsP = cp.equals(p);
System.out.println("cpEqualsP = " + cpEqualsP); // false
}
- 위는 대칭성을 위배하는 코드의 예이다.
Point
는 좌표만 보기 때문에ColorPoint
와 비교가 가능하다.- 단, 반대는 불가능하다.
대칭성을 지키는 코드
@Override
public boolean equals(Object o) {
if(!(o instanceof Point)) {
return false;
}
if (!(o instanceof ColorPoint)) {
Point p = (Point) o;
return p.equals(this);
}
return super.equals(o) && ((ColorPoint) o).color == color;
}
- 간단히
Point
인스턴스일 때는 색상을 비교하지 않고, 좌표만 비교하도록 코드를 구성했다. - 이제 대칭성을 만족한다.
- 그러나 추이성이 지켜지지 않는다.
@Test
@DisplayName("추이성을 만족하지 않는 코드")
public void symmetryViolation3() {
Point p = new Point(1, 0);
ColorPoint cp = new ColorPoint(1, 0, "red");
ColorPoint cp2 = new ColorPoint(1, 0, "blue");
boolean pEqualsCp = p.equals(cp);
System.out.println("pEqualsCp = " + pEqualsCp);
boolean cpEqualsP = cp.equals(p);
System.out.println("cpEqualsP = " + cpEqualsP);
boolean pEqualsCp2 = p.equals(cp2);
System.out.println("pEqualsCp2 = " + pEqualsCp2);
boolean cp2EqualsP = cp2.equals(p);
System.out.println("cp2EqualsP = " + cp2EqualsP);
boolean cpEqualsCp2 = cp.equals(cp2);
System.out.println("cpEqualsCp2 = " + cpEqualsCp2);
}
cp.equals(p) = true
cp2.equals(p) = true
cp.equals(cp2) = false
cp2.equals(cp) = false
위와 같은 결과가 나와 추이성을 만족하지 못하는 코드가 나왔다.
구체 클래스를 확장해 새로운 값을 추가하면서
equals()
규약을 만족시킬 방법은 존재하지 않는다.
리스코프 치환원칙을 위배하는 코드
@Override
public boolean equals(Object o) {
if (o == null || o.getClass() != getClass()) {
return false;
}
Point p = (Point) o;
return p.x == x && p.y == y;
}
instanceof
연산을 쓰지 않고o.getClass()
로 비교하면, 오직 같은 클래스일 때만equals()
가 동작한다.- 하지만 이렇게 되면,
Point
의 하위 클래스가 어디서든Point
로 활용될 수는 없게 되는 것이다. - 리스코프 치환원칙 위배가 된다.
- 하지만 이렇게 되면,
public static boolean onUnitCircle(Point p) {
final Set<Point> unitCircle = Set.of(
new Point(1, 0), new Point(0, 1),
new Point(-1, 0), new Point(0, -1)
);
return unitCircle.contains(p);
}
public class CounterPoint extends Point {
private static final AtomicInteger counter = new AtomicInteger();
public CounterPoint(int x, int y) {
super(x, y);
counter.incrementAndGet();
}
public static int numberCreated() { return counter.get(); }
}
@Test
public void unitCircleTest() {
boolean onUnitCircle = onUnitCircle(new Point(0, 1));
System.out.println("onUnitCircle = " + onUnitCircle); // true
boolean onUnitCircle2 = onUnitCircle(new CounterPoint(0, 1));
System.out.println("onUnitCircle2 = " + onUnitCircle2); // false
}
위 예제에서, CounterPoint
는 Point
를 상속받아 x, y
좌표를 나타내는 것이 동일한데, onUnitCircle()
메서드에 무조건 false
만 반환하게 된다. contains()
함수가 내부적으로 Point
의 equals()
를 이용하기 때문일 것이다.
getClass()
메서드로 클래스 비교를 하는 것이 아닌, instanceof
를 이용했다면 정상적으로 동작했을 것이다.
상속대신 컴포지션을 이용하여 Point
확장해보기
static class NewColorPoint {
private final Point point;
private final String color;
public NewColorPoint(int x, int y, String color) {
this.point = new Point(x, y);
this.color = color;
}
public Point asPoint() {
return point;
}
@Override
public boolean equals(Object o) {
if(!(o instanceof NewColorPoint)) {
return false;
}
NewColorPoint cp = (NewColorPoint) o;
return cp.equals(point) && cp.color.equals(color);
}
}
이렇게 구성하면, 애초에 상속받은 적이 없으니 리스코프 치환원칙에서 벗어난다.
일반 규약을 지키지 않는 잘못된 자바 API들
java.sql.Timestamp
는java.util.Date
를 확장한 후에 필드를 추가해서 일반 규약을 지킬 수 없다.- 그래서 컬렉션에 넣거나, 대칭성 있는 비교를 수행하려 하면 디버그하기 까다로운 현상을 만날 수도 있다.
equals()
에 신뢰할 수 없는 자원이 끼어들게 하면 안된다.
java.net.URL
은equals()
가 일반규약을 지키지 않는다.- 호스트 이름이 같으면,
equlas()
가 참이 나오는데, 사실 이건 네트워크를 거치지 않으면 알 수 없는 정보이다.
- 호스트 이름이 같으면,
위와 같은 문제를 피하려면, 항시 메모리에 존재하는 객체만을 사용한 결정적 계산만 수행해야 한다.
null
검사
null
검사는 사실instanceof
연산을 하며 쉽게 검증된다.instanceof
는 첫번째 피연산자가null
이면 무조건false
를 반환한다.
equals()
메서드를 구현하는 절차
==
연산자로 자신의 참조인지 먼저 확인한다.- 필드가 아주 많은 객체를 비교한다면, 이런 단순한 연산으로 매우 많은 절약을 할 수 있다.
instanceof
연산자로 입력이 올바른 타입인지 확인한다.null
방지와 캐스팅 에러 방지를 해준다.
- 입력을 올바른 타입으로 형변환한다.
- 핵심 필드들이 일치하는지 하나씩 확인한다.
- 기본 타입은
==
으로 확인하고,float
,double
은Float.compare()
와 같은 정적 메서드를 활용하자. (부동소수점 때문) - 배열의 모든 원소가 핵심 필드라면,
Arrays.equals()
메서드들 중 하나를 활용하자. null
값을 정상 값으로 취급하는 참조 타입 필드의 경우Object.equals(Object, Object)
메서드로 방지할 수 있다.
- 기본 타입은
필드를 비교할 때
- 필드를 비교할 때는 성능이 싼 필드부터 비교하면 좋다.
- 핵심 필드로부터 계산되는 파생필드가 있는 경우, 파생 필드를 비교하는 것이 더 빠르진 않은지 생각해볼 필요가 있다.
equals()
를 다 구현했다면?
위에서 배운 일반 규약을 잘 지키는지 확인해보자
equals()
메서드 구현 마지막 주의사항
equals()
를 재정의할 땐 반드시hashCode()
도 재정의하자.- 너무 복잡하게 해결하려 하지말자.
Object
외의 타입을 파라미터로 받는equals()
를 만들지 말자.@Override
를 항상 명시하여, 이러한 일을 방지하자.- 이러한 실수는 한번 하면 찾기도 힘들 수 있다.
가급적 IDE에서 제공하는
equals()
,hashCode()
구현 기능을 이용하는 것이 실수를 예방하기 좋다.
핵심
- 가급적
equals()
를 재정의하지 말자. - 굳이 재정의 해야 할 때는 항상 규약을 명심해야 한다.
'Java > 이펙티브 자바' 카테고리의 다른 글
이펙티브 자바, 쉽게 정리하기 - item 12. toString을 항상 재정의하라 (0) | 2021.12.30 |
---|---|
이펙티브 자바, 쉽게 정리하기 - item 11. equals를 재정의하려거든 hashCode도 재정의하라 (0) | 2021.12.29 |
이펙티브 자바, 쉽게 정리하기 - item9. try-finally보다는 try-with-resources를 사용하라 (0) | 2021.12.28 |
이펙티브 자바, 쉽게 정리하기 - item8. finalizer와 cleaner 사용을 피하라 (0) | 2021.12.27 |
이펙티브 자바, 쉽게 정리하기 - item7. 다 쓴 객체 참조를 해제하라 (0) | 2021.12.27 |