본문 바로가기
JAVA/Effective Java

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

by 민휘 2023. 5. 29.

이 문서가 다루는 것

  • String Constant Pool
  • equals의 일반 규약과 규약을 어기는 경우
  • 리스코프 치환 원칙
  • 바람직한 equals 구현 방법

세줄 요약

  • equals는 필요하지 않다면 재정의하지 않는다. 필요하다면 동치관계(반사, 대칭, 추이, 일관, null 아님)을 지켜야한다.
  • 특히 상속 관계에서 equals를 비교하게 되면 하위 타입에서 사용하는 필드를 포함해서 상위 타입과 하위 타입을 비교하지 말아라. 상위 타입과 하위 타입 비교는 상위 타입의 필드로만 해라. 하위 클래스에서 자신의 필드를 포함한 equals를 사용하고 싶다면 상속 말고 합성으로 구현하고 포인트 뷰를 열어라.
  • equals의 바람직한 정의 : 물리적 동치성 비교 → instanceOf 체크 후 변환 → 필드 비교

 


 

참고 : 2장은 Object의 메소드를 다룬다

 

물리적 동치성과 논리적 동치성

  • 물리적 동치성(객체 식별성) : 메모리 주소, 즉 참조가 동일하다. ==을 사용한 비교
  • 논리적 동치성 : 객체가 가진 필드 값이 동일하다. equals을 사용한 비교
  • 50달러 지폐 두개. 두 지폐는 물리적으로 다르지만, 그 가치는 동일하다.
  • 문자열의 동치성 비교 : 자바는 문자열을 String literal로 생성하면 힙 영역 내의 String Constant Pool에 저장하여 관리하므로, 참조가 동일하다. 하지만 new로 String 인스턴스를 생성하면 참조가 다르다.

public class main{
   public static void main(String[] args){
        String s1 = "cat";
        String s2 = "cat";

        System.out.println(s1 == s2);
        System.out.println(s1.equals(s2));
    }
}

 

 

equals는 이왕이면 재정의하지 말자

 

equals는 재정의하기 쉬워보이지만 곳곳에 함정이 도사리고 있어서 자칫하면 끔찍한 결과를 초래한다. 다음에서 열거한 상황 중 하나에 해당한다면 재정의하지 않는 것이 최선이다. 주로 논리적 동치성 비교가 개별 클래스를 위해 필요하지 않은 경우이다.

 

  • 각 인스턴스가 본질적으로 고유하다 : 싱글톤, enum, 값을 표현하는 것이 아니라 동작하는 객체를 표현하는 클래스. 예를 들어 Thread.
  • 인스턴스의 논리적 동치성을 검사할 일이 없다
  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다 : Set - AbstractSet, List - AbstractList, Map - AbstractMap
  • 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다 : 더 철저하게 막고 싶다면 equals를 재정의하여 에러를 던지게 할 수 있다.
// 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다
// 사이즈 체크
// 컬렉션 타입으로 존재 유무 확인
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에는 재정의된 equals가 없음
// 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다
// 더 철저하게 막고 싶다면 equals를 재정의하여 에러를 던지게 할 수 있다.
@Override public boolean equals(Object o) {
		throw new AssertionError();
}

 

 

 

equals를 재정의해야한다면, 일반 규약을 따라 동치관계를 구현하자

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

논리적 동치성을 확인해야하는데, 상위 클래스의 equals가 논리적 동치성을 비교하지 않는 경우에는 equals를 재정의해야한다. Integer나 String 같은 값 클래스가 이에 속한다. String의 부모 클래스는 Object, Integer의 부모 클래스는 Number이다.

public class Object {
		public boolean equals(Object obj) {
        return (this == obj); // 물리적 동치만 판단
    }
}
// equals를 재정의한 Integer
public final class Integer extends Number
        implements Comparable<Integer>, Constable, ConstantDesc {

		public boolean equals(Object obj) {
        if (obj instanceof Integer) {
            return value == ((Integer)obj).intValue();
        }
        return false;
    }

}

 

동치 관계

equals 메소드가 쓸모 있으려면 모든 원소가 같은 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야 한다. 동치관계를 만족시키려면 다음 요건을 만족해야한다.

  • 반사성 : x.equals(x) == true
  • 대칭성 : x.equals(y) == y.equals(x)
  • 추이성 : x.equals(y) && y.equals(z), x.equals(z)
  • 일관성 : x.equals(y) == x.equals(y)
  • null 아님 : x.equals(null) == false

 

