10. equals()는 일반 규약을 지켜 재정의하라
equals 재정의하지 않은 경우
equals()
를 재정의하지 않을 경우 클래스의 인스턴스는 오직 자기 자신과만 같게 된다.- == 연산자와 동일하게 동작하여 객체의 메모리 주소를 비교한다.
- 객체 식별성(
identity
)을 비교한다.
class Food {
private final String name;
private final int price;
public Food(String name, int price) {
this.name = name;
this.price = price;
}
}
public class Main {
public static void main(String[] args) {
Food food1 = new Food("Bread", 2000);
Food food2 = new Food("Bread", 2000);
System.out.println(food1 == food2); // false
System.out.println(food1.equals(food2)); // false
}
}
equals()를 재정의할 필요가 없는 경우
- 각 인스턴스가 본질적으로 고유할 경우
- ex)
Thread
- 각 스레드는 본질적으로 고유하다.
- ex)
- 인스턴스의 논리적 동치성을 검사할 일이 없을 경우
- 객체 식별성, 즉
Object.equals()
비교로 충분한 경우
- 객체 식별성, 즉
상위 클래스에서 재정의한 equals()
가 하위 클래스에도 딱 들어맞는 경우Set
구현체는AbstractSet
이 구현한equals
를 상속받아 사용한다.
AbstractSet
은 equals
를 재정의한다.
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
은 AbstractSet
을 상속받는다. AbstractSet
으로부터 상속받은 equals()
를 사용한다.
public class HashSet<E>
extends AbstractSet<E>
implements Set<E>, Cloneable, java.io.Serializable
{
- 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없는 경우
- 외부에서 인스턴스를 비교하지 않고 내부 구현에서만 사용할 경우이다.
- 또한
equals()
를 호출할 일이 없다면 굳이 재정의할 필요가 없다.- 호출시
AssertionError()
를 던지도록 할 수도 있다.
- 호출시
@Override
public boolean equals(Object obj) {
throw new AssertionError(); // 호출 시 에러 발생
}
equals()를 재정의해야 할 경우
- 조건 1 :
객체 식별성(두 객체가 물리적으로 같은가)
가 아니라논리적 동치성
을 확인해야하는 경우 - 조건 2 : 상위 클래스의
equals()
가 논리적 동치성을 비교하도록 재정의되지 않았을 경우 - 주로
값 클래스
가 여기에 해당한다.- 값 클래스라도 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스 (정적 팩토리 메서드)라면 재정의할 필요가 없다.
public class Currency {
private final String code;
private static final Map<String, Currency> CACHE = new HashMap<>();
private Currency(String code) {
this.code = code;
}
// 정적 팩토리 메서드로 인스턴스 통제
// 같은 통화코드는 항상 같은 인스턴스 반환
public static Currency getInstance(String code) {
return CACHE.computeIfAbsent(code, Currency::new);
}
}
Currency usd1 = Currency.getInstance("USD");
Currency usd2 = Currency.getInstance("USD");
System.out.println(usd1 == usd2); // true
System.out.println(usd1.equals(usd2)); // true (Object.equals로도 충분)
enum
의 경우도 논리적으로 같은 인스턴스가 하나만 존재함을 보장하므로 재정의할 필요가 없다.
public enum PaymentType {
CREDIT_CARD,
DEBIT_CARD,
CASH;
}
PaymentType type1 = PaymentType.CREDIT_CARD;
PaymentType type2 = PaymentType.CREDIT_CARD;
System.out.println(type1 == type2); // true
System.out.println(type1.equals(type2)); // true (Object.equals로도 충분)
예시
- 같은 이름을 가진 음식이라면 논리적으로 동일하다고 여겨져야 하는 경우
class Food {
private final String name;
private final int price;
public Food(String name, int price) {
this.name = name;
this.price = price;
}
@Override
public boolean equals(final Object o) {
if (!(o instanceof Food food)) {
return false;
}
return Objects.equals(name, food.name); // 이름이 같을 경우 true 반환
}
@Override
public int hashCode() {
return Objects.hashCode(name);
}
}
public class Main2 {
public static void main(String[] args) {
Food food1 = new Food("Bread", 2000);
Food food2 = new Food("Bread", 2500);
System.out.println(food1 == food2); // false
System.out.println(food1.equals(food2)); // true
}
}
equals() 재정의 규약
equals()
메서드는 동치관계를 구현하며, 다음을 만족한다.
참고 ) 동치 관계
- 집합을 서로 같은 원소들로 이뤄진 부분 집합(동치 클래스)으로 나누는 연산
- 모든 원소가 논리적으로 동일한 동치류라면, 어떤 원소와도 서로 교환할 수 있어야 한다.
반사성
x.equals(x) = true
- 객체는 자기 자신과 같아야 한다.
대칭성
x.equals(y) = true
이면y.equals(x) = true
- 두 객체는 서로에 대한 동치 여부에 똑같이 답해야한다.
대칭성 위배 코드
package effectivejava.chapter3.item10;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
// 코드 10-1 잘못된 코드 - 대칭성 위배! (54-55쪽)
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;
}
// 문제 시연 (55쪽)
public static void main(String[] args) {
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";
System.out.println(cis.equals(s)); // true
System.out.println(s.equals(cis)); // false - 대칭성 위배!
}
}
CaseInsensitiveString
의 equals
를 대소문자를 구분하지 않고 비교하지만, 문자열의 equals()
는 대소문자를 구분하여 동등성을 판단한다.
String 클래스는 CaseInsensitiveString의 존재를 모르기 때문이다.
- 대칭성을 지킨 equals()
- 같은 타입의 객체만 비교하도록 한다.
// 수정한 equals 메서드 (56쪽)
@Override public boolean equals(Object o) {
return o instanceof CaseInsensitiveString &&
((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
CaseInsensitiveString과 같은 클래스의 인스턴스인지 확인한다.
추이성
x.equals(y) = true
이고y.equals(z) = true
이면x.equals(z) = true
이다.- 첫번째 객체와 두번째 객체가 같고, 두번째 객체와 세번째 객체가 같으면, 첫번째 객체와 세번째 객체도 같아야 한다.
추이성 위배 코드
상위 클래스에는 없는 필드를 하위 클래스에서 추가한다고 가정해보자.
- 상위 클래스
package effectivejava.chapter3.item10;
// 단순한 불변 2차원 정수 점(point) 클래스 (56쪽)
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;
}
@Override
public int hashCode() {
return 31 * x + y;
}
}
- 하위 클래스 (추이성 위배)
package effectivejava.chapter3.item10.inheritance;
import effectivejava.chapter3.item10.Color;
import effectivejava.chapter3.item10.Point;
// Point에 값 컴포넌트(color)를 추가 (56쪽)
public class ColorPoint extends Point {
private final Color color;
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
// 코드 10-2 잘못된 코드 - 대칭성 위배! (57쪽)
@Override public boolean equals(Object o) {
if (!(o instanceof ColorPoint))
return false;
return super.equals(o) && ((ColorPoint) o).color == color;
}
public static void main(String[] args) {
// 첫 번째 equals 메서드(코드 10-2)는 대칭성을 위배한다. (57쪽)
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);
System.out.println(p.equals(cp)); // true
System.out.println(cp.equals(p)); // false
}
}
하위 클래스의 equals()는 추이성을 위배한다.
- 부모 객체인 Point는 x, y 필드로만 동등성을 비교한다.
p.equals(cp)는 true
- 자식 객체인
ColorPoint
는 x,y 필드뿐 아니라 color까지 비교한다.cp.equals(p)는 false
ColorPoint의 인스턴스가 아닐 경우 비교대상의 equals()를 사용한다면?
// 코드 10-3 잘못된 코드 - 추이성 위배! (57쪽)
@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;
}
public static void main(String[] args) {
// 두 번째 equals 메서드(코드 10-3)는 추이성을 위배한다. (57쪽)
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
System.out.printf("%s %s %s%n",
p1.equals(p2), p2.equals(p3), p1.equals(p3));
}
}
- 객체 타입이 달라도 같은 객체로 여겨질 수 있는데, 이는 추이성을 깬다.
- Point 객체로 동등성 비교할 경우 색상을 무시하여 비교하게 된다.
각 객체의 equals() 구현 방식이 다를 수 있기 때문에, 같은 타입의 객체만 동등성 비교 대상으로 삼는 것이 좋다.
- 무한 재귀에 빠질 수 있다.
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;
if (!(o instanceof ColorPoint))
return o.equals(this); // SmellPoint.equals(ColorPoint) 호출
ColorPoint cp = (ColorPoint) o;
return super.equals(o) && cp.color == color;
}
}
public class SmellPoint extends Point {
private final String smell;
public SmellPoint(int x, int y, String smell) {
super(x, y);
this.smell = smell;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Point)) return false;
if (!(o instanceof SmellPoint))
return o.equals(this); // ColorPoint.equals(SmellPoint) 호출
SmellPoint sp = (SmellPoint) o;
return super.equals(o) && sp.smell.equals(smell);
}
}
ColorPoint cp = new ColorPoint(1, 2, Color.RED);
SmellPoint sp = new SmellPoint(1, 2, "sweet");
cp.equals(sp) // ColorPoint.equals() 실행
→ sp.equals(cp) // SmellPoint.equals() 실행
→ cp.equals(sp) // 다시 처음으로 돌아가서 무한 반복...
- ColorPoint의 equals가 호출되어 SmellPoint의 equals를 호출
- SmellPoint의 equals가 ColorPoint의 equals를 다시 호출
- 1번으로 돌아가 무한 반복
구체 클래스를 확장(extends)해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.
equals()
의 instanceof
를 getClass
검사로 바꾸면 어떨까?
Point(상위 클래스)
의instanceof
를getClass
로 바꾼 코드
// 잘못된 코드 - 리스코프 치환 원칙 위배! (59쪽)
@Override public boolean equals(Object o) {
if (o == null || o.getClass() != getClass())
return false;
Point p = (Point) o;
return p.x == x && p.y == y;
}
- 상위 클래스의
Point
를instanceof
가 아닌getClass
로 변경할 경우getClass
: 같은 구현 클래스의 객체와 비교할 때만true
를 반환한다.리스코프 치환 원칙
을 위배한다.
리스코프 치환 원칙: 어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요하다.
따라서 그 타입의 모든 메서드가 하위 타입에서도 똑같이 잘 작동해야한다.
// Point의 평범한 하위 클래스 - 값 컴포넌트를 추가하지 않았다. (59쪽)
public class CounterPoint extends Point {
private static final AtomicInteger counter =
new AtomicInteger();
public CounterPoint(int x, int y) {
super(x, y);
counter.incrementAndGet();
}
public static int numberCreated() { return counter.get(); }
}
// CounterPoint를 Point로 사용하는 테스트 프로그램
public class CounterPointTest {
// 단위 원 안의 모든 점을 포함하도록 unitCircle을 초기화한다. (58쪽)
private static final Set<Point> unitCircle = Set.of(
new Point( 1, 0), new Point( 0, 1),
new Point(-1, 0), new Point( 0, -1));
public static boolean onUnitCircle(Point p) {
return unitCircle.contains(p);
}
public static void main(String[] args) {
Point p1 = new Point(1, 0);
Point p2 = new CounterPoint(1, 0);
// true를 출력한다.
System.out.println(onUnitCircle(p1));
// true를 출력해야 하지만, Point의 equals가 getClass를 사용해 작성되었다면 그렇지 않다.
System.out.println(onUnitCircle(p2));
}
}
위 원칙을 따르는 결과가 나오려면 Point
를 상속받은 하위 클래스의 인스턴스가 비교대상으로 들어오더라도 논리적 동치성을 비교할 수 있어야 한다.
=> instanceof
를 사용해야 한다.
해결 방법 : 상속 대신 컴포지션을 사용하라. ⭐️
- 필드를 추가하면서
대칭성
과추이성
모두 지킬 수 있다.
컴포지션 : 새로운 클래스에 기존 클래스의 인스턴스를 참조하는 private 필드를 두는 방식
package effectivejava.chapter3.item10.composition;
import effectivejava.chapter3.item10.Color;
import effectivejava.chapter3.item10.Point;
import java.util.Objects;
// 코드 10-5 equals 규약을 지키면서 값 추가하기 (60쪽)
public 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)) {
return false;
}
ColorPoint cp = (ColorPoint) o;
return cp.point.equals(point) && cp.color.equals(color);
}
@Override
public int hashCode() {
return 31 * point.hashCode() + color.hashCode();
}
}
- 대칭성, 추이성 문제 해결 : 같은 인스턴스 타입이 아니면 false
- 상속으로 인한 문제(리스코프 치환 원칙 위배) 를 피할 수 있다.
- Point 클래스로 비교하고 싶을 경우 view를 통해 비교할 수 있다.
p.equals(cp.asPoint()); // true
자바 라이브러리에도 구체 클래스를 확장해 값을 추가한 클래스가 있지만 (Timestamp) 실수이니 따라해서는 안된다.
상속에서의 equals() 결론
- 상속을 통해
equals
를 확장해서 값을 추가하게 되면equals
규약(대칭성, 추이성 등)을 위배하게 된다. - 따라서
equals()
구현시 동일한 타입의 객체끼리만 비교해야한다.- 하지만
getClass
는 리스코프 치환 원칙에 위배되므로 사용하면 안된다.instanceof
를 사용해야한다.
- 즉, 구체 클래스를 확장(
extends
)해 새로운 값을 추가하면서equals
규약을 만족시킬 방법은 존재하지 않는다.
- 하지만
- 해결 방법 : 상속 대신 컴포지션을 사용하라.
- 새로운 필드를 추가하고 싶다면 상속 대신 클래스를
private
필드로 포함시키는 방식을 사용하자.
- 새로운 필드를 추가하고 싶다면 상속 대신 클래스를
일관성
x.equals(y)
를 반복해서 호출하면 항상true
를 반환하거나 항상false
를 반환한다.- 두 객체가 같다면 (어느 하나 혹은 두 객체 모두가 수정되지 않는 한) 앞으로도 영원히 같아야 한다.
불변, 가변 객체
불변 객체
는 한번 동등하지 않으면 항상 동등하지 않아야 한다.가변 객체
는 두 객체가 상태가 변경되지 않는 한, 항상 비교 결과가 같아야한다.
클래스를 작성할 때는 불변 클래스로 만드는게 나을지 심사숙고하자 (item 17)
equals
의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안된다.- Url의 equals는
ip 주소
로 비교하는데, 네트워크를 통하므로 결과가 항상 같다고 보장할 수 없다.- 설계 실수 코드이다.
equals()
는 항시 메모리에 존재하는 객체만을 사용한 결정적 계산만 수행해야 한다.
- Url의 equals는
null-아님
null이 아닌 모든 참조 값 x에 대해, x.equals(null) = false
이다.
equals 규약을 어기면 프로그램이 이상하게 동작하거나 종료될 수 있다.
- 모든 객체가
null
과 같지 않아야 한다. - 명시적으로
null
이 아닌지 체크할 필요는 없다.instanceof
에서 묵시적으로null
을 검사하기 때문이다.
양질의 equals 메서드 구현 방법
package effectivejava.chapter3.item10;
// 코드 10-6 전형적인 equals 메서드의 예 (64쪽)
public final class PhoneNumber {
private final short areaCode, prefix, lineNum;
public PhoneNumber(int areaCode, int prefix, int lineNum) {
this.areaCode = rangeCheck(areaCode, 999, "지역코드");
this.prefix = rangeCheck(prefix, 999, "프리픽스");
this.lineNum = rangeCheck(lineNum, 9999, "가입자 번호");
}
private static short rangeCheck(int val, int max, String arg) {
if (val < 0 || val > max)
throw new IllegalArgumentException(arg + ": " + val);
return (short) val;
}
@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;
}
// 나머지 코드는 생략 - hashCode 메서드는 꼭 필요하다(아이템 11)!}
- == 연산자를 이용해 자기 자신의 참조인지 확인한다.
- 기본 타입은 == , 참조 타입은
equals()
,float
과Double
의 경우compare
(부동 소수점 비교) null
도 정상 값으로 취급해야할 경우Object.equals(object, object)
사용하기 (NPE 방지)
- 기본 타입은 == , 참조 타입은
public class Person {
private int id; // 기본 타입 (비교 비용 낮음)
private String name; // 문자열 (비교 비용 중간)
private Address address; // 복잡한 객체 (비교 비용 높음)
@Override
public boolean equals(Object o) {
if (o == this) return true;
if (!(o instanceof Person)) return false;
Person other = (Person) o;
// 비용이 적은 순서대로 비교
return id == other.id // 1. 기본 타입 비교
&& Objects.equals(name, other.name) // 2. 문자열 비교
&& Objects.equals(address, other.address); // 3. 복잡한 객체 비교
}
}
비교하기 어려울 경우 표준형끼리 비교하면 경제적이다.
필드 비교는 다를 가능성이 더 크거나 비용이 싼 필드를 먼저 비교하자.
instanceof
연산자로 입력이 올바른 타입인지 확인한다.- 올바른 타입 : 같은 클래스이거나 그 클래스의 하위 클래스
- 입력을 올바른 타입으로 형변환한다.
- 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사한다.
equals를 다 구현했다면 대칭성, 추이성, 일관성이 지켜졌는지 확인해보자.
반사성과 null-아님도 만족해야하지만 이 둘이 문제되는 경우는 별로 없다.
equals 구현시 주의사항
equal
를 재정의할땐hashcode
도 반드시 재정의하자- 너무 복잡하게 해결하려 들지 말자.
- 필드들의 동치성만 비교해도 지킬 수 있다.
- 별칭(
alias
, 같은 객체를 가리키는 다른 참조) 사용은 X, 핵심 필드만 비교
Object
외의 타입을 매개변수로 받는equals
메서드는 선언하지 말자.- 오버라이드가 아닌 다중 정의가 된다..
- 사람이 직접 작성하는 것보다는 IDE나 구글의 AutoValue에 맡기는 편이 낫다.
결론
- 꼭 필요한 경우가 아니면 equals를 재정의하지 말자.
- 재정의해야할 때는 해당 클래스의 핵심 필드 모두를 빠짐없이, 다섯가지 규약을 확실히 지켜가며 비교해야 한다.
'Java > effective java' 카테고리의 다른 글
[Effective Java] 3장. 모든 객체의 공통 메서드 - 0. 들어가기 (0) | 2025.01.12 |
---|---|
[Effective Java] Item 9. try-finally보다는 try-with-resources를 사용하라. (0) | 2025.01.12 |
[Effective Java] Item 8. finalizer와 cleaner 사용을 피하라. (0) | 2025.01.12 |
[Effective Java] Item 7. 다 쓴 객체의 참조를 해제하라. (0) | 2025.01.12 |
[Effective Java] Item 6. 불필요한 객체 생성을 피하라. (0) | 2025.01.12 |