본문 바로가기

JAVA/Effective Java

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

728x90

1. Object.equals

 

   equals 메소드는 Object 클래스의 메소드로 객체의 동치성을 판별하기 위한 메소드이다. 특히 Collections 패키지의 Set , Map의 key 등에서 key의 동치성을 판별하기 위해 자주 사용된다.

 

 

    public boolean equals(Object obj) {
        return (this == obj);
    }

 

  위 코드는 Object equals의 정의이다. 보다시피 객체의 레페런스 값을 비교한다. 그러므로 Object 클래스를 상속받은 클래스에서 equals를 재정의하지 않는다면 클래스 멤버변수가 같더라도 레퍼런스 값이 다르면 equals는 false를 리턴한다. 이러한 특징때문에 equals를 재정의하게 되는데 Collections의 Set, Map의 키 등은 전적으로 equals에 의존하기 때문에 잘못 정의하면 매우 치명적인 오류를 일으킬 수 있다.  

 

2. Equals의 재정의

 

  equals는 논리적으로 객체의 동치성을 판별할때 재정의한다. 주로 값을 표현하는 클래스 Integer, String 에서 물리적인 객체비교가 아니라 객체 내부의 값이 같은지를 알고 싶을때 재정의한다. equals를 재정의할떄는 반드시 아래의 규약을 만족해야한다. 

 

1. 반사성 : x.equals(x) 는 반드시 true 

2. 대칭성 : x.equals(y) == y.equals(x)

3. 추이성 : x.equals(y) , y.equals(z) x.equals(z)

4. 일관성 : x.equals(y)를 반복했을때 항상 같은 값을 반환한다.

5. null-아님 : null이 아닌 x에 대해 x.equals(null) == false

 

이는 Java Object 명세에 적혀있는 내용이기도 하다.

docs.oracle.com/javase/7/docs/api/java/lang/Object.html

 

Object (Java Platform SE 7 )

Called by the garbage collector on an object when garbage collection determines that there are no more references to the object. A subclass overrides the finalize method to dispose of system resources or to perform other cleanup. The general contract of fi

docs.oracle.com

3. equals 재정의 요구사항

 

 

3-1 반사성

  객체를 자기자신과 비교했을때 같아야된다는 것이다. 사실 이 특징은 일부로 트롤하는 경우가 아니고서야 만족하기 어렵기 떄문에 그냥 넘어가겠다.

 

3-2 대칭성

 

   대칭성이란 서로에 대해서 동치 여부가 같게 나와야 된다는 것이다. (x.equals(y) == y.equals(x))

public final class CaseInsensitiveString {
    private final String s;


    public CaseInsensitiveString(String s) {
        if (s == null)
            throw new NullPointerException();
        this.s = 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 static void main(String[] args) {
        CaseInsensitiveString A = new CaseInsensitiveString("BB");
        String B = "bb";

        System.out.println(A.equals(B)); //true
        System.out.println(B.equals(A)); //false
    }

   CaseSensitiveString는 대소문자를 구별하지 않는 String 으로 equals를 재정의한 클래스이다. equals를 자세히 보면 CaseInsenstiveString 뿐만 아니라 String 과도 비교를 한다. 문제가 되는 점은 CaseSensitiveString은 String 의 존재를 알지만, String 은 CaseSensitiveString의 존재를 모른다는 것이다. String의 equals에서 CaseInsensitive는 String의 인스턴스가 아니므로 false를 반환한다. 이 문제를 해결하기 위해서 CaseSensitiveString 과 String 를 비교하는 부분을 지우면 된다.

 

3-3 추이성

  삼단논법을 떠올리면된다. 1==2 이고 2==3 이면 1==3을 만족해야된다는 것이다. 2차원 좌표를 나타내는 Point와 색깔이 있는 점인 ColorPoint가 있다고 가정한다. ColorPoint 에서 Ppint의 equals를 재정의하지 않으면 Color라는 정보를 구별할수 없기 때문에 다음과 같이 재정의해보겠다.

 

@Override public boolean equals(Object o) {
        if (!(o instanceof ColorPoint))
        return false;
        return super.equals(o) && ((ColorPoint) o).color == color;
        }
        
        
        
public class PointTest {
    public static void main(String[] args) {
        Point p = new Point(1,2);
        ColorPoint cp = new ColorPoint(1,2, Color.BLACK);

        System.out.println(p.equals(cp)); //true
        System.out.println(cp.equals(p)); //false
    }
}

Point에서 ColorPoint를 비교할때는 ColorPoint가 Point의 인스턴스이고 x,y좌표 가 같기때문에 True를 리턴한다. 반면 ColorPoint에서 Point를 비교하면 Point가 ColorPoint의 인스턴스가 아니므로 false를 리턴한다. 이는 앞서 말한 대칭성을 위반한다.

 

  이를 수정하기 위해 ColorPoint가 아닌 Point 좌표만 비교하게 바꿔보겠다.

 

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

        if (!(o instanceof ColorPoint)) // o가 Point 객체이면 색상은 비교하지 않음
        return o.equals(this);

        // o가 ColorPoint이므로 모든 정보 비교
        return super.equals(o) && ((ColorPoint)o).color == color;
        }
public class PointTest {
    public static void main(String[] args) {
        ColorPoint cp2 =  new ColorPoint(1,2,Color.RED);
        Point p = new Point(1,2);
        ColorPoint cp = new ColorPoint(1,2, Color.BLACK);

        System.out.println(cp2.equals(p)); //true
        System.out.println(p.equals(cp)); //true

        System.out.println(cp2.equals(cp));// False 추이성이 깨졋다.
    }
}

