제육's 휘발성 코딩
article thumbnail
반응형

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

Object는 자바의 최상위 클래스이며, 모든 클래스의 부모 역할을 한다. Object의 메서드 중 equals는 보통 논리적 동치성 즉, String 이나 Integer 처럼 값 클래스 들의 주솟값이 아닌 값을 비교하기 위해 재정의 해서 사용한다.

equals 재정의를 사용하지 않아도 되는 경우

  • 각 인스턴스가 본질적으로 고유한 경우
    • Thread 처럼 값을 표현하는 것이 아닌 동작하는 개체를 표현하는 클래스는 Object 클래스의 equals 메서드에서 기본적으로 제공한다.
  • 논리적 동치성(logical equality)을 검사할 일이 없는 경우
  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 적합한 경우
    • 예를 들어 Set 인터페이스는 AbstractSet 이 구현한 equals를 사용하는 것을 의미한다.
  • 클래스가 private하거나 default인 경우

equals 재정의를 사용하는 경우

값을 비교하기 위해서 주솟값 비교인 객체 식별 확인이 아닌, 논리적 동치성을 확인할 때를 의미한다. 값 클래스의 경우에도 싱글톤과 같은 인스턴스 통제 클래스이거나, Enum의 경우에는 매번 다른 인스턴스를 생성하기 때문에 재정의를 하지 않아도 된다.

따라서, 재정의를 하는 경우 다음과 같은 규약을 따른다.

  • 반사성(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)를 반복해서 호출하면 항상 같은 값을 반환한다.
  • null-아님 : null이 아닌 모든 참조 값 x에 대해 x.equals(null)은 항상 false다.

 

대칭성

import java.util.Objects;

public class EqualsTest {
  public static void main(String[] args) {
    CaseInsensitiveString cis = new CaseInsensitiveString("a");
    String s = "a";
    System.out.println(cis.equals(s)); // true
    System.out.println(s.equals(cis)); // false
  }
}

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;
  }
}
  • 다음과 같이 CaseInsensitiveString를 구현하면 대칭성을 위배한다. 그 이유는 String의 equals는 CaseInsensitiveString의 존재를 모르기 때문이다.
  • 참고로 equalsIgnoreCase 는 대소문자를 구분안하고 비교하는 메서드이다.
@Override
public boolean equals(Object o) {
  return o instanceof CaseInsensitiveString && ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
  • 다음과 같이 String과의 연동하겠다는 허황한 꿈을 버리고 해당 인스턴스에 대해서만 비교하는 것이 올바르다. 그렇게 구현한다면 위의 두 방식 모두 false가 반환되어 대칭성을 지킬 수 있다.

 

추이성

package effective.item10;

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

class ColorPoint extends Point {
  private final Color color;

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

enum Color {
  RED, BLUE, GREEN
}

class PointTest {
  public static void main(String[] args) {
    ColorPoint cp = new ColorPoint(1,1,Color.RED);
    ColorPoint cp2 = new ColorPoint(1,1,Color.BLUE);
    System.out.println(cp.equals(cp2)); // true : 대칭성 위배
  }
}
  • 다음과 같이 Point 클래스를 상속받은 ColorPoint 가 있다. 상속받아서 재정의된 부모의 equals는 물려 받았지만, Color 에 대한 비교는 이루어지지 않았다.
class ColorPoint extends Point {
  private final Color color;

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

  @Override
  public boolean equals(Object o) {
    if (!(o instanceof ColorPoint))
      return false;
    return super.equals(o) && ((ColorPoint) o).color == color;
  }
}
  • 다음과 같이 자식의 입장에서도 color를 비교해주는 기능을 추가하여 해결할 수 있다. 하지만 문제가 발생한다.
ColorPoint cp1 = new ColorPoint(1, 1, Color.RED);
Point p2 = new Point(1, 1);
System.out.println(cp1.equals(p2)); // false
System.out.println(p2.equals(cp1)); // true
  • 다음과 같이 비교한다면 대칭성에 문제가 발생한다.
@Override
public boolean equals(Object o) {
  if (!(o instanceof Point))
    return false;
  if (!(o instanceof ColorPoint))
    return o.equals(this);
  return super.equals(o) && ((ColorPoint) o).color == color;
}

class PointTest {
    public static void main(String[] args) {
        ColorPoint cp1 = new ColorPoint(1, 1, Color.RED);
        Point p2 = new Point(1, 1);
        ColorPoint cp3 = new ColorPoint(1, 1, Color.BLUE);
        System.out.println(cp1.equals(p2));
        System.out.println(p2.equals(cp3));
        System.out.println(cp1.equals(cp3));
    }
}
  • 다음과 같이 Point로 오는 경우 자기 자신을 비교하여 무시하면 대칭성은 해결할 수 있다. 하지만 추이성이 깨져버린다. 그 이유는 cp1과 cp3의 비교에서는 색상까지 비교하기 때문이다.

이 현상은 모든 객체지향 언어의 동치관계에서 나타나는 근본적인 문제다.

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

 

리스코프 치환 원칙 위배

  • 얼핏보면 instanceof 검사를 getClass 검사로 바꾸면 해결될 것처럼 보인다. 하지만 하위 클래스는 부모 클래스로 활용이 될 수없어 리스코프 치환 원칙을 위배하게 된다.
  • 예를 들어, Point 클래스에서 새로운 하위 타입인스턴스를 getClass를 사용해 equals를 작성했다면, 비교 시 instanceof는 true를 반환하지만 getClass는 항상 false를 반환한다.

 

구체 클래스의 하위 클래스에 값을 추가하는 방법이 없다는 결론이 나온다.

하지만, 우회하는 방법이 한 가지 존재한다. 이는 상속이 아닌, 컴포지션(has ~ a) 관계를 사용하는 것이다.

package effective.item10;

import java.util.Objects;

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 ColorPoint) {
            Point p = ((ColorPoint) o).asPoint();
            return p.x == x && p.y == y;
        }
        if (!(o instanceof  Point))
            return false;

        Point p = (Point) o;
        return p.x == x && p.y == y;
    }
}

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) {
            ColorPoint cp = (ColorPoint) o;
            return cp.point.equals(point) && cp.color.equals(color);
        }
        else if (o instanceof Point) {
            Point p = (Point) o;
            return p.equals(point);
        }
        return false;
    }
}

