본문 바로가기
개발 공부/Java

이펙티브 자바 아이템 10 - equals 는 일반 규약을 지켜 재정의하라 - 핵심 정리

by 개발인생 2022. 10. 19.
반응형

아이템 10 - equals 는 일반 규약을 지켜 재정의하라 - 핵심 정리

이 글은 백기선 님의 이펙티브 자바 강의와 이펙티브 자바 3 / E 편을 참고하여 작성하였습니다.

모든 클래스는 전부 Object 클래스를 상속 받는다

Object 클래스에 있는 메서드를 오버라이딩 할 수 있다.

이번 아이템에서는 equals 메서드 에 대해서 다룬다.

equals 메서드는 직접 구현할 때도 있고 아닐 때도 있지만 보통은 IDE 나 Tool 을 사용해 equals 메서드를 정의하게 된다.

equals 메서드 를 만들지 않아도 된다면 만들지 않는게 최선이다.

equals 메서드 를 만들지 않아도 되는 상황은 다음과 같다.

각 인스턴스가 본질적으로 고유할 때.

싱글톤 패턴을 구현했다고 가정해보자.

그 Object는 그 자체로 고유할 수 밖에 없다.

이런 상황에서는 굳이 equals 메서드 가 필요하지 않다.

enum 역시 근본적으로 단 하나만 존재 하기 때문에 마찬가지이다.


인스턴스의 ‘논리적 동치성’을 검사할 필요가 없을 때

5천원 짜리 지폐 2장이 있다고 가정해보자.

5천원 짜리 지폐 2장은 같은 걸까, 다른 걸까?

이 질문의 대답은 어떤것이 같냐고 물어보느냐에 따라 다르다.

값이 같냐고 물어본다면 5천원 지폐 2장은 같은 것이 된다.

이것을 논리적 동치성 이라고 한다.

하지만 5천원 지폐 2장은 엄연히 다른 돈이다.

Object 가 제공하는 기본적인 equals 메서드 는 객체의 동일성을 비교한다.

Object 가 제공하는 기본적인 equals 메서드 로 따지자면 5천원 지폐 2장은 서로 다르다는 결과를 낸다.

대표적으로 문자열 같은 경우 논리적 동치성 을 검사한다.


상위 클래스에서 재정의한 equals 가 정의되어 있을 때

대표적인 경우로는 List, Set 을 상속해서 구현하는 경우가 있다.

List, Set 의 상위 클래스에에 equals 메서드 가 구현되어있기 때문에 굳이 직접 구현할 필요가 없다.

클래스가 private 이거나 package-private 이고 equals 메서드를 호출할 일이 없을 때

어딘가 굉장히 제한적인 클래스이고, 그 클래스에서 equals 메서드를 호출할 일이 없을 때는

equals 메서드 재정의할 필요가 없다.

public 클래스인 경우에는 equals 메서드 가 호출이 되지 않을거라는 보장이 없다.

public 클래스를 List, Set 에 넣게되면 equals 메서드 가 호출된다.


equals 메서드 를 재정의 해야하는 경우라면 몇가지 따라야할 규약이 있다.

반사성

A.equals(A) == true

반사성은 본인이 본인과 비교를 했을 떄 같다고 나와야하는 걸 말한다.

거울에 비친 것과 같다고 생각하면 된다.

대칭성

A.equals(B) == B.equals(A)

A 객체에서 B 객체를 비교한 결과와 B 객체에서 A 객체를 비교한 결과가 같아야한다.

둘 중 한쪽의 결과가 다르다면 대칭성이 꺠진 것 이다.

// 코드 10-1 잘못된 코드 - 대칭성 위배! (54-55쪽)
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;
   }
}

CaseInsensitiveString 클래스를 작성한다.

   @Override public boolean equals(Object o) {
      if (o instanceof CaseInsensitiveString)
         return s.equalsIgnoreCase(
               ((CaseInsensitiveString) o).s); // 전달받은 Object 를 형변환 하여 필드값 비교
      if (o instanceof String)  // 한 방향으로만 작동한다!
         return s.equalsIgnoreCase((String) o); // 전달받은 Object가 String이라면 String과 문자열비교
      return false;
   }

위와 같이 equals 메서드를 오버라이딩 하고있다.

CaseInsensitiveString 클래스를 String 클래스와 동급으로 사용하려하는 코드이다.

이렇게 코드를 작성하면 대칭성이 깨진다.

public class main {
   public static void main(String[] args) {
      CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
      String polish = "polish";
      System.out.println(cis.equals(polish)); // true 가 나온다.

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

      System.out.println(list.contains(polish)); // false 가 나온다.
   }
}   

위의 코드에서 보면 CaseInsensitiveString 객체에서 String 객체를 비교하면 true 가 나온다.

하지만 list.contains(polish) 코드에서는 false 가 나온다.