  그랬더니 추이성이 꺠졌다 심지어 이 equals 함수는 Point의 하위 클래스가 ColorPoint 하나라고 가정하고 만든것이다.  ColorPoint가 아닌 다른 APoint의 하위 클래스가 생긴다면, ColorPoint equals는 APoint의 equals를 APoint의 equals는 ColorPoint의 equals를 호출하는 순환 호출이 발생할것이다. 그렇기 때문에 하위 클래스에서 새로운 값을 추가하며 equals를 재정의하는 것을 하지 않아야 한다. 만약 정말 필요하다면 상속이 아닌 Composition을 사용하여 has-a 관계로 equals를 재정의하는 것이 좋다

public class ColorPoint{
    private final Point point;

    private final Color color;

    public ColorPoint(int x, int y, Color color){
        if(color == null)
          throw new NullPointerException();
        point = new Point(x,y);
        this.color = 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); 
    }
}

 

 

3-4 일관성

 

 두 객체가 변하지 않은 이상 객체의 비교값이 변하면 안된다는 것이다. 이 조건이 만족되기 위해서는 equals를 하는데 클래스가 신뢰할수 없는 자원이 들어가서는 안된다. java.net.URL에서의 equals를 보면 URL와 매핑된 IP 주소를 비교하는데 URL to IP 주소 는 주로 DNS를 통해 이루어진다. DNS 서버라던지 지역이라던지 여러 요인으로 인해 같은 URL이지만 다른 URL를 리턴할수도 있다는 뜻이다. 그러므로 equals를 재정의할때는 반드시 메모리에 존재하는 객체 정보만을 사용하여 equals를 수행해야한다.

 

 /* URL equals
   * Two URL objects are equal if they have the same protocol, reference
     * equivalent hosts, have the same port number on the host, and the same
     * file and fragment of the file.<p>
     *
     * Two hosts are considered equivalent if both host names can be resolved
     * into the same IP addresses; else if either host name can't be
     * resolved, the host names must be equal without regard to case; or both
     * host names equal to null.<p>
     */
     
 public boolean equals(Object obj) {
        if (!(obj instanceof URL))
            return false;
        URL u2 = (URL)obj;

        return handler.equals(this, u2);
    }

/* handler equals*/
protected boolean equals(URL u1, URL u2) {
        String ref1 = u1.getRef();
        String ref2 = u2.getRef();
        return (ref1 == ref2 || (ref1 != null && ref1.equals(ref2))) &&
               sameFile(u1, u2);
}

 

 

3-5 null - 아님

 

비교했을때 NullPointerException이 발생하는 것을 막기 위해 맨 상단부에 다음을 추가한다.

@Override public boolean equals(Object o){
  if(o ==null) return false;
}

 

 

4 올바르게 equals를 재정의하는 방법

 

4-1 == 연산자를 사용하여 입력이 자기 자신의 참조인지 확인한다. 성능 최적화용도로 비교연산이 복잡할 경우 성능상 이득을 볼수 있다.

 

4-2 instanceof 연산자로 equals의 매개변수로 들어온 입력값이 올바른 타입인지 확인한다. 이 과정을 거치지 않는다면 형변환을 할때 ClassCastException이 발생하게 된다. 그러므로 반드시 타입 체크를 한다.

 

4-3 필요한 형변환을 한다.

 

4-4 입력객체와의 필드가 같은지 비교한다. 하나라도 다르면 당연히 false다. 이 때 float, double를 제외한 기본 타입 필드는 ==로 float와 double은 각각 Float.compare, Double.compare로 참조 타입 필드는 equals를 사용하여 비교한다.

다를 가능성이 더 큰 필드를 앞에 둬서 성능 최적화를 하는 것도 좋은 방법이다.

 

4-5 equals의 매개변수는 반드시 Object 타입으로 받는다. 입력타입이  Object가 아닌 equals 메소드는 equals의 재정의가 아니라 다중정의고, 이는 오히려 방해가 된다. 이런 실수를 막기 위해 @Override 어노테이션을 일관되게 사용하는 습관이 좋다.

 

4-6 다 구현했다면 3의 특성이 만족하는지 테스트한다. (대칭성, 추이성, 일관성)

 

4-7 사실은 이런 귀찮은 작업들을 사람이 직접 할 필요가 없다. 구글에서 만든 AutoValue 프레임워크를 사용한다.

 

@AutoValue 어노테이션만 붙이면 다음과 같은 기능들이 자동으로 추가된다.

 

www.baeldung.com/introduction-to-autovalue

 

Introduction to AutoValue | Baeldung

AutoValue is a source code generator for value objects; simply put - it auto-generates the value-type objects with toString(), equals() and hashCode() implementations

www.baeldung.com