equals
메서드는 재정의하기 쉬워 보이지만 곳곳에 함정이 도사리고 있어서 끔찍한 결과를 초래할 수 있다. 함정을 피하는 가장 쉬운 길은 아예 재정의하지 않는 것이다.
다음과 같은 상황 중 하나라도 해당하면 재정의하지 않는 것이 좋다.
Thread
가 좋은 예로, Object
의 equals
메서드는 이러한 클래스에 딱 맞게 구현되었다.논리적 동치성(logicl equality)
을 검사할 일이 없다.equals
가 하위 클래스에도 딱 들어맞는다.private
이거나 pakage-private
이고, equals
메서드를 호출할 일이 없다.다음과 같은 상황이면 equals
를 재정의하면 된다.
equals
가 논리적 동치성을 비교하도록 재정의되지 않았을 때Integer
와 String
같은 값 클래스가 여기에 해당한다. 물론 값 클래스라 해도, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하면(아이템 1, Enum
) equals
를 재정의하지 않아도 된다.equals
메서드를 재정의할 때는 동치관계를 구현하며 다음의 Object
명세에 적힌 규약을 반드시 따라야 한다.
null
이 아닌 모든 참조 값 x
에 대해, x.equals(x)
는 true
다.null
이 아닌 모든 참조 값 x
, y
에 대해, x.eqauls(y)
가 true
면 y.equals(x)
도 true
다.null
이 아닌 모든 참조 값 x
, y
, z
에 대해, x.equals(y)
가 true
이고 y.equals(z)
가 true
면 x.equals(z)
도 true
다.null
이 아닌 모든 참조 값 x
, y
에 대해, x.equals(y)
를 반복해서 호출하면 항상 true
를 반환하거나 항상 false
를 반환한다.null
아님: null
이 아닌 모든 참조 값 x
에 대해. x.equals(null)
은 false
다.수많은 클래스는 전달받은 객체가 equals
규약을 지킨다고 가정하고 동작한다.
Object
명세의 동치 관계는 쉽게 말하면 집합을 서로 같은 원소들로 이뤄진 부분집합으로 나누는 연산이다.
단순히 말하면 객체는 자기 자신과 같아야 한다는 뜻이다. 이 규약을 어기는게 더 어렵다.
두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻이다. 다음은 대칭성 위배의 예시 코드이다.
public final class CaseInsensitiveString {
private final String s;
public CaseInsensitiveString(String s){
this.s= Objects.requireNonNull(s);
}
/**
* 대칭성 위반 코드
* @param o
* @return boolean
*/
@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;
}
}
public class Main {
public static void main(String[] args) {
//대칭성 위배
CaseInsensitiveString cis=new CaseInsensitiveString("Polish");
String s="polish";
System.out.println(cis.equals(s));
System.out.println(s.equals(cis));
}
}
true
false
위 코드는 대소문자를 구별하지 않는 문자열을 구현한 클래스이다. 당연히 CaseInsensitiveString
클래스는 equals
는 String
클래스를 대소문자 구별없이 비교해야 하는 것은 알지만 문제는 String
클래스이다. String
클래스의 equals
는 CaseInsensitiveString
을 대소문자 구별없이 비교해야 한다는 것을 모른다. 그래서 출력 결과를 보면 알 수 있듯이 대칭성을 위반한 것이다. 위에서 강조했듯이 수많은 클래스들이 equals
가 규약을 지켜서 구현했다고 가정하고 동작한다. equals
규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없다.
위 코드의 해결법은 String
클래스와 CaseInsensitiveString
을 연동한다는 바보같은 꿈을 버려야 한다. 아래 코드처럼 수정하면 된다.
public final class CaseInsensitiveString {
private final String s;
public CaseInsensitiveString(String s){
this.s= Objects.requireNonNull(s);
}
/**
* CaseInsensitiveString의 equals를 String과 연동한다는 미친 짓은 하지말아야한다. 아래의 코드와 같이 짜면 된다.
* @param o
* @return boolean
*/
@Override
public boolean equals(Object o) {
return o instanceof CaseInsensitiveString && ((CaseInsensitiveString)o).s.equalsIgnoreCase(s);
}
}
public class Main {
public static void main(String[] args) {
CaseInsensitiveString cis=new CaseInsensitiveString("Polish");
String s="polish";
System.out.println(cis.equals(s));
System.out.println(s.equals(cis));
}
}
false
false
추이성은 첫 번째 객체가 두 번째 객체와 같고, 두 번째 객체가 세 번째 객체와 같다면, 첫 번째 객체와 세 번째 객체가 같아야 한다는 뜻이다.
public enum Color {
RED, BLUE, GREEN
}
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;
}
/**
* 대칭성 위반 코드
* @param o
* @return boolean
*/
// @Override
// public boolean equals(Object o) {
// if(!(o instanceof ColorPoint))
// return false;
// return super.equals(o)&&((ColorPoint) o).color==color;
// }
/**
* 추이성 위반 코드
* @param o
* @return boolean
*/
@Override
public boolean equals(Object 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;
}
}
주석처리된 코드와 같이 Point
의 equals
와 색상 정보 비교로 ColorPoint
의 equals
를 구현하면 대칭성 위반이다. Point
의 equals
는 색상 정보를 무시하고 ColorPoint
의 equals
는 입력 매개변수의 클래스 종류가 달라서 매번 false
를 반환하기 때문이다. 그렇다면 색상을 무시하게 하면 어떻게 될까?
public class Main {
public static void main(String[] args) {
// 추이성 위배
ColorPoint colorPoint1=new ColorPoint(1, 2, Color.BLUE);
Point point1=new Point(1, 2);
ColorPoint colorPoint2=new ColorPoint(1, 2, Color.RED);
System.out.println(colorPoint1.equals(point1));
System.out.println(point1.equals(colorPoint2));
System.out.println(colorPoint1.equals(colorPoint2));
}
}
true
true
false
colorPoint1
과 point1
이 서로 같고 point1
과 colorPoint2
가 같다는 결과를 출력하는데 colorPoint1
과 colorPoint2
는 다르다는 결과를 출력하고 있다. ColorPoint
의 equals
는 추이성을 위반하고 있는 equals
인 것이다.
구체 클래스를 확장해 새로운 값을 추가하면서 equals
규약을 만족시킬 방법은 존재하지 않는다.
두 객체가 같다면(어느 하나 혹은 두 객체가 모두가 수정되지 않는 한) 앞으로도 영원히 같아야 한다는 뜻이다. 가변 객체는 비교 시점에 따라 서로 다를 수도 혹은 같을 수도 있지만, 불변 객체는 한번 다르면 끝까지 달라야하고 반대도 마찬가지이다.
클래스가 불변이든 가변이든 equals
의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다. 예를 들어 java.net.URL
의 equals
는 주어진 URL
과 매핑된 호스트의 IP
주소를 이용해 비교한다. 호스트의 이름을 IP
주소로 바꾸려면 네트워크를 통해야 하는데, 그 결과가 항상 같다고 보장할 수 없다. 이는 URL
의 equals
가 일반 규약을 어기게 하고, 실무에서도 종종 문제를 일으킨다.
null
아님규약 이름 그대로 모든 객체가 null
과 같지 않아야 한다는 뜻이다.
==
연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.instanceof
연산자로 입력이 올바른 타입인지 확인한다.false
를 반환한다.어떤 필드를 먼저 비교하느냐가 equals
성능을 좌우하기도 한다. 최상의 성능을 바란다면 다를 가능성이 더 크거나 비교하는 비용이 싼 필드를 먼저 비교하면 좋다.
equals
를 다 구현했다면 세 가지만 자문해보는 것이 좋다. 대칭적인지, 추이성이 있는지, 일관적인지 말이다.
마지막으로 주의해야 하는 사항들이다.
equals
를 재정의할 때는 hashcode
도 반드시 재정의하자equals
규약을 어렵지 않게 지킬 수 있다. 오히려 너무 공격적으로 파고들다가 문제를 일으키기도 한다.equals
메서드는 선언하지 말자. Object 외의 타입을 매개변수로 받는 equals
메서드를 선언하면 오버라이딩이 아니라 오버로딩을 한 것이다. 기본 equals
를 그대로 둔 채 추가한 것일지라도, 타입을 구체적으로 명시한 equals
는 해가 된다. 이 메서드는 하위 클래스에서 @Override
이 긍정 오류를 내게 하고 보안 측면에서도 잘못된 정보를 준다.테스트 코드는 너무 뻔하고 지루하다. 구글에서 만든 AutoValue
프레임워크를 사용하면 지루한 작업을 대신해주고 사람의 부주의로 발생하는 실수를 저지르지 않기 때문에 사용하는 것이 좋다.