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) = truecp2.equals(p) = truecp.equals(cp2) = falsecp2.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 |