반사성

x.equals(x) == true

객체는 자신과 같아야 한다. 이 요건은 일부러 어기는 경우가 아니라면 만족시키기 어렵다. 이 요건을 어긴 클래스의 인스턴스를 컬렉션에 넣은 다음 contains 메소드를 호출하면 방금 넣은 인스턴스가 없다고 할 것이다. contains도 equals를 내부적으로 사용하기 때문이다.

 

대칭성을 위반하는 경우

x.equals(y) == y.equals(x) // 이 조건을 만족해야한다

 

1. CaseInsensitiveString 예제

CaseInsensitiveString는 내부 필드로 String s를 가진다. 그리고 equals에서 대소문자를 구분하지 않고 철자가 동일하면 true를 반환한다. Object가 CaseInsensitiveString인 경우와 String인 경우를 둘다 검사한다.

// 대칭성이 깨진 equals
// CaseInsensitiveString의 equals는 String 비교 가능
// String의 equals는 CaseInsensitiveString 비교 불가능
public final class CaseInsensitiveString {
    private final String s;

    public CaseInsensitiveString(String s) {
        this.s = Objects.requireNonNull(s);
    }

    // Broken - violates symmetry!
    @Override public boolean equals(Object o) {
        if (o instanceof CaseInsensitiveString)
            return s.equalsIgnoreCase(
                    ((CaseInsensitiveString) o).s);
        if (o instanceof String)  // One-way interoperability!
            return s.equalsIgnoreCase((String) o);
        return false;
    }
}

// String은 CaseInsensitiveString을 몰라..
public final class String {
		public boolean equals(Object anObject) {
        if (this == anObject) {
            return true;
        }
        return (anObject instanceof String aString)
                && (!COMPACT_STRINGS || this.coder == aString.coder)
                && StringLatin1.equals(value, aString.value);
    }
}

 

CaseInsensitiveString은 String에 대해 true를 반환할 수 있지만, 그 반대는 불가능하다. 대칭성이 깨진다.

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
	
	// 논리적 동치성을 만족해야하는데 그러지 못함
	List<CaseInsensitiveString> list = new ArrayList<>();
	list.add(cis);
	
	System.out.println(list.contains(s)); // false
}

 

String을 바꿀 순 없으니 CaseInsensitiveString가 String을 모르게 맞춰줘야한다.

public final class CaseInsensitiveString {
    // Fixed equals method (Page 40)
    @Override public boolean equals(Object o) {
        return o instanceof CaseInsensitiveString &&
                ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
    }
}

 

 

2. 상속을 이용하는 ColorPoint 예제

 

x, y 좌표를 가지며 해당 필드로 논리적 일치성을 판단하는 Point 클래스가 있다.

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

    // See Item 11
    @Override public int hashCode()  {
        return 31 * x + y;
    }
}

 

Point 클래스를 상속하여 색깔을 가지는 ColorPoint를 구현했다. 색깔을 나타내는 enum Color을 추가하고, 논리적 일치성 비교를 위해 부모 클래스의 equals로 x, y 좌표를 비교하고 Color을 추가호 비교하는 equals를 구현한다.

public enum Color { RED, ORANGE, YELLOW, GREEN, BLUE, INDIGO, VIOLET }

public class ColorPoint extends Point {
    private final Color color;

    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }

    // Broken - violates symmetry!  (Page 41)
    @Override public boolean equals(Object o) {
        if (!(o instanceof ColorPoint))
            return false;
        return super.equals(o) && ((ColorPoint) o).color == color;
    }
}

 

Point의 equals는 x, y만 비교하므로 ColorPoint가 들어오면 동일하다고 판단하는데, ColorPoint의 equals는 색깔까지 비교하므로 Point가 들어오면 다르다고 판단한다. 역시 대칭성을 위반한다.

public static void main(String[] args) {
	// First equals function violates symmetry (Page 42)
	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
}

 

 

추이성을 위반하는 경우

if x.equals(y) && y.equals(z)
then x.equals(z)

 

1. 대칭성을 위반한 ColorPoint 개선

위에서 살펴본 상속을 이용한 ColorPoint에서 대칭성을 위반한 equal를 보완하기 위해, ColorPoint에서 Point 타입을 받으면 Point의 equals를 호출하도록 조건을 추가했다.