cis.equals(polish) 의 값과 polish.equals(cis) 의 값이 다르다는 뜻이다.

String 클래스에서는 우리가 작성한 CaseInsensitiveString 클래스 자체를 고려하지 않기때문이다.

// 코드 10-1 잘못된 코드 - 대칭성 위배! (54-55쪽)
public final class CaseInsensitiveString {
   private final String s;

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

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

대칭성을 위배하지 않으려면 본인의 타입에 대해서만 비교를 해야한다.

equals 메서드에서 다른 타입을 지원하면 문제가 복잡해지고, 대칭성이 깨지기 쉽다.

추이성

A.equals(B) && B.equals(C), A.equals(C)

A 객체와 B 객체가 같고 B 객체가 C 객체와 같으면 A 객체는 C 객체와 같다.

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 (this == o) {
         return true;
      }

      if (!(o instanceof Point)) {
         return false;
      }

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

   public static void main(String[] args) {
      Point point = new Point(1, 2);
      List<Point> points = new ArrayList<>();
      points.add(point);
      System.out.println(points.contains(new Point(1, 2)));
   }

   // 아이템 11 참조
   @Override public int hashCode()  {
      return 31 * x + y;
   }
}

Point 클래스를 작성한다.

여기까지는 깔끔하다.

문제는 상속을 받은 뒤에 생긴다.

public class ColorPoint extends Point {
   private final Color color;

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

Point 클래스를 상속받은 ColorPont 클래스를 작성한다.

여기서 Color 는

public enum Color { RED, ORANGE, YELLOW, GREEN, BLUE, INDIGO, VIOLET }

이렇게 따로 enum 으로 생성한다.

Color 필드가 추가되었으니 equals 메서드를 어떻게 정의해야할까?

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

부모 클래스의 equals 를 사용하고 color 를 다시 비교하는 방법이다.

겉으로는 좋아보이지만 equals 메서드의 대칭성을 깨뜨린다.

public class main {
   public static void main(String[] args) {
        Point p = new Point(1, 2);
        ColorPoint cp = new ColorPoint(1, 2, Color.RED);
        System.out.println(p.equals(cp) + " " + cp.equals(p));
   }
}   

Point 와 ColorPoint 클래스를 비교한다.

Point 클래스에서 ColorPoint 클래스를 비교하면 True 의 결과가 나오지만

ColorPoint 클래스에서 Point 클래스를 비교하면 False 의 결과가 나온다.

@Override public boolean equals(Object o) {
        if (!(o instanceof ColorPoint))
            return false;
        return super.equals(o) && ((ColorPoint) o).color == color;

Point 클래스는 ColorPoint 타입이 아니기 때문에 위의 if 문에서 false 가 반환되게 된다.

그렇다면 타입을 고려해서 비교하면 어떻게 될까?

public class ColorPoint extends Point {
   private final Color color;

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

   // 코드 10-3 잘못된 코드 - 추이성 위배! (57쪽)
   @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;
   }
}

위의 코드처럼 equals 메서드를 수정해보자.

위의 equals 메서드처럼 equals 를 작성하면

ColorPoint 클래스와 같은 레벨의 Point 를 상속받은 클래스를 equals 로 비교하게 되면

서로의 equals 를 계속 호출하게되어 StackOverFlow 에러가 발생한다.

public class main {
   public static void main(String[] args) {
      ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
      Point p2 = new Point(1, 2);
      ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
      System.out.printf("%s %s %s%n",
            p1.equals(p2), p2.equals(p3), p1.equals(p3));
   }
}   

위의 코드를 실행해보면 추이성이 깨지는 걸 확인할 수 있다.

이러한 상황에서 추이성을 보장하기위해 상위 클래스는 상위 클래스에서 비교하고

하위 클래스는 하위 클래스에서 비교시키려고 시도할 수도 있다.

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

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

   // 잘못된 코드 - 리스코프 치환 원칙 위배! (59쪽)
    @Override public boolean equals(Object o) {
        if (o == null || o.getClass() != getClass())
            return false;
        Point p = (Point) o;
        return p.x == x && p.y == y;
    }

   // 아이템 11 참조
   @Override public int hashCode()  {
      return 31 * x + y;
   }
}

위처럼 getClass 를 통해 타입이 아닌 클래스를 꺼내서 비교하게 수정했다.

위처럼 작성하게되면 리스코프 치환 원칙 을 위배하게 된다.

public class CounterPoint extends Point {
    private static final AtomicInteger counter =
            new AtomicInteger();

    public CounterPoint(int x, int y) {
        super(x, y);
        counter.incrementAndGet();
    }
    public static int numberCreated() { return counter.get(); }
}

Point 를 상속받은 CounterPoint 클래스를 작성한다.

