💥 개요
equals 메서드는 재정의하기 쉬워 보이지만, 곳곳에 함정이 있습니다. 문제를 피하는 방법은 재정의하지 않는것이고, 아래와 같은 상황 중 하나에 해당한다면 재정의하지 않는것이 좋습니다.
- 각 인스턴스가 본질적으로 고유하다. 값을 표현하는게 아니라 동작하는 개체를 표현하는 클래스(예를 들어 Thread) equals는 이미 이 객체에 딱 맞게 구현되어 재정의 할 필요가 없습니다.
- 인스턴스의 논리적 동치성(logical equality)를 검사할 일이 없다. java.util.regex.Pattern은 equals를 재정의해서 두 Pattern의 인스턴스가 같은 정규표현식을 나타내는지(논리적 동치성)를 검사하는 방법도 있습니다.
- 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다. 대부분의 set 구현채는 AbstractSet이 구현한 equals를 상속받아 쓰고, List 구현체나 Map구현체들도 동일합니다.
- 클래스가 private이거나 package-private이고, equals 메서드를 호출할 일이 없다.
🤷♂️ 재정의가 필요할 때
객체 식별성(object identity; 두 객체가 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야 할 때 필요합니다. 주로 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때, 값 클래스(Inetger, String)들이 이에 해당합니다.
이렇게 하면 Map의 key나 Set의 원소로 사용이 가능해집니다.
값 클래스라고 해도, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스라면 equals를 재정의 하지 않아도 됩니다. Enum도 이에 해당합니다. 이런 클래스는 논리적으로 같은 인스턴스가 2개 이상 만들어지지 않으니 논리적 동치성과 객체 식별성이 사실상 동일한 의미가 됩니다.
💡 equals 재정의 시 필수적인 일반 규약
논리적 동치성과 같이 equal를 재정의 해야할 때 반드시 지켜야 할 일반 규약이 있습니다.
이를 어기면 프로그램은 예상과 다르게 동작하거나 종료되고, 문제가 발생하면 원인을 찾기도 매우 힘들어집니다. 그리고 컬렉션 클래스를 포함한 수 많은 클래스들은 전달받은 객체가 equals 규약을 지켰다고 가정하고 동작합니다. 우리가 만드는 클래스들도 물론 포함입니다.
1. 반사성 (null을 제외한 값 x에 대해 x.equals(x) = true)
단순히 객체는 자기 자신과 같아야 하는것을 말합니다. 이 요건을 어긴 클래스의 인스턴스를 컬렉션에 넣은 뒤 contains를 하면 방금 넣은 인스턴스가 없다고 응답하게 됩니다.
2. 대칭성 (null을 제외한 값x,y에 대해 x.equals(y) = y.equals(x)
두 객체는 서로에 대한 동치 여부에 똑같이 답해야 합니다.
// 코드 10-1 잘못된 코드 - 대칭성 위배! (54-55쪽)
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 s = "polish";
List<CaseInsensitiveString> list = new ArrayList<>();
list.add(cis);
System.out.println(list.contains(s));
}
}
위에서 cis.equals(s)는 true를 응답하지만, s.equals(cis)는 false를 응답하게 됩니다.
물론 ArrayList의 원소로 cis를 추가하고, contains(s)를 했을때도 동일합니다.
이는 명백히 대칭성의 위반을 하게 되는 예시입니다. equals 규약을 위반하게 되면 그 객체를 사용하게 되는 다른 객체들이 어떻게 반응할지 알 수 없습니다.
이를 해결하려면 instanceof를 통해 CaseInsensitiveString 클래스인지 판단해야 합니다. (즉 String 객체를 형 변환해 봐야, 해결할 수 없는 문제)
3.추이성 (null을 제외한 값 x,y,z에 대해, x.equals(y) = y.equals(z) = z.equals(x)
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;
}
}
public class ColorPoint extends Point {
private final Color color;
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
// 코드 10-2 잘못된 코드 - 대칭성 위배! (57쪽)
@Override public boolean equals(Object o) {
if (!(o instanceof ColorPoint))
return false;
return super.equals(o) && ((ColorPoint) o).color == color;
}
public static void main(String[] args) {
// 첫 번째 equals 메서드(코드 10-2)는 대칭성을 위배한다. (57쪽)
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);
System.out.println(p.equals(cp) + " " + cp.equals(p));
}
}
Point를 상속한 ColorPoint에서는 새로운 필드인 Color가 추가되었습니다. 하지만 여기서 위와 같이 구현하게 되면 p.equals(cp)는 true를 cp.equals(p)는 false를 반환합니다. ColorPoint.equals가 Point와 비교할 때 색상을 무시하게 되면 해결이 될까요?
// 코드 10-3 잘못된 코드 - 추이성 위배! (57쪽)
@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;
}
public static void main(String[] args) {
// 두 번째 equals 메서드(코드 10-3)는 추이성을 위배한다. (57쪽)
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));
}
이 코드의 실행 결과는 p1.equals(p2)는 true, p2.equals(p3)도 true지만 p1.equals(p3)는 false가 출력되어 추이성을 위배하게 됩니다. p1의 Color는 RED, p2의 Color는 BLUE니까요.
구체 클래스를 확장해서 새로운 값을 추가한다면 equals 규약을 만족시킬 방법은 없습니다. 객체 지향적 추상화의 이점을 포기하지 않은 이상 말이죠. 이 말은 equals안에 instanceof 검사를 getClass 검사로 바꾸면 규약도 지키고, 값도 추가하며, 구체 클래스를 상속할 수 있다는 해결방법 처럼 들립니다.
// 잘못된 코드 - 리스코프 치환 원칙 위배!
@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;
}
이 코드는 리스코프 치환 원칙을 위배하게 됩니다. 같은 구현 클래스와 비교할 때만 true를 반환합니다. 좋아보이지만, Point의 하위 클래스는 정의상 Point이므로 어디서든 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(); }
}
// CounterPoint를 Point로 사용하는 테스트 프로그램
public class CounterPointTest {
// 단위 원 안의 모든 점을 포함하도록 unitCircle을 초기화한다. (58쪽)
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);
// true를 출력한다.
System.out.println(onUnitCircle(p1));
// true를 출력해야 하지만, Point의 equals가 getClass를 사용해 작성되었다면 그렇지 않다.
System.out.println(onUnitCircle(p2));
}
}
리스코프 치환 원칙(Liskov Substitution Principle)에 따르면, 어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요합니다. 따라서 그 타입의 모든 메서드가 하위 타입에서도 모두 잘 작동해야 합니다. '이는 Point는 어디서든 Point로 잘 활용되야 한다.'를 뜻합니다.
하지만 Point에서 equals를 getClass를 사용해 작성했다면, 이 코드는 false를 반환하게 됩니다. 이유는 onUnitCircle에서 사용한 Set을 포함 대부분 컬렉션에서 equals를 이용하는데 CounterPoint의 인스턴스는 어떤 Point와도 같을 수 없기 떄문입니다.
이렇게 구체 클래스를 상속해 추가 할 방법은 없지만 상속 대신 컴포지션을 사용하면 우회할 수 있습니다.
// 코드 10-5 equals 규약을 지키면서 값 추가하기 (60쪽)
public class ColorPoint {
private final Point point;
private final Color color;
public ColorPoint(int x, int y, Color color) {
point = new Point(x, y);
this.color = Objects.requireNonNull(color);
}
/**
* 이 ColorPoint의 Point 뷰를 반환한다.
*/
public Point asPoint() {
return point;
}
@Override public boolean equals(Object o) {
if (!(o instanceof ColorPoint))
return false;
ColorPoint cp = (ColorPoint) o;
return cp.point.equals(point) && cp.color.equals(color);
}
@Override public int hashCode() {
return 31 * point.hashCode() + color.hashCode();
}
}
4.일관성 (null을 제외한 값 x,y에 대해 x.equals(y)를 반복해서 호출하면 항상 true나 false중 하나만 반환
두 객체가 같다면 (어느 하나가 수정되지 않는 한) 앞으로 영원히 같아야 합니다. 가변 객체는 비교 시점에 따라 달라질 수 있지만, 불변 객체는 한번 다르면 끝까지 달라야 합니다.
클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안됩니다.
java.net.URL의 equals는 주어진 URL과 매핑된 호스트의 IP 주소를 이용해 비교합니다. 호스트 이름을 IP 주소로 바꾸려면 네트워크를 통해야 하는데, 그 결과가 항상 같다고 보장할 수 없습니다. 이는 URL의 equals가 일반 규약을 어기게 하고, 실무에서도 종종 문제를 일으킵니다. 이렇게 구현한것은 큰 실수니 절대 따라하면 안됩니다.
/**
* The host's IP address, used in equals and hashCode.
* Computed on demand. An uninitialized or unknown hostAddress is null.
*/
private transient InetAddress hostAddress;
하위 호환성이 발목을 잡아 잘못된 동작을 바로잡을 수도 없습니다. 이런 문제를 피하려면 equals는 항시 메모리에 존재하는 객체만을 사용한 결정적 계산만 수행해야 합니다.
5. null아님 (null을 제외한 값 x에 대해 x.equals(null)은 false다.
이름처럼 모든 객체가 null과 같지 않아야 한다는 뜻입니다. o.equals(null)이 true일 수는 없겠지만 NPE가 발생할 수 있어 입력시 if(o == null) return false; 와 같이 null인지를 확인해 보호하여야 합니다.
하지만 이러한 검사는 필요없고 if(!(o instanceof MyType)) return false;와 같은 코드를 통해 instanceof가 첫 번째 피연산자가 null이면 false를 반환하여 저렇게 명시적인 null 검사는 하지 않아도 됩니다.
✨올바른 equals 메서드 구현 방법
- == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
- instanceof 연산자로 입력이 올바른 타입인지 확인한다.
- 입력을 올바른 타입으로 형변환한다.
- 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다.
- hashcode도 반드시 재정의한다.
- 너무 복잡하게 해결하려 들지 말자. 필드들의 동치성만 검사해도 equals 규격을 어렵지 않게 지킬 수 있다.
- Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자.
equals를 작성하고 테스트 하는 일은 매우 지루하고 코드도 항상 뻔합니다.
이를 대신해줄 오픈소스로 구글에서 만든 AutoValue 프레임워크를 사용해서 클래스에 어노테이션만 달면 알아서 테스트 메서드를 작성해주고 수정되어도 자동으로 작성됩니다.
꼭 필요한 경우가 아니면 equals를 재정의하지 말자.
많은 경우 Object의 equals가 여러분이 원하는 비교를 정확히 수행해줍니다.
재정의해야 할 때는 그 클래스의 핵심 필드 모두를 빠짐없이 다섯 가지 규약을
확실히 지켜가며 비교해야 합니다.
❤️ 스터디 질답 정리
Q. 값 클래스면 equals를 재정의하지 않아도 되는 경우에 예시는 뭐가 있을까요?
A. 객체 식별성과 논리적 동치성이 같은 의미를 지닐 때, 즉 인스턴스 통제 클래스이면서 하나의 객체만 생성할 수 있도록 하는 싱글톤과 같은 형태인 경우에는 값 클래스로 사용하면서 equals를 정의하지 않아도 됩니다.
Q. Point와 Point를 상속한 서브클래스인 CounterPoint를 contains로 비교할 때 왜 실패하게 되나요?
onUnitCircle에서 사용한 Set을 포함 대부분 컬렉션에서 equals를 이용하는데 CounterPoint의 인스턴스는 어떤 Point와도 같을 수 없기 떄문입니다.
Q. 버츄얼 호스팅이 뭔가요?
A. 하나의 서버에 여러개의 도메인을 연결하는것
Q. equals 메서드를 구현할 때 null-아님은 왜 검사하지 않나요?
A. instanceof가 첫 번째 피연산자가 null이면 false를 반환
'Study > 이펙티브 자바' 카테고리의 다른 글
[Effective Java 3E] toString을 항상 재정의하라 (0) | 2023.09.01 |
---|---|
[Effective Java 3E] equals를 재작성하려거든 hashcode도 재정의하라 (0) | 2023.08.31 |
[Effective Java 3E] try-finally 보다는 try-with-resources를 사용하라 (0) | 2023.08.26 |
[Effective Java 3E] finalizer와 cleaner 사용을 피하라 (0) | 2023.08.25 |