<aside> <img src="/icons/help-alternate_gray.svg" alt="/icons/help-alternate_gray.svg" width="40px" />
Object는 객체를 만들 수 있는 구체 클래스지만 기본적으로는 상속해서 사용하도록 설계되었다. Object에서 final이 아닌 메서드는 모두 오버라이딩를 염두에 두고 설계된 것이라 재정의 시 지켜야 하는 일반 규약이 명확히 정의되어 있다. 메서드를 잘못 구현하면 대상 클래스가 이 규약을 준수한다고 가정하는 클래스를 오동작하게 만들 수 있다. 3장에서는 final이 아닌 Object 메서드들을 언제 어떻게 재정의해야 하는지를 다룬다.
</aside>
/제목재정의 하지 않아도 되는 경우
각 인스턴스가 본질적으로 고유할 때
값 클래스(Integer
나 String
처럼 값을 표현하는 클래스)가 아닌 동작하는 개체를 표현하는 클래스
ex) Thread
인스턴스의 ‘논리적 동치성’을 검사할 일이 없을 때
ex) java.util.regax.Pattern
은 equals
를 재 정의해 두 Pattern
의 정규표현식을 비교
상위 클래스에서 재 정의한 equals가 하위 클래스에도 딱 들어맞을 때
ex) Set
은 AbstractSet
이 구현한 equals
를 상속, List
는 AbstractList
, Map
은 AbstractMap
클래스가 private이나 package-private이고 equals를 호출할 일이 없을 때 아래와 같이 구현해 equals가 실수로라도 호출되는 걸 막을 수 있다.
@Override public boolean equals(Object o) {
throw new AssertionError(); // 호출 금지
}
재정의 해야 하는 경우
equals
가 논리적 동치성을 비교하도록 재정의 되지 않았을 때 (주로 값 클래스)ex) 두 값 객체를 equals
로 비교하는 경우, 객체가 같은지가 아니라 값이 같은지를 알고싶을 것이다.
equals
가 논리적 동치성을 확인하도록 재정의하면, 값 비교는 물론 Map
의 키와 Set
의 원소로 사용할 수 있게 된다.
하지만 값 클래스여도 같은 인스턴스가 둘 이상 만들어지지 않는 인스턴스 통제 클래스라면 재정의하지 않아도 된다. Enum도 여기에 해당한다. 이런 클래스에서는 어차피 논리적으로 같은 인스턴스가 2개 이상 만들어지지 않으니 논리적 동치성과 객체 식별성이 사실상 똑같은 의미가 된다. 따라서 Object의 equals가 논리적 동치성까지 확인 해준다고 볼 수 있다.
equals 메서드를 재정의할 때 반드시 지켜야 하는 일반 규약
먼저 Object 명세에서 말하는 동치관계란? 두 객체가 서로 같은지(동등한지)를 정의하는 방법이다. 쉽게 말해, 동치관계는 객체를 비교할 때 논리적으로 같은지 여부를 확인할 기준을 제공하는 개념으로 집합을 서로 같은 원소들로 이뤄진 부분집합으로 나누는 연산으로 본다면 이 부분집합을 동치류(equivalence class; 동치 클래스)라 한다. equals 메서드가 쓸모 있으려면 모든 원소가 같은 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야 한다.
이제 동치관계를 만족시키기 위한 다섯 요건을 살펴보자. 1. 반사성
> `null`이 아닌 모든 참조 값 x에 대해, `x.equals(x)`는 `true`다.
>
단순히 말하면 객체는 자기 자신과 같아야 한다.
```java
public class ProgrammingLanguage {
private String name;
public ProgrammingLanguage(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true; // 반사성 만족
if (o == null || getClass() != o.getClass()) return false;
ProgrammingLanguage that = (ProgrammingLanguage) o;
return name.equals(that.name);
}
@Override
public int hashCode() {
return name.hashCode();
}
public static void main(String[] args) {
Set<ProgrammingLanguage> set = new HashSet<>();
ProgrammingLanguage language = new ProgrammingLanguage("java");
set.add(language);
System.out.println(set.contains(language)); // true 출력 (반사성 만족)
}
}
```
2. 대칭성
> `null`이 아닌 모든 참조 값 x, y에 대해, `x.equals(y)`가 `true`면 `y.equals(x)`도 `true`다.
>
두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다.
```java
//잘못된 코드 - 대칭성 위배
// 대칭성을 위반한 클래스
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`의 존재를 모른다는 데 있다. 대칭성을 명백히 위반한다.
```java
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";
cis.equals(s); // true
s.equals(cis); // false
```
**해결 - `CaseInsensitiveString`끼리만 비교하도록 한다.**
```java
//대칭성을 만족하게 수정
@Override public boolean equals(Object o){
return o instanceof CaseInsensitiveString && ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
```
3. 추이성
> `null`이 아닌 모든 참조 값 x, y, z에 대해, `x.equals(y)`가 `true`이고, `y.equals(z)`도 `true`면 `x.equals(z)`도 `true`다.
>
첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체와 세 번째 객체도 같아야 한다.
상위 클래스에 없는 새로운 필드를 하위 클래스에 추가하며 `equals`를 재정의할 때 자주 발생하는 문제다.
예시로 살펴보자.
```java
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;
}
...
}
```
```java
public class ColorPoint extends Point {
private final Color color;
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
...
}
```
equals 메서드는 어떻게 해야 할까? 그대로 둔다면 Point의 구현이 상속되어 색상 정보는 무시한 채 비교를 수행한다.
```java
//비교 대상이 또 다른 ColorPoint이고 위치와 색상이 같을 때만 true를 반환
@Override public boolean equals(Object o) {
if(!o instanceof ColorPoint)
return false;
return super.equals(o) && ((ColorPoint) o).color == color;
}
//잘못된 코드 - 대칭성 위배!
```
```java
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);
p.equals(cp); // true
cp.equals(p); // false
```
색상을 무시하도록 하면 해결될까?
```java
@Override public boolean equals(Obejct 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;
}
```
```java
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, 추이성 위배
```
이 방식은 대칭성은 지켜주지만, 추이성을 깨버린다.
또한 이 방식은 무한 재귀에 빠질 위험도 있다.
```java
//SmellPoint.java의 equals
@Override public boolean equals(Obejct o){
if(!(o instanceof Point))
return false;
if(!(o instanceof SmellPoint))
return o.equals(this);
return super.equals(o) && ((SmellPoint) o).color == color;
}
```
```java
ColorPoint p1 = new ColorPoint(1,2, Color.RED);
SmellPoint p2 = new SmellPoint(1,2);
// 1. p1.equals(p2) 호출
// -> ColorPoint의 equals() 실행
// -> if(!(o instanceof ColorPoint)) 통과하지 못함, p2.equals(p1) 호출
// 2. p2.equals(p1) 호출
// -> SmellPoint의 equals() 실행
// -> if(!(o instanceof SmellPoint)) 통과하지 못함, p1.equals(p2) 호출
// 3. 다시 p1.equals(p2) 호출 -> 무한 반복... StackOverflow Error
```
그럼 해법은 무엇일까? 사실 이 현상은 모든 객체 지향 언어의 동치관계에서 나타나는 근본적인 문제다. 구체 클래스를 확장해 새로운 값을 추가하면서 `equals` 규약을 만족시킬 방법은 존재하지 않는다. 객체 지향적 추상화의 이점을 포기하지 않는 한은 말이다.
이 말은 얼핏, `equals` 안의 `instanceof` 검사를 `getClass` 검사로 바꾸면 규약도 지키고 값도 추가하면서 구체 클래스를 상속할 수 있다는 뜻으로 들린다.
```java
@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`를 반환한다.
해당 코드는 리스코프 치환 원칙을 위배하기 때문에 잘못된 코드이다.
해결 1- 상속 대신 컴포지션을 사용하라(`equals` 규약을 지키면서 값 추가하기)
```java
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(){ // view 메서드 패턴
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);
}
...
}
```
`Point`를 상속하는 대신 `Point`를 `ColorPoint`의 *private* 필드로 두고, `ColorPoint`와 같은 위치의 일반 `Point`를 반환하는 뷰(view 메서드)를 *public*으로 추가하는 식이다.
- `ColorPoint` vs `ColorPoint`: `ColorPoint`의 `equals`를 이용하여 color값까지 모두 비교
- `ColorPoint` vs `Point`: `ColorPoint`의 `asPoint`를 이용하여 `Point`로 바꿔, `Point`의 `equals`를 이용해 x, y비교
- `Point` vs `Point`: `Point`의 `equals`를 이용해 x, y값 모두 비교
자바 라이브러리에도 구체 클래스를 확장해 값을 추가한 클래스가 종종 있다.
ex) `java.sql.Timestamp`: `java.util.Date` 확장 후 `nanoseconds` 필드 추가.
→ `Timestamp`의 `equals`는 대칭성을 위배하며, `Date`와 섞어 쓸 때 엉뚱하게 동작할 수 있다. 그래서 `Timestamp` API 설명에는 `Date` 와 섞어 쓸 때의 주의사항을 언급하고 있다.
해결 2- **추상 클래스의 하위 클래스 사용하기**
추상 클래스의 하위 클래스에서는 `equals` 규약을 지키면서도 값을 추가할 수 있다. 상위 클래스의 인스턴스를 직접 만드는 게 불가능하기 때문에, 하위 클래스끼리의 비교가 가능하다.
4. 일관성
> `null`이 아닌 모든 참조 값 x, y에 대해, `x.equals(y)`를 반복해서 호출하면 항상 `true`이거나 `false`다.
>
두 객체가 같다면 (어느 하나 혹은 두 객체 모두가 수정되지 않는 한) 앞으로도 영원히 같아야 한다.
- 가변 객체의 경우 비교 시점에 따라 서로 다를 수도 혹은 같을 수도 있다.
- 불변 객체는 한번 다르면 끝까지 달라야 한다.
- 클래스가 불변이든 가변이든 `equals`의 판단에 신뢰할 수 없는 자원이 끼어들게해서는 안 된다.
ex) `java.net.URL`의 `equals`는 주어진 URL과 매핑된 호스트의 IP주소를 이용해 비교하는데, 호스트 이름을 IP주소로 바꾸려면 네트워크를 통해야 하므로 그 결과가 항상 같다고 보장할 수 없다. → `equals`는 항시 메모리에 존재하는 객체만을 사용한 결정적(deterministic) 계산만 수행해야 한다.
5. **null**-아님
> `null`이 아닌 모든 참조 값 x에 대해, `x.equals(null)`은 `false`다.
>
모든 객체가 `null`과 같지 않아야 한다.
```java
//잘못된 명시적 null 검사 - 필요 없다!
@Override
public boolean equals(Object o) {
if(o == null)
return false;
...
}
```
```java
//올바른 묵시적 null 검사 - 이쪽이 낫다.
@Override
public boolean equals(Object o) {
if(!(o instanceof MyType))
return false;
MyType myType = (MyType) o;
...
}
```
동시성을 검사하려면 `equals`는 건네받은 객체를 적절히 형변환한 후 필수 필드들의 값을 알아내야 한다.
따라서, 형변환에 앞서 `instanceof` 연산자로 입력 매개변수가 올바른 타입인지 검사해야 한다.
입력이 `null`이면 타입 확인 단계에서 `false`를 반환하므로 `null` 검사를 명시적으로 하지 않아도 된다.
정리: 양질의 equals 메서드 구현 방법
==
연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.자기 자신이면 true
를 반환한다. 단순한 성능 최적화용으로 비교 작업이 복잡한 상황일 때 값어치를 한다.instanceof
연산자로 입력이 올바른 타입인지 확인한다.
가끔 해당 클래스가 구현한 특정 인터페이스를 비교할 수도 있다. 이런 인터페이스를 구현한 클래스라면 equals
에서 (클래스가 아닌) 해당 인터페이스를 사용해야한다.
ex) Set
, List
, Map
, Map.Entry
등 컬렉션 인터페이스들instanceof
연산자로 입력이 올바른 타입인지 검사 했기 때문에 이 단계는 100% 성공한다.true
를 반환한다.equals 구현 시 주의할 추가 사항
잘 구현된 예
AutoValue 프레임워크
equals
(hashCode
도 마찬가지)를 작성하고 테스트하는 작업을 대신해줄 오픈 소스로 클래스에 애너테이션 하나만 추가하면 AutoValue가 이 메서드들을 알아서 작성해준다.
핵심 정리
아이템 10의 연장선으로 해당 아이템의 제목을 지키지 않으면, hashCode의 일반 규약을 어기게 되어 클래스의 인스턴스를 컬렉션(HashMap, HashSet …)의 원소로 사용할 때 문제를 일으킬 것이다.