[Effective Java] 아이템 10. equals는 일반 규약을 지켜 재정의하라
예제 코드는 여기서 보실 수 있습니다.
0. 들어가며
equals 메서드는 두 개체가 같은지 확인하는데 사용됩니다.
Java 에서 두 개체가 같은지 확인하는 기준은 동일성
과 동등성
두가지로 나누어집니다.
0.1 동일성
동일성 : 두 개 이상의 사상(事象)이나 사물이 서로 같은 성질.
동일성이란 두 개체가 완전히 같은지를 나타내는 성질입니다.
위 그림의 refVar2 와 refVar3 는 지역변수로서는 개별적으로 존재하지만, 같은 객체2를 가리킵니다.
즉, refVar2 == refVar3 이 성립하며 이는 두 변수 refVar2, refVar3 은 동일하다고 표현할 수 있습니다.
0.2 동등성
동등성 : 가치, 등급 따위가 서로 똑같음.
동등성
이란 두 개체가 논리적으로 같은지를 나타내는 성질입니다.
String nickName1 = new String("jinan159");
String nickName2 = new String("jinan159");
System.out.println(nickName1 == nickName2); // false (동일성)
System.out.println(nickName1.equals(nickName2)); // true (동등성)
위 예제 코드에 두 개의 String 인스턴스 nickName1 과 nickName2 가 있습니다.
nickName1 과 nickName2 는 서로 다른 인스턴스지만 값은 같습니다.
이 경우 동일
하진 않지만, 동등
하다는 것을 알 수 있습니다.
이렇게 되는 이유는 String 의 equals 는 인스턴스의 값이 같은지 확인하도록 재정의 되어있기 때문입니다.
그러면 Java 의 상위 클래스인 Object 클래스의 equals 메서드는 어떻게 되어있을까요?
public class Object {
...
public boolean equals(Object obj) {
return (this == obj);
}
...
}
Object 클래스의 equals 메서드는 ==
연산자를 사용하여 두 인스턴스의 동일성
을 비교합니다.
즉, Java 에서는 기본적으로 equals 메소드가 두 인스턴스의 동일성
을 비교하고 있다고 생각할 수 있습니다.
그런데 위의 String 의 경우처럼, 개발하면서 동등성
을 비교해야할 경우가 생기게 될 수 있습니다.
이런 경우에는 equals 메서드를 재정의해서 사용하면되는데요.
하지만 논리적으로 같은지 확인하는 로직을 잘못 구현한다면 예기치 않은 결과를 초래할 수 있습니다.
이 때문에 이펙티브자바에서는 재정의하지 않아도 되는 경우에 대해 먼저 언급합니다.
1. 재정의 하지 않아도 되는 경우
- 인스턴스가 본질적으로 고유한 경우
- 인스턴스가 논리적으로 같은지 검사할 일이 없는 경우
- 상위 클래스의 equasl 가 하위 클래스에도 딱 맞는 경우
- 클래스가 private, package-private 이고, equals 를 호출할 일이 없는 경우
각각의 경우에 대해 조금 더 자세히 살펴보겠습니다.
1.1 인스턴스가 본질적으로 고유한 경우
equals 메서드를 재정의하지 않았다면, 두 확인하는 로직이 기본입니다.
예를 들어 OS 관점에서 Thread 자체는 논리적, 물리적으로 독립적인 개체입니다.
그래서 이를 추상화하여 구현한 Java 의 Thread 또한 본질적으로 고유한 성질을 가져야 합니다.
즉, 해당 요소나 개념등이 본질적으로 고유한 성질을 가지고 있다면, 재정의할 필요가 없다고 생각하면 됩니다.
1.2 인스턴스가 논리적으로 같은지 검사할 일이 없는 경우
1.1 의 Thread 예시에서 이어서 생각해보겠습니다.
Thread 는 본질적으로 고유하니, 당연히 논리적으로 같은지 검사할 필요가 없습니다.
반대로 주민번호, 이름을 가진 Person 이라는 클래스가 있다고 생각해 봅시다.
우리나라는 주민번호가 같으면 같은 사람으로 취급합니다.
따라서, 이 경우에는 주민번호가 같은 사람은 논리적으로 같은 사람으로 취급할 수 있습니다.
즉, Person 인스턴스의 equals 는 주민번호가 같으면 동일한 인스턴스라는 논리적 의미를 구현하게 됩니다.
class Person {
private int 주민번호;
private String 이름;
@Override
public boolean equals(Object o) {
if (!(o instanceof Person)) {
return false;
}
Person person = (Person) o;
return 주민번호 == person.주민번호;
}
}
1.3 상위 클래스의 equasl 가 하위 클래스에도 딱 맞는 경우
상위 클래스에서 구현한 equals 가 하위 클래스에도 동일하게 적용될 수 있다면, 굳이 재정의하지 않아도 됩니다.
아래는 HashSet, LinkedHashSet, TreeSet 등의 상위 클래스인 AbstractSet 클래스 입니다.
Set 내부의 구성요소가 모두 동일하면 논리적으로 같은 Set 이라고 취급됩니다.
public abstract class AbstractSet<E> extends AbstractCollection<E> implements Set<E> {
...
public boolean equals(Object o) {
if (o == this) return true;
if (!(o instanceof Set)) return false;
Collection<?> c = (Collection<?>) o;
if (c.size() != size()) return false;
try {
return containsAll(c);
} catch (ClassCastException | NullPointerException unused) {
return false;
}
}
...
}
// -------------------------------------------------------------------------------------------
HashSet<Integer> set1 = new HashSet<>();
LinkedHashSet<Integer> set2 = new LinkedHashSet<>();
TreeSet<Integer> set3 = new TreeSet<>();
int num = 1;
set1.add(num); set2.add(num); set3.add(num);
System.out.println(set1.equals(set2)); // true
System.out.println(set2.equals(set3)); // true
System.out.println(set1.equals(set3)); // true
Set 은 ‘중복되지 않는 요소들의 집합’ 입니다.
이를 확인하는데에는 AbstractSet 에 있는 equals 메서드만으로 충분하기 때문에 굳이 재정의 하지 않아도 됩니다.
그래서 HashSet, LinkedHashSet, TreeSet 이 구현체들은 실제로 모두 equals 를 재정의 하지 않았습니다.
1.4 클래스가 private, package-private 이고, equals 를 호출할 일이 없는 경우
클래스가 private 혹은 package-private 이라는 것은, 특정 클래스나 패키지의 내부에서만 사용할 클래스라는 의미죠.
그리고 해당 클래스는 equals 를 호출할 일이 없는데 혹시라도 불리는 것을 방지하기 위해서는 아래와 같은 방법이 있다고 합니다.
@Override
public boolean equals(Object o) {
throw new AssertionError(); // 호출 금지!
}
이 경우는 흔치않은 경우일 것 같습니다만, 이런 경우도 고려하더라 정도로만 생각하고 넘어가면 될 것 같습니다.
2. 정의해야하는 경우
그럼 반대로 정의해야하는 경우는 언제일까요?
- 두 인스턴스의 논리적
동등성
을 확인해야 할 경우
대표적인 예로 String 을 살펴보겠습니다.
// String 클래스의 equals 메서드
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String aString = (String)anObject;
if (coder() == aString.coder()) {
return isLatin1() ? StringLatin1.equals(value, aString.value)
: StringUTF16.equals(value, aString.value);
}
}
return false;
}
단순히 인스턴스의 동등성
을 비교하는것이 아니라, 그 안에있는 값이 같은지를 확인합니다.
하지만 앞서 언급한 것 처럼 equals 메소드는 Java 언어 내부적으로도 호출하여 사용하는 중요한 메서드입니다.
그래서 Java 에서는 equals 메소드를 구현할 때 지켜야할 규약을 정의해놓았습니다.
3. Object 클래스의 equals 규약
Object 클래스의 equals 메서드 주석을 보면 위와 같이 규약이 명시되어있습니다.
이를 간단히 풀어서 정리해보면 다음과 같습니다.
- 반사성 : 자기 자신과 비교하면 항상 true 여야 한다
- 대칭성 : 역으로 비교해도 항상 같은 결과가 나와야 한다
- 추이성 : 첫 번째와 두번째가 같고, 두 번째와 세 번째가 같으면, 첫 번째와 세번째도 같아야 함
- 일관성 : 항상 같은 결과를 반환해야 함
- null-아님 : 비교 대상이 null 아님
이 항목들을 하나씩 살펴보겠습니다.
3.1 반사성
반사성 : 자기 자신과 비교하면 항상 true 여야 한다.
- x ≠ null && x.equals(x) == true
객체는 자기 자신과 같아야 한다는 아주 기본적인 규약입니다.
앞서 1.3 에서 살펴봤던 AbstractSet 의 equals 메서드에서는 containsAll 이라는 메서드가 사용됩니다.
이는 AbstractCollection 에 정의되어 있는데요.
public abstract class AbstractCollection<E> implements Collection<E> {
...
public boolean containsAll(Collection<?> c) {
for (Object e : c)
if (!contains(e)) // 여기서 각 요소에 대해 contains 를 호출
return false;
return true;
}
public boolean contains(Object o) {
Iterator<E> it = iterator();
if (o==null) {
while (it.hasNext())
if (it.next()==null)
return true;
} else {
while (it.hasNext())
if (o.equals(it.next())) // 여기서 equals 가 사용된다.
return true;
}
return false;
}
...
}
AbstractCollection 에서는 대상이 있는지 확인하는 로직에서 결국 equals 가 사용됩니다.
하지만 이를 잘못 구현해놓으면 중복이 안되어야하는 Set 에 중복이 발생할 수 있습니다.
3.2 대칭성
대칭성 : 역으로 비교해도 항상 같은 결과가 나와야 한다
- x ≠ null && y ≠ null
- x.equals(y) == true 라면, x.equals(y) == true 도 성립해야함
equals 를 재정의 하며 로직이 복잡해지면 대칭성은 자칫하면 어길 수 있는데요.
책에 나오는 예제는 다음과 같습니다.
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;
}
}
---
CaseInsensitiveString cis = new CaseInsensitiveString("hello");
String str = new String("hello");
System.out.println(cis.equals(str)); // true
System.out.println(str.equals(cis)); // false
CaseInsensitiveString 는 equals 에서 String 타입에 맞게 처리를 하여 true 를 반환합니다.
하지만 String 은 CaseInsensitiveString 에 맞게 처리하지 않아 false 를 반환합니다
이렇게 대칭성을 어기게될 수 있는 것이죠.
3.3 추이성
추이성 : 첫 번째와 두번째가 같고, 두 번째와 세 번째가 같으면, 첫 번째와 세번째도 같아야 함
위 설명 그대로 이해하면 되며, 책에서는 Point 객체를 예제로 사용합니다.
(가독성을 위해 코드가 일부 수정되었습니다.)
class Point {
private final int x;
private final int 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;
}
}
class ColorPoint extends Point {
private final Color color;
...
@Override
public boolean equals(Object o) {
if (!(o instanceof Point)) return false;
// 좌표 비교
if (!(o instanceof ColorPoint)) return o.equals(this);
// 좌표, 색상 비교
ColorPoint cp = (ColorPoint) o;
return super.equals(cp) && this.color == cp.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)); // true (좌표 같음)
System.out.println(p1.equals(p3)); // false (좌표 같음, 색상 다름)
// --------------------------------------------------------------------
// 추이성 위반을 해결하기 위해서 Point view 를 갖는 방법이 있다고 합니다.
class ColorPoint {
private final Point point;
private final Color color;
...
public Point asPoint() {
return this.point;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint)) false;
ColorPoint cp = (ColorPoint) o;
return cp.point.equals(this.point) && && this.color == cp.color;
}
}
위 처럼 p1, p2 는 동등하고 p2, p3 도 동등한데 p1, p3 은 동등하지 않습니다.
이 경우가 추이성을 어긴 경우입니다.
그래서 이펙티브자바에서는 이를 해결하기 위한 방법으로 내부에 view 를 갖는것을 제안합니다.
Point 를 상속을 하지않고, 연관 관계로 풀어내는 것이죠.
그리고 더 좋은 방법은 Point 클래스를 추상클래스로 만들어버린다면 이 문제가 해결된다고 합니다.
추이성 예제에서 발생한 문제들은 Point 와 ColorPoint 두 종류의 인스턴스를 비교하면서 발생했는데요.
Point 를 추상클래스로 만든다면 Point 를 인스턴스화 할 수 없으니 이러한 문제는 사라지기 때문입니다.
그리고 번외로 또 다른 예시를 한번 살펴보겠습니다.
// new Person(주민번호, 이름);
Person personA = new Person(1, "A Name");
Person personB = new Person(2, "B Name");
Person personAA = new Person(1, "AA Name");
System.out.println(personA.equals(personB)); // false
System.out.println(personB.equals(personAA)); // false
System.out.println(personA.equals(personAA)); // true
//이 예시가 추이성을 위반한 것이 아니냐고 할 수 있는데, 추이성은 '같을때' 만 해당된다.
1.2 에서 살펴봤던 Person 클래스를 다시 가져와봤습니다.
personA, personB 는 서로 다르고, personB, personAA 도 서로 다릅니다.
여기서 추이성을 착각하면 personA 와 personAA 도 달라야 하는것 아닌가 오해할 수 있는데요.
추이성은 같은때만 해당된다는 것을 유념해야 합니다.
3.4 일관성
일관성 : 항상 같은 결과를 반환해야 함
이와 관련하여 java.net.URL 클래스가 이 규약을 위반하는 대표적인 사례라고 소개합니다.
URL 클래스의 equals 는 매핑된 호스트의 IP 주소를 비교하는데, 이는 네트워크 상태에 영향을 받기 때문입니다.
즉, equals 는 JVM 메모리상의 정보로만 동등성을 비교해야합니다.
3.5 null-아님
null-아님 : 비교 대상이 null 아님
비교 대상이 null 이 아니어야 한다는 뜻입니다.
@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;
}
위 코드는 위에서 살펴본 Point 클래스의 equals 메소드입니다.
첫 번째줄의 instanceof 연산자는 첫번째 피연산자(o)가 null 이라면 false 를 반환한다고 합니다.
그래서 이러한 묵시적 null 검사를 활용하는것이 좋다고 합니다.
4. 정리
==
연산자를 사용해동일성
을 먼저 비교- 성능 최적화용, 비교 작업이 복잡할 경우에 추천합니다.
instanceof
연산자로 null 과 타입을 체크- 입력을 올바른 타입으로 변환
- 입력 객체와 자신이 대응되는 ‘핵심’ 필드들이 모두 일치하는지 검사
- 이는 비즈니스에 따라 PK 가 되는 필드들이 해당될 것 같습니다.
- 인터페이스를 구현했다면, getter 메서드를 사용하는것이 좋습니다.
- 비교 비용이 싼 필드를 먼저 비교하는것을 추천
- 핵심필드로 계산할 수 있는 파생 필드는 비교 비추천
- ex) 주문 클래스(
가격
,개수
)에서총주문금액
필드의 경우
- ex) 주문 클래스(
4.1 번외
- float, double 의 래퍼타입인 Float, Double 의 경우에는 compare 메서드를 추천
- 특수한 부동소수값을 다룰 때, equals 를 사용하면 오토박싱이 일어날 경우 성능이 좋지 않습니다.