DEV ℧ Developer Diary

[EffectiveJava] item10 - equals는 일반 규약을 지켜 재정의하라.

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

equals를 재정의 하지 않는 경우!

equals 메서드는 재정의하기 쉬워 보이지만 곳곳에 함정이 도사리고 있어서 자칫하면 끔찍한 결과를 초래한다.

만약 담의 상황중에 하나의 상황에 해당한다면, 재정의 하지 않는것이 최선이다.

  • 각 인스턴스가 본질적으로 고유하다.

예를 들면, 값이 아닌 동작을 나타내는 객체로 Thread가 해당된다.

  • 인스턴스의 ‘논리적 동치성(logical equality)’을 검사할 일이 없다.

java.util.regex.Pattern와 같이 재정의가 필요하지 않다면 기본 equals로 충분하다.

  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.

Set 구현체는 AbstractSer, List 구현체는 AbstractList, Map 구현체 AbstractMap 으로부터 상속받아 그대로 쓴다

  • 클래스가 private이거나 package-private이고, equals 메서드를 호출할 일이 없다.

해당 패키지에 실수로라도 호출되는 걸 막고싶다면 다음과 같이 구현하자.

@Override public boolean equals(Object o) {
    throw new AssertionError(); // 호출 금지!
}

equals를 재정의가 필요한 경우!

equals를 재정의해야 할 때는 언제일까?

객체 식별성(object identity; 두객체가 물리적으로 같은지)이 아니라 논리적 동치성을 확인해야 할때, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의 되지 않았을 때 사용한다.

물리적 동치성? 논리적 동치성?

물리적 동치성이란? 메모리에 저장된 주소의 값이 일치한지 비교하는 것을 말한다. 논리적 동치성이란? 주소의 값이 아닌 참조 객체 내부의 변수를 비교하는 것이다. 비교할 값을 정하고 값을 비교하여 두 객체가 같다면 서로 “논리적으로 같다”라고 한다.

public class Test {
    public static void main(String[] args) {

        /* 물리적 동치성 */
        String physicalStr1 = "abc";
        String physicalStr2 = "abc";
        String physicalStr3 = new String("abc");
        StringBuilder physicalSb1 = new StringBuilder("abc");
        StringBuilder physicalSb2 = new StringBuilder("abc");

        /**
         * physicalStr1와 physicalStr2의 주소값은 String pool에 의해 같은 주소를 참조한다.
         * physicalStr2와 physicalStr3는 new String에 String을 새롭게 인스턴스화 했으므로 주소값이 다르다.
         * physicalSb1와 physicalSb2는 동일하게 new로 새롭게 인스턴스화 했으므로 주소값이 다르다.
         */
        System.out.println(physicalStr1 == physicalStr2); /* true */
        System.out.println(physicalStr2 == physicalStr3); /* false */
        System.out.println(physicalSb1 == physicalSb2); /* false */

        /* 논리적 동치성 */
        String logicalStr1 = "abc";
        String logicalStr2 = "abc";
        String logicalStr3 = new String("abc");
        StringBuilder logicalSb1 = new StringBuilder("abc");
        StringBuilder logicalSb2 = new StringBuilder("abc");

        /**
         * logicalStr1와 logicalStr2는 참조하는 변수의 값이 동일하다.
         * logicalStr2와 logicalStr3는 새로운 인스턴스르 생성했지반 참조하는 변수의 값이 동일하다.
         * logicalSb1와 logicalSb2는 참조하는 변수의 값이 동일하지만, String과 StringBuilder의 equals의 구현부가 다르다.
         */
        System.out.println(logicalStr1.equals(logicalStr2)); /* true */
        System.out.println(logicalStr2.equals(logicalStr3)); /* true */
        System.out.println(logicalSb1.equals(logicalSb2)); /* false */
    }
}

equals메서드의 일반 규약

equals 메서드를 재정의 할 때는 반드시 일반 규약을 따라야 한다.

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

  • 반사성(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(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;
    }
}

해당 equals구현체를 비교해보자

CaseInsensitiveString cis = new CaseInsensitiveString("Pikachu");
String s = "Pikachu";

두 문자를 참조한 변수를 equals로 비교 해준다면, cis.equals(s)는 true를 반환한다. CaseInsensitiveString의 equals에 String을 명시했기 때문이다.

하지만 s.equals(cis)는 false를 반환한다. String은 CaseInsensitiveString의 존재를 모르기 때문이다.

또다른 예시로 해당 객체를 Collection객체인 List에 넣어 비교해보자.

List<CaseInsensitiveString> list = new ArrayList<>();
list.add(cis);

해당 예시에서 list.contains(s)를 호출하면 false를 반환하지만 다른 jdk에서는 true 혹은 런타임 예외를 반환 할 수 도 있다.

이렇게 equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없다.

해당 문제를 해결해 올바르게 재정의한다면 다음과 같다.

