[이펙티브 자바] 아이템10 | equals는 일반규약을 지켜 재정의하라

제롬·2022년 1월 18일
0

이펙티브자바

목록 보기
10/25

equals를 재정의해야할까?

클래스의 인스턴스를 그냥 두면 오직 자기 자신과만 같게 된다. 그러니 아래와 같은 상황 중 하나에 해당된다면 재정의하지 않는것이 좋다.

  • 값을 표현하는것이 아니라 개체를 표현하는 클래스
    • Thread 의 경우 Objectequals()로도 충분하다.
  • 인스턴스의 논리적 동치성을 검사할 일이 없다.
    • 논리적 동치성이 필요하지 않다면 Object의 기본 equals()로도 충분하다.
  • 상위 클래스에서 재정의한 equals()가 하위 클래스에도 딱 들어맞는다.
    • Set 구현체는 AbstractSet이 구현한 equals()를 사용하고 List 구현체들은 AbstractList 로부터, Map 구현체들은 AbstractMap으로부터 상속받아 사용한다.
  • 클래스가 private이거나 package-private이면 equals()를 호출할 일이없다.
    • 이런 경우 안전하게 equals()를 오버라이딩해서 예외를 발생시켜 호출되는것을 막자.

[클래스 접근제어자가 private 또는 package-private인 경우]

@Override
public boolean equals(final Object obj) {
    throw new AssertionError(); // 예외를 발생시켜 호출되는것을 막아야한다.
}

