equals는 일반 규약을 지켜 재정의하라(Effective Java).

Choizz·2023년 8월 8일
0

이펙티브 자바

목록 보기
9/13

이번 포스팅은 이펙티브 자바의 아이템 중 "equals는 일반 규약을 지켜 재정의하라."라는 내용에 대한 것 입니다.
그래서 이번에 Object 클래스의 equals를 재정의하는 방법에 대해 다루어 보겠습니다.

개발을 하다 보면 equals() 메서드를 종종 사용하는 경우가 있습니다.
보통 String 클래스에서 Object 클래스의 equals를 오버라이딩한 것을 자주 사용하는 것 같습니다.

여기서 사용한 코드는 백기선님의 이펙티브 자바 완벽 공략 1부에 나오는 코드를 사용할 것 입니다.


equals를 재정의할 필요가 없는 경우

책에서는 eqauls를 재정의하지 않은 것이 최선이라고 이야기합니다.
이러한 경우는 아래와 같습니다.

(1) 인스턴스가 본질적으로 고유할 경우

  • 만약 인스턴스가 싱글턴으로 단 하나만 존재한다면 비교할 것이 없기 때문에 equals를 재정의할 필요가 없습니다.

(2) 인스턴스의 논리적 동치성을 검사할 필요가 없는 경우

  • 인스턴스들 끼리 equals를 사용해서 논리적을 같은 지를 검사할 필요가 없는 경우는 Object의 equals로 충분할 것입니다.
    • 예를 들어, Pattern 체에서 정규표현식이 같은지를 비교할 수 있겠지만, 굳이 그럴 필요가 없기 때문에 Object equals를 사용하는 것 입니다.

(3) 상위 클래스에서 재정의한 equals가 하위 클래스에서도 적절할 경우

  • Collection 프레임워크에서 재정의한 equals를 사용하는 것을 의미합니다.
    • AbstractSet이 구현한 equals, List 구현체들은 AbstractList로부터, Map 구현체들은 AbstractMap에서 상속 받은 것을 사용합니다.

(4) 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없는 경우

  • 내부 클래스에서 equals가 사용되지 않는 경우를 의미한다고 생각하시면 됩니다.

equals를 재정의 해야 하는 경우

equals를 재정의 해야하는 경우는, 논리적으로 같은지를 확인해야 할때 사용합니다.

대표적인 예로 String을 들 수 있습니다.

  • 아래 코드의 str1과 str2는 논리적인 내용은 같지만 메모리 주소가 다르기 때문에 == 비교를 하게되면 다르다고 나옵니다.
  • 하지만, String에서 재정의한 equals를 사용하면, 논리적으로 같은 인스턴스이므로 true가 리턴됩니다.
String str1 = new String("test1")
String str2 = new String("test1")

str1==str2 //false
str1.equals(str2) //true

Value Oject(값 클래스)에서 주로 equals를 재정의 해야합니다. 즉, 객체마다 데이터를 가지는 클래스에서 equals를 사용할 경우 재정의를 해서 논리적인 동치성을 확인해야하는 것입니다.


equals를 재정의 하는 방법

equals를 재정의할 경우 반드시 따라야 하는 규약이 있습니다.
이것은 equals를 재정의 하는 클래스들은 모두 이 규약을 지켜야한다는 것을 의미합니다.

  • 반사성(reflexivity) : null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 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다.

대칭성 위반 예시

  • 아래 코드는 equal를 재정의한 것은 대칭성을 위반하고 있습니다.
  • equals 코드를 살펴 보면, 어떤 객체(o)가 들어오고, 그것이 CaseInsensitiveString 타입이면, 객체가 가지고 있는 필드와 비교를 합니다.
public final class CaseInsensitiveString {
    private final String s;

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

	@Override //1
    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;
    }
}
  • 아래 코드를 보면 String에서 재정의한 equals와 CaseInsensitiveString에서 재정의한 equals가 다른 결과를 내는 것을 알 수 있습니다. 한 쪽은 true이고, 한 쪽은 false를 리턴합니다.
  • 대칭성의 정의를 다시 살펴보면, null이 아닌 모든 참조 값 x,y에 대해 x.equals(y)가 true면 y.equals(x)도 true다. 인데, 이것을 위반하고 있습니다. 즉, CaseInsensitiveString에서 재정의한 equals와 String에서 정의한 equals가 다르게 재정의 되었기 때문입니다.
  • CaseInsensitiveString에서 equals 재정의 규약이 위배된 것입니다.