// CounterPoint를 Point로 사용하는 테스트 프로그램
public class CounterPointTest {
    // 단위 원 안의 모든 점을 포함하도록 unitCircle을 초기화한다. (58쪽)
    private static final Set<Point> unitCircle = Set.of(
            new Point( 1,  0), new Point( 0,  1),
            new Point(-1,  0), new Point( 0, -1));

    public static boolean onUnitCircle(Point p) {
        return unitCircle.contains(p);
    }

    public static void main(String[] args) {
        Point p1 = new Point(1,  0);
        Point p2 = new CounterPoint(1, 0);

        // true를 출력한다.
        System.out.println(onUnitCircle(p1));

        // true를 출력해야 하지만, Point의 equals가 getClass를 사용해 작성되었다면 그렇지 않다.
        System.out.println(onUnitCircle(p2));
    }
}

CounterPoint 클래스는 Point 클래스에 재정의 되어있는 equals 메서드를 사용한다.

onUnitCircle(p1) 는 true 를 반환한다.

하지만 onUnitCircle(p2) 는 true 를 반환해야하지만 false 를 반환하게 된다.

리스코프 치환 원칙상위 클래스 타입으로 동작하는 코드를 하위 클래스 타입의 인스턴스를 주더라도 그대로 동작해야한다 는 원칙이다.

즉, Point 클래스의 하위타입인 CounterPoint 클래스의 인스턴스를 파라미터로 주었을 때 동일한 결과가 나와야한다는 것이다.

위의 코드는 리스코프 치환 원칙 을 위배한다.

Set 에서 equals 메서드와 hashcode 를 확인하는데

Point 클래스의 equals 메서드에서 getClass 를 사용하기 때문에 False 가 반환되는 것이다.

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 static void main(String[] args) {
      Point point = new Point(1, 2);
      List<Point> points = new ArrayList<>();
      points.add(point);
      System.out.println(points.contains(new Point(1, 2)));
   }

   // 아이템 11 참조
   @Override public int hashCode()  {
      return 31 * x + y;
   }
}

instanceof 를 이용한다면 어떻게 될까?

// CounterPoint를 Point로 사용하는 테스트 프로그램
public class CounterPointTest {
    // 단위 원 안의 모든 점을 포함하도록 unitCircle을 초기화한다. (58쪽)
    private static final Set<Point> unitCircle = Set.of(
            new Point( 1,  0), new Point( 0,  1),
            new Point(-1,  0), new Point( 0, -1));

    public static boolean onUnitCircle(Point p) {
        return unitCircle.contains(p);
    }

    public static void main(String[] args) {
        Point p1 = new Point(1,  0);
        Point p2 = new CounterPoint(1, 0);

        // true를 출력한다.
        System.out.println(onUnitCircle(p1));

        // true를 출력한다.
        System.out.println(onUnitCircle(p2));
    }
}

리스코프 치환 원칙을 위반하지 않는다.

상속을 사용하게되면 equals 메서드를 재정의하는데 많은 문제가 생긴다.

상속받은 클래스에서 아무런 필드도 추가하지 않았다면 상위 클래스의 equals 메서드를 그대로 사용해도 된다.

상위 클래스에서는 하위 클래스를 고려할 필요없이 equals 메서드를 정의하면 된다.

만약 상속을 받은뒤 필드를 추가하게 된다면 equals 규약을 만족시키는 방법은 존재하지 않는다.

이미 Java 코드에도 이런 경우가 존재한다.

public class EqualsInJava extends Object {

   public static void main(String[] args) throws MalformedURLException {
      long time = System.currentTimeMillis();
      Timestamp timestamp = new Timestamp(time);
      Date date = new Date(time);

      // 대칭성 위배! P60
      System.out.println(date.equals(timestamp)); // true
      System.out.println(timestamp.equals(date)); // false
   }
}

대표적으로 Date 클래스를 상속받아 만든 Timestamp 클래스가 있다.

Timestamp 클래스와 Date 클래스는 대칭성이 깨져있다.

그래서 컴포지션을 권장한다.

어떠한 상속을 받아 필드를 추가하고 싶은 경우에는 상속을 하지말고

컴포지션을 사용해 필드를 추가한다.

// 코드 10-5 equals 규약을 지키면서 값 추가하기 (60쪽)
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);
   }

   @Override public int hashCode() {
      return 31 * point.hashCode() + color.hashCode();
   }
}

새로운 클래스를 정의하고 상속할 클래스와 추가할 필드를 나란히 정의한다.

