equals()
메서드는 Object 클래스에 정의되어 있는 메서드로 일반적으로는 두 객체가 동일한지 아닌지를 확인할때 사용하곤 한다. 이 equals()
메서드는 우리가 직접 사용하는 경우 말고도 예를 들어 set 이나 list 같은 자료구조에서도 contains()
등의 메서드 안에서 사용하는 경우가 많으므로, 해당 메서드를 잘못 구현할 경우, 이 equals() 함수가 일반 규약에 맞게 재정의되어있다고 가정하여 구현된 클래스들이 제대로 작동하지 않을 가능성이 높아진다.
그래서 이런 문제를 가장 현명하게 회피하는 길은 아예 재정의하지 않는 것이다.
그냥 두면 그 클래스의 인스턴스는 오직 자기 자신과만 같게 된다.
두 객체가 물리적으로 같은가를 판별할 때가 아닌, 논리적 동치성 을 확인해야 하는데, 해당 클래스의 상위 클래스가 논리적 동치성을 비교하도록 재정의되지 않았을 때라고 할 수 있다.
equals 메서드를 재정의할 때는 반드시 일반 규약을 따라야 한다. 다음은 Object 명세에 적힌 규약이다.
x.equals(x)
는 true 이다.x.equals(y)
가 true 이면 y.equals(x)
도 true 이다.x.equals(y)
와 y.equals(z)
가 true 이면 x.equals(z)
도 true이다.x.equals(y)
를 반복해서 호출하면 항상 true를 반환하거나 항상 false 를 반환한다.x.equals(null)
은 false이다.객체는 자기자신과 같아야 한다는 뜻으로, 얼핏보면 만족하지 않기가 더 어려워 보이는 조건이다.
이 조건을 어긴 클래스의 인스턴스를 collection 에 넣은 다음 contains
메서드를 호출하면 방금 넣은 인스턴스가 없다고 답할 것이다.
두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻.
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;
}
}
위의 코드에서 equals는 일반 문자열과도 비교를 시도한다.
하지만 string 클래스가 제공하는 equals 를 사용했을 때와 CaseInsensitiveString이 제공하는 클래스의 equals를 사용했을 때의 작동이 다르기 때문에 대칭성이 깨지게 된다.
이를 해결하려면, CaseInsensitiveString 의 equals 를 String 과도 연동하겠다는 생각을 버려야 한다.
@Override
public boolean equals(Object o) {
return o instance of CaseInsensitiveString &&
((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
추이성이란 첫번째 객체와 두번째 객체가 같고, 두번째 객체와 세번째 객체가 같다면, 첫번째 객체와 세번째 객체도 같아야 한다는 뜻이다.
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;
}
}
위의 클래스에서 확장해 점에 색상을 더해본 ColorPoint 클래스를 선언해보자.
class ColorPoint extends Point {
private final Color color;
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
}
여기서 equals 함수를 구성하려면, 추가된 속성인 color 까지 같아야 동치성이 만족되도록 짜야될 것이다.
class ColorPoint extends Point {
private final Color color;
@Override
public boolean equals(Object o) {
if(!(o instanceof ColorPoint)) return false;
return super.equals(o) && this.color == ((ColorPoint) o).color;
}
}
이런식으로 짜면 point 와 ColorPoint 의 비교에서 대칭성이 위배될 것이다.
Point p = new Point(1,2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);
p.equals(cp);
cp.equals(p);
그렇다면 색상을 무시하도록 하여 비교하게 되면 해결될까?
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;
}
}
이는 대칭성을 지키지만 추이성을 깨버린다.
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
그렇다면 근본적인 해결책은 무엇일까..?
사실 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 수 있는 방법은 존재하지 않는다.
아래코드처럼 instanceof 검사를 getClass 검사로 바꾸면 규약도 지키고 값도 추가하면서 구체 클래스를 상속할 수 있을 것만 같다. 한번 살펴보자.
class Point {
private final int x, y;
private static final Set<Point> unitCircle = Set.of(
new Point(0, -1),
new Point(0, 1),
new Point(-1, 0),
new Point(1, 0)
);
public static boolean onUnitCircle(Point p) {
return unitCircle.contains(p);
}
@Override
public boolean equals(Object o) {
if(o == null || o.getClass() != this.getClass()) {
return false;
}
Point p = (Point) o;
return this.x == p.x && this.y = p.y;
}
}
이제 값을 추가하지 않는 방식으로 point 를 확장한 클래스를 선언해보자.
만들어진 인스턴스의 개수를 생성자에서 세보도록 하자.
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(); }
}
리스코프 치환 원칙에 따르면, 어떤 타입에 있어 중요한 속성은 그 하위 타입에서도 마찬가지로 중요하다.
이 예시에 따르자면 Point 의 하위 클래스는 정의상 여전히 Point 이므로, 어디서든 Point 로서 활용될 수 있어야 한다.
하지만 CounterPoint 의 인스턴스를 onUnitCircle 메서드에 넘기면 Point 클래스의 equals를 getClass 를 통해 작성한 만큼, 같은 점이더라도 equals에서 다르게 나오게 된다.
public ColorPoint {
private Point point;
private Color color;
public ColorPoint(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);
}
}
괜찮은 우회 방식으로는 Point를 상속하는 대신 Point 를 ColorPoint 의 private 필드로 두고, ColorPoint 와 같은 위치의 일반 Point를 반환하는 뷰 메서드를 public 으로 추가하면 equals 가 정상적으로 작동할 것이다.
일관성이란 두 객체가 같다면 앞으로도 영원이 가아야 한다는 뜻이다.(두 객체 다 수정되지 않는다는 전제 하에)
모든 객체가 null 과 같지 않아야 한다는 뜻이다.
equals 메서드는 입력이 null 인지를 확인하는 검사가 필요하지 않다. 아래 코드처럼 묵시적 null 검사로 instance 를 검사할 때 자체적으로 이루어지는게 낫다.
@Override
public boolean equals(Object o) {
if(!(o instanceof MyType))
return false;
MyType mt = (MyType) o;
}
==
연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.instanceof
연산자로 입력이 올바른 타입인지 확인한다위의 주의사항들을 반영한 equals 메서드의 좋은 예시이다.
public final class PhoneNumber {
private final short areaCode, prefix, lineNum;
public PhoneNumber(int areaCode, int prefix, int lineNum){
this.areaCode = rangeCheck(areaCode, 999, "");
this.prefix = rangeCheck();
this.lineNum = rangeCheck();
}
private static short rangeCheck(int val, int max, String arg) {
if(val < 0 || val > max)
throw new IllegalArgumentException(arg + ": " + val);
return (short) val;
}
@Override
public boolean equals(Object o) {
if(o==this) return true;
if(!(o instanceof PhoneNumber)) return false;
PhoneNumber pn = (PhoneNumber) o;
return pn.lineNum == lineNum && pn.prefix == prefix
&& pn.areaCode == areaCode;
}
}
그리고 무엇보다 재정의할거면 AutoValue 프레임워크를 사용하여 클래스에 annotation 하나만 추가하여 직접 작성하지 말도록 하자.