e.g. 만원 짜리 지폐 두 장은 같은 것인가 다른 것인가? (액수를 묻는지, 지폐 고유번호를 묻는지를 따라 다르다.)
기본으로 Object에 정의되어있는 equals는 둘을 다르다고 판단한다.
대표적으로 논리적 동치성으로 판단하는 예로는 문자열이 있다.
상위 클래스에서 재정의한 equals가 하위 클래스에도 적절하다.
e.g. List, Set 등을 상속받아 구현한 경우
AbstractList, AbstractMap 등에 이미 구현되어 있기 때문이다.
클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다.
클래스가 public인 경우에는 누구나 사용할 수 있기 때문에 어떻게 쓰일지 예측이 불가하기 때문이다.
핵심 정리: equals 규약
반사성: A.equals(A) == true
거울과 같이, 본인이 본인과 비교 시 동일하다고 판단.
e.g. this.object == object
대칭성: A.equals(B) == B.equals(A)
CaseInsensitiveString (예시 코드 참고)
추이성: A.equals(B) && B.equals(C), A.equals(C)
쉽게 이해하면 삼단논법
Point, ColorPoint(inherit), CounterPointer, ColorPoint(comp)
일관성: A.equals(B) == A.equals(B)
null-아님: A.equals(null) == false
핵심 정리: equals 구현 방법
== 연산자를 사용해 자기 자신의 참조인지 확인한다.
instanceof 연산자로 올바른 타입인지 확인한다.
입력된 값을 올바른 타입으로 형변환 한다.
입력 객체와 자기 자신의 대응되는 핵심 필드가 일치하는지 확인한다.
구글의 AutoValue 또는 Lombok을 사용한다.
IDE의 코드 생성 기능을 사용한다.
과제) 자바의 Record를 공부하세요.
핵심 정리: 주의 사항
equals를 재정의 할 때 hashCode도 반드시 재정의하자. (아이템 11)
너무 복잡하게 해결하지 말자.
e.g. URL : 일관성 위배 가능성이 있다. 논리적 동치성으로 판단하도록 하자.
Object가 아닌 타입의 매개변수를 받는 equals 메서드는 선언하지 말자.
Double, Float 처럼 부동 소수를 제공하는 경우에는 compare()을 사용하도록 하자.
primitive type이라면 "==" 비교하도록 하자.
Object의 equals를 사용해 null인 경우도 처리할 수 있다.
결론은 equals를 올바르게 직접 구현하는 것은 쉽지 않다.
그래서 위의 구현 방법에서 AutoValue, Lombok, IDE 등을 사용하는 것을 추천한 것이다.
예시 코드
1. 대칭성
아래는 잘못된 equals 재정의의 예시이다.
CaseInsensitiveString은 equals에서 String을 알고 있지만, String은 CaseInsensitiveString을 알지 못하기 때문에 벌어지는 문제이다.
(양방향이 아닌 단방향으로 작동하기 때문이다.)
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 static void main(String[] args) {
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String polish = "polish";
System.out.println(cis.equals(polish)); // true
System.out.println(polish.equals(cis)); // false
List<CaseInsensitiveString> list = new ArrayList<>();
list.add(cis);
System.out.println(list.contains(polish)); // false
}
}
올바르게 사용하려면 equals를 다음과 같이 수정해야한다.
(아래의 코드로 main()을 실행하면 콘솔에 false, false, false 를 확인할 수 있으며 대칭성이 지켜지는 것을 확인할 수 있다.)
@Override public boolean equals(Object o) {
return o instanceof CaseInsensitiveString &&
((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
2. 상속 관계에서 잘못된 equals 재정의
좌표를 의미하며 좌표가 같을 때 동일하다 판단하도록 equals를 재정의한 Point 클래스.
ColorPoint 클래스는 Point를 상속받았으며, (편의상 뒤에 숫자로 구분) equals1()과 equals2()는 잘못된 경우이다.
전달 받은 객체가 Point면 색상을 무시하고 비교하고, ColorPoint인 경우 색상까지 비교하도록 한다.
굉장히 위험한 코드이다.
만약 ColorPoint와 동일한 레벨의 클래스를 만들어 equals를 호출하면 무한하게 서로를 호출하며 stack overflow가 뜨게 된다.
main()의 예시에서는 추이성에 위배가 된다.
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 (this == o) {
return true;
}
if (!(o instanceof Point)) {
return false;
}
Point p = (Point) o;
return p.x == x && p.y == y;
}
}
public class ColorPoint extends Point {
private final Color color;
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
// @Override
public boolean equals1(Object o) {
if (!(o instanceof ColorPoint))
return false;
return super.equals(o) && ((ColorPoint) o).color == color;
}
// @Override
public boolean equals2(Object o) {
if (!(o instanceof Point))
return false;
if (!(o instanceof ColorPoint))
return o.equals(this);
return super.equals(o) && ((ColorPoint) o).color == color;
}
public static void main(String[] args) {
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);
System.out.println(p.equals(cp) + " " + cp.equals(p)); // true false
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
System.out.printf("%s %s %s%n",
p1.equals(p2), p2.equals(p3), p1.equals(p3));
}
}
3. 상속 관계에서 잘못된 equals 재정의 2
그렇다면 아래와 같이 Point의 equals를 재정의한다면 괜찮지 않을까?
(CounterPoint는 Point를 상속받았으며 equals를 재정의하지 않은 클래스이다.)
리스코프의 치환 원칙을 위반한다.
상위 클래스 타입으로 동작하는 코드가 있다면, 그 상위 클래스 타입 대신 그 하위 클래스 타입을 넣는다면 잘 동작해야한다.
그러나 아래의 onUnitCircle(p2);는 false를 반환하게 된다.
Point의 equals에서 p2를 비교할 때 getClass()는 Point, o.getClass()는 CounterPoint이기 때문이다.
이 예시와 달리, 필드 값이 추가되지 않고 그냥 상속만 했더라면, 하위 클래스에서 equals 또한 재정의하지 않고 사용해도 올바르게 작동한다.
책에서도 언급되어 있기를,
"구체 클래스를 확장해서 새로운 값을 추가하면 equals 조약을 만족시킬 방법은 존재하지 않는다."
이미 자바에서도 대칭성에 위배가 된 Timestamp와 Date가 존재할 정도이다.
상속이 아니라 composition을 이용해 해결할 수 있다. (다음 예시 코드)
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 == null || o.getClass() != getClass())
return false;
Point p = (Point) o;
return p.x == x && p.y == y;
}
}
public class CounterPointTest {
private static final Set<Point> unitCircle = Set.of(
new Point( 1, 0), new Point( 0, 1),
new Point(-1, 0), new Point( 0, -1));
public static boolean onUnitCircle(Point p) {
return unitCircle.contains(p);
}
public static void main(String[] args) {
Point p1 = new Point(1, 0);
Point p2 = new CounterPoint(1, 0);
System.out.println(onUnitCircle(p1));
System.out.println(onUnitCircle(p2));
}
}
4. Composition을 통해 equals 규약 지키기
자기 자신의 타입인지 확인 후, 가진 값들이 각각 equal한지만 검사하면 된다.
(Enum의 특성상, enum의 equals는 기본적으로 객체의 동일성만 확인한다.)
asPoint()를 통해Point 특성만 외부에 노출시킬 수도 있다.
바로 윗 예시 코드의 Point p2 = new CounterPoint(1, 0); 부분을
Point p2 = new CompositionCounterPoint(1, 0, Color.RED).asPoint(); 로 고치면
equals 규약을 지키며 사용할 수 있다.
public class CompositionColorPoint {
private final Point point;
private final Color color;
public CompositionColorPoint(int x, int y, Color color) {
point = new Point(x, y);
this.color = Objects.requireNonNull(color);
}
아이템 10. equals는 일반 규약을 지켜 재정의하라
핵심 정리 : equals를 재정의 하지 않는 것이 최선
핵심 정리: equals 규약
핵심 정리: equals 구현 방법
== 연산자를 사용해 자기 자신의 참조인지 확인한다.
핵심 정리: 주의 사항
compare()
을 사용하도록 하자.예시 코드
1. 대칭성
아래는 잘못된 equals 재정의의 예시이다.
CaseInsensitiveString
은 equals에서 String을 알고 있지만, String은CaseInsensitiveString
을 알지 못하기 때문에 벌어지는 문제이다. (양방향이 아닌 단방향으로 작동하기 때문이다.)올바르게 사용하려면 equals를 다음과 같이 수정해야한다. (아래의 코드로 main()을 실행하면 콘솔에 false, false, false 를 확인할 수 있으며 대칭성이 지켜지는 것을 확인할 수 있다.)
2. 상속 관계에서 잘못된 equals 재정의
좌표를 의미하며 좌표가 같을 때 동일하다 판단하도록 equals를 재정의한 Point 클래스. ColorPoint 클래스는 Point를 상속받았으며, (편의상 뒤에 숫자로 구분)
equals1()
과equals2()
는 잘못된 경우이다.equals1()
p.equals(cp)
는 true (Point의 equals에서 ColorPoint는 Point의 instance이다.)cp.equals(p)
는 false (ColorPoint의 equals에서 Point는 ColorPoint의 instance가 아니다.)equals2()
3. 상속 관계에서 잘못된 equals 재정의 2
그렇다면 아래와 같이 Point의 equals를 재정의한다면 괜찮지 않을까? (CounterPoint는 Point를 상속받았으며 equals를 재정의하지 않은 클래스이다.)
onUnitCircle(p2);
는false
를 반환하게 된다.getClass()
는 Point,o.getClass()
는 CounterPoint이기 때문이다.4. Composition을 통해 equals 규약 지키기
자기 자신의 타입인지 확인 후, 가진 값들이 각각 equal한지만 검사하면 된다. (Enum의 특성상, enum의 equals는 기본적으로 객체의 동일성만 확인한다.)
asPoint()
를 통해Point 특성만 외부에 노출시킬 수도 있다.바로 윗 예시 코드의
Point p2 = new CounterPoint(1, 0);
부분을Point p2 = new CompositionCounterPoint(1, 0, Color.RED).asPoint();
로 고치면private final Point point; private final Color color;
public CompositionColorPoint(int x, int y, Color color) { point = new Point(x, y); this.color = Objects.requireNonNull(color); }
public Point asPoint() { return point; }
@Override public boolean equals(Object o) { if (!(o instanceof ColorPoint)) return false; CompositionColorPoint cp = (CompositionColorPoint) o; return cp.point.equals(point) && cp.color.equals(color); }
@Override public int hashCode() { return 31 * point.hashCode() + color.hashCode(); } }
완벽 공략
완벽 공략 24. Value 기반의 클래스
클래스처럼 생겼지만 int 처럼 동작하는 클래스
완벽 공략 25. StackOverflowError
로컬 변수와 객체가 저장되는 공간의 이름은?
완벽 공략 26. 리스코프 치환 원칙
객체 지향 5대 원칙 SOLID 중에 하나.