티스토리 뷰
Java로 알고리즘 풀 때 Overriding 해 본 equals이다.
equals를 재정의 하면 안되는 조건에 대해 소개해준다.
- 각 인스턴스가 본질적으로 고유하다.
Object는 기본적으로 equals 메소드의 비교를 레퍼런스가 같은지를 확인한다.
Thread와 같이 값을 표현하는 것이 아닌 동작하는 개체를 표현하는 클래스는 재정의 하지 않는 것이 좋다. - 인스턴스의 논리적 동치성(logical equality)을 검사할 일이 없다.
두 인스턴스가 물리적으로 같은지가 아닌 논리적으로 같은지를 확인해야 할 때 재정의가 필요하다.
클라이언트 코드에서 이를 호출할 일도 없고 필요도 없다면 굳이 재정의할 필요가 없다. - 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.
설계를 완벽하게 해서 하위 클래스에서 이를 재정의할 필요 없이 그대로 가져다 쓰면 된다는 뜻
Set의 구현체는 AbstractSet의 equals를 그대로 가져다 쓴다.
첫 번째 이유에서처럼 Object의 equals가 해당 클래스의 비교조건에 부합한다면 그대로 써도 된다. - 클래스가 private이거나 package-private이고 equals 메소드를 호출할 일이 없다.
클래스 형식이 위와 같으면 밖에서 해당 클래스를 생성할 수 없어 비교할 일이 없어진다.
그래도 혹시 호출을 막고 싶으면 호출 시 에러를 던지게 끔 재정의 할 수 있다.
@Override public boolean equals(Object o){
throw new AssertionError();
}
equals를 재정의 해야할 때는 언제인가?
두 클래스의 레퍼런스가 같은지를 알고 싶은 것이 아닌
내부 값이 같은지를 확인하고 싶을 때 재정의하면 된다.
값을 나타내는 클래스라고 해도 값이 같은 인스턴스가
둘 이상 만들어지지 않으면 재정의하지 않아도 된다.
Enum도 여기에 해당하는데 컴파일 단계에
상수 객체가 생성된 이후 변하지 않기 때문에 레퍼런스 비교로 값이 같음을 확인할 수 있다.
equals를 재정의할 때 지켜야할 규약들이다.
- 반사성(reflexivity): null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true이다.
-> 자기 자신과의 비교는 반드시 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이다.
그냥 수학시간에 봤던 기본 규칙들을 나열해 놓은 것 같다.
이걸 어길 수가 있을까라고 생각했지만 클래스의 상속을 고려하면서
메소드를 설계해야 하면 조금 골치 아파진다.
반사성은 일부로 어기는 것이 아니면 어기기 쉽지 않다. 넘어가자.
대칭성을 위배한 케이스이다.
// 코드 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;
}
...
}
CaseInsensitiveString의 equals는 내부의 String 객체를 가지고
같은 CaseInsensitiveString 클래스와 String 클래스의 비교를 하고 싶다.
CaseInsensitiveString에서 같은 리터럴을 같은 String과 equals를 호출하면 true 반환하지만
String의 equals는 내가 만든 이 뚱딴지같은 클래스의 존재를 알리가 없다.
따라서 역으로 equals를 호출하면 false를 반환한다.
String과 동시에 비교하겠다는 꿈을 버려야 한다.
// 수정한 equals 메서드 (56쪽)
@Override public boolean equals(Object o) {
return o instanceof CaseInsensitiveString &&
((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
여기서 equals 메소드는 자기 자신과
그 하위 타입에 대해서만 적용하는 것이 좋다는 것을 알 수 있다.
추이성은 "1과 2가 같고 2와 3이 같으면 1과 3이 같다"이다.
아래의 코드를 보면
// 단순한 불변 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;
}
}
좌표를 가지는 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;
}
}
그리고 이를 상속받아 색깔 속성을 추가한 ColorPoint 클래스이다.
Point의 equals는 ColorPoint의 색깔 속성을 무시하고
ColorPoint는 클래스 타입에서 걸러져 false를 반환한다.
이는 대칭성에 위배되는 재정의 이다.
대칭성을 해결하기 위한 코드이다.
// 코드 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;
}
ColorPoint의 equals를 클래스 타입이 Point 타입일 때는 색깔을 무시하는 비교를,
같은 클래스 타입일 때는 색깔까지 비교해 준다.
이는 대칭성은 해결할 수 있지만 추이성에 위배되는 코드이다.
// 두 번째 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);
p1 -> p2 = true, p2 -> p3 가 true로 추이성이 지켜지려면 p1 -> p3도 true를 반환해야 한다.
그러나 p1과 p3는 가운데 있는 p2와 색깔 비교를 하지 않았고
p1 -> p3는 색깔까지 비교하므로 false를 반환한다.
이 방식은 무한 재귀에 빠질 수 있다고 한다. 이게 무슨 소린가 봤는데
타입이 같지 않으면 상대방의 equals 메소드를 호출한다.
Point를 상속한 또 다른 클래스도 이렇게 구현했다면 둘의 타입이 서로 같이 않아
무한으로 서로의 equals 메소드를 호출한다.
근데 이건 super.equals()로 바꾸면 되는 거 아닌가?.. 왜 저렇게 짰는지 모르겠다.
이 현상은 해결할 방법이 없다고 한다.
클래스를 확장해 다른 변수를 추가하게 되면 equals 규약을 만족시킬 방법이 없다.
그러면 상속관계까지 고려해 클래스를 파악하는 instance of 대신
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의 하위 클래스는 Point로써 활용될 수 있어야 한다.
// 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));
}
}
CounterPoint는 Point를 상속받은 클래스이다.
콜렉션 API에서는 equals 메소드를 통해 같은 객체인지 비교한다.
그러나 Point의 equals의 재정의를 정확히 Point 클래스가 아니면 false를 반환해 예상한 결과가 나오지 않는다.
이는 리스코프 치환 원칙에 위배되는 설계이다.
이를 해결하기 위한 좋은 방법이 있다.
// 코드 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);
}
}
Point를 상속하지 않고 필드로 두는 방법이다.
이러면 모든 equals 규약을 만족할 수 있다.
그러나 이젠 Point로는 살 수 없어진다.
치환을 못하니 치환법칙에 위배되지 않는다.
실제 자바 라이브러리인 java.sql.Timestamp도 java.util.Date를 확장해 eqauls 규약을 어겼다고 한다.
이미 많은 클라이언트 코드에서 이를 쓰고 있을 테니 고칠 수도 없고
eqauls를 쓰는데 주의하라는 문구를 남겼다. 이런 실수를 하지 않게 조심해야겠다.
일관성은 한 번 x.equals(y)가 true를 반환했으면 영원히 true, false를 반환했으면 영원히 false를 반환해야 한다.
immutable 클래스라면 맞는 말이지만 가변 객체의 경우에는 달라질 수 있다.
null-아님은 null과의 equals 비교는 무조건 false를 반환해야 한다.
일부로 null이면 true를 반환하게 하지 않는 이상 어기기 쉽지 않다.
지금까지 해왔듯이 맨 위에서 instance of로 클래스 타입을 체크하면 자연스럽게 해결된다.
equals를 만드는 가이드라인이다.
- ==을 통해 레퍼런스를 비교한다.
-> 자기 자신과의 비교라면 아래의 비교 로직을 수행할 필요가 없다. - instance of를 통해 클래스 타입을 체크한다.
-> 아래에서 클래스 타입 변환 시 Exception 발생을 막는다. - 입력을 타입 캐스팅한다.
-> 2번을 통과했으므로 100퍼센트 동작한다. - 입력 객체와 자신의 핵심 필드들을 하나하나 비교한다.
-> 이 부분을 읽으면서 아까 위에서 상대방의 eqauls 메소드를 통해 비교한 이유를 알았다.
비교하고자 하는 타입이 인터페이스라면 해당 인터페이스의 메소드만 호출하여
발생할 수 있는 오류를 막을 수 있다.
클래스라면 내 자신의 필드를 접근해 비교할 수 있겠지만 상대방의 equals를 호출해도 같은 결과이기 때문에
상대방의 equals를 통해 비교하는 습관을 가지는 게 좋을 것 같다.
이번엔 클래스를 비교하는 법에 대해 소개한다.
float과 double을 제외한 premitive 타입은 ==을 통해 비교하고
레퍼런스 타입은 equals 메소드를 통해 비교해야 한다.
float과 double은 ==을 통해 비교하지 못하는가?
Float.NaN, -0.0f와 같은 특수한 부동 소수점을 비교하기 위해서
Float.compare(float, float)을 통해 비교해야 한다.
이건 처음 알았다 ㄷㄷ;
Float.equals를 활용할 수도 있지만 이는 float에 오토박싱 적용돼 성능 저하가 우려된다.
그런데 이걸 쓰는 상황은 Rapper 클래스와 premitive 타입 간의 비교일 텐데
Float.compare(float, float) 이걸 써도 언박싱으로 성능저하가 우려되는 것 아닌가?
내 생각엔 클래스를 생성하는 오토박싱이 비용이 더 커서 이렇게 표현한 것 같다.
비교하기 아주 복잡한 필드를 지니는 클래스는 그 필드의 표준형(canonical form)을 저장해 해결할 수 있다고 한다.
표준형에 대해 검색해도 어떤 의미인지 제대로 알 수가 없었다.
일단은 그런갑다하고 넘어가려 한다. 이후의 아이템에서 답을 찾기를 바라며..
어떤 필드를 먼저 비교하느냐에 따라 equals의 성능이 좌우되기도 한다.
&& 연산자는 앞에서 false면 뒤는 더 이상 검사하지 않고 리턴한다.
다를 가능성이 크거나 비교하는 비용이 싼 친구들을 앞에 배치하면 메소드의 성능을 향상시킬 수 있다.
equals를 재정의 했다면, 세 가지를 자문해 보자
대칭성, 추이성, 일관성을 지켰는가?
equals 메소드를 AutoValue를 이용해 작성했으면 테스트할 필요가 없다고 한다.
AutoValue는 Lombok처럼 equals나 Builder 같은 메소드를 생성해 주는 라이브러리라고 한다.
여기서 생성해 주는 equals가 매우 완벽해서 테스트할 필요가 없다고 한다.
마지막으로 주의사항 세 가지이다.
- equals를 재정의할 땐 hashcode도 반드시 재정의하라
set 같은 자료구조에 담을 때 이야기 같다. - 너무 복잡하게 해결하려 들지 말아라.
넵! - Object 타입 외에 매개변수로 받는 equals는 재정의 하지 말아라.
이건 뭐 자바 처음 배울 때 배우는 내용이다.
메소드 파라미터가 달라지면 같은 시그니쳐가 아니게 된다.
이는 Object의 eqauls를 재정의하는 것이 아닌
그냥 내가 만든 equals가 되게 된다.
@Override 어노테이션을 통해 오류를 방지하자.
한참 책 읽으면서 블로그를 작성했는데
결론이 라이브러리 써서 equals를 만들어라라니
정말 악마 같은 책이다.
코드 -
'Book > Effective Java' 카테고리의 다른 글
Effective Java - item 13. clone 재정의는 주의해서 진행하라 (0) | 2023.03.22 |
---|---|
Effective Java - Item 11. equals를 재정의하려거든 hashCode도 재정의하라 (0) | 2023.01.24 |
Effective Java - Item 9. try-finally보다는 try-with-resources를 사용하라 (0) | 2023.01.08 |
Effective Java - Item 8. finalizer와 cleaner 사용을 피하라 (0) | 2023.01.08 |
Effective Java - Item 7. 다 쓴 객체 참조를 해제하라 (0) | 2023.01.07 |