[Effective Java] equals 재정의 일반 규약 (아이템10. equals는 일반 규약을 지켜 재정의하라)
꼭 필요한 경우에만 equals를 재정의하자
- Equals를 재정의 해야 할 때는 논리적으로 같음을 비교하고 싶을 때 이다.
객체가 같은지 구분하는게 아니라, 객체 내 값들을 비교해야 한다.
예시) Apple객체에서 weight, color를 모두 비교해서 같다고 판단해야 하는데, Object.equals는 객체가 같은지만 검사한다.
이런 경우엔 equals를 오버라이딩해서 apple.weight과 color를 모두 비교해야 한다.
- 값 클래스 내 인스턴스가 고유하게 존재할 때, equals를 재정의하지 않아도 된다.
예시) ENUM
값이 같은 인스턴스가 만들어지지 않으므로, 재정의하지 않고 사용해도 된다.
항상 다른 값이 비교될 것이고, false를 리턴할 것이므로 재정의할 필요가 없다!
따라서 논리적으로 같음을 판단하고 싶다면, equals를 재정의해야 한다.
Object에서 명세하는 일반 규약이 있는데,
이를 지키지 않으면 프로그램에 치명적인 논리적인 오류가 생기게 되고 디버깅도 쉽지 않으므로(논리적 오류를 논리레이어 최하단 equals까지가야 파악되므로.)
equals 재정의 코드를 작성할 때 정확하게 지키자.
equals 재정의 일반규약
1. 반사성(reflexivity)
x.equals(x) = true // 같은 값을 비교할 때 같다고 판단해야 한다.
2. 대칭성(symmetry)
x.equals(y) = y.equals(x)이 보장되어야 한다.
equals()를 사용할 때 주체객체와 매개변수가 swap되어도 대상은 같으므로 두 결과가 같음이 보장되어야 한다.
[대칭성 위배 사례]
public final 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;
}
...
}
public final class String{
...
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
...
}
CaseInsensitiveString cis = new CasInsensitiveString("Polish");
String s = "polish";
cis.equals(s) = true;
s.equals(cis) = false; -> String의 equals는 CaseInsensitiveString이 if(anObject instanceof String)을 통과하지 못하므로, 가장 하단의 false를 반환.
3. 추이성(transitivity)
x->y, y->z라면 x->z가 보장되어야 한다.
[추이성 위배 사례]
- 위치를 가지고 있는 Point 객체
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 p.x == x && p.y == y;
}
...
}
- 위치와 색깔을 가지고 있는 ColorPoint 객체(extends Point)
public class ColorPoint extends Point{
private final Color color;
public ColorPoint(int x, int y, Color color){
super(x,y); //Point 생성자로 생성
this.color = color;
}
// 대칭성에 위배되지 않도록 Point면 색상을 제외하고 비교, ColorPoint면 색상까지 포함하여 비교
@Override public boolean equals(Object o){
if(!(o instanceof Point))
return false;
//o가 일반 Point이면 색상을 무시하고 비교 (대칭성에 위배되지 않도록)
if(!(o instanceof ColorPoint))
return o.equals(this);
// o가 ColorPoint면 색상까지 비교한다.
return super.equals(o) && ((ColorPoint) o).color == color;
}
...
}
Point p = new Point(1,2);
ColorPoint cp = new ColorPoint(1,2,Color.RED);
라면 문제가 없는 것처럼 보인다.
p.equals(cp) == cp.equals(p)로 대칭성을 지킨다!
하지만 ColorPoint를 하나 더 만든다면 어떻게 될까??
ColorPoint p1 = new ColorPoint(1,2,Color.RED);
Point p2 = new Point(1,2);
ColorPoint p3 = new ColorPoint(1,2,Color.BLUE);
p1.equals(p2) = p2.equals(p3) = true; // 좌표만 비교하니까!
p1.equals(p3) = false; // 색깔까지 비교하니까!
로 추이성에 위배된다.
또한 무한재귀에 빠질 수 있다. (StackOverFlow 발생)
given) SmellPoint 클래스 (extends Point)
SmellPoint도 ColorPoint와 똑같이 equals를 구현한다. Point가 들어오면 좌표만 비교, SmellPoint가 들어오면 smell까지 비교해서 대칭성을 지킨다.
public class SmellPoint extends Point{
private final Smell smell;
public ColorPoint(int x, int y, Smell smell){
super(x,y); //Point 생성자로 생성
this.smell = smell;
}
// 대칭성에 위배되지 않도록 Point면 색상을 제외하고 비교, ColorPoint면 색상까지 포함하여 비교
@Override public boolean equals(Object o){
if(!(o instanceof Point))
return false;
//o가 일반 Point이면 smell 무시하고 비교 (대칭성에 위배되지 않도록)
if(!(o instanceof SmellPoint))
return o.equals(this);
// o가 SmellPoint면 smell까지 비교한다.
return super.equals(o) && ((SmellPoint) o).color == color;
}
...
}
when)
colorPoint.equals(smellPoint)를 수행
then) 무한재귀...!!
-> colorPoint.equals(smellPoint)를 진행하면
-> 매개변수가 ColorPoint인스턴스가 아니므로 smellPoint.equals(colorPoint)를 수행
-> 매개변수가 SmeelPoint인스턴스가 아니므로 colorPoint.equals(smellPoint)를 수행
-> 매개변수가 ColorPoint인스턴스가 아니므로 smellPoint.equals(colorPoint)를 수행
...가 계속 반복되어 무한재귀에 빠진다.
꽤 큰한 케이스라 조심해야한다. 객체지향개념에서 동치성만 고려한다면 항상 발생하는 상황이다.
해결시도1)
그렇다면 instanceOf 대신 getClass로 같은 클래스인지 정확하게 구분한다면 되지 않을까??
@Override public boolean equals(Object o){
if(o == null | o.getClass() != getClass()) // 같은 클래스가 아니라면 false를 반환
return false;
Point p = (Point)o;
return p.x == x && p.y == y;
}
-> 이건 하위클래스들이 Point객체를 활용할 수 없게 되기 때문에, 리스코프 치환 원칙을 위배한다.
*참고) 리스코프 치환 원칙
어떤 타입의 중요한 속성이라면 하위타입에서도 똑같이 동작해야 한다. (상속의 특징을 활용할 수 있어야한다.로 들린다)
해결시도2)
해결시도3) [GOOD] 상속대신 컴포지션을 활용. 하위클래스에 값을 추가한다.
상속하는 대신 Point를 ColorPoint에 private 필드로 사용한다.
ColorPoint와 같은 위치에 일반 Point를 반환하는 뷰메서드를 public으로 추가한다.
4. 일관성(consistency)
두 객체가 같다면 객체가 수정되지 않는 한 영원히 같아야 한다.
equals의 판단로직에 신뢰할 수 없는 자원이 있으면 안된다!
Ex) 호스트 이름을 ip주소로 바꿔서 ip값이 같은지 비교.
Class Host{
@Override public void equals(Obejct o){
return getIp(o).equals(getIp(this))
}
public String getIp(String host) { ... 네트워크 연결해 변환 요청 }
}
이 결과가 항상 같음이 보장되지 않으므로 일관성이 깨질. 수 있다 (네트워크 끊기거나, 타임아웃 에러...., 그쪽 로직이 바뀌어서 다른 값 내릴때 등)
-> 항시 메모리에 존재하는 객체만을 사용해야 한다.
5. null이 아닌 값을 null이라고 판단하면 안됨
s.equals(null) = false가 보장되어야 한다.
이것도 쉬워보이지만
아주 쉽게, 개발자가 예상치 못하는 포인트에서 NPE가 발생하기 때문에
A.equals(null) = true가 반환될 수 있다
Sol1) null인지 검사하는 보호코드를 넣는다. (= 명시적 null 검사)
// 명시적 null 검사
@Override public boolean equals(Object o){
if(o == null)
return false;
...
}
Sol2) instanceOf로 매개변수가 올바른 타입인지 검사 (= 묵시적 null 검사)
Null이라면 instanceOf를 통과하지 못하기 때문에!
// (better) 묵시적 null 검사
@Override public boolean equals(Object o){
if(!(o instanceof MyType))
return false;
MyType mt = (MyType)o;
...
}
equals 메서드 구현방법
위 위배사례들을 보면 구현하기 복잡해보이지만, 아래 구현 순서를 따라 구현하면 매우 쉽다.
1. == 연산자 활용해 자기자신의 참조인지 확인한다.
2. InstanceOf로 입력이 올바른 타입인지 검사한다.
3. 입력을 올바른 타입으로 변환한다. (2를 통과했기 때문에 무조건 에러없이 변환된다)
4. 핵심필드들이 모두 일치하는지 하나씩 값 검사를 진행한다. (= 원하는 equals 로직)
- String.class의 equals()메서드
public boolean equals(Object anObject) {
if (this == anObject) { // 1)
return true;
}
if (anObject instanceof String) { // 2)
String anotherString = (String)anObject; //3)
int n = value.length;
if (n == anotherString.value.length) { //4)
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
자바에서 재정의된 equals메서드를 보면 다 이런식으로 구현방법을 지켜서 작성되어있다!
직접 재정의할 때는 위 구현 원칙을 지키고 테스트 코드를 통해 검증해야한다.
AutoValue 오픈소스를 활용하면 equals를 알아서 만들어준다.