CaseInsensitiveString a = new CaseInsensitiveString("test");
String b = "test";

a.equals(b) // true;
b.equals(a) // false;

아래 처럼, 다시 재정의 해보면 어떨까요??

  • 둘 다 false가 나오는 것을 확인할 수 있습니다.
  • 만약 둘 다 true가 나오는 경우가 있다면, 대칭성을 보장한다고 볼 수 있게 됐습니다.
 @Override //2
 public boolean equals(Object o) {
        return o instanceof CaseInsensitiveString &&
                ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}

jshell> a.equals(b) //터미널에서 jshell이라는 것을 사용하여 테스트해봤습니다.
$16 ==> false

jshell> b.equals(a)
$17 ==> false
  • 아래 코드는 a와 b를 비교했을 때, 둘다 true가 나왔습니다.
jshell> CaseInsensitiveString a = new CaseInsensitiveString("test");
a ==> CaseInsensitiveString@5e9f23b4

jshell> CaseInsensitiveString b = new CaseInsensitiveString("test");
b ==> CaseInsensitiveString@378fd1ac

jshell> a.equals(b)
$20 ==> true

jshell> b.equals(a)
$21 ==> true

헷갈릴 수 있겠지만 정리해 보겠습니다.
"test"라는 문자열을 가지고 대칭성에 대해 알아보고 있습니다.
하나는 CaseInsensitiveString 객체의 필드값으로 저장되고, 하나는 String 자체에 저장되었습니다.

  • 대칭성은 null이 아닌 모든 참조 값 x,y에 대해 x.equals(y)가 true면 y.equals(x)도 true다. 라는 조건을 만족해야 합니다.
  • //2 에 재정의된 equals를 사용하면, String과 CaseInsensitiveString의 문자열 비교는 둘 다 false가 나왔습니다.
    • 당연한 결과입니다. 둘은 타입 자체가 다르기 때문이죠!!

      만약 getter를 추가해서 equals를 비교하면,
      String c = a.getS();
      String b = "test"
      c.equals(b); //true
      b.equals(c); //true
      가 나옵니다. 당연합니다. String 타입에 내용도 같기 때문입니다.

  • 반면, 둘 다 CaseInsensitiveString 객체로 비교했을 때, 둘 다 true가 나왔습니다.
    • 두 인스턴스가 다르면 false가 나오고 같으면 true가 나옵니다. 대칭성이 성립되었습니다!!
  • //1에서 정의한 equals는 두 인스턴스를 CaseInsensitiveString로 하면 둘 다 true가 나오지만,
    String 객체와 비교시 하나는 true, 하나는 false가 나왔습니다. 대칭성에 위반한 것입니다.
    • 만약 둘 다 false가 나왔다면, 대칭성 규약을 지킨 것이겠죠!

상속을 할때 equals 규약 위반을 지키지 못하는 경우

아래 클래스는 x 좌표와 y 좌표를 가집니다.

public class Point {
    protected final int x;
    protected 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를 상속하는 ColorPoint라는 클래스를 만들었습니다.

  • Color라는 필드가 추가됐습니다.

public class ColorPoint extends Point {
    private final Color color;

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

만약 ColorPoint에서 equals()를 사용하려면, Point에 재정의한 equals를 사용해야 할까요??
그렇게 된다면, 문제가 있습니다.
ColorPoint에는 Color라는 필드가 있는데 Point의 equals는 Color에 관한 비교를 할 수 없기 때문입니다.

그렇다면, ColorPoint에 Color를 비교할 수 있도록 equals를 재정의해보겠습니다.
그런데, 아래 코드처럼, 작성을 하게 되면, 대칭성이 위반하게 됩니다.

