Skip to content

Item 10. equals는 일반 규약을 지켜 재정의하라 #11

@hyunsoo10

Description

@hyunsoo10

Chapter : 3. 모든 객체의 공통 메서드

Item : 10. equals는 일반 규약을 지켜 재정의하라

Assignee : hyunsoo10


🍑 서론

equals : 두 객체가 동등한지 여부를 확인(두 객체가 메모리 상에서 동일한 위치)
==     : 실제 값이 같은지 확인

⚠️ equals 메서드를 재정의해서 사용할 때 유의해야 하는데, 다음의 상황에서는 재정의하지 않는 것이 최선이다.

  • 각 인스턴스가 본질적으로 고유할 때
    • 값을 표현하는 것이 아닌 동작하는 개체를 표현하는 클래스인 경우(Thread)
  • 인스턴스의 논리적 동치성을 검사할 필요가 없을 때
    • 논리적 동치성을 검사할 필요가 없을 때는 Object의 기본 equals만으로해결가능
  • 상위 클래스에서 재정의한 equals가 하위클래스에도 딱 들어맞을 때
  • 클래스가 private이거나 package-private(default)이고 equals 메서드를 호출할 일이 없을 때
@Override
public boolean equals(Object o) {
    throw new AssertionError(); // 호출 금지
}

🍑 본론

그렇다면 equals를 재정의해야 할 때는 언제인가

🔷 객체 식별성이 아니라 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때

값 객체는 Integer와 String처럼 값을 표현하는 클래스를 말하는데, 두 값 객체를 equals로 비교하는 프로그래머는 객체가 같은 것인지가 아니라 값이 같은지를 알고 싶을 것이다.

❗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(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;
         }
         
     }
    
     public class Main {
         public static void main(String[] args) {
    
             CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
             String s = "polish";
    
             System.out.println("cis.equals(s) = " + cis.equals(s)); //true
             System.out.println("s.equals(cis) = " + s.equals(cis)); //false
    
         }
     }

    CaseInsensitiveString의 equals를 String과의 연동을 포기하면 equals를 아래처럼 사용할 수 있다.

        @Override
        public boolean equals(Object o) {
            return o instanceof CaseInsensitiveString
             &&((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
        }
        CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
        CaseInsensitiveString cis2 = new CaseInsensitiveString("polish");
    
        System.out.println("cis.equals(cis2) = " + cis.equals(cis2)); //true
        System.out.println("cis2.equals(cis) = " + cis2.equals(cis)); //true
  • 추이성(transitivity):

    • null이 아닌 모든 참조 값 x, y, z에 대해, x.equals(y)가 true이고 y.equals(z)도 true면, x.equals(z)도 true다.
    • 첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체와 세 번째 객체도 같아야 한다.
      //부모
      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) {
              System.out.println("Point.equals");
              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;
          }
          
          @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;
          }
      }
      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);
    
          //p1.equals(p2)와 p2.equals(p3)는 색상을 무시하여 true 반환
          //p1.equals(p3)는 색상을 고려하게 되어 false 를 반환
          //추이성 위반
          System.out.println("p1.equals(p2) = " + p1.equals(p2)); //true
          System.out.println("p2.equals(p3) = " + p2.equals(p3)); //true
          System.out.println("p1.equals(p3) = " + p1.equals(p3)); //false
      }

    해결 방안으로는 상속 대신 컴포지션을 활용하라(아이템 18)를 참고

      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);
          }
    
          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();
          }
      }
  • 일관성(consistency):

    • null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.
    • 두 객체가 같다면 (수정 되지 않는 한) 앞으로도 영원히 같아야 한다.
    • 예를들어 java.net.URL의 equals는 주어진 URL과 매핑된 호스트의 IP 주소를 이용해 비교한다. 호스트 이름을 IP 주소로 바꾸려면 네트워크를 통해야 하는데, 그 결과가 항상 같다고 보장할 수 없음(잘못 설계된 것임)
  • Not null:

    • null이 아닌 모든 참조값 x에 대해, x.equals(null)은 false다.
    • 말 그대로 모든 객체과 null과 같지 않아야 한다.

🍑 결론

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

자기 자신이면 true를 반환, 단순한 성능최적화 용도로 비교 작업이 복잡한 상황일 때 유용하다.

2️⃣ instanceof 연산자로 입력이 올바른 타입인지 확인한다.

이때의 올바른 타입은 equals가 정의된 클래스인 것이 보통이지만 가끔은 그 클래스가 구현한 인터페이스가 될 수도 있다.

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

앞서 2번에서 instanceof로 검사를 했기 때문에 이 단계는 100% 성공

4️⃣ 입력 객체와 자기 자신의 대응되는 "핵심" 필드들이 모두 일치하는지 하나씩 검사한다.

모든 필드가 일치하면 true, 하나라도 다르면 false를 반환

- float과 double은 compare 메서드
- float, double을 제외한 기본 타입 필드는 ==
- 참조 타입 필드는 각각의 equals 메서드
  • 어떤 필드를 먼저 비교하느냐가 equals의 성능을 좌우하기도 한다. 다를 가능성이 크거나 비교하는 비용이 싼 필드를 먼저 비교하다.
  • 동기화용 락(lock) 필드 같이 객체의 논리적 상태와 관련 없는 필드는 비교하면 안된다.
  • 핵심 필드로부터 계산해낼 수 있는 파생 필드 역시 굳이 비교할 필요는 없지만, 파생 필드를 비교하는 쪽이 더 빠를 때도 있다. 파생 필드가 객체 전체의 상태를 대표하는 상황이 그렇다.

✏️ equals를 다 구현했으면 세가지 자문을 해보자

1. 대칭적인가?

2. 추이성이 있는가?

3. 일관적인가?

이상의 비법에 따라 작성한 PhoneNumber 클래스용 equals 메서드

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

    public PhoneNumber(short areaCode, short prefix, short lineNum) {
        this.areaCode = rangeCheck(areaCode, 999, "지역코드");
        this.prefix = rangeCheck(prefix, 999, "프리픽스");
        this.lineNum = rangeCheck(lineNum, 9999, "가입자 번호");
    }

    private static short rangeCheck(int val, int max, String arg) {
        if(val < 0 || val > max)
            throw new IllegalArgumentException(arg + ": " + val);
        return (short) val;
    }

    @Override
    public boolean equals(Object o) {
        if(o == this)
            return true;
        if(!(o instanceof PhoneNumber))
            return false;
        PhoneNumber phoneNumber = (PhoneNumber) o;
        
        return phoneNumber.lineNum == lineNum
                && phoneNumber.prefix == prefix
                && phoneNumber.areaCode == areaCode;
    }
}

❕마지막 주의사항

  • equals를 재정의할 땐 hashCode도 반드시 재정의 하자(아이템11)
  • Object외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자
    • 매개변수를 Object가 아닌 다른 타입으로 받으면 해당 equals는 equals 메서드를 재정의(오버 라이딩)한게 아니라 다중정의(오버로딩)한 것이다.
  • 꼭 필요한 경우가 아니면 equals를 재정의하지말자. 많은 경우에 Object의 equals는 우리가 원하는 비교를 정확하게 수행해준다.

Referenced by

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions