[이펙티브 자바] 아이템 10. equals는 일반 규약을 지켜 재정의하라

HyunKyu Lee·2024년 6월 24일
0

이펙티브 자바

목록 보기
2/3

아이템 10. equals는 일반 규약을 지켜 재정의 하라

equals 메서드는 재정의하기 쉬워 보이지만 곳곳에 함정이 있기 때문에 자칫 잘못하면 끔찍한 결과를 초래한다. 따라서 아예 재정의하지 않는 편이 나을 수도 있는데 다음과 같은 상황 중 하나에 해당하면 재정의하지 않는 것이 최선이다.

🐳 equals를 재정의하지 않아도 되는 경우

1. 각 인스턴스가 본질적으로 고유할 때
값을 표현하기 위한 인스턴스가 아니라 동작하는 개체를 표현하는 클래스 (ex. Thread)

2. 인스턴스의 논리적 동치성을 검사할 일이 없을 때
즉 equals로 동일성 비교(==)만 하면 될 때

3. 상위 클래스에서 정의한 equals가 하위 클래스에도 딱 들어맞을 때
Set 구현체 같은 경우 AbstractSet이 구현한 equals를 상속받아서 쓰고, List 구현체들은 AbstractList로부터, Map 구현체들은 AbstractMap으로부터 상속받아 그대로 쓴다.

4. 클래스가 private이거나 package-private이고 equals메서드를 호출할 일이 없을 때

🐳 equals를 재정의 해야하는 경우

논리적 동치성을 확인해야 하는데 상위 클래스의 equals 메서드가 논리적 동치성을 비교하도록 재정의되지 않았을 때에 equals를 재정의해야 한다.
두 값 객체를 equals로 비교하는 프로그래머는 객체가 같은지(동일한지)가 아니라 값이 같은지를 알고싶어 할 것이다. 동등성을 확인하도록 equals를 재정의해두면 그 인스턴스는 Map의 key와 Set의 원소로 사용할 수도 있다.
만약 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스라면 equals를 재정의하지 않아도 된다.

🐳 equals를 재정의 할때 따라야하는 일반 규약

equals는 동치관계를 구현하며, 다음을 만족한다.

  • 반사성: null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true이다.
  • 대칭성: null이 아닌 모든 참조 값 x, y에 대해, x.equals(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)를 반복해서 호출하면 항상 같은 값을 반환한다.
  • null-아님: null이아닌 모든 참조 값 x에 대해, x.equals(null)은 false이다.

반사성 (reflexivity)

객체가 자기 자신과 같아야 한다.

public class Apple {

	public static void main(String[] args) {
		List<Apple> list = new ArrayList<>();
		Apple apple = new Apple();
		list.add(apple);
		System.out.println(list.contains(apple)); // 반사성을 만족하지 못한다면 false가 나온다
	}
}


대칭성(symmetry)

두 객체가 서로에 대한 동치 여부에 똑같이 답해야 한다.

잘못된 코드 - 대칭성 위배!

// 대칭성을 위반한 클래스
public final class CaseInsensitiveString{
  private final String s;

  public CaseInsensitiveString(String s){
    this.s = Obejcts.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을 알고 있지만, String의 equals는 CaseInsensitiveString의 존재를 모른다는 데 있다.

CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";
System.out.println(cis.equals(s) + " " + s.equals(cis)); // true false

해결 방법
CaseInsensitiveString을 String과도 연동하겠다는 허황된 꿈을 버리고 CaseInsensitiveString 끼리만 비교하도록 한다.

//대칭성을 만족하게 수정
@Override public boolean equals(Object o){
  return o instanceof CaseInsensitiveString && ((CaseInsensitiveString) o).s.equalsIgnoreCase(s); 
}

추이성(transitivity)

첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같아면, 첫 번째 객체와 세 번째 객체도 같아야 한다.

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;
	}

	...
}

잘못된 코드 - 대칭성 위배!
Point 클래스를 확장한 ColorPoint에서 부모의 x, y좌표에 더해 color까지 같은지 확인하도록 equals 재정의

 @Override public boolean equals(Object o) {
    	if(!o instanceof ColorPoint)
    		return false;
    	return super.equals(o) && ((ColorPoint) o).color == color;
    }
    
 public static void main(){
      Point p = new Point(1,2);
      ColorPoint cp = new ColorPoint(1,2, Color.RED);
      p.equals(cp);    // true
      cp.equals(p);    // false
    }

ColorPoint의 equals는 입력 매개변수의 클래스 종류가 다르기 때문에 false를 반환


잘못된 코드 - 추이성 위배!
만약 ColorPoint가 Point와 비교할 때는 color를 무시하고 비교하도록 구현한다면?

 @Override public boolean equals(Obejct o){
      if(!(o instanceof Point))
        return false;
      if(!(o instanceof ColorPoint))
        return o.equals(this);
      return super.equals(o) && ((ColorPoint) o).color == color;
    }
    
 public static void main(){
      ColorPoint p1 = new ColorPoint(1,2, Color.RED);
      Point p2 = new Point(1,2);
      ColorPoint p3 = new ColorPoint(1,2, Color.BLUE);
      p1.equals(p2);    // true 
      p2.equals(p3);    // true 
      p1.equals(p3);    // false
    }

이 방식은 대칭성은 지켜주지만 추이성을 깨버린다.
구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.


잘못된 코드 - 리스코프 치환 원칙 위배!
클래스의 타입이 같을 때만 eequals 비교

@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;
    }
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();}

}

리스코프 치환 원칙에 따르면 어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요하다. 이는 Point의 하위 클래스는 정의상 여전히 Point이므로 어디서든 Point로 활용이 가능해야 한다.
하지만 CounterPoint는 x, y의 값과는 무관하게 어떠한 Point값과의 비교에서도 true를 반환받을 수 없다. 이는 리스코프 치환 원칙에 벗어나는 동작이다.


해결 - 상속 대신 컴포지션을 사용하라

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(){ // view 메서드 패턴
    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);
  }
}

일관성(consistency)

두 객체가 같다면 (어느 하나 혹은 두 객체 모두가 수정되지 않는 한) 앞으로도 영원히 같아야 한다.

  • 가변 객체라면 비교 시점에 따라 서로 같거나 다를 수 있는 반면에, 불변 객체는 한번 다르면 끝까지 달라야 한다.
  • 클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다.
    ex) java.net.URL의 equals는 주어진 URL과 매핑된 호스트의 IP주소를 이용해 비교하는데,
    호스트 이름을 IP주소로 바꾸려면 네트워크를 통해야 하므로 그 결과가 항상 같다고 보장할 수 없다.
    → 이 문제를 피하려면 equals는 항시 메모리에 존재하는 객체만을 사용한 결정적(deterministic) 계산만 수행해야 한다.

null-아님

모든 객체가 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 myType = (MyType) o;
}

instanceof 연산자 사용 단계에서 null타입이 들어오면 false를 반환하므로 null검사를 명시적으로 하지 않아도 된다.

🐳 equals 메서드 구현 방법

1. ==연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
== 연산을 하여 자기 자신이면 true를 반환한다. 단순한 성능 최적화용으로 비교 작업이 복잡한 상황일 때 값어치를 한다.

2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.
가끔 해당 클래스가 구현한 특정 인터페이스를 비교할 수도 있다.
이런 인터페이스를 구현한 클래스라면 equals에서 (클래스가 아닌) 해당 인터페이스를 사용해야한다.
ex) Set, List, Map, Map.Entry

3. 입력을 올바른 타입으로 형변환 한다.
2번에서 instanceof 연산자로 입력이 올바른 타입인지 검사 했기 때문에 이 단계는 100% 성공한다.

4. 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다.
모두 일치해야 true를, 하나라도 다르면 false를 반환한다.

equals를 다 구현했다면 세 가지만 자문해보자. 대칭적인가? 추이성이 있는가? 일관적인가?
자문에서 씉내지 말고 단위 테스트를 작성하자!


잘 구현된 예

public 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;
    }
}

참고
이펙티브 자바

profile
backend developer

0개의 댓글