이번 아이템의 핵심적인 내용은 다음 한 줄이다.
equals를 다 구현했다면 세 가지만 자문해보자.
대칭적인가? 추이성이 있는가? 일관적인가?
📌 equals 메서드를 재정의하지 않는 경우
equals 메서드는 재정의하기 쉬워 보이지만, 생각 이상으로 신경써야 할 점들이 많아 자칫하면 끔찍한 결과로 이어진다.
따라서, 재정의하지 않아도 되는 경우에는 안 하는 것이 최선이다.
1️⃣ 각 인스턴스가 본질적으로 고유하다.
Integer나 String처럼 값을 표현하는 값 클래스가 아닌 동작하는 개체를 표현하는 클래스.
예를 들어, Thread가 좋은 예로써 Object의 equals 메서드는 이러한 클래스에 딱 맞게 구현되어 있다.
2️⃣ 인스턴스의 '논리적 동치성(logical equality)'을 검사할 일이 없다.
논리적 동치성이란, 객체의 참조 주소가 같은지를 검사하지 않고, 객체의 값(value)이 같은 경우를 나타내는 것이다.
이를테면, 두 개의 Person 객체가 있을 때, 두 인스턴스의 정보(이름, 나이, 주소 등)가 모두 같으면 논리적으로 같은 객체라고 할 수 있다.
그런데 이러한 경우를 생각해보자.
java.util.regex.Pattern에서 equals를 재정의하여 두 Pattern의 인스턴스가 같은 정규표현식을 나타내는지 검사(즉, 논리적 동치성을 검사)하는 방법이 있다.
하지만 설계자가 판단했을 때, 클라이언트가 이 방식을 원하지 않거나 필요하지 않다고 한다면 object의 equals만으로 해결 가능하다.
3️⃣ 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어 맞는다.
예를 들어, Set은 AbstractSet이 구현한 equals를 상속받고, List는 AbstractList, Map은 AbstractMap을 상속받아 그대로 쓴다.
4️⃣ 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다.
equals를 호출할 일이 없다면, 실수로라도 호출되는 것을 막기 위해 다음과 같이 구현해둘 수도 있다.
@Override public boolean equals(Object o) {
throw new AssertionError(); // 호출 금지!
}
📌 equals를 재정의해야 하는 경우
객체 식별성(object identity; 두 객체가 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되어 있지 않았을 때다. (주로 값 클래스들이 해당된다.)
예를 들어, 두 값의 객체를 equals로 비교했을 때, 참조 동등성이 아니라 논리적 동치성을 확인하도록 재정의하면, 값 비교는 물론이고 Map의 key와 Set의 원소로 사용할 수 있다.
그러나, 값 클래스여도 같은 인스턴스가 둘 이상 만들어지지 않는 인스턴스 통제 클래스라면 재정의하지 않아도 된다.
📌 equals 메서드는 동치관계(equivalence relation)를 구현한다.
동치 클래스(equivalence class)는 집합을 서로 같은 원소들로 이루어진 부분집합으로 나누는 연산이다.
equals 메서드가 쓸모 있으려면 모든 원소가 같은 동치류에 속한 어떤 원소와도 비교할 수 있어야 하는데, 다음과 같은 다섯 가지 조건을 만족시켜야 한다.
- 반사성(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다.
📌 반사성(reflexivity)
객체는 자기 자신과 같아야 한다.
일부로 어기지 않는 이상 만족시키지 못 하기가 더 어렵다.
public class Foo{
private String bar;
public Foo(String bar){
this.bar = bar;
}
public static void main(){
Set<Foo> set = new HashSet<>();
Foo foo = new Foo("hello");
set.add(foo);
System.out.println(set.contains(foo)); // false일 경우, 반사성을 만족하지 못하는 경우이다.
}
}
📌 대칭성(symmetry)
두 객체는 서로의 대한 동치 여부에 똑같이 답해야 한다.
대소문자를 구별하지 않는 문자열을 구분하는 CaseInsensitiveString 클래스를 구현한 코드를 살펴보자.
// 대칭성을 위반한 클래스
public final class CaseInsensitiveString{
private final String s;
public CaseInsensitiveString(String s){
this.s = Obejcts.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;
}
}
CaseInsensitiveString의 equals는 String을 알지만, String의 equals는 CaseInsensitiveString의 존재를 모른다.
예를 들어, 다음과 같이 객체를 이용해보자.
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";
cis.equals(s); // true
s.equals(cis); // false
cis.equals(s)는 true를 리턴하지만, s.equals(cis)는 false를 리턴하므로 명백하게 대칭성을 위반한다.
List<CaseInsensitiveString> list = new ArrayList<>();
list.add(cis);
list.contains(s) // OpenJDK에 따라 결과가 달라질 수 있다.
현재의 OpenJDK에서는 다행히 false를 반환하지만, 이건 구현 나름이라 다른 JDK에서도 false를 반환할 것이라는 것을 보장할 수 없다.
즉, equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할 지 예측하기 어렵다.
이 문제를 해결하는 방법은 CaseInsensitiveString의 equals를 String과도 연동하겠다는 허황된 꿈을 버려야 한다.
CaseInsensitiveString끼리만 비교하면 이러한 문제는 발생하지 않는다.
//대칭성을 만족하게 수정
@Override public boolean equals(Object o){
return o instanceof CaseInsensitiveString &&
((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
📌 추이성(transitivity)
첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같으면, 첫 번째 객체와 세 번째 객체도 같아야 한다.
개념은 쉽지만, 자칫하면 어기기도 쉽다.
보통 상위 클래스에 없는 새로운 필드를 하위 클래스에 추가하여 equals를 재정의할 때 자주 발생하는 문제다.
2차원에서 점을 표현하는 Point 클래스를 정의하고, 이 클래스를 확장해서 색상 필드를 가지는 ColorPoint 클래스를 만들어보자.
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;
}
...
}
이 경우 equals 메서드를 어떻게 해야할까?
만약 재정의 하지 않는다면, 색상 정보는 무시한 채 비교 수행을 한다.
equals 규약을 어긴 것은 아니지만, Color라는 중요한 정보를 놓치게 되므로 받아들일 수 없는 상황이다.
이 문제를 해결하기 위한 몇 가지 아이디어가 떠오를 수 있을텐데, 각각의 문제점들을 파헤쳐 보자.
1️⃣ 대칭성 위배
@Override public boolean equals(Object o) {
if(!o instanceof ColorPoint)
return false;
return super.equals(o) && ((ColorPoint) o).color == color;
}
public static void main(){
Point p = new Point(1,2);
ColorPoint cp = new ColorPoint(1,2, Color.RED);
p.equals(cp); // true
cp.equals(p); // false
}
얼핏 보면, 부모 클래스인 Point의 equals와 색상 필드 비교를 위한 color 속성을 비교하고 있으므로 괜찮아 보인다.
하지만 이 메서드는 Point를 ColorPoint에 비교한 결과와 그 둘을 바꾸어 비교한 결과가 다를 수 있다.
Point는 equals의 색상을 무시하고 있고, ColorPoint의 equals는 입력 매개변수의 클래스 종류가 다르다며 매번 false만 반환할 것이다.
그렇다면 이번에 ColorPoint.equals가 Point와 비교할 때는 색상을 무시하도록 하면 해결되지 않을까?
2️⃣ 추이성 위배
@Override public boolean equals(Obejct 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(){
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); // true
p2.equals(p3); // true
p1.equals(p3); // false
}
p1과 p2, p2와 p3는 색상을 비교하지 않으므로 true를 반환하지만, p1과 p3는 색상을 비교하므로 false를 반환한다.
따라서, 이 방식은 추이성에 위배되므로 옳지 않다.
심지어 해당 코드는 무한 재귀에 빠질 위험도 존재한다.
Point의 또 다른 하위 클래스 SmellPoint를 만들고, 같은 방식의 equals를 구현했다고 가정하자.
그리고 myColorPoint.equals(mySmellPoint)를 호출하면 if(!(o instanceof ColorPoint)) 조건문에서 걸려 SmellPoint의 equals를 호출하고, 또 다시 ColorPoint.equals()를 호출하게 됨으로 StackOverflowError를 일으킨다.
이 현상은 모든 객체 지향 언어의 동치관계에서 나타나는 근본적인 문제로써,
구체 클래스를 확장해 새로운값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.
3️⃣ 리스코프 치환 원칙 위배
✒️ 리스코프 치환 원칙 (Liskov Substitution Principle)
개방 폐쇄 원칙을 받쳐 주는 다형성에 관한 원칙을 제공한다.
상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다.
즉, 어떤 타입에 있어 중요한 속성이라면, 그 하위 타입에서도 마찬가지로 중요하다는 의미다.
부모 타입 객체를 상속 받고 있는 하위 객체 하나가 있을 때를 가정해보자.
public class Foo {
...
public void fooMethod() { ... }
...
}
class Bar extends Foo {
...
public void barMethod(Foo f) {
f.fooMethod();
}
...
}
barMethod(new Bar());
상위 타입 Foo 객체를 사용하는 barMethod에 하위 타입 객체를 전달해도 barMethod()는 정상적으로 동작해야 한다.
위에서 강조한 말을 오해하여 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로써 활용될 수 있어야 한다.
// 단위 원 안의 모든 점을 포함하도록 unitCircle을 초기화한다.
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 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 메서드에 넘기면, x,y 값과는 무관하게 false를 반환할 수 있다.
Set을 포함하여 대부분의 컬렉션은 이 작업에 equals 메서드를 이용한다.
Point 클래스의 equals를 getClass로 정의했다면, CounterPoint의 인스턴스는 어떤 Point와도 같을 수가 없다.
반면, Point의 equals를 instanceof 기반으로 올바르게 구현했다면 CounterPoint 인스턴스를 건네줘도 onUnitCircle 메서드가 제대로 동작한다.
따라서, instanceof 사용을 유지하되 다른 우회 방법을 선택해야만 한다.
1️⃣ 상속 대신 컴포지션을 사용하라 (Item 18)
Point를 상속하는 대신, Point를 ColorPoint의 private 필드로 두고, ColorPoint와 같은 위치의 일반 Point를 반환하는 view 메서드를 public으로 추가하는 방식이다.
public class ColorPoint {
private final Color color;
private final Point point;
public ColorPoint(int x, int y, Color color){
this.point = new Point(x,y);
this.color = Objects.requireNonNull(color);
}
public Point asPoint(){ // ColorPoint의 Point 뷰를 반환한다.
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(cp);
}
...
}
- ColorPoint와 ColorPoint : ColorPoint.equals()로 color까지 비교
- ColorPoint와 Point : ColorPoint의 asPoint를 이용해 Point로 바꾸어, Point.equals로 x,y 비교
- Point와 Point : Point의 equals로 x, y값 비교
2️⃣ 추상 클래스의 하위 클래스를 사용하라
상위 클래스의 인스턴스를 직접 만드는 게 불가능하기 때문에, 하위 클래스끼리 비교가 가능하다.
📌 일관성(consistency)
두 객체가 같다면 (어느 하나 혹은 두 객체가 모두가 수정되지 않는 한) 앞으로도 영원히 같아야 한다.
가변 객체의 경우에는 비교 시점에 따라 서로 다를 수도 혹은 같을 수도 있지만, 불변 객체는 한 번 다르면 끝까지 달라야 한다.
하지만 클래스가 불변이고 가변이고를 떠나서, equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다.
예를 들어, java.net.URL의 equals는 주어진 URL과 매핑된 호스트이 IP주소를 이용해서 비교하는데,
호스트 이름을 IP주소로 바꾸려면 네트워크를 통해야 하므로 그 결과가 항상 같다고 보장할 수가 없다.
따라서, equals는 항시 메모리에 존재하는 객체만을 사용한 결정적(deterministic)인 계산만 수정해야 한다.
📌 null-아님
모든 객체가 null과 같지 않아야 한다.
// 명시적 null 검사 -> 필요 없다!
@Override public boolean equals(Object o) {
if (o == null)
return false;
...
}
// 묵시적 null 검사 -> 이게 낫다.
@Override public boolean equals(Object o) {
if(!(o instanceof MyType))
return false;
MyType mt = (MyType) o;
...
}
어차피 동치성 검사를 위해서, equals는 건네받은 객체를 instanceof 연산자로 형변환이 가능한지를 알아내야 한다.
즉, 입력이 null이면 어차피 타입 확인 단계에서 false를 반환하므로 null 검사를 명시적으로 하지 않아도 된다.
📌 equals 메서드 구현 방법 단계
1️⃣ == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
자기 자신이면 true를 반환하는데, 단순 성능 최적화 용이다. 비교 작업이 복잡한 상황일 때 사용하면 값어치를 한다.
2️⃣ instanceof 연산자로 입력이 올바른 타입임을 확인한다.
가끔 해당 클래스가 구현한 특정 인터페이스를 비교할 수도 있다.
이런 인터페이스 구현 클래스라면 equals에서 클래스가 아닌 해당 인터페이스를 사용해야 한다.
ex. Set, List, Map, Map.Entry 등 컬렉션 인터페이스들
3️⃣ 입력을 올바른 타입으로 형변환한다.
2번에 의해 이 단계는 100% 성공을 보장한다.
4️⃣ 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다.
모두 일치해야 true를 반환한다.
📌 equals 구현 시 주의할 사항
1️⃣ 기본 타입은 == 연산자로 비교하고, 참조 타입은 equals 메서드로 비교하라.
2️⃣ float, double 필드는 정적 메서드 Float.compare(float, float)과 Double.compare(double, double)로 비교하라.
Float.NaN, -0.0f, 특수한 부동 소수 값으로 인해 특별 취급 해야하나 equals 메서드를 사용하면 오토 방식을 수반하므로 성능상 이슈가 발생할 수 있다.
3️⃣ 배열 필드는 원소 각각을 지침대로 비교하고, 모두가 핵심 필드라면 Arrays.equals()를 사용하라
4️⃣ null 정상값 취급을 방지하라
Object.equals(obj, obj)로 비교하여 NullPointException 발생을 예방하자.
5️⃣ 비교하기 복잡한 필드를 가진 클래스는 그 필드의 표준형(canonical form)을 저장해둔 후 표준형끼리 비교하라
가변 객체라면 값이 바뀔 때마다 표준형을 최신 상태로 갱신해주어야 한다.
6️⃣ 필드의 비교 순서는 equals 성능을 좌우한다.
다를 가능성이 크거나 비교 비용이 싼 필드부터 우선 비교하자.
혹여, 파생 필드가 객체 전체 상태를 대표하는 경우, 파생 필드부터 비교하자.
7️⃣ equals를 재정의할 때는 hashcode도 반드시 재정의하라. (Item 11)
8️⃣ 너무 복잡하게 해결하려 들지 마라
필드 동치성만 검사해도 equals 규약은 어렵지 않게 지킬 수 있다. 일반적으로 별칭(alias)은 비교하지 않는 게 좋다.
9️⃣ Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 마라
// 입력 타입은 Object여야 한다!
// 이 경우 Override가 아니라 Overload가 된다.
public boolean equals(MyClass o) { ... }
// 잘못 작성했으므로 컴파일 자체가 되지 않는다. -> 빠르게 오류 감지 가능
@Override public boolean equals(MyClass o) { ... }
📌 AutoValue Framework
equals와 hashcode를 작성하고 테스트하는 작업을 대신 해주는 오픈 소스다.
클래스에 어노테이션 하나만 추가하면, 이 귀찮은 작업들을 AutoValue가 알아서 처리해준다.
비록, IDE가 나중에 클래스가 수정된 것을 자동으로 알아채지는 못하니 테스트 코드를 작성해둬야 한다는 단점이 있긴 하나, 사람이 직접 작성하는 것보다는 IDE에 맡기는 편이 낫다.