아이템 10. equals는 일반 규약을 지켜 재정의하라. - ksw6169/effective-java GitHub Wiki
개요
- equals는 규약에 따라 재정의되지 않으면 끔찍한 결과를 초래한다. 컬렉션 클래스들을 포함해 수많은 클래스는 전달받은 객체가 equals 규약을 지킨다고 가정하고 동작하기 때문이다. equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없다.
- 잘못 재정의 하는 것을 피하는 가장 쉬운 방법은 equals를 아예 재정의하지 않는 것이다. 하지만 그냥 두면 그 클래스의 인스턴스는 오직 자기 자신과만 같게 된다. 그러니 다음과 같이 열거한 상황 중 하나에 해당된다면 재정의하지 않는 것이 최선이다.
equals를 재정의하지 않아도 되는 상황
1. 각 인스턴스가 본질적으로 고유하다.
- 값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스가 여기에 해당한다.
- Thread가 좋은 예로 Object.equals() 는 이러한 클래스에 딱 맞게 구현되었다.
public boolean equals(Object obj) {
return (this == obj);
}
2. 인스턴스의 논리적 동치성(logical equality) 을 검사할 일이 없다.
- Pattern 클래스는 equals를 재정의해서 두 Pattern의 인스턴스가 같은 정규표현식을 나타내는지 검사하는, 즉 논리적 동치성을 검사하도록 하였다.
- 이런 비교가 필요하지 않다면 Object.equals() 만으로 비교가 가능하다.
3. 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.
- Set, List, Map 구현체들은 각각 AbstractSet, AbstractList, AbstractMap 으로부터 equals를 상속받아 그대로 쓴다.
4. 클래스가 private 이거나 package-private이고 equals 메소드를 호출할 일이 없다.
- equals가 실수로라도 호출되는 것을 막고자 한다면 다음처럼 구현해두는 것이 좋다.
@Override
public boolean equals(Object o) {
throw new AssertionError(); // 호출 금지!
}
equals를 재정의해야 하는 상황
1. 객체 식별성이 아니라 객체의 논리적 동치성을 확인해야 할 때
- 객체 식별성이 아니라 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때 equals를 재정의해야 한다.
- 주로 값 클래스들이 여기에 해당하며 값 클래스란 Integer와 String처럼 값을 표현하는 클래스를 말한다.
- 값 클래스라 해도 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스나 Enum인 경우 논리적으로 같은 인스턴스가 2개 이상 만들어지지 않으니 논리적 동치성과 객체 식별성이 사실상 똑같은 의미가 되므로 equals를 재정의하지 않아도 된다.
객체 식별성(Object identity)
두 객체가 물리적으로 같은지 여부를 의미한다.
equals() 를 재정의할 때 따라야 하는 규약
- equals를 재정의할 때는 반드시 일반 규약을 따라야 한다. 다음은 Object 명세에 적힌 규약이다.
equals 는 동치 관계(equivalence relation) 를 구현하며, 다음을 만족한다.
- 반사성(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)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.
- null 아님 : null이 아닌 모든 참조 값 x에 대해 x.equals(null)은 false다.
동치 관계(equivalence relation)
- 다르게 보이지만, 실제로는 같은 것들을 집합으로 하는 관계를 말한다.
- 어떤 집합에 대해 정의된/성립된 연산이, 반사적, 대칭적, 추이적인 세 조건을 만족하는 경우를 동치 관계에 있다고 한다. 이 연산으로 이루어진 각 분할들은 동치류(equivalence class: 동치 클래스) 를 이루게 된다.
- 즉, 각 부분 집합에서 데이터(원소)들이 서로 간에 특정 연산에 대한 반사적, 대칭적, 추이적인 세 조건을 만족할 때 이를 동치 관계에 있다고 한다.
- equals 메소드가 쓸모 있으려면 모든 원소가 같은 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야 한다.
1. 반사성(Reflexivity)
- 객체는 자기 자신과 같아야 한다는 성질이다.
- 이 요건을 어기면 객체를 컬렉션에 넣은 뒤 contains() 를 호출했을 때 방금 넣은 인스턴스가 없다고 답할 것이다.
null이 아닌 모든 참조 값 x에 대해 x.equals(x)는 true다.
2. 대칭성(Symmetry)
- 두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻이다.
null이 아닌 모든 참조 값 x, y에 대해 x.equals(y)가 true면 y.equals(x)도 true다.
- 아래 예제는 대칭성에 위배되는 예제다.
public 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의 equals로 String을 비교하면 true가 나올 수 있지만, 반대로 String의 equals로 비교하면 false가 나온다. 즉, 한 방향으로만 작동하므로 CaseInsensitiveString의 equals 는 대칭성을 명백히 위반한다.
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";
System.out.println(cis.equals(s)); // true
System.out.println(s.equals(cis)); // false
- 이 문제를 해결하려면 다음과 같이 CaseInsensitiveString 객체간에만 비교가 가능하도록 수정해야 한다.
@Override
public boolean equals(Object o) {
return o instanceof CaseInsensitiveString &&
((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
3. 추이성(Transitivty)
- A, B가 같고, B, C가 같다면 A와 C도 같아야 한다는 뜻이다.
null이 아닌 모든 참조 값 x, y, z에 대해 x.equals(y)가 true이고 y.equals(z)도 true면 x.equals(z)도 true다.
- 아래 예제는 대칭성은 지켜주지만 추이성에 명백히 위배된다.
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;
}
@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;
}
}
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
System.out.println(p1.equals(p2)); // true
System.out.println(p2.equals(p3)); // false
- 이 문제를 해결하는 방법(구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법)은 존재하지 않는다. 대신 "상속 대신 컴포지션을 이용" 하는 방법을 이용해 우회해서 해결할 수는 있다. Point를 상속하는 대신 Point를 ColorPoint의 private 필드로 두고 ColorPoint와 같은 위치의 일반 Point를 반환하는 뷰 메소드를 public으로 추가하는 식이다.
public class ColorPoint {
private final Point point;
private final Color color;
public ColorPoint(int x, int y, Color color) {
this.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))
return false;
ColorPoint cp = (ColorPoint) o;
return cp.point.equals(point) && cp.color.equals(color);
}
...
}
예외 사항
- 추상 클래스의 하위 클래스에서라면 equals 규약을 지키면서도 값을 추가할 수 있다.
- 예를 들어 아무런 값을 갖지 않는 추상 클래스인 Shape를 위에 두고 이를 확장하여 radius 필드를 추가한 Circle 클래스와 length, width를 추가한 Rectangle 클래스를 만들 수 있다.
- 상위 클래스를 직접 인스턴스로 만드는게 불가능하다면 지금까지 이야기한 문제들은 일어나지 않는다.
4. 일관성(Consistency)
- 두 객체가 같다면 (객체가 수정되지 않는 한) 앞으로도 영원히 같아야 한다는 뜻이다.
- 가변 객체는 비교 시점에 따라 서로 다르거나 같을 수 있지만, 불변 객체는 한번 다르면 끝까지 달라야 한다.
null이 아닌 모든 참조 값 x, y에 대해 x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.
- 클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안된다. 이 제약을 어기면 일관성 조건을 만족시키기가 아주 어렵다.
- 예를 들어
java.net.URL
의 equals는 주어진 URL과 매핑된 호스트의 IP 주소를 이용해 비교한다. 호스트 이름을 IP 주소로 바꾸려면 네트워크를 통해야 하는데 그 결과가 항상 같다고 보장할 수 없다. 이는 URL의 equals가 일반 규약을 어기게 하고, 실무에서도 종종 문제를 일으키게 한다. - 이런 문제를 피하려면 equals는 항시 메모리에 존재하는 객체만을 사용한 결정적(deterministic) 계산만 수행해야 한다.
5. null 아님
- 모든 객체가 null과 같지 않아야 한다는 뜻이다.
null이 아닌 모든 참조 값 x에 대해 x.equals(null)은 false다.
- equals에서 비교 대상 객체가 null인지를 확인하기 위해 명시적 null 검사는 필요하지 않다. 대신instanceof 연산자로 입력 매개변수가 올바른 타입인지 검사하면 된다.
- equals가 타입을 확인하지 않으면 잘못된 타입이 인수로 주어졌을 때 ClassCastException을 던지므로 일반 규약을 위배하게 되기 때문이다.
@Override
public boolean equals(Object o) {
/**
* instanceof는 두 번째 피연산자와 무관하게 첫 번째 피연산자가 null이면 false를 반환한다.
* 따라서 o가 null이면 여기서 false로 반환된다.
*/
if (!(o instanceof MyType))
return false;
MyType mt = (MyType) o;
...
}
양질의 equals 메소드 구현 방법 정리
- 다음은 양질의 equals 메소드 구현 방법을 단계별로 정리한 내용이다.
1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
- 자기 자신이면 true를 반환한다. 이는 단순한 성능 최적화용으로 비교 작업이 복잡한 상황일 때 값어치를 한다.
2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.
- 이 때 올바른 타입은 equals가 정의된 클래스가 보통이지만 그 클래스가 구현한 특정 인터페이스가 대상이 될 수도 있다. 어떤 인터페이스는 자신을 구현한 (서로 다른) 클래스끼리도 비교할 수 있도록 equals 규약을 수정하기도 한다. 이런 인터페이스를 구현한 클래스라면 equals에서 클래스가 아닌 해당 인터페이스를 사용해야 한다.
- Set, List, Map, Map.Entry 등의 컬렉션 인터페이스들이 여기에 해당한다.
3. 입력을 올바른 타입으로 형변환한다.
- instanceof 검사 후에 형변환을 수행하면 된다.
4. 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다.
- 모든 필드가 일치하면 true를, 하나라도 다르면 false를 반환한다.
equals 재정의 시 주의 사항
1. 타입별로 다르게 비교하라.
-
float과 double을 제외한 기본 타입 필드 : == 연산자로 비교
-
참조 타입 필드 : 각각의 equals 메소드로 비교
-
float과 double 필드 : Float.compare(float, float), Double.compare(double, double) 로 비교
- float과 double은 Float.NaN, -0.0f, 특수한 부동소수값 등을 다루기 때문에 다른 필드들과는 다르게 비교한다.
- Float과 Double의 equals를 대신 사용할 수 있지만 이는 오토박싱을 수반하므로 성능이 저하될 수 있다.
-
배열 필드 : 원소 각각을 앞의 지침대로 비교한다. 모든 원소가 핵심 필드라면 Array.equals 메소드들 중 하나를 사용하라.
2. null을 정상 값으로 취급하는 참조 타입 필드이 있으니 주의하라.
- 이런 필드는 정적 메소드인 Objects.equals(Object, Object)로 비교해 NPE를 방지하자.
3. 비교하기 복잡한 필드를 가진 클래스는 표준형으로 비교하라.
- 비교하기가 아주 복잡한 필드를 가진 클래스도 있다. 이럴 때는 그 필드의 표준형(canonical form)을 저장해둔 후 표준형끼리 비교하면 훨씬 경제적이다.
- 이 기법은 특히 불변 클래스에 제격이며 가변 객체라면 값이 바뀔 때마다 표준형을 최신 상태로 갱신해줘야 한다.
4. 성능 향상을 위해 다를 가능성이 더 크거나 비교하는 비용이 싼 필드를 먼저 비교하자.
- 동기화용 락(lock) 필드 같이 객체의 논리적 상태와 관련 없는 필드는 비교하면 안된다. 핵심 필드로부터 계산해낼 수 있는 파생 필드 역시 비교할 필요는 없지만, 파생 필드를 비교하는 쪽이 더 빠를 때도 있다. 파생 필드가 객체 전체의 상태를 대표하는 상황이 그렇다.
- 예를 들어 자신의 영역을 캐시해두는 Polygon 클래스가 있다고 하면 모든 변과 정점을 일일이 비교할 필요 없이 캐시해둔 영역만 비교하면 결과를 곧바로 알 수 있다.
5. 대칭성, 추이성, 일관성을 확인하라.
- 단위 테스트를 이용해 대칭성, 추이성, 일관성을 확인하라.
- 단, equals 를 AutoValue를 이용해 작성했다면 테스트를 생략해도 안심할 수 있다.
- 나머지 요건인 반사성과 null 아님도 만족해야 하지만 이 둘이 문제되는 경우는 별로 없다.
6. equals를 재정의할 땐 hashCode도 반드시 재정의하라.
7. 너무 복잡하게 해결하려 들지 마라.
- 필드들의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있다.
- 오히려 공격적으로 파고들다가 더 큰 문제를 일으키기도 한다.
- 일반적으로 별칭(alias)은 비교하지 않는 게 좋다. 예를 들어 File 클래스라면 심볼릭 링크를 비교해 같은 파일을 가리키는지를 확인하려 들면 안된다.
8. Object 외의 타입을 매개변수로 받는 equals 는 선언하지 마라.
- 다음은 Object.equals 를 재정의한 것이 아니라 다중 정의한 것이다.
// 컴파일 되지 않음
public boolean equals(MyClass o) {
...
}
- 타입을 구체적으로 명시한 equals는 오히려 해가 된다. 하위 클래스에서의 @Override 어노테이션이 긍정 오류(false positive) 을 내게 하고 보안 측면에서도 잘못된 정보를 준다.
- @Override 를 일관되게 사용하면 이러한 실수를 예방할 수 있다. (equals를 잘못 재정의했을 때 @Override를 사용하면 컴파일 오류가 발생함)
긍정 오류(false positive)
주어진 조건이 존재하지 않을 때 존재함을 나타내는 결과를 말한다. 반대로는 주어진 조건이 존재할 때 존재하지 않음을 나타내는 결과인 false negative가 있다.
책에서는 상위 클래스에서 equals 가 재정의되지 않았음에도 하위 클래스에서 상위 클래스가 equals를 재정의하였다고 인식하게 되는 것을 뜻하는 용어로 사용하였다.
AutoValue
- 구글에서 만든 오픈소스 코드 자동 생성 라이브러리로 equals를 작성, 테스트하는 작업을 대신 수행할 수 있다.
- 클래스에 어노테이션을 추가한 다음 rebuild를 수행하면 AutoValue가 메소드들을 알아서 작성해준다.
- 대다수의 IDE도 같은 기능을 제공하지만 생성된 코드가 AutoValue만큼 깔끔하거나 읽기 좋지는 않다.
- 또한 IDE는 나중에 클래스가 수정된 것을 인지하지 못하므로 테스트 코드를 작성해둬야 한다는 단점이 있다.
참고 자료
- Effective Java 3/E
- 동치 관계ㅣ정보통신기술용어해설
- 거짓 양성과 거짓 음성ㅣ위키백과