enum Color {
    RED, BLUE, GREEN
}

class PointTest {
    public static void main(String[] args) {
        ColorPoint cp1 = new ColorPoint(1, 1, Color.RED);
        Point p2 = new Point(1, 1);
        ColorPoint cp3 = new ColorPoint(1, 1, Color.RED);
        System.out.println(cp1.equals(p2)); // true
        System.out.println(p2.equals(cp3)); // true
        System.out.println(cp1.equals(cp3)); // true
    }
}
  • 다음과 같이 컴포지션을 활용하여 ColorPoint 에서는 Point의 객체인 경우 x,y 만을 비교할 수 있도록 구현할 수 있다.
  • 자바 라이브러리에서도 구체 클래스를 확장해 값을 추가한 클래스가 종종 있다. 대표적으로 java.sql.Timestamp가 해당된다. 해당 라이브러리의 equalsDate 객체와 대칭성을 위배한다. 즉, 해당 설계는 오류이다.

 

추상 클래스의 하위 클래스에서라면 equals 규약을 지키면서도 값을 추가할 수 있다. 예컨데 아무런 값을 갖지 않는 추상 클래스인 Shape를 두고, 이를 확장하여 radius 필드를 추가한 Circle 클래스와, length와 width 필드를 추가한 Rectangle 클래스를 만들 수 있다.

상위 클래스를 직접 인스턴스로 만드는게 불가능하다면 지금까지의 문제들은 발생하지 않는다.

 

일관성

일관성은 두 객체가 같다면 수정되지 않는 한 영원히 같아야 한다는 의미다. 즉, 불변 객체는 반드시 일관성을 지켜야 한다. 따라서 클래스를 작성할 때 불변 클래스와 가변 클래스 중 심사숙고하는 부분이 필요하다.

불변, 가변 여부를 떠나서 equals 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안된다. 예를 들어 java.net.URLequals는 매핑된 URL과 호스트 IP 주소를 이용해 비교한다. 호스트 이름을 IP 주소로 바꾸려면 네트워크를 통해 변경하기 때문에 항상 같다고 보장할 수 없다. 그래서 URLequals가 일반 규약을 어기게 되고, 실무에서도 종종 문제가 발생하곤 한다.

 

null 아님

null 아님은 모든 객체가 null과 같지 않아야 한다는 뜻이다. 의도하지 않았지만 발생할 경우 NPE를 던지게 될 것이다. 다음 코드처럼 입력이 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;
  ...
}
  • 묵시적 null 검사

올바른 equals 구현 방법

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

  public PhoneNumber(...){...}

  @Override
  public boolean equals(Object o) {
    if (o == this)
      return true;
    if (!(o instanceof PhoneNumber))
      return false;
    PhoneNumber pn = (PhoneNumber) o;
      return pn.lineNum == lineNum && pn.prefix == prefix && pn.areaCode == areaCode;
    }
}
  • == 연산자를 사용하여 입력이 자기 자신의 참조인지 확인한다.
  • instanceof 연산자로 입력이 올바른 타입인지 확인한다.
  • 입력을 올바른 타입으로 형변환 한다.
  • 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 검사한다.

 

equals를 다 구현했다면 대칭성, 추이성, 일관성 이 세 가지를 자문하고 단위테스트를 통해 검증을 하자. 세 가지 요건 중 하나라도 실패한다면 원인을 찾아서 고치는 습관을 갖자. 반사성과 null 아님도 만족하는 것이 좋지만 이 두 가지 경우가 문제되는 경우는 거의 없다.

꼭 필요한 경우가 아니라면 equals를 재정의하지 말자. equals를 재정의할 땐 hashCode도 반드시 재정의하자. (다음 포스팅) Object 외의 타입을 매개변수로 받는 equals는 선언하지 말자.

반응형
profile

제육's 휘발성 코딩

@sasca37

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요! 맞구독은 언제나 환영입니다^^