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

문법식·2022년 3월 11일
0

Effective Java 3/E

목록 보기
10/52

equals 메소드 재정의 상황

equals 메서드는 재정의하기 쉬워 보이지만 곳곳에 함정이 도사리고 있어서 끔찍한 결과를 초래할 수 있다. 함정을 피하는 가장 쉬운 길은 아예 재정의하지 않는 것이다.
다음과 같은 상황 중 하나라도 해당하면 재정의하지 않는 것이 좋다.

  • 각 인스턴스가 본질적으로 고유하다.
    값을 표현하는게 아니라 동작하는 개체를 표현하는 클래스가 여기 해당한다. Thread가 좋은 예로, Objectequals메서드는 이러한 클래스에 딱 맞게 구현되었다.
  • 인스턴스의 논리적 동치성(logicl equality)을 검사할 일이 없다.
  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.
  • 클래스가 private이거나 pakage-private이고, equals메서드를 호출할 일이 없다.

다음과 같은 상황이면 equals를 재정의하면 된다.

  • 객체 식별성(두 객체가 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때
    주로 IntegerString 같은 값 클래스가 여기에 해당한다. 물론 값 클래스라 해도, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하면(아이템 1, Enum) equals를 재정의하지 않아도 된다.

equals 메소드 재정의 규약

equals 메서드를 재정의할 때는 동치관계를 구현하며 다음의 Object 명세에 적힌 규약을 반드시 따라야 한다.

  • 반사성(reflexivity): null이 아닌 모든 참조 값 x에 대해, x.equals(x)true다.
  • 대칭성(symmetry): null이 아닌 모든 참조 값 x, y에 대해, x.eqauls(y)truey.equals(x)true다.
  • 추이성(transitivity): null이 아닌 모든 참조 값 x, y, z 에 대해, x.equals(y)true이고 y.equals(z)truex.equals(z)true다.
  • 일관성(consistency): null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.
  • null 아님: null이 아닌 모든 참조 값 x에 대해. x.equals(null)false다.

수많은 클래스는 전달받은 객체가 equals 규약을 지킨다고 가정하고 동작한다.

Object 명세의 동치 관계는 쉽게 말하면 집합을 서로 같은 원소들로 이뤄진 부분집합으로 나누는 연산이다.

반사성

단순히 말하면 객체는 자기 자신과 같아야 한다는 뜻이다. 이 규약을 어기는게 더 어렵다.

대칭성

두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻이다. 다음은 대칭성 위배의 예시 코드이다.

코드

public final class CaseInsensitiveString {
    private final String s;

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

    /**
     * 대칭성 위반 코드
     * @param o
     * @return boolean
     */
    @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;
    }
}
public class Main {
    public static void main(String[] args) {
        //대칭성 위배
        CaseInsensitiveString cis=new CaseInsensitiveString("Polish");
        String s="polish";

        System.out.println(cis.equals(s));
        System.out.println(s.equals(cis));
    }
}

출력 결과

true
false

위 코드는 대소문자를 구별하지 않는 문자열을 구현한 클래스이다. 당연히 CaseInsensitiveString 클래스는 equalsString 클래스를 대소문자 구별없이 비교해야 하는 것은 알지만 문제는 String 클래스이다. String 클래스의 equalsCaseInsensitiveString을 대소문자 구별없이 비교해야 한다는 것을 모른다. 그래서 출력 결과를 보면 알 수 있듯이 대칭성을 위반한 것이다. 위에서 강조했듯이 수많은 클래스들이 equals가 규약을 지켜서 구현했다고 가정하고 동작한다. equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없다.
위 코드의 해결법은 String 클래스와 CaseInsensitiveString을 연동한다는 바보같은 꿈을 버려야 한다. 아래 코드처럼 수정하면 된다.

수정 코드

public final class CaseInsensitiveString {
    private final String s;

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

    /**
     * CaseInsensitiveString의 equals를 String과 연동한다는 미친 짓은 하지말아야한다. 아래의 코드와 같이 짜면 된다.
     * @param o
     * @return boolean
     */
    @Override
    public boolean equals(Object o) {
        return o instanceof CaseInsensitiveString && ((CaseInsensitiveString)o).s.equalsIgnoreCase(s);
    }
}
public class Main {
    public static void main(String[] args) {
        CaseInsensitiveString cis=new CaseInsensitiveString("Polish");
        String s="polish";

        System.out.println(cis.equals(s));
        System.out.println(s.equals(cis));
    }
}