public class ColorPoint extends Point {
    private final Color color;

    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }

    // Broken - violates transitivity! (page 42)
    @Override public boolean equals(Object o) {
        if (!(o instanceof Point))
            return false;

        // If o is a normal Point, do a color-blind comparison
        if (!(o instanceof ColorPoint))
            return o.equals(this);

        // o is a ColorPoint; do a full comparison
        return super.equals(o) && ((ColorPoint) o).color == color;
    }
}

 

이렇게 만들면 대칭성은 지켜지지만, 추이성을 위반한다.

public static void main(String[] args) {
	// Second equals function violates transitivity (Page 42)
	ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
	Point p2 = new Point(1, 2);
	ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
	
	// if both true
	System.out.println(p1.equals(p2)); // true
	System.out.println(p2.equals(p3)); // true
	// should be true
	System.out.println(p1.equals(p3)); // false
}

 

그 이유는 p2는 색깔을 고려하지 않고 동일하다고 판단해서 p1, p3와 동일하다고 하지만, 막상 p1과 p3는 색깔을 비교하므로 다르다고 판단한다. 추이성을 위반한다.

 

게다가 이렇게 하위 타입에서 자신의 타입을 거르도록 equals를 짜면, Point 클래스의 하위 클래스를 작성했을 때 문제가 생긴다. 동일한 레벨의 하위 타입인 SmellPoint를 추가했을때, ColorPoint equals의 두번째 조건에 걸려서 SmellPoint의 equals를 호출하고, SmellPoint의 equals에서 또 두번째 조건에 걸려 ColorPoint의 equals를 호출한다. 결국 무한 재귀 호출에 걸려 StackOverflowError가 발생한다.

public class ColorPoint extends Point {
	@Override public boolean equals(Object o) {
		if (!(o instanceof Point)) return false;
		
		// SmellPoint equals 호출
		if (!(o instanceof ColorPoint)) return o.equals(this);
		
		// o is a ColorPoint; do a full comparison
		return super.equals(o) && ((ColorPoint) o).color == color;
	}
}

public class SmellPoint extends Point {
	@Override public boolean equals(Object o) {
		if (!(o instanceof Point)) return false;
		
		// ColorPoint equals 호출
		if (!(o instanceof SmellPoint)) return o.equals(this);
		
		// o is a ColorPoint; do a full comparison
		return super.equals(o) && ((ColorPoint) o).color == color;
	}
}

 

2. instanceOf 대신 getClass 사용

위에서 Point의 equals가 instanceOf를 사용했기 때문에 그 하위 타입의 equals까지 영향을 미쳤다. Point에서 하위 타입을 거르고 Point의 구체 클래스만 비교하도록 만들면 어떻게 될까?

public class Point {
    private final int x;
    private final int y;
    @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와 동일할 수 없게 된다. 이는 리스코프 치환 원칙을 정면으로 깨뜨린다. 리스코프 치환 원칙은 Point의 하위 클래스는 정의상 여전히 Point이므로 어디서든 Point로 활용될 수 있어야 한다고 제시한다. 하지만 getClass로 비교하면 Point와 ColorPoint는 언제나 다르다.

 

문제가 되는 상황을 코드로 살펴보자. 원 안에 포함되는 점을 Set으로 관리하고, 해당 Set에 Point가 포함되는지 알 수 있는 contains를 사용한다. p1은 포함된다고 하지만, 동일한 좌표를 가진 ColorPoint는 포함되지 않는다고 판단한다. 그 이유는 ColorPoint가 Point의 getClass 조건에 의해 동일한 타입이 아니어서 걸러지기 때문이다.

public class CounterPointTest {
		// Set 초기화
    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 ColorPoint(1,  0, Color.BLUE);

        // Prints true
        System.out.println(onUnitCircle(p1)); // true

        // Should print true, but doesn't if Point uses getClass-based equals
        System.out.println(onUnitCircle(p2)); // false
    }
}

 

계속 문제가 되는 것은 하위 클래스에 새로운 값을 추가했을 때 상위 타입과 하위 타입들을 비교하면서 발생하는 동치관계 위반이다. 그런데 사실 구체 클래스를 상속해 새로운 값을 추가하면서 equals 규약을 만족시키는 방법은 존재하지 않는다. 하위 클래스에 값이 추가되면 논리적 동치성을 판단할 때 해당 조건과 상위 타입인지 다른 타입인지 구분하는 조건을 추가해야한다. equals는 필드가 추가되면 다른 타입에 종속되어 상위 타입과의 대칭성, 추이성을 지킬 수 없다. → 값을 숨기고 하위 타입의 값 비교를 사용하지 않는 합성 방식 사용!

 