	@Override
    public boolean equals(Object o) {
        if(!(o instanceof ColorPoint))
            return false;
        return super.equals(o) && ((ColorPoint)o).color == color;
    }
  • Point에 재정의한 equals에 ColorPoint 인스턴스가 들어가면, true가 리턴됩니다.
    • Color 필드를 비교하지 않고, x와 y만 비교하기 때문입니다.
  • ColorPoint에서 재정의한 equals는 false가 리턴됩니다.
    • Point는 ColorPoint 타입이 아니기 때문입니다.
  • 대칭성이 위반되었습니다.
//대칭성 위반
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);

p.eqauls(cp); // true
cp.eqauls(p); // false
  • ColorPoint에 Point 객체가 들어올 수 있도록 다시 equals를 재정의해 보겠습니다. Point 객체가 들어오면 Color는 비교하지 않습니다.
  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;
 }
  • 그러나, 이렇게 재정의를 하는 것은 추이성을 위반하게 된 것입니다.
  • 추이성의 정의를 다시 보면, null이 아닌 모든 참조 값 x,y,z에 대해, x.equals(y)가 true이고, y.equals(z)도 true면, x.equals(z)도 true다. 인데, 이것을 위반하고 있습니다.
  • Color 필드를 비교 대상에서 제외했기 때문입니다.
ColorPoint p1 = new ColorPoint(1,2,Color.RED);
Point p2 = new Point(1,2);
ColorPoint p3 = new ColorPoint(1,2,Color.BLUE);

p1.eqauls(p2); // true
p2.eqauls(p3); // true
p1.eqauls(p3); // false 

그러면, Point 객체에 있는 equals를 새롭게 재정의 해보겠습니다.

  • 아래의 코드는 getClass()를 이용하여 비교를 하고 있습니다.
  • 여기서 equals 메서드에 ColorPoint가 들어가면 false를 리턴하게 됩니다.
  • 상위 타입을 상속, 구현한 하위 타입은 상위 타입의 역할을 할 수 있다는 리스코프 치환 원칙을 위배하고 있습니다. 클래스가 다르기 때문입니다.
    // 잘못된 코드 - 리스코프 치환 원칙 위배
    @Overrid
    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를 상속한 ColorPoint에 필드 값이 존재한다면, equals 규약을 지킬 방법이 없어보입니다.
만약, ColorPoint에 필드가 없다면, Point에서 재정의한 equals를 사용해도 될 것 입니다.


상속한 객체에 필드를 추가해야 하는 경우는 Composition을 사용한다.

  • 아래 코드처럼 Point 객체를 상속보단 Composition한다면, 보다 수월하게 대칭성과 추이성에 맞게 재정의 할 수 있습니다.
  • 각각의 클래스에서 재정의한 equals를 사용할 수 있기 때문입니다.
public class ColorPoint {
    private final Point point;
    private final Color color;

    public ColorPoint(Point point, Color color) {
        this.point = point;
        this.color = Objects.requireNonNull(color);
    }

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

equals 메서드 구현 단계

(1) == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.

  • 자기 자신일 경우 true를 반환합니다. 성능 최적화용이라고 생각하면 됩니다.

(2) instaceof 연산자로 입력이 올바른 타입인지 확인한다.

  • 아닐 경우 false를 리턴합니다.

(3) 입력을 올바른 타입으로 형변환한다.

(4) 입력된 인스턴스와 핵심적인 필드들이 모두 일치하는지 확인한다.

  • 핵심적이지 않은 필드들은 비교하지 않아도 됩니다. 예를 들어, 동기화에 쓰는 필드들은 어떤 객체가 가지고 있는 특수한 필드가 아니기 때문입니다.
  • float, double은 compare로 비교하고, 원시 타입은 ==, 레퍼런스 타입은 equals를 사용합니다.

(5) 해시코드 또한 재정의한다.

  • 자료구조에서 해시코드를 사용하기 때문에 equals를 재정의하는 경우 해시코드 또한 재정의해야합니다.

(6) Object가 아닌 타입의 매개변수를 받는 equals 메서드는 지양한다.

  • 이것은 메서드 시그니쳐를 바꾸는 것이므로 오버라이딩이 아닌 오버로딩입니다.

정리

  • 만드는 과정이 상당히 복잡합니다. 그러니 IDE나 lombok을 이용하는 것이 좋습니다.
  • Java 17을 사용한다면, record 타입을 사용하는 것도 좋은 방법일 것 같습니다.

reference

profile
집중

2개의 댓글

comment-user-thumbnail
2023년 8월 8일

유익한 글이었습니다.

1개의 답글