equals는 일반 규약을 지켜 재정의하라
Object는 자바의 최상위 클래스이며, 모든 클래스의 부모 역할을 한다. Object의 메서드 중 equals
는 보통 논리적 동치성 즉, String
이나 Integer
처럼 값 클래스 들의 주솟값이 아닌 값을 비교하기 위해 재정의 해서 사용한다.
equals 재정의를 사용하지 않아도 되는 경우
- 각 인스턴스가 본질적으로 고유한 경우
Thread
처럼 값을 표현하는 것이 아닌 동작하는 개체를 표현하는 클래스는 Object 클래스의 equals 메서드에서 기본적으로 제공한다.
- 논리적 동치성(logical equality)을 검사할 일이 없는 경우
- 상위 클래스에서 재정의한
equals
가 하위 클래스에도 적합한 경우- 예를 들어
Set
인터페이스는AbstractSet
이 구현한equals
를 사용하는 것을 의미한다.
- 예를 들어
- 클래스가 private하거나 default인 경우
equals 재정의를 사용하는 경우
값을 비교하기 위해서 주솟값 비교인 객체 식별 확인이 아닌, 논리적 동치성을 확인할 때를 의미한다. 값 클래스의 경우에도 싱글톤과 같은 인스턴스 통제 클래스이거나, Enum의 경우에는 매번 다른 인스턴스를 생성하기 때문에 재정의를 하지 않아도 된다.
따라서, 재정의를 하는 경우 다음과 같은 규약을 따른다.
- 반사성(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)를 반복해서 호출하면 항상 같은 값을 반환한다.
- null-아님 : null이 아닌 모든 참조 값 x에 대해 x.equals(null)은 항상 false다.
대칭성
import java.util.Objects;
public class EqualsTest {
public static void main(String[] args) {
CaseInsensitiveString cis = new CaseInsensitiveString("a");
String s = "a";
System.out.println(cis.equals(s)); // true
System.out.println(s.equals(cis)); // false
}
}
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;
}
}
- 다음과 같이
CaseInsensitiveString
를 구현하면 대칭성을 위배한다. 그 이유는 String의 equals는 CaseInsensitiveString의 존재를 모르기 때문이다. - 참고로
equalsIgnoreCase
는 대소문자를 구분안하고 비교하는 메서드이다.
@Override
public boolean equals(Object o) {
return o instanceof CaseInsensitiveString && ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
- 다음과 같이
String
과의 연동하겠다는 허황한 꿈을 버리고 해당 인스턴스에 대해서만 비교하는 것이 올바르다. 그렇게 구현한다면 위의 두 방식 모두 false가 반환되어 대칭성을 지킬 수 있다.
추이성
package effective.item10;
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;
}
}
class ColorPoint extends Point {
private final Color color;
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
}
enum Color {
RED, BLUE, GREEN
}
class PointTest {
public static void main(String[] args) {
ColorPoint cp = new ColorPoint(1,1,Color.RED);
ColorPoint cp2 = new ColorPoint(1,1,Color.BLUE);
System.out.println(cp.equals(cp2)); // true : 대칭성 위배
}
}
- 다음과 같이
Point
클래스를 상속받은ColorPoint
가 있다. 상속받아서 재정의된 부모의equals
는 물려 받았지만,Color
에 대한 비교는 이루어지지 않았다.
class ColorPoint extends Point {
private final Color color;
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint))
return false;
return super.equals(o) && ((ColorPoint) o).color == color;
}
}
- 다음과 같이 자식의 입장에서도 color를 비교해주는 기능을 추가하여 해결할 수 있다. 하지만 문제가 발생한다.
ColorPoint cp1 = new ColorPoint(1, 1, Color.RED);
Point p2 = new Point(1, 1);
System.out.println(cp1.equals(p2)); // false
System.out.println(p2.equals(cp1)); // true
- 다음과 같이 비교한다면 대칭성에 문제가 발생한다.
@Override
public boolean equals(Object o) {
if (!(o instanceof Point))
return false;
if (!(o instanceof ColorPoint))
return o.equals(this);
return super.equals(o) && ((ColorPoint) o).color == color;
}
class PointTest {
public static void main(String[] args) {
ColorPoint cp1 = new ColorPoint(1, 1, Color.RED);
Point p2 = new Point(1, 1);
ColorPoint cp3 = new ColorPoint(1, 1, Color.BLUE);
System.out.println(cp1.equals(p2));
System.out.println(p2.equals(cp3));
System.out.println(cp1.equals(cp3));
}
}
- 다음과 같이 Point로 오는 경우 자기 자신을 비교하여 무시하면 대칭성은 해결할 수 있다. 하지만 추이성이 깨져버린다. 그 이유는 cp1과 cp3의 비교에서는 색상까지 비교하기 때문이다.
이 현상은 모든 객체지향 언어의 동치관계에서 나타나는 근본적인 문제다.
구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.
리스코프 치환 원칙 위배
- 얼핏보면 instanceof 검사를 getClass 검사로 바꾸면 해결될 것처럼 보인다. 하지만 하위 클래스는 부모 클래스로 활용이 될 수없어 리스코프 치환 원칙을 위배하게 된다.
- 예를 들어, Point 클래스에서 새로운 하위 타입인스턴스를 getClass를 사용해 equals를 작성했다면, 비교 시 instanceof는 true를 반환하지만 getClass는 항상 false를 반환한다.
구체 클래스의 하위 클래스에 값을 추가하는 방법이 없다는 결론이 나온다.
하지만, 우회하는 방법이 한 가지 존재한다. 이는 상속이 아닌, 컴포지션(has ~ a) 관계를 사용하는 것이다.
package effective.item10;
import java.util.Objects;
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 ColorPoint) {
Point p = ((ColorPoint) o).asPoint();
return p.x == x && p.y == y;
}
if (!(o instanceof Point))
return false;
Point p = (Point) o;
return p.x == x && p.y == y;
}
}
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) {
ColorPoint cp = (ColorPoint) o;
return cp.point.equals(point) && cp.color.equals(color);
}
else if (o instanceof Point) {
Point p = (Point) o;
return p.equals(point);
}
return false;
}
}
enum Color {
RED, BLUE, GREEN
}
class PointTest {
public static void main(String[] args) {
ColorPoint cp1 = new ColorPoint(1, 1, Color.RED);
Point p2 = new Point(1, 1);
ColorPoint cp3 = new ColorPoint(1, 1, Color.RED);
System.out.println(cp1.equals(p2)); // true
System.out.println(p2.equals(cp3)); // true
System.out.println(cp1.equals(cp3)); // true
}
}
- 다음과 같이 컴포지션을 활용하여
ColorPoint
에서는Point
의 객체인 경우 x,y 만을 비교할 수 있도록 구현할 수 있다. - 자바 라이브러리에서도 구체 클래스를 확장해 값을 추가한 클래스가 종종 있다. 대표적으로
java.sql.Timestamp
가 해당된다. 해당 라이브러리의equals
는Date
객체와 대칭성을 위배한다. 즉, 해당 설계는 오류이다.
추상 클래스의 하위 클래스에서라면 equals 규약을 지키면서도 값을 추가할 수 있다. 예컨데 아무런 값을 갖지 않는 추상 클래스인 Shape를 두고, 이를 확장하여 radius 필드를 추가한 Circle 클래스와, length와 width 필드를 추가한 Rectangle 클래스를 만들 수 있다.
상위 클래스를 직접 인스턴스로 만드는게 불가능하다면 지금까지의 문제들은 발생하지 않는다.
일관성
일관성은 두 객체가 같다면 수정되지 않는 한 영원히 같아야 한다는 의미다. 즉, 불변 객체는 반드시 일관성을 지켜야 한다. 따라서 클래스를 작성할 때 불변 클래스와 가변 클래스 중 심사숙고하는 부분이 필요하다.
불변, 가변 여부를 떠나서 equals
판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안된다. 예를 들어 java.net.URL
의 equals
는 매핑된 URL과 호스트 IP 주소를 이용해 비교한다. 호스트 이름을 IP 주소로 바꾸려면 네트워크를 통해 변경하기 때문에 항상 같다고 보장할 수 없다. 그래서 URL
의 equals
가 일반 규약을 어기게 되고, 실무에서도 종종 문제가 발생하곤 한다.
null 아님
null 아님은 모든 객체가 null
과 같지 않아야 한다는 뜻이다. 의도하지 않았지만 발생할 경우 NPE
를 던지게 될 것이다. 다음 코드처럼 입력이 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;
...
}
- 묵시적 null 검사
올바른 equals 구현 방법
public final class PhoneNumber {
private final short areaCode, prefix, lineNum;
public PhoneNumber(...){...}
@Override
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof PhoneNumber))
return false;
PhoneNumber pn = (PhoneNumber) o;
return pn.lineNum == lineNum && pn.prefix == prefix && pn.areaCode == areaCode;
}
}
==
연산자를 사용하여 입력이 자기 자신의 참조인지 확인한다.instanceof
연산자로 입력이 올바른 타입인지 확인한다.- 입력을 올바른 타입으로 형변환 한다.
- 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 검사한다.
equals를 다 구현했다면 대칭성, 추이성, 일관성 이 세 가지를 자문하고 단위테스트를 통해 검증을 하자. 세 가지 요건 중 하나라도 실패한다면 원인을 찾아서 고치는 습관을 갖자. 반사성과 null 아님도 만족하는 것이 좋지만 이 두 가지 경우가 문제되는 경우는 거의 없다.
꼭 필요한 경우가 아니라면 equals를 재정의하지 말자. equals를 재정의할 땐
hashCode
도 반드시 재정의하자. (다음 포스팅)Object
외의 타입을 매개변수로 받는 equals는 선언하지 말자.