출력 결과

false
false

추이성

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

코드

public enum Color {
    RED, BLUE, GREEN
}
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;
    }

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

    /**
     * 추이성 위반 코드
     * @param o
     * @return boolean
     */
    @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;
    }
}

주석처리된 코드와 같이 Pointequals와 색상 정보 비교로 ColorPointequals를 구현하면 대칭성 위반이다. Pointequals는 색상 정보를 무시하고 ColorPointequals는 입력 매개변수의 클래스 종류가 달라서 매번 false를 반환하기 때문이다. 그렇다면 색상을 무시하게 하면 어떻게 될까?

코드

public class Main {
    public static void main(String[] args) {
        // 추이성 위배
        ColorPoint colorPoint1=new ColorPoint(1, 2, Color.BLUE);
        Point point1=new Point(1, 2);
        ColorPoint colorPoint2=new ColorPoint(1, 2, Color.RED);

        System.out.println(colorPoint1.equals(point1));
        System.out.println(point1.equals(colorPoint2));
        System.out.println(colorPoint1.equals(colorPoint2));
    }
}

출력 결과

true
true
false

colorPoint1point1이 서로 같고 point1colorPoint2가 같다는 결과를 출력하는데 colorPoint1colorPoint2는 다르다는 결과를 출력하고 있다. ColorPointequals는 추이성을 위반하고 있는 equals 인 것이다.

구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.

일관성

두 객체가 같다면(어느 하나 혹은 두 객체가 모두가 수정되지 않는 한) 앞으로도 영원히 같아야 한다는 뜻이다. 가변 객체는 비교 시점에 따라 서로 다를 수도 혹은 같을 수도 있지만, 불변 객체는 한번 다르면 끝까지 달라야하고 반대도 마찬가지이다.
클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다. 예를 들어 java.net.URLequals는 주어진 URL과 매핑된 호스트의 IP주소를 이용해 비교한다. 호스트의 이름을 IP주소로 바꾸려면 네트워크를 통해야 하는데, 그 결과가 항상 같다고 보장할 수 없다. 이는 URLequals가 일반 규약을 어기게 하고, 실무에서도 종종 문제를 일으킨다.

null 아님

규약 이름 그대로 모든 객체가 null과 같지 않아야 한다는 뜻이다.

좋은 equals 메소드 구현 방법

  • == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
  • instanceof 연산자로 입력이 올바른 타입인지 확인한다.
    그렇지 않다면 false를 반환한다.
  • 입력을 올바른 타입으로 형변환한다.
  • 입력 객체와 자기 자신의 대응되는 "핵심"필드들이 모두 일치하는지 하나씩 검사한다.

어떤 필드를 먼저 비교하느냐가 equals 성능을 좌우하기도 한다. 최상의 성능을 바란다면 다를 가능성이 더 크거나 비교하는 비용이 싼 필드를 먼저 비교하면 좋다.

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

마지막으로 주의해야 하는 사항들이다.

  • equals를 재정의할 때는 hashcode도 반드시 재정의하자
  • 너무 복잡하게 해결하려 들지 말자.
    필드들의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있다. 오히려 너무 공격적으로 파고들다가 문제를 일으키기도 한다.
  • Object 외의 타입을 매개변수로 받는 equals메서드는 선언하지 말자. Object 외의 타입을 매개변수로 받는 equals메서드를 선언하면 오버라이딩이 아니라 오버로딩을 한 것이다. 기본 equals를 그대로 둔 채 추가한 것일지라도, 타입을 구체적으로 명시한 equals는 해가 된다. 이 메서드는 하위 클래스에서 @Override이 긍정 오류를 내게 하고 보안 측면에서도 잘못된 정보를 준다.

테스트 코드는 너무 뻔하고 지루하다. 구글에서 만든 AutoValue 프레임워크를 사용하면 지루한 작업을 대신해주고 사람의 부주의로 발생하는 실수를 저지르지 않기 때문에 사용하는 것이 좋다.

profile
백엔드

0개의 댓글