3. 그렇다면 뭘 써야해? 합성!

 

합성을 사용하면 하위 타입에서 추가되는 값은 신경쓰지 않고 상위 타입에서만 비교가 가능하다. 그러면서도 하위 타입의 equals는 지킬 수 있다. ColorPoint를 만들때 Point를 상속받지 말고 필드로 가지도록 하고, 외부에서 ColorPoint를 바라볼 때 Point와 관련된 메시지만 받을 수 있도록 포인트 뷰 메소드인 asPoint()를 추가한다.

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

    /**
     * Returns the point-view of this color 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();
    }
}

 

그러면 사용하는 쪽에서 ColorPoint를 Point로 받을 수 있으므로, ColorPoint의 동등성 비교를 할때 Point의 equals를 사용한다. 이 코드에서는 ColorPoint에서 색깔을 포함하는 equals를 사용하지 않고 Point가 x, y를 비교하는 equals를 사용한다. 상위 타입으로만 비교하므로 색깔이라는 추가된 필드가 끼어들지 않아서 대칭성과 추이성을 지킬 수 있다.

public static void main(String[] args) {
	Point p1 = new Point(1,  0);
	Point p2 = new ColorPoint(1,  0, Color.BLUE).asPoint();
	
	// Prints true
	System.out.println(onUnitCircle(p1));
	
	// Prints true
	System.out.println(onUnitCircle(p2));
}

 

일관성

가변 객체는 비교 시점에 따라 다를 수도 있지만, 불변 객체는 한번 다르면 끝까지 달라야 한다. 그리고 클래스가 불변이든 가변이든 equals 판단에 신뢰할 수 없는 자원이 끼어들면 안된다. 이 제약을 어기면 일관성 조건을 만족시키기가 어렵다.

 

예를 들어 URL 타입을 비교할 때 도메인과 url을 받는데, 이 도메인을 비교할 때 도메인이 가리키는 실제 IP까지 비교한다. 그래서 Virtual IP를 사용하는 도메인으로 비교하면 네트워크 상황에 따라 URL 비교가 달라질 수 있다. 도메인을 비교하는데 IP 자원이 끼어든 것이므로 일관성 유지가 안된다.

URL google1 = new URL("https", "about.google", "/products/");
URL google2 = new URL("https", "about.google", "/products/");
System.out.println(google1.equals(google2)); // IP 바뀌면 false

 

null 아님

모든 객체는 null과 같지 않아야 한다. 그리고 NPE가 터지지 않도록 equals에 null 체크도 추가해야한다. null 체크를 할때 instanceof를 사용하면 null인 경우도 같이 걸러지므로 이렇게 표현하는 것이 낫다.

// 명시적 null
if (o == null) return false;
// 묵시적 null
if (!(o instanceof Mytype)) return false;

 

equals 구현법 정리

  1. == 연산자를 이용해 입력이 자기 자신의 참조인지 확인 : 단순한 성능 최적화용으로, 비교 작업이 복잡한 상황일 때 가치가 있다.
  2. instanceof 연산자로 입력이 올바른 타입인지 확인 : 보통 올바른 타입은 equals가 정의된 클래스이다. 가끔은 올바른 타입이 그 클래스의 인터페이스일 수도 있다. 어떤 인터페이스는 구현 클래스끼리도 비교할 수 있도록 equals 규약을 수정하는데, 이런 경우 equals에서 인터페이스를 사용해야한다. Set, List, Map, Map.Entry 등의 컬렉션 인터페이스가 여기에 해당한다.
  3. 입력을 올바른 타입으로 형변환 : 2번을 통과했으므로 100% 통과
  4. 입력 객체와 자기 자신의 대응되는 핵심 필드가 일치하는지 모두 검사 : 하나라도 다르면 false를 반환한다. 2번에서 인터페이스를 사용했다면 입력의 필드 값을 가져올 때도 그 인터페이스의 메서드를 사용한다. 타입이 클래스라면 해당 필드에 직접 접근할 수도 있다.

 

마지막 주의사항

  • equals를 재정의할 땐 hashCode도 반드시 재정의하자.
  • 너무 복잡하게 해결하지 말자. 필드의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있다.
  • Object 외의 타입을 매개변수로 받는 equals는 선언하지 말자. Object.equals를 재정의하는 것이 아니라 오버로딩한 것이다.
  • AutoValue나 Lombok 같은 보일러 플레이트 생성 라이브러리로 equals를 작성할 수 있다.