[Effective Java] equals는 일반 규약을 지켜 재정의하라

equals의 재정의

equals는 다음과 같은 상황 중 하나에 해당한다면 재정의 하지 않는 것이 최선이다.

각 인스턴스가 본질적으로 고유하다

값을 표현하는게 아니라 동작하는 개체를 표현하는 클래스가 여기에 해당된다. Thread가 좋은 예이다.

인스턴스의 논리적 동치성을 검사할 일이 없다

애초에 클래스의 논리적 동치성(logical equality)를 검사할 일이 없다면 Object의 기본 equals 만으로 해결된다.

상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다

예를들어 Set 구현체는 AbstractSet이 구현한 equals를 쓰고 List의 구현체는 AbstractList로 부터, Map은 AbstractMap으로부터 상속받아 그대로 쓴다.

클래스가 private이거나 package-private이고 equals메소드를 호출할 일이 없다

실수로라도 equals가 호출되는것을 방지하고자 한다면 다음과 같이 코드를 작성하면 된다.

1
2
3
4
@Override
public boolean equals(Object o) {
throw new AssertionError(); //호출 금지!
}

equals의 재정의

equals의 재정의는 객체 식별성이 아닌 논리적 동치성을 확인하는 상황에서 클래스의 equals가 논리적 동치성을 확인하지 않도록 정의되었을 때 재정의한다. 주로 값 클래스들이 이에 해당된다. 인스턴스는 값을 비교하길 원하므로 프로그래머의 기대에 부응은 물론 Map의 키와 Set의 원소로 사용할 수 있게 된다. equals 메소드를 재정의할 때는 반드시 일반 규약을 따라야 한다.

equals의 규약

equals 메소드는 동치관계(equivalence relation)을 구현하며 다음을 만족한다. 동치관계란 집합을 서로 같은 원소들로 이루어진 부분집합으로 나누는 연산이다.

반사성(reflexivity)

null이 아닌 모든 참조 값 x에 대해 x.equals(x)는 true다.

대칭성(symmetry)

null이 아닌 모든 참조 값 x, y에 대해 x.equals(y)가 true면 y.equals(x)도 true다

다음은 대칭성을 위배한 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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;
}
// do Something...
}
1
2
CaseInsensitiveString cis = new CaseInsensittiveString("Polish");
String s = "polish";

이는 cis.equals(s)는 true를, s.equals(cis)는 false를 반환한다. 그렇기 때문에 다음과 같이 수정해야 한다.

1
2
3
4
5
@Override
public boolean equals(Object o) {
return o instanceof CaseInsensitiveString &&
((CaseInsensitiveString) o).s.equalsIgnoreCases(s);
}

추이성(transitivity)

null이 아닌 모든 참조 값 x, y, z에 대해 x.equals(y)가 true이고 y.equals(z)도 true이면 x.equals(z)도 true다.

2차원 좌표를 표현하는 다음과 같은 클래스를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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;
}

// do Something
}

이제 이를 확장한 ColorPoint 클래스를 보자.

1
2
3
4
5
6
7
8
9
10
class ColorPoint extends Point {
private final Color color;

public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}

// do Something
}

이제 equals 메소드를 어떻게 해야할까? 그대로 둔다면 equals 규약을 어긴것은 아니지만 중요한 정보를 놓치는 옳지 못한 상황이다. 다음은 어떨까?

1
2
3
4
5
6
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint))
return false;
return super.equals(o) && this.color == ((ColorPoint) o).color;
}

이 메소드는 대칭성을 위반한다. 그럼 이거는 어떨까?

1
2
3
4
5
6
7
8
9
10
11
12
@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;
}

이는 대칭성은 지켜주지만 추이성은 위배한다. 그럼 해법은 무엇일까? 사실 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다. 이 말은 마치 equals 안의 instanceof 검사를 getClass 검사로 바꾸면 규약도 지키고 값도 추가하면서 구체 클래스를 상속할 수 있는 것 처럼 들린다. 그럼 이거는 어떨까?

1
2
3
4
5
6
7
@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;
}

이번 equals는 LSP를 위반한다. Point의 하위 클래스는 정의상 여전히 Point이므로 어디서든 Point로써 활용될 수 있어야 하지만 이는 그렇지 못하다. 이는 상속 대신 컴포지션을 사용하면 해결할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class 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);
}
}

일관성(consistency)

null이 아닌 모든 참조 값 x, y에 대해 x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.

not null

null이 아닌 모든 참조 값 x에 대해 x.equals(null)은 false다.

1
2
3
4
5
6
7
8
// 묵시적 null 검사
@Override public boolean equals(Object o) {
if(!(o instanceof MyClass))
return false;
MyClass mc = (MyClass) o;

// do Something
}

equals 구현 방법

  1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
  2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.
  3. 입력을 올바른 타입으로 형변환한다.
  4. 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사한다.

float와 double을 제외한 기본 타입 필드는 == 연산자로 비교하고 참조 타입 필드는 equals로, float와 double은 Float.compare와 Double.compare로 다루어야한다. 이는 부동소수 값을 다뤄야 하기 때문이다. Float.equals와 Double.equals는 오토박싱을 수반할 수 있으니 성능상 좋지 않다.

때로는 null도 정상으로 취급하는 참조 타입인 필드도 있다. 이런 필드는 정적 메서드인 Object.equals(Object, Object)로 비교해 NullPointerException 발생을 예방하자.

어떤 필드를 먼저 비교하느냐가 equals의 성능을 좌우하기도 한다. 최상의 성능을 바란다면 다를 가능성이 더 크거나 비교하는 비용이 싼 필드를 먼저 비교하자. 동기화용 락과 같이 객체의 논리적 상태와 관련 없는 필드는 비교하면 안된다.

주의사항

  1. equals를 재정의할 땐 hashCode도 반드시 재정의하자
  2. 너무 복잡하게 해결하려 들지 말자
  3. Object 외의 타입을 매개변수로 받는 equals 메소드는 선언하지 말자.

특히 3번 같은 경우는 Override가 아닌 Overloading이 된다. 이를 예방하려면 @Override 애노테이션을 적극 활용하자.

equals를 작성하고 테스트하는 일은 지루하고 테스트도 뻔하다. 구글의 AutoValue 프레임워크를 사용할 수 있다. 혹은 Lombok의 @EqualsAndHashCode도 있다.

Author: Song Hayoung
Link: https://songhayoung.github.io/2020/08/06/Languages/Effective%20JAVA/item10/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.