equals를 재정의해야 하는 경우

  • 객체 식별성(두 객체가 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals()논리적 동치성 을 비교하도록 재정의되지 않았을 때 주로 IntegerString 처럼 값 클래스들이 여기에 해당한다.

  • 단, 값 클래스라고해도 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스라면 equals를 재정의하지 않아도된다. Enum이 여기에 해당된다.

논리적 동치성

논리적 동치성 비교란 참조 타입 변수를 비교하는 것이다. 비교할 값을 정하고 값을 비교하여 두 객체가 같다면 서로 "논리적으로 같다" 라고 한다.

[논리적 동치성 비교]

public class Sample {
    public static void main(String[] args) {
    	String string1 = "논리적 동치성 비교";
        String string2 = "논리적 동치성 비교";
    
        final StringBuilder stringBuilder1 = new StringBuilder();
        final StringBuilder stringBuilder2 = new StringBuilder();

        stringBuilder1.append("논리적 동치성 비교X");
        stringBuilder1.append("논리적 동치성 비교X");

        System.out.println(stringBuilder1.equals(stringBuilder2)); //String의 equals사용
        System.out.println(string1.equals(string2)); // object의 equals사용
    }
}
// 실행결과
false
true
  • String, Integer, Double 같은 값 클래스들은 equals가 논리적 동치성 비교를 위해 이미 오버라이딩 되어있다.
  • StringBuilder의 경우 Objectequals를 그대로 사용한다.
  • 사용자가 만든 클래스에 논리적 동치성 비교가 필요하다면 equals를 재정의해 사용해야 한다.

equals 재정의 일반 규약

equals 메서드는 동치관계를 구현하며 아래와 같은 규약을 만족한다.

  • 반사성
  • 대칭성
  • 추이성
  • 일관성
  • null-아님

동치 클래스(equivalent class)

집합을 서로 같은 원소들로 이루어진 부분집합으로 나누는 연산으로 equals 가 쓸모 있으려면 모든 원소가 같은 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야 한다.

반사성(reflexivity)

  • null이 아닌 모든 참조 값 x에 대해, x.equals(x) = true
  • 즉, 객체는 자기 자신과 같아야한다.

대칭성(symmetry)

  • null이 아닌 모든 참조 값 x,y에 대해, x.equals(y) = true 이면 y.equals(x) = true 이다.
  • 두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 의미이다.

[잘못된 코드 - 대칭성 위배]

public class CaseInsensitiveString {
    private final String s;

    public CaseInsensitiveString(final String s) {
        this.s = Objects.requireNonNull(s);
    }
    
    // 대칭성 위배
    @Override
    public boolean equals(final Object obj) {
        if(obj instanceof CaseInsensitiveString){
            return s.equalsIgnoreCase(((CaseInsensitiveString) obj).s);
        }
        
        if(obj instanceof String){ // 한 방향으로만 작동
            return s.equalsIgnoreCase((String) obj);
        }
        
        return false;
    }
    ...
}
...
CaseInsensitiveString caseInsensitiveString = new CaseInsensitiveString("Polish");
String s = "polish";

System.out.println(caseInsensitiveString.equals(s));
System.out.println(s.equals(caseInsensitiveString));
// 실행결과
true
false

caseInsensitiveString.equals(s)s.equals(caseInsensitiveString)의 결과가 같지 않아 대칭성을 위반하고 있다는것을 알 수 있다.

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

[해결 - CaseInsentiveString 의 equals를 String과 연동하지 않는다.]

@Override
public boolean equals(final Object obj) {
        return obj instanceof CaseInsensitiveString && ((CaseInsensitiveString) obj).s.equalsIgnoreCase(s);
}

추이성(transitivity)

  • null이 아닌 모든 참조 값 x,y,z에 대해, x.equals(y) = true 이면 y.equals(z) = true 이면 x.equals(z) = true 이다.
  • 첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체와 세 번째 객체도 같아야 한다는 뜻이다.
  • 추이성 은 상위 클래스에 없는 새로운 필드를 하위 클래스에 추가하며 equals를 재 정의할 때 자주 발생하는 문제다.

[상위 클래스 - Point 와 필드를 추가한 하위클래스 - ColorPoint]

public class Point {
    private final int x;
    private final int y;

    public Point(final int x, final int y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public boolean equals(final Object obj) {
        if (!(obj instanceof Point)) {
            return false;
        }

        Point point = (Point) obj;
        return point.x == x && point.y == y;
    }
    ...
}

public class ColorPoint extends Point {
    private final Color color;

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

[잘못된 코드 - 대칭성 위배]

@Override
public boolean equals(final Object obj) {
        if (!(obj instanceof ColorPoint)) {
            return false;
        }
        
        return super.equals(obj) && ((ColorPoint) obj).color == color; 
}
...
public ColorPoint(final int x, final int y, final Color color) {
        super(x, y);
        this.color = color;
}
...
public static void main(String[] args) {
        Point point = new Point(1, 2);
        ColorPoint colorPoint = new ColorPoint(1, 2, Color.RED);

        System.out.println(point.equals(colorPoint));
        System.out.println(colorPoint.equals(point));
}
// 실행결과
true
false

위 코드에서 point.equals(colorPoint)true를 반환하고 colorPoint.equals(point)false를 반환한다. ColorPointequals는 입력 매개변수의 클래스 종류가 다르다면 매번 false만 반환할 것이다.

[잘못된 코드 - 추이성 위배]

@Override
public boolean equals(final Object obj) {
        if (!(obj instanceof Point)) {
            return false;
        }
        
        // obj 가 일반 Point 이면 색상을 무시하고 비교한다.
        if(!(obj instanceof ColorPoint)){
            return obj.equals(this);
        }

        // obj 가 ColorPoint 이면 색상까지 비교한다.
        return super.equals(obj) && ((ColorPoint) obj).color == color;
}
...
public static void main(String[] args) {
        ColorPoint point1 = new ColorPoint(1, 2, Color.RED);
        Point point2 = new Point(1, 2);
        ColorPoint point3 = new ColorPoint(1, 2, Color.BLUE);

        System.out.println(point1.equals(point2));
        System.out.println(point2.equals(point3));
        System.out.println(point1.equals(point1));
}
// 실행결과
true
true
false

위 방식은 대칭성은 지켜주지만 추이성은 깨뜨려버린다. point1.equals(point2)point2.equals(point3)true를 반환하는데 point1.equals(point3)false 를 반환하기 때문이다. 이는 point1point3 비교에서 색상까지 고려했기 때문이다.

이 현상은 모든 객체 지향 언어의 동치관계에서 나타나는 근본적인 문제로 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.

하지만, 괜찮은 우회방법이 하나 있는데 바로 조합(컴포지션)을 이용하는 것이다.

조합(Composition)

기존 클래스가 새로운 클래스의 구성요소로 쓰인다.

  • 기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 한다. 컴포지션을 통해 새 클래스의 인스턴스 메서드들은 기존 클래스에 대앙하는 메서드를 호출해 그 결과를 반환한다.

[조합(컴포지션)을 이용한 해결방법]

public class ColorPoint{
    private final Point point;
    private final Color color;

    public ColorPoint(final int x, final int y, final Color color) {
        point = new Point(x,y);
        this.color = Objects.requireNonNull(Color);
    }
    
    public Point asPoint(){
        return point;
    }

    @Override
    public boolean equals(final Object obj) {
        if(!(obj instanceof ColorPoint)){
            return false;
        }
        
        ColorPoint colorPoint = (ColorPoint) obj;
        return colorPoint.point.equals(point) && colorPoint.color.equals(color);
    }
    ...
}

위 코드는 equals 규약을 지키면서 값을 추가하고 있다. ColorPointequals를 이용해ColorPointColorPoint를 비교, ColorPointasPointColorPointPoint 를 비교하고 PointequalsPointPoint 비교.

Point를 상속하는 대신 PointColorPointprivate 필드로 두고 ColorPoint와 같은 위치의 일반 Point를 반환하는 뷰 메서드를 public으로 추가하는 방식이다.

일관성(consistencty)

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

  • 일관성 은 두객체가 같다면 (어느하나 혹은 두 객체가 모두 수정되지 않는 한) 앞으로도 영원히 같아야 한다는 뜻이다. 가변 객체는 비교 시점에 따라 다를 수도 혹은 같을 수도 있는 반면, 불변 객체는 한번 다르면 끝까지 달라야한다.

  • 클래스가 불변이든 가변이든 equals()의 판단에 신뢰할 수 없는 자원이 끼어 들게 해서는 안된다. 이 제약을 어길시 일관성 조건을 만족시키기가 어렵다.

null-아님

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

  • 모든 객체가 null과 같지 않아야한다는 것이다. 이 규약은 NullPointerException을 던지는 경우도 허용하지 않는다.

[묵시적 null 검사]

@Override
    public boolean equals(final Object obj) {
        if(!(obj instanceof MyType) ){
            return false;
        }
        
        MyType myType = (MyType) obj;
}

동시성을 검사하려면 equals()를 건네받은 객체를 적절히 형변환한 후 필수 필드들의 값을 알아내야한다. 그러려면 형변환에 앞어 instanceof 연산자로 입력 매개변수가 올바른 타입인지 검사해야한다. 입력이 null이면 타입 확인단계에서 false를 반환하므로 null검사를 하지 않아도 된다.

equals 메서드 단계별 구현 방법

  • == 연산자를 사용해 입력이 자기 자신의 참조인지 확인.
  • instanceOf 연산자로 입력이 올바른 타입인지 확인
  • 입력을 올바른 타입으로 형변환한다.
  • 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사한다.
  • 대칭성, 추이성, 일관성, 반사성, null-아님을 확인한다.

[잘 구현된 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 구현시 주의사항

  • equals를 재정의할 때 hashCode도 반드시 재정의하자.
  • 필드의 동치성만 검사해도 규약을 지킬수 있다.
  • Object 외의 타입을 매개변수로 받는 equals는 선언하지 말자.

AutoValue 프레임워크

equals(hashCode도 마찬가지)를 작성하고 테스트 작업을 대신해줄 구글에서 만든 오픈소스이다. 클래스에 에너테이션 하나만 추가하면 알아서 메서드들을 작성해준다.

정리

꼭 필요한 경우가 아니라면 equals를 재정의하지 말자 많은 경우에 Objectequals가 원하는 비교를 정확히 수행해준다. 재 정의해야 할 때는 그 클래스의 핵심 필드 모두를 빠짐없이, 다섯가지 규약을 지켜가며 비교해야한다.


[Reference]
이펙티브 자바 아이템 10 equals는 일반 규약을 지켜 재정의 하라
objey general contract when overriding equals
아이템10. equals는 일반 규약을 지켜 재정의하라

0개의 댓글