equals 메서드는 재정의하기 쉬워 보이지만, 실제로 재정의 할 때는 주의해야 할 점이 많다.
이 문제를 회피하려면 그냥 equals를 재정의하지 않으면 된다. 재정의하지 않고 그냥 둔다면, 해당 클래스의 인스턴스는 오직 자기 자신과만 같게 된다.
다음은 equals를 재정의하지 않는 것이 최선인 경우들이다.
- 각 인스턴스가 본질적으로 고유하다. 값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스가 여기 해당한다. Thead가 좋은 예로, Object의 equals 메서드는 이러한 클래스에 딱 맞게 구현되었다.
- 인스턴스의 논리적 동치성(logical equality)을 검사할 일이 없다.
- 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다. 예를 들어 대부분의 Set 구현체는 AbstractSet이 구현한 equals를 상속받아 쓰고, List 구현체들은 AbstractList로부터, Map 구현체들은 AbstractMap으로부터 상속받아서 그대로 사용한다.
- 클래스가 private 이거나 pakage-private이고 equals를 호출할 일이 없다.
그러면 equals를 재정의해야 할 때는 언제일까?
객체 식별성(object identity; 두 객체가 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때이다. 주로 값 클래스들이 여기 해당한다.
값 클래스?
값 클래스란 Integer와 String 처럼 값을 표현하는 클래스를 말한다. 두 값 객체를 equals로 비교하는 프로그래머는 개체가 같은지가 아니라 값이 같은지를 알고 싶어 할 것이다. equals가 논리적 동치성을 확인하도록 재정의해두면, 그 인스턴스는 값을 비교하길 원하는 프로그래머의 기대에 부응함을 물론 Map의 키와 Set의 원소로 사용할 수 있게 된다.
값 클래스라 해도, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스(아이템 1)라면 equals를 재정의하지 않아도 된다. Enum도 여기에 해당한다.
이런 클래스에서는 어차피 논리적으로 같은 인스턴스가 2개 이상 만들어지지 않으니 논리적 동치성과 객체 식별성이 사실상 똑같은 의미가 된다. 따라서 Object의 equals가 논리적 동치성까지 확인해준다고 볼 수 있다.
equals 메서드를 재정의할 때는 반드시 일반 규약을 따라야 한다.
다음은 Object 명세에 적힌 규약이다.
equals 메서드는 동치관계(equivalence realation)을 구현하며, 다음을 만족한다.
반사성(reflexivity) : null 이 아닌 모든 참조 값 x에 대해서, x.equals(x)는 true이다.
대칭성(symmetry) : null 이 아닌 모든 참조 값 x, y에 대해서, x.equals(y)가 true면 y.equals(x)도 true이다.
추이성(transitivity) : null 이 아닌 모든 참조 값 x, y, z에 대해, x.equals(y)가 true이고, y.equals(z)도 true이면 x.equals(z)도 true이다.
일관성(consistency) : null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.
null - 아님 : null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false이다.
해당 규칙이 이해가 안된다고 그냥 넘어가서는 안된다.
이 규약을 어긴 체 equals를 재정의한다면 프로그램이 이상하게 동작하거나 종료될 것이고, 원인을 찾기도 어려워 질 것이다.
그렇다면 동치관계란 무엇일까?
쉽게 말해, 집합을 서로 같은 원소들로 이루어진 부분집합으로 나누는 것이며, 이때 나누어진 부분집합을 동치류라고 한다.
(R 을 공집합이 아닌 집합 A위의 동치 관계라 할 때, A의 원소 a와 관계된 모든 원소들의 집합을 a의 동치류라 한다. )
equals가 쓸모 있으려면 모든 원소가 같은 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야 한다.
동치관계를 만족시키기 위한 다섯가지 조건
반사성
단순히 말하면 객체는 자기 자신과 같아야 한다는 듯이다. 이 요건은 일부러 어기는 경우가 아니라면 만족시키지 못하는 것이 더 어려워 보인다. 만약 이 요건을 어긴 클래스의 인스턴스를 컬렉션에 넣은 다음 contains 메서드를 호출하면 방금 넣은 인스턴스가 없다고 답할 것이다.
대칭성
두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻이다.
반사성 요건과는 달리 대칭성 요건은 잘못하면 어길 수 있다.
대소문자를 구별하지 않는 문자열을 구현한 다음 클래스를 예로 살펴보자
public 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("a");
String s = "A";
System.out.println(s.equals(cis)); // false
System.out.println(cis.equals(s)); // true
List<CaseInsensitiveString> list = new ArrayList<>();
list.add(cis);
System.out.println(list.contains(s));//false
}
}
해당 문제를 해결하려면 CaseInsentiveString의 equals 를 String과도 연동하겠다는 허황한 꿈을 버려야 한다.
그 결과 equals는 다음처럼 간단한 모습으로 바뀐다.
@Override
public boolean equals(Object o){
return o instanceof CaseInsensitiveString && ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
추이성
첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체와 세 번째 객체도 같아야 한다는 뜻이다. 이 요건도 간단하지만 자칫하면 어기기 쉽다. 상위 클래스에서는 없는 새로운 필드를 하위 클래스에 추가하는 상황을 생각해보자. equals 비교에 영향을 주는 정보를 추가한 것이다. 간단히 2차원에서의 점을 표현하는 클래스를 예로 들어보자.
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 obj) {
if(!(obj instanceof Point))
return false;
Point p = (Point) obj;
return p.x == x && p.y == y;
}
}
이제 이 클래스를 확장에서 Color 속성을 가진 ColorPoint클래스를 만들어보자.
public class ColorPoint extends Point{
private final Color color;
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
}
만약 여기서 equals 메서드를 그대로 둔다면, 색상 정보는 무시한 체 점의 좌표만 비교하게 된다.
다음처럼 코드를 작성하면 어떨까?
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 equals(Object obj) {
if(!(obj instanceof ColorPoint))
return false;
return super.equals(obj) && ((ColorPoint) obj).color == color;
}
}
이 메서드는 일반 Point를 ColorPoint에 비교한 결과와 그 둘을 바꿔 비교한 결과가 다를 수 있다.
Point의 equals는 색상을 무시하여 비교를 수행하고, ColorPoint의 equals는 입력 매개변수의 클래스 종류가 다르다며 매번 false만 반환할 것이다.
(대칭성 위배)
Point p = new Point(1,2);
ColorPoint cp = new ColorPoint(1,2, Color.RED);
System.out.println(p.equals(cp));//true
System.out.println(cp.equals(p));///false
그렇다면 ColorPoint.equals가 Point와 비교할 때는 색상을 무시하도록 하면 해결될까?
@Override
public boolean equals(Object obj) {
if(!(obj instanceof Point))
return false;
if(!(obj instanceof ColorPoint))
return obj.equals(this);
return super.equals(obj) && ((ColorPoint) obj).color == color;
}
이 방식은 대칭성을 지켜주지만 추이성을 지켜주지 못한다.
ColorPoint p1 = new ColorPoint(1,2, Color.RED);
Point p2 = new Point(1,2);
ColorPoint p3 = new ColorPoint(1,2, Color.BLACK);
System.out.println(p1.equals(p2));//true
System.out.println(p2.equals(p3));///true
System.out.println(p1.equals(p3));///false
어떻게 이런 상황을 해결할 수 있을까?
사실 이 현상은 모든 객체지향 언어의 동치관계에서 나타나는 근본적인 문제이며, 구체 클래스를 확장해서 새로운 값을 추가하면서 equals 규칙을 만족시킬 방법은 존재하지 않는다. 객체 지향적 추상화의 이점을 포기하지 않는 한은 말이다.
그렇다면 다음처럼 Point의 equals를 구현하면 어떻게 될까?
@Override
public boolean equals(Object obj) {
if(obj == null || obj.getClass() != getClass()) return false;
Point p = (Point) obj;
return p.x == x && p.y == y;
}
이번 equals는 같은 구현 클래스의 객체와 비교할 때만 true를 반환한다. 괜찮아 보이지만 이는 리스코프 치환 원칙을 무시한다.
다음은 그 예시이다.
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);
}
주어진 점이 (반지름이 1인) 단위 원 안에 있는지를 판별하는 메서드를 구현한 것이다.
이제 Point를 확장해보도록 하겠다.
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();
}
}
리스코프 치환 원칙에 따르면, 어떤 타입의 모든 메서드는 하위 타입의 메서드에서도 똑같이 작동해야 한다.
그런데 CounterPoint의 인스턴스를 onUnitCircle 메서드에 넘기면 어떻게 될까?
Point 클래스의 equals를 getClass를 사용해 작성했다면 onUnitCircle은 false를 반환할 것이다.
CounterPoint 인스턴스의 x, y값과는 무관하게 말이다.
CounterPoint의 인스턴스는 어떤 Point와도 같을 수 없기 때문에 이러한 일이 발생하였다. 만약 Point의 equals를 getClass가 아닌 instanceof 기반으로 구현했다면 제대로 동작했을텐데 말이다.
구체 클래스의 하위 클래스에서 값을 추가할 방법은 없으나 우회 방법은 존재한다.
상속 대신 합성(composition)을 사용하는 방법으로, Point를 상속하는 대신 Point를 ColorPoint의 private 필드로 두고, ColorPoint와 위치가 같은 일반 Point를 반환하는 뷰(view) 메서드를 public으로 추가하는 식이다.
public class ColorPoint{
private final Color color;
private final Point point;
public ColorPoint(Color color, int x, int y ) {
this.color = color;
this.point = new Point(x, y);
}
public Point asPoint(){
return point;
}
@Override
public boolean equals(Object obj) {
if(!(obj instanceof ColorPoint)) return false;
ColorPoint cp = (ColorPoint) obj;
return cp.point.equals(point) && cp.color.equals(color);
}
}
일관성
두 객체가 같다면 (어느 하나 혹은 두 객체 모두가 수정되지 않는 한)앞으로도 영원히 같아야 한다는 뜻이다.
가변 객체는 비교 시점에 따라 서로 다를 수도 혹은 같은 수도 있는 반면, 불변 객체는 한번 다르면 끝까지 달라야 한다.
클래스가 불변이던 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안된다.
예를 들면 java.net.URL의 equals는 주어진 URL과 매핑된 호스트의 IP주소를 이용해 비교한다.
호스트 이름을 IP주소로 바꾸려면 네트워크를 통해야 하는데, 그 결과가 항상 같다고 보장할 수 없다.
URL의 equals를 이렇게 구현한 것은 실수였으니 절대 따라해서는 안 된다.
이러한 문제를 피하려면 equals는 항시 메모리에 존재하는 객체만을 사용한 결정적(determinisitc) 계산만 수행해야 한다.
null - 아님
공식 이름이 없으므로 이렇게 이름지었다. 모든 객체가 null과 같지 않아야 한다는 뜻이다.
equals 구현법
지금까지의 내용을 종합해서 equals 메서드 구현 방법을 단계별로 정리해보겠다.
- == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다. 이는 단순한 성능 최적화용이다.
- instanceof 연산자로 입력이 올바른 타입인지 확인한다.
- 입력을 올바른 타입으로 형변환한다. (instanceof 검사를 했기 때문에 무조건 성공한다.)
- 입력 객체와 자기 자신의 대응되는 "핵심" 필드들이 모두 일치하는지 하나씩 검사한다.
float와 double을 제외한 기본 타입 필드는 == 연산자로 비교하고, 참조 타입 필드는 각각의 equals 메서드로, float와 double은 각각의 정적 메서드인 Float.compare(float, float), Double.compate(double, double)로 비교한다.
이 둘을 특별 취급하는 이유는 Float.NaN, -0.0f, 특수한 부동소수 값 등을 다뤄야 하기 때문이다.
equals를 다 구현했다면 다음 세가지만 자문해보자.
대칭적인가? 추이성이 있는가? 일관적인가?
자문에서 끝내지 말고 단위 테스트를 돌려서 확인해보자.
그리고 마지막으로 주의해야 할 사항이다.
- equals를 재정의할 땐 hashCode도 반드시 재정의하자. (아이템 11)
- 너무 복잡하게 해결하려 들지 말자.
- Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자.
AutoValue
equals를 작성하고 이를 테스트해주는 프레임워크로 구글이 만들었다.
클래스에 애너테이션 하나만 추가하면 AutoValue가 이 메서드들을 알아서 작성해준다.
대다수의 IDE도 같은 기능을 제공하지만 생성된 코드가 AutoValue만큼 깔끔하거나 읽기 좋지는 않다.
또한 IDE는 나중에 클래스가 수정된 걸 자동으로 알아채지는 못하니 테스트코드를 작성해둬야 한다.
그래도 사람보다는 IDE가 낫다!
핵심
꼭 필요한 경우가 아니라면 equals를 재정의하지 말자. 많은 경우에 Object의 equals가 우리가 원하는 비교를 정확히 수행해준다.
📔 Reference
'☕️ Java > 이펙티브 자바' 카테고리의 다른 글
[Effective Java] 아이템 17 - 변경 가능성을 최소화하라 (0) | 2023.02.23 |
---|---|
[Effective Java] 아이템 15 - 클래스와 멤버의 접근 권한을 최소화하라 (0) | 2022.02.11 |
[Effective Java] 아이템 9 - try-finally 보다는 try-with-resources를 사용하라 (0) | 2022.02.04 |
[Effective Java] 아이템 8 - finalizer와 cleaner 사용을 피하라 (0) | 2022.02.04 |
[Effective Java] 아이템 7 - 다 쓴 객체 참조를 해제하라 (0) | 2022.02.03 |