그리고 Point 타입의 뷰를 제공한다.

   /**
    * 이 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);

equals 메서드는 위와같이 재정의한다.

자기 자신의 타입을 확인하고 자신의 필드를 검사한다.

import java.awt.*;

public class CounterPointTest {

   private static final Set<Point> unitCircle = Set.of(
         new Point(1, 0), new Point(0, 1),
         new Point(-1, 0), new Point(0, -1));

   public static boolean onUnitCircle(Point p) {
      return unitCircle.contains(p);
   }

   public static void main(String[] args) {
      Point p1 = new Point(1, 0);
      Point p2 = new ColorPoint(1, 0, Color.RED).asPoint();

      // true 
      System.out.println(onUnitCircle(p1));

      // true
      System.out.println(onUnitCircle(p2));
   }
}

이전의 CounterPointTest 코드에서 방금 생성한 ColorPoint 를 사용하면 위와같다.

컴포지션을 사용하는 경우에 상속받은 클래스를 사용하고 싶다면 해당 필드에 대한 뷰를 이용해 사용한다.

일관성

A.equals(B) == A.equals(B)

A.equals(B) 를 호출했으면 두번째 A.equals(B) 를 호출했을 때도 결과가 같아야한다.

하지만 일관성은 깨질 수 있다.

객체 안에 들어있는 값이 바뀌면 일관성이 깨질 수 있다.

불변 객체처럼 한번 값이 셋팅되고 그 뒤로 바뀌지 않는 객체라면 일관성이 항상 보장된다.

일관성은 불변 객체냐 가변 객체냐에 따라 달라진다.

불변 객체라면 항상 A.equals(B) 의 결과가 같아야 한다.

일관성을 지키도록 equals 메서드를 구현하려면 너무 복잡하게 구현하지 않아야한다.

public class EqualsInJava extends Object {

   public static void main(String[] args) throws MalformedURLException {

      // 일관성 위배 가능성 있음. P61
      URL google1 = new URL("https", "about.google", "/products/");
      URL google2 = new URL("https", "about.google", "/products/");
      System.out.println(google1.equals(google2));
   }
}

URL 클래스의 경우 equals 메서드 호출 시 호스트의 ip가 가르키는 최종 주소를 찾아 비교하게 되는데

이때문에 같은 호스트 이더라도 equals 메서드의 결과가 다르게 나올 수 있다.

null 이 아님

A.equals(null) == false

어떤 인스턴스가 equals 메서드에 null 을 넘겼을 때 False 가 나와야한다.


equals 메서드가 지켜야하는 규약에 대해 알아보았다.

어떻게 구현해야 equals 메서드를 잘 구현할 수 있을까


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 (this == o) {
         return true;
      }

      if (!(o instanceof Point)) {
         return false;
      }

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

   public static void main(String[] args) {
      Point point = new Point(1, 2);
      List<Point> points = new ArrayList<>();
      points.add(point);
      System.out.println(points.contains(new Point(1, 2)));
   }

   // 아이템 11 참조
   @Override public int hashCode()  {
      return 31 * x + y;
   }
}

Point 클래스를 만들어보자

   @Override public boolean equals(Object o) {
      if (this == o) { 
         return true;
      }

      if (!(o instanceof Point)) {
         return false;
      }

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

위와같이 equals 메서드를 구현한다.

가장 먼저 자기 자신과 같은 인스턴스인지 확인한다.

반사성이 일치하는지 확인하는 것이다.

다음으로 instanceof 를 이용해 타입을 비교한다.

타입이 일치하면 해당 타입으로 형변환 을 한다.

마지막으로 객체가 가지고 있는 값들 중에서 반듯이 같아야 하는 값들을 비교한다.

핵심적인 필드들만 비교하는게 중요하다.

Double.compare();
Float.compare();

만약 값들이 부동소숫점에 영향을 받는다Double 이나 Float 이 가지고 있는

compare 메서드를 이용해 비교한다.

프리미티브 타입인 경우에는 == 를 사용해 비교한다.

레퍼런스 타입은 해당 타입이 가지고 있는 equals() 를 사용해 비교한다.

만약 null 이 들어와도 되는 경우 라면 Objects.equals() 를 사용해 비교한다.


이렇게 직접 equals 규약을 따르면서 메서드를 구현하기는 어렵다.

그래서 우리는 Tool 을 사용한다.

  • 구글의 AutoValue
  • Lombok
  • IDE 코드 생성 기능
  • 자바 17버전의 Record

보통은 Lombok 을 사용하게 된다.

자바 17버전을 사용한다면 Record 를 사용하는 방법이 있다.

IDE 를 사용하여 만들 수 있지만 단점으로는 다른 방법에 비해 코드가 지저분해지는 단점이 있다.

또한 필드가 늘어날때 equals 메서드와 hashcode 를 IDE 를 사용하여 다시 만들어줘야한다.


equals 메서드 재정의 했을 때 주의할 점은 다음과 같다.

  • equals 를 재정의 할 때 hashCode 도 반드시 재정의하자
  • 너무 복잡하게 해결하지 말자.
  • Object 가 아닌 타입의 매개변수를 받는 equals 메서드는 선언하지 말자
반응형

댓글