/* 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다.

추이성은 첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체와, 세 번째 객체도 같아야 한다는 뜻이다.

간단히 2차원에서 점을 표현하는 클래스를 예로 들어보자.

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

이렇다면 equals는 어떻게 해야할까? equals를 재정의 하여 점과 색상이 같은 경우 같은 객체라는것을 재정의 해보도록 하자

/* 대칭성 위반!! */
@Override public boolean equals(Object o){
    if(!(o instanceof ColorPoint))
        return false;
    return super.equals(o)&&((ColorPoint)o).color==color;
}

위치와 색상이 같을 경우 true를 반환하는 equals를 작성했다.

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

해당 equals의 경우 p.equals(cp)는 true, cd.equals(p)는 false를 반환한다. 그렇다면 ColorPoint.equals가 Point와 비교할 때는 색상을 무시하도록 하면 어떨까?

/* 추이성 위배!! */
@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 p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);

이제 p1.equals(p2)와 1p2.equals(p3)는 true를 반환하지만, p1.equals(p3)`가 false를 반환 한다. p1과 p3의 비교에서는 색상까지 고려 됐기 때문이다.

해당 문제점에 대한 해결법은 구체 클래스(Point)를 확장해 새로운 값(PointColor)을 추가하면서 equals를 규약을 만족시킬 방법은 존재 하지 않는다.

equals안의 instanceof 검사를 getClass로 바꾸면, 리스코프 치환법칙을 위반 하게 되므로 해당 방법도 옳바르지 않다.

리스코프 치환의 법칙 : 상위 타입의 객체를 하위 타입의 객체로 치환해도 동작에 문제가 없어야 한다.

결국, 구체 클래스의 하위 클래스에서 값을 추가할 방법은 없지만 Point를 상속하는 대신 Point를 ColorPoint의 private 필드로 두고 ColorPoint 와 같은 위치의 일반 Point를 반환하는 뷰 메서드를 public으로 추가하는 방식이다. 해당 방식은 item18에서 만나볼 수 있다.

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.sql.Timestampjava.util.Date를 확장 한 후 nanoseconds 필드를 추가 했다.

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

일관성은 두 객체가 같다면 앞으로도 영원히 같아야 한다는 뜻이다. equals의 판단에 신뢰할 수 없는 자원이 끼어 들게 해서는 안된다. 이 제약을 어기면 일관성 조건을 만족시키기가 아주 어렵다.

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

마지막으로 해당 요건은 모든 객체가 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 mt = (MyType) o;
}

위와 같은 경우는 equals가 타입을 확인 하지 않으면 잘못된 타입이 인스로 주어졌을 때 ClassCastException을 던져서 일반 규약을 위배하게 된다. 하지만, 두번째의 instanceof는 두 번째 피연산자와 무관하게 첫 번째 피연산자가 null이면 false를 반환한다.

equals 구현방법을 정리해보자.

지금까지의 내용으로 equals의 구현방법을 단계별로 정리 해보자.

  1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
  2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.
  3. 입력이 올바른 타입으로 형변환 한다.
  4. 입력 객체와 자기 자신의 대응되는 ‘핵심’필드들이 모두 일치하는지 하나씩 검사한다.

equals를 다 구현했다면 세가지만 자문해보자. 대칭적인가? 추이성이 있는가? 일관적인가?

아래는 equals를 재정의한 정상적인 예시이다.

public class PhoneNumber {
    private final short areaCode, prefix, lineNum;

    public PhoneNumber(final short areaCode, final short prefix, final short lineNum) {
        this.areaCode = rangeCheck(areaCode, 999, "지역코드");
        this.prefix = rangeCheck(prefix, 999, "프리픽스");
        this.lineNum = rangeCheck(lineNum, 999, "가입자 번호");
    }

    private short rangeCheck(final int val, final int max, final String arg) {
        if (val < 0 || val > max)
            throw new IllegalArgumentException(arg + ": " + val);
        return (short) val;
    }

    @Override
    public boolean equals(final Object obj) {
        if (obj == this)
            return true;
        if (!(obj instanceof PhoneNumber))
            return false;
        PhoneNumber phoneNumber = (PhoneNumber) obj;
        return phoneNumber.lineNum == lineNum && phoneNumber.prefix == prefix
                && phoneNumber.areaCode == areaCode;
    }
}

마지막으로 몇가지 주의사항을 더 당부하겠다.

  • equals를 재정의할땐 hashCode도 반드시 재정의하자 -> 다음장에서 배울 수있다.
  • 너무 복잡하게 해결하려 들지 말자
  • Object 외의 타입을 매개변수로 받는 equals메서드는 선언하지 말자.
/* 잘못된 예 - 입력 타입은 반드시 Object여야 한다! */
public boolean equals(MyClass o) {
    ...
}

equals의 성능을 높히려면

어떤 필드를 먼저 비교하느냐가 equals의 성능을 좌우하기도 한다. 최상의 성능을 바란다면 다를 가능성이 더 크거나 비교하는 비용이 싼 필드를 먼저 비교하자. 동기화용 락(lock)필드와 같이 객체의 논리적 상태와 관련없는 필드는 비교하면 안된다.

##