값은 표현하는 것이 아닌 동작을 표현하는 개체는 equals를 재정의할 이유가 없다. 그 중 하나의 예는 Thread이다.
프로그래밍의 기본 원칙중 하나로 굳이 사용하지 않을 메서드는 구현하지 않는 것이다. equals를 비교할 일이 없는데 구현하는 것은 오버 프로그래밍이다.
상속받은 하위 클래스의 경우 super.equals가 딱 들어 맞는다면 굳이 구현할 필요없다. 실제 예시는 java의 AbstractList, AbstractMap등이 있다.
만약 호출 자체를 강제로 막고 싶다면 equals를 override에서 예외를 던지면 된다.
@Override
public boolean equals(Object o) {
throw new AssertionError();
}
주로 값 객체의 경우 값의 비교를 통해 논리적 동치성을 비교한다. 이런 경우에 equals를 재정의한다.
equals를 구현하기 위해서는 다음과 같은 동치 관계를 만족해야한다.
반사성과 일관성을 유사해서 묶었다. 자기자신은 항상 참이고 그 결과는 불변해야한다. VO 객체를 살펴보자. VO의 규칙은 다음과 같다.
만약 불변으로 정의하지 않는다면 변수는 매우 불안정해지고 equals와 hashCode를 활용하는 hashSet, hashMap이 값 객체의 안정성을 보장할 수 없다. 그렇기에 final로 정의해서 사용한다.
동등한 객체가 아닌 서로 다른 객체를 비교할 때 이런 오류를 범한다. 객체는 항상 동등
한 클래스 레벨에서 비교해야 안전하다. 실수할 수 있는 오류를 살펴보자
public class Title {
private final String name;
public Title(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (o instanceof Title) {
return name.equals(((Title)o).name);
}
if (o instanceof String) {
return name.equals(o);
}
return false;
}
@Override
public int hashCode() {
return Objects.hash(name);
}
}
지금 보면 동등한 객체거나 하위 객체이면 내부 값 객체를 비교하고, String 값이면 String와 Title 객체의 equals를 비교한다. 그러나 이것은 대칭성을 만족하지 않는다.
class SolutionTest {
@Test
void test() {
Title title = new Title("name");
assertThat(title.equals("name")).isTrue(); // 성공
assertThat("name".equals(title)).isTrue(); // 실패
}
}
당연한 결과이기는 하지만 실패한다. 대칭성을 만족하지 않으므로 equals의 적절한 예가 아니다. 혹시 이런 질문을 할 수 있다.
외부의 String와 내부 인스턴스 변수의 동등성을 비교하고 싶은데 어떡하지??
이런 질문 자체가 객체지향스러움에서 벗어나는 행위이다. 객체를 단순히 data transfer object 용도로 사용하는 것이 아니면.. 이것도 equals를 사용하는 것이 아니라 getter로 가져와서 사용한다.
위의 질문의 가장 큰 문제점은 내부 인스턴스 변수가 외부에 노출된다는 것이다. 이런 방법은 바람직하지 않다.
추이성은 3단 논법이 동등성에 적용되느냐의 문제다. 예를 들어
A == B and B ==C 라고 가정하자 당연히 A == C가 성립하지 않을까? 맞다. equals는 위와 같이 추이성을 만족해야 한다. 그러나 equals를 구현하다 보면 실수하는 경우가 있다.. 이와 같은 문제는 특정 인스턴스를 조건문에 따라 비교하기도 하고, 안하는 경우가 있다면 발생한다.
왠만하면 동일 객체 및 필요한 인스턴스는 모두 비교하자.. 이게 제일 안전하다.
동등성 일반규약에는 없었지만 상속하는 경우 리스코프 원칙이 중요하다. 이는 java 라이브러리에서도 실수한 흔적이 있다. 생각보다 자바 고수들도 실수할 수 있는 부분이다.
예를 들어 Point와 이를 상속하는 ColorPoint를 구현했다고 가정하자.
public class Point {
private final int y;
private final int x;
public Point(int y, int x) {
this.y = y;
this.x = x;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Point point = (Point) o;
return y == point.y && x == point.x;
}
@Override
public int hashCode() {
return Objects.hash(y, x);
}
}
public enum Color {
RED,
BLUE,
GREEN,
YELLOW
}
public class ColorPoint extends Point {
private final Color color;
public ColorPoint(int y, int x, Color color) {
super(y, x);
this.color = color;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
if (!super.equals(o)) {
return false;
}
ColorPoint that = (ColorPoint) o;
return color == that.color;
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), color);
}
}
equals와 hashcode는 intellij에서 제공하는 getClass() 형식으로 비교하도록 짯다 문제는 다음의 테스트 코드이다.
class SolutionTest {
@Test
void test() {
Set<Point> set = Set.of(new Point(0, 0),
new Point(0, 1),
new Point(1, 0),
new Point(1, 1));
boolean contains = set.contains(new ColorPoint(0, 0, Color.BLUE));
assertThat(contains).isTrue(); //실패
}
}
Collections에서는 타입의 동등성 비교를 통해 메모리 풀에 저장하는데 ColorPoint는 Point와 비교하는 것이 아닌 본인 클래스와 비교하기 때문이다. 이것의 문제점은 리스코프 원칙에 의하면 상속 클래스는 상속 특징 외에 부모가 가진 특징 모두 활용할 수 있어야 한다. 이 원칙에 의하면 ColorPoint가 Collections에 들어가더라도 여전히 Point로써의 특징 또한 가져야 한다. 이를 위해선 2가지 방법이 존재한다.
gelClass()가 안전하지만 정확한 비교를 하기 때문에 상속에는 적합하지 않다. 그러나instanceof를 활용한 방법은 복잡하다. 그 이유는 HashMap이나 HashSet을 사용하는 경우 equals 뿐만 아니라 hashCode를 활용한다. 그러나 상속 클래스는 멤버 변수가 더 추가되었는데 부모 클래스가 hashCode가 같다고 할 수 있을까? 이렇게 따져 볼 것이 많기 때문에 구현이 쉽지 않다.
class ColorPoint {
private final Point point;
private final Color color;
public ColorPoint(int y, int x, Color color) {
this.point = new Point(y, x);
this.color = color;
}
public Point asPoint() { // view 메서드
return this.point;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
ColorPoint that = (ColorPoint) o;
return Objects.equals(point, that.point) && color == that.color;
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), color);
}
}
좀더 직관적인 코딩이 가능하다. 컴포넌트를 활용해 ColorPoint는 Point라는 Value Object를 가지고 있도록 하는 것이다. 개인적으로 equals hashCode를 쓰는 값 객체같은 경우는 상속보다는 다음과 같은 방식을 선호한다.
IDE를 이용해 자동생성하면 마음이 제일 편하다. Intellij의 경우에는 명시적인 방식으로 equals를 구현하는데 책에서는 묵시적인 방식이 더 간결하고 추천하는 방식이라 한다.
2가지 방식 중 어느 것을 사용해도 상관없다. 다음의 과정을 거치자
equals hashcode는 보통 라이브러리나 IDE를 활용하기 때문에 굳이 테스트할 이유가 없지만 본인이 어쩔수 없이 구현해야 상황이라면 일반 규약 4가지 (반사성, 대칭성, 추이성, 불변성)을 테스트해보자