Effective Java - 모든 객체의 공통 메서드(1) : equals는 일반 규약을 지켜 재정의하라

목포·2022년 4월 13일
0

이펙티브 자바

목록 보기
11/13

equals는 일반 규약을 지켜 재정의하라

equals 메서드는 몇 가지 규약을 제대로 지키지 않을 경우 의도와 다르게 동작하여 프로그램에 오류를 발생시킬 수 있다. 재정의하지 않고 그냥 두면 그 클래스의 인스턴스는 오직 자기 자신과만 같게 된다.

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

  • 각 인스턴스가 본질적으로 고유한 경우
    예컨대 Thread는 값이 아닌 동작 개체를 표현하는 클래스이기 때문에 동일한 인스턴스가 애초에 없다. Object의 equals로 충분하다.
  • 인스턴스의 논리적 동치성을 검사할 일이 없는 경우
    값을 비교해서 동등한지 비교할 일이 없다면 논리적 동치성 검사를 할 일이 없다는 것이다.
    java.util.regex.Pattern은 같은 정규표현식인지 비교하고 싶을 수도 있다. 하지만, Pattern 클래스 설계자는 이 클래스 내에 그런 의도를 가지고 만든 클래스가 아니기 때문에 기본 Object equals로 해결할 수 있다.
  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는 경우
    상위 클래스에서 구현한 equals 메서드로 충분한 경우 재정의할 것이 아니라 상위 클래스에 정의된 equals를 사용하면 된다.(Set의 구현체는 AbstractSet이 구현한 equals를, Map 구현체는 AbstractMap에서 상속받아 쓴다.)
  • 클래스가 private이거나 package-private이고 equals 메서드를 굳이 호출할 일이 없는 경우
    걍 Equals가 실수로라도 호출되는 걸 막고싶다면 다음과 같이 구현하면 된다.
@Override 
public boolean equals(Object o) {
	throw new AssertionError(); // 호출 금지
}

재정의 해야하는 경우

equals를 재정의 해야하는 경우는 객체 식별성(두 객체가 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야 하는데 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의 되지 않았을 때이다.
주로 값 클래스들이 여기 해당한다. (Integer나 String 같은) 두 값 객체를 equals로 비교하는 프로그래머는 객체가 같은지가 아니라 값이 같은지를 알고 싶어할 것이다.

이렇게 논리적 동치성을 확인하도록 재정의해두면 Map의 키와 Set의 원소로 사용할 수 있게 된다. Enum과 같이 값 클래스라 해도, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스라면 굳이 재정의할 필요가 없다. 어차피 논리적으로 같은 인스턴스가 2개 이상 만들어지지 않으니 논리적 동치성 = 객체 식별성이 되는 것이다.

equals 메서드를 재정의할 때의 따라야하는 Object 명세 규약

  • 반사성(reflexivity)
    null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true이다.

  • 대칭성(symmetry)
    null이 아닌 모든 참조 값 x,y에 대해, x.equals(y)가 true면 y.equals(x)도 true다.

/* 대칭성 위배의 경우 */

// 대소문자를 구별하지 않는 문자열을 구현한 다음 살펴보자
public final class CaseInsensitiveString() {
	private final String s;

	public CaseInsensitiveString() {
		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("Polish");

String s = "polish";

cis.equals(s); // true
s.equals(cis); // false
/* 한 방향으로만 작용한다. String의 equals는 일반 String을 알고 있지만 String의 equals는 Case InsensitiveString의 존재를 모르기 때문이다. */

List<CaseInsensitiveString> list = new ArrayList<>();
list.add(cis);
list.contains(s); // false
/* 위 예제는 equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없음을 보여준다. */


// CaseInsensitiveString의 eqauls를 String과 연동하겠다는 허황된 꿈을 버려야한다.
// 고치기
@Override
public boolean equals(Object o) {
	return o instanceof CaseInsensitiveString && ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
  • 추이성(transitivity)
    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 fianl Color color;
	
	public ColorPoint(int x, int y, Color color) {
		super(x, y);
		this.color = color;
	}
}

// 위 경우라면 색상 정보는 무시한 채 비교하게 될 것이다. 규약을 어긴건 아니지만 중요한 정보를 놓치게 될 것이다.

// 대칭성 위배
// 다음 코드는 Point.equals 시 색상 비교를 무시하게 되고 ColorPoint 객체에서는 Point 객체를 받지 않기 때문에 항상 false를 반환하게 될 것이다. 
@Override
public boolean equals(Object o) {
	if(!(o instanceof ColorPoint)) return false;
	return super.equals(o) && ((ColorPoint) o).color == color;

// 예제
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);

p.equals(cp); // true
cp.equals(p); //false


// ColorPoint.equals가 Point와 비교할 때 색상을 무시하도록 하면 해결 될까?
// 추이성 위배
@Override
public boolean equals(Object o) {
	if(!(o instanceof Point)) return false;
	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 p2 = new ColorPoint(1, 2, Color.BLUE);

p1.equals(p2); // true
p2.equals(p3); // true
p1.equals(p3); // false


// 그렇다면 해결방법은?
// 사실상 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 수는 없다. 
// 그렇다면 getClass 검사로 바꾸면 어떻게 될까
// 리스코프 치환 원칙 위배
@Override
public boolean equals(Object o) {
// Point 클래스의 equlas
	if(o == null || o.getClass != getClass()) return false;
	Point p = (Point) o;
	return p.x == x && p.y = y;
}
// 위 코드는 같은 구현 클래스의 객체와 비교할 때만 true를 반환한다. 하지만, 실제로 활용할 수는 없다. Point의 하위 클래스는 정의상 여전히 Point이므로 어디서든 Point로써 활용될 수 있어야하는데 그렇지 못하기 때문이다.

//단위 원 안의 모든 점을 포함하도록 unitCircle을 초기화한다.
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 class CounterPoint extends Point {
	private static final AtomicInteger counter = new AtomicInteger();
	public ConterPoint(int x, int y) {
		super(x, y);
		counter.incrementAndGet();
	}

	public static int numberCreated() {return counter.get(); 
}	
/* 리스코프 치환 원칙에 따르면 어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요하다. 따라서 그 타입의 모든 메서드가 하위 타입에서도 똑같이 잘 작동해야한다. -> 이는 Point의 하위 클래스는 정의상 여전히 Point이므로 어디서든 Point로써 활용될 수 있어야 한다는 말

그런데 CounterPoint의 인스턴스를 onUnitCircle에 넘기게되면 onUnitCircle은 x,y값과 무관하게 false를 반환할 것이다. 어쨌든 CounterPoint의 인스턴스는 Point와 본질적으로 다르기 때문 */

결국 구체 클래스의 하위 클래스에서 값을 추가할 방법은 없지만 “상속 대신 컴포지션을 사용하라(아이템 18)” 라는 조언을 이용할 수 있다. (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) {
		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);
	}
	.. 생략
}

참고로, Java 라이브러리인 Timestamp와 Date가 이런 이슈를 가지고 있다.

  • 일관성(consistency)
    null이 아닌 모든 참조 값 x,y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.

가변 객체는 비교 시점에 따라 서로 다를 수도 혹은 같을 수도 있지만, 불변 객체는 그렇지 않다. 하지만, 클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안된다. 예컨대 java.net.URL의 equals는 주어진 URL과 매핑된 호스트의 IP 주소를 이용해 비교한다. 호스트 이름을 IP 주소로 바꾸려면 네트워크를 통해야 하는데 그 결과가 항상 같다고 보장할 순 없다. (이 역시 실수)

이런 문제를 피하려면 항시 메모리에 존재하는 객체만 사용한 ‘결정적 계산’만을 수행해야 한다.

  • null-아님
    null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false이다.

동치성 검사 시 equals는 건네받은 객체를 적절히 형변환하여 필수 필드들의 값을 알아내야하며, 그 전에 instanceof로 연산자 매개변수가 올바른 타입인지 검사한다.

instanceof첫 번째 피연산자가 null이면 flase를 반환한다. 때문에 null 검사를 명시적으로 하지 않아도 된다.

@Override
public boolean equals(Object o) {
	if(!(o instanceof MyType)) {
		return false;
	MyType my = (MyType) o
	}
}

양질의 equals 메서드 작성 순서

  1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
    -> 자기 자신이면 true를 반환한다. 이는 단순한 성능 최적화용으로 비교작업이 복잡한 상황일 때 좋다.
  2. instanceof 로 입력이 올바른 타입인지 확인한다.
    -> 그렇지 않으면 false를 반환한다. 이 떄의 올바른 타입은 equals가 정의된 클래스인 것이 보통이지만, 가끔은 그 클래스가 구현한 특정 인터페이스일 때도 있다. 어떤 인터페이스는 자신을 구현한 (서로 다른) 클래스끼리 비교할 수 있도록 equals 규약을 수정하기도 한다. 이런 인터페이스를 구현한 클래스라면 equals에서 (클래스가 아닌) 해당 인터페이스를 사용해야한다. Set, List, Map, Map.Entry가 여기에 해당한다.
  3. 입력을 올바른 타입으로 형변환한다.
    -> 2번 단계에서 instanceof 검사 했으니 이 단계는 100% 성공한다.
  4. 입력 객체와 자기 자신읟 ㅐ응되는 ‘핵심’ 필드들이 모두 일치하는지 하나 씩 검사한다.
    -> 모든 필드가 일치하면 true, 하나라도 다르면 false. 2단계에서 인터페이스를 사용했다면 입력 필드 값을 가져올 때도 그 인터페이스의 메서드를 사용해야한다. 타입이 클래스라면 (접근 권한에 따라) 해당 필드에 직접 접근할 수도 있다.

주의할 점

  • equals를 재정의할 땐 hashCode도 반드시 재정의하자
  • 너무 복잡하게 해결하려 들지 말자.
    -> 예를들어, 별칭을 비교하려고 한다든가, File의 경우 심볼릭 링크를 비교해 같은 파일을 가리키는지.. 이런 경우
  • Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자.

AutoValue

AutoValue를 사용하면 깔끔하고 보기 좋게 메서드들을 작성해준다. 물론, 테스트 코드를 잘 작성해둬야 한다.

profile
mokpo devlog